mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 09:45:18 +00:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa45ee3aea | ||
|
|
e8fb4a6333 | ||
|
|
4cfcefcda6 | ||
|
|
3e137d784c | ||
|
|
452e9e74fd | ||
|
|
3afd74ffc8 | ||
|
|
951e056a55 | ||
|
|
d265cfc765 | ||
|
|
473f5c7cce | ||
|
|
b5834cfc76 | ||
|
|
097d916da7 | ||
|
|
4b000b0785 | ||
|
|
19437a0bc6 | ||
|
|
34bae4a89d | ||
|
|
0331871980 | ||
|
|
f9f6ac92b8 | ||
|
|
ca374fd823 | ||
|
|
96f2986d65 | ||
|
|
b6e45e3228 | ||
|
|
c88ab3dd05 | ||
|
|
7d83310bc2 | ||
|
|
264a95b61a | ||
|
|
8562a76367 | ||
|
|
7e86d2061f | ||
|
|
a136b5b7e9 | ||
|
|
c043cf6354 | ||
|
|
72093f9648 | ||
|
|
187a5c3195 | ||
|
|
02b2a006c5 | ||
|
|
2403a6eb90 | ||
|
|
e47c5ae7b3 | ||
|
|
c1f5868d2d | ||
|
|
0515f4c6e8 | ||
|
|
8c7e3e7b9b | ||
|
|
90a5e8ba9d | ||
|
|
6206184862 | ||
|
|
74cf0d89cc | ||
|
|
b4b6aa5ca6 | ||
|
|
1e5a52f39f | ||
|
|
3fc64f6168 | ||
|
|
9964710de2 | ||
|
|
806c420313 | ||
|
|
194dedac43 | ||
|
|
9af46a9ff8 | ||
|
|
da3fcd5a21 | ||
|
|
b282536080 | ||
|
|
e156356c71 | ||
|
|
81a6e18d04 |
@@ -5,14 +5,27 @@ on:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Swift Tests
|
||||
name: Run Swift Tests (${{ matrix.name }})
|
||||
runs-on: macos-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false # Don't cancel other matrix jobs when one fails
|
||||
matrix:
|
||||
include:
|
||||
- name: app
|
||||
path: .
|
||||
- name: BitLogger
|
||||
path: localPackages/BitLogger
|
||||
- name: BitFoundation
|
||||
path: localPackages/BitFoundation
|
||||
- name: Noise
|
||||
path: localPackages/Noise
|
||||
- name: Nostr
|
||||
path: localPackages/Nostr
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
@@ -20,8 +33,14 @@ jobs:
|
||||
- name: Set up Swift
|
||||
uses: swift-actions/setup-swift@v2
|
||||
|
||||
- name: Build the package
|
||||
run: swift build
|
||||
- name: Cache build artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ matrix.path }}/.build
|
||||
key: ${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
|
||||
${{ runner.os }}-${{ matrix.name }}-
|
||||
|
||||
- name: Run Tests
|
||||
run: swift test --parallel
|
||||
run: swift test --parallel --quiet --package-path ${{ matrix.path }}
|
||||
|
||||
@@ -8,6 +8,7 @@ plans/
|
||||
## AI
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
.claude/
|
||||
|
||||
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
||||
*.xcscmblueprint
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
MARKETING_VERSION = 1.5.0
|
||||
MARKETING_VERSION = 1.5.1
|
||||
CURRENT_PROJECT_VERSION = 1
|
||||
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||
@@ -9,3 +9,4 @@ DEVELOPMENT_TEAM = L3N5LHJD5Y
|
||||
CODE_SIGN_STYLE = Automatic
|
||||
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat
|
||||
APP_GROUP_ID = group.chat.bitchat
|
||||
|
||||
+13
-3
@@ -17,7 +17,10 @@ let package = Package(
|
||||
],
|
||||
dependencies:[
|
||||
.package(path: "localPackages/Arti"),
|
||||
.package(path: "localPackages/Noise"),
|
||||
.package(path: "localPackages/BitFoundation"),
|
||||
.package(path: "localPackages/BitLogger"),
|
||||
.package(path: "localPackages/Nostr"),
|
||||
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
||||
],
|
||||
targets: [
|
||||
@@ -25,13 +28,17 @@ let package = Package(
|
||||
name: "bitchat",
|
||||
dependencies: [
|
||||
.product(name: "P256K", package: "swift-secp256k1"),
|
||||
.product(name: "BitFoundation", package: "BitFoundation"),
|
||||
.product(name: "BitLogger", package: "BitLogger"),
|
||||
.product(name: "Noise", package: "Noise"),
|
||||
.product(name: "Nostr", package: "Nostr"),
|
||||
.product(name: "Tor", package: "Arti")
|
||||
],
|
||||
path: "bitchat",
|
||||
exclude: [
|
||||
"Info.plist",
|
||||
"Assets.xcassets",
|
||||
"_PreviewHelpers/PreviewAssets.xcassets",
|
||||
"bitchat.entitlements",
|
||||
"bitchat-macOS.entitlements",
|
||||
"LaunchScreen.storyboard",
|
||||
@@ -43,15 +50,18 @@ let package = Package(
|
||||
),
|
||||
.testTarget(
|
||||
name: "bitchatTests",
|
||||
dependencies: ["bitchat"],
|
||||
dependencies: [
|
||||
"bitchat",
|
||||
.product(name: "BitFoundation", package: "BitFoundation"),
|
||||
.product(name: "Nostr", package: "Nostr")
|
||||
],
|
||||
path: "bitchatTests",
|
||||
exclude: [
|
||||
"Info.plist",
|
||||
"README.md"
|
||||
],
|
||||
resources: [
|
||||
.process("Localization"),
|
||||
.process("Noise")
|
||||
.process("Localization")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
@@ -8,9 +8,6 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
|
||||
|
||||
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
|
||||
|
||||
> [!WARNING]
|
||||
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](https://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
|
||||
|
||||
## License
|
||||
|
||||
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
Generated
+66
-4
@@ -10,6 +10,12 @@
|
||||
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };
|
||||
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
|
||||
A63163B62F80CB2500B8B128 /* Noise in Frameworks */ = {isa = PBXBuildFile; productRef = A63163B52F80CB2500B8B128 /* Noise */; };
|
||||
A63163B82F80CB2D00B8B128 /* Noise in Frameworks */ = {isa = PBXBuildFile; productRef = A63163B72F80CB2D00B8B128 /* Noise */; };
|
||||
A63180832F8103AB00B8B128 /* Nostr in Frameworks */ = {isa = PBXBuildFile; productRef = A63180822F8103AB00B8B128 /* Nostr */; };
|
||||
A63180852F8103B600B8B128 /* Nostr in Frameworks */ = {isa = PBXBuildFile; productRef = A63180842F8103B600B8B128 /* Nostr */; };
|
||||
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9472F80953E001CF9B9 /* BitFoundation */; };
|
||||
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9492F809550001CF9B9 /* BitFoundation */; };
|
||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
|
||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; };
|
||||
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; };
|
||||
@@ -154,16 +160,22 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
files = (
|
||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
|
||||
A63163B82F80CB2D00B8B128 /* Noise in Frameworks */,
|
||||
3EE336D150427F736F32B56C /* P256K in Frameworks */,
|
||||
A63180852F8103B600B8B128 /* Nostr in Frameworks */,
|
||||
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
|
||||
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */,
|
||||
);
|
||||
};
|
||||
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
files = (
|
||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
||||
A63163B62F80CB2500B8B128 /* Noise in Frameworks */,
|
||||
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
||||
A63180832F8103AB00B8B128 /* Nostr in Frameworks */,
|
||||
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
|
||||
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */,
|
||||
);
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
@@ -223,6 +235,9 @@
|
||||
B1D9136AA0083366353BFA2F /* P256K */,
|
||||
A6E3E5712E7703760032EA8A /* BitLogger */,
|
||||
A6E3EA802E7706A80032EA8A /* Tor */,
|
||||
A6BCF9492F809550001CF9B9 /* BitFoundation */,
|
||||
A63163B72F80CB2D00B8B128 /* Noise */,
|
||||
A63180842F8103B600B8B128 /* Nostr */,
|
||||
);
|
||||
productName = bitchat_macOS;
|
||||
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
||||
@@ -303,6 +318,9 @@
|
||||
4EB6BA1B8464F1EA38F4E286 /* P256K */,
|
||||
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
||||
A6E3EA7E2E7706720032EA8A /* Tor */,
|
||||
A6BCF9472F80953E001CF9B9 /* BitFoundation */,
|
||||
A63163B52F80CB2500B8B128 /* Noise */,
|
||||
A63180822F8103AB00B8B128 /* Nostr */,
|
||||
);
|
||||
productName = bitchat_iOS;
|
||||
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
||||
@@ -344,6 +362,9 @@
|
||||
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
||||
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */,
|
||||
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */,
|
||||
A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */,
|
||||
A63180812F8103AB00B8B128 /* XCLocalSwiftPackageReference "localPackages/Nostr" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 90;
|
||||
projectDirPath = "";
|
||||
@@ -548,6 +569,7 @@
|
||||
CODE_SIGNING_REQUIRED = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
ENABLE_PREVIEWS = NO;
|
||||
INFOPLIST_FILE = bitchat/Info.plist;
|
||||
@@ -558,7 +580,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -608,6 +630,7 @@
|
||||
CODE_SIGNING_REQUIRED = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = bitchat/Info.plist;
|
||||
@@ -618,7 +641,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -654,7 +677,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -746,7 +769,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -907,6 +930,18 @@
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = localPackages/Noise;
|
||||
};
|
||||
A63180812F8103AB00B8B128 /* XCLocalSwiftPackageReference "localPackages/Nostr" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = localPackages/Nostr;
|
||||
};
|
||||
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = localPackages/BitFoundation;
|
||||
};
|
||||
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = localPackages/BitLogger;
|
||||
@@ -934,6 +969,33 @@
|
||||
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
|
||||
productName = P256K;
|
||||
};
|
||||
A63163B52F80CB2500B8B128 /* Noise */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Noise;
|
||||
};
|
||||
A63163B72F80CB2D00B8B128 /* Noise */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */;
|
||||
productName = Noise;
|
||||
};
|
||||
A63180822F8103AB00B8B128 /* Nostr */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Nostr;
|
||||
};
|
||||
A63180842F8103B600B8B128 /* Nostr */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = A63180812F8103AB00B8B128 /* XCLocalSwiftPackageReference "localPackages/Nostr" */;
|
||||
productName = Nostr;
|
||||
};
|
||||
A6BCF9472F80953E001CF9B9 /* BitFoundation */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = BitFoundation;
|
||||
};
|
||||
A6BCF9492F809550001CF9B9 /* BitFoundation */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */;
|
||||
productName = BitFoundation;
|
||||
};
|
||||
A6E3E56F2E77036A0032EA8A /* BitLogger */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = BitLogger;
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Nostr
|
||||
import Tor
|
||||
import SwiftUI
|
||||
import BitFoundation
|
||||
import UserNotifications
|
||||
|
||||
@main
|
||||
@@ -26,9 +28,11 @@ struct BitchatApp: App {
|
||||
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
||||
#endif
|
||||
|
||||
private let idBridge = NostrIdentityBridge()
|
||||
private let idBridge = NostrIdentityBridge(keychain: KeychainManager())
|
||||
|
||||
init() {
|
||||
GeoRelayDirectory.setupShared(dependencies: .live())
|
||||
NostrRelayManager.setupShared(dependencies: .live())
|
||||
let keychain = KeychainManager()
|
||||
let idBridge = self.idBridge
|
||||
_chatViewModel = StateObject(
|
||||
@@ -279,8 +283,3 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
var nilIfEmpty: String? {
|
||||
self.isEmpty ? nil : self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,18 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
||||
@Published private(set) var duration: TimeInterval = 0
|
||||
@Published private(set) var progress: Double = 0
|
||||
|
||||
/// rounded so 4.9s shows "00:05"
|
||||
var roundedDuration: Int {
|
||||
guard duration.isFinite else { return 0 }
|
||||
return Int(duration.rounded())
|
||||
}
|
||||
|
||||
/// ceil so "00:01" stays visible until playback ends, capped to rounded duration
|
||||
var remainingSeconds: Int {
|
||||
let remaining = max(0, duration - currentTime)
|
||||
return min(roundedDuration, Int(ceil(remaining)))
|
||||
}
|
||||
|
||||
private var player: AVAudioPlayer?
|
||||
private var timer: Timer?
|
||||
private var url: URL
|
||||
|
||||
@@ -2,8 +2,7 @@ import Foundation
|
||||
import AVFoundation
|
||||
|
||||
/// Manages audio capture for mesh voice notes with predictable encoding settings.
|
||||
/// Recording runs on an internal serial queue to avoid AVAudioSession contention.
|
||||
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
||||
actor VoiceRecorder {
|
||||
enum RecorderError: Error {
|
||||
case microphoneAccessDenied
|
||||
case recorderInitializationFailed
|
||||
@@ -12,21 +11,16 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
||||
|
||||
static let shared = VoiceRecorder()
|
||||
|
||||
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
|
||||
private let paddingInterval: TimeInterval = 0.5
|
||||
private let maxRecordingDuration: TimeInterval = 120
|
||||
static let minRecordingDuration: TimeInterval = 1
|
||||
|
||||
private var recorder: AVAudioRecorder?
|
||||
private var currentURL: URL?
|
||||
private var stopWorkItem: DispatchWorkItem?
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: - Permissions
|
||||
|
||||
@discardableResult
|
||||
nonisolated
|
||||
func requestPermission() async -> Bool {
|
||||
#if os(iOS)
|
||||
return await withCheckedContinuation { continuation in
|
||||
@@ -47,106 +41,88 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
||||
|
||||
// MARK: - Recording Lifecycle
|
||||
|
||||
@discardableResult
|
||||
func startRecording() throws -> URL {
|
||||
try queue.sync {
|
||||
if recorder?.isRecording == true {
|
||||
throw RecorderError.recordingInProgress
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
guard session.recordPermission == .granted else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
#if targetEnvironment(simulator)
|
||||
// allowBluetoothHFP is not available on iOS Simulator
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP]
|
||||
)
|
||||
#else
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
||||
)
|
||||
#endif
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
#endif
|
||||
|
||||
let outputURL = try makeOutputURL()
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 16_000,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderBitRateKey: 16_000
|
||||
]
|
||||
|
||||
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
||||
audioRecorder.delegate = self
|
||||
audioRecorder.isMeteringEnabled = true
|
||||
audioRecorder.prepareToRecord()
|
||||
audioRecorder.record(forDuration: maxRecordingDuration)
|
||||
|
||||
recorder = audioRecorder
|
||||
currentURL = outputURL
|
||||
stopWorkItem?.cancel()
|
||||
stopWorkItem = nil
|
||||
return outputURL
|
||||
if recorder?.isRecording == true {
|
||||
throw RecorderError.recordingInProgress
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
guard session.recordPermission == .granted else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
#if targetEnvironment(simulator)
|
||||
// allowBluetoothHFP is not available on iOS Simulator
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP]
|
||||
)
|
||||
#else
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
||||
)
|
||||
#endif
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
#endif
|
||||
|
||||
let outputURL = try makeOutputURL()
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 16_000,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderBitRateKey: 16_000
|
||||
]
|
||||
|
||||
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
||||
audioRecorder.isMeteringEnabled = true
|
||||
audioRecorder.prepareToRecord()
|
||||
audioRecorder.record(forDuration: maxRecordingDuration)
|
||||
|
||||
recorder = audioRecorder
|
||||
currentURL = outputURL
|
||||
return outputURL
|
||||
}
|
||||
|
||||
func stopRecording(completion: @escaping (URL?) -> Void) {
|
||||
queue.async { [weak self] in
|
||||
guard let self = self, let recorder = self.recorder, recorder.isRecording else {
|
||||
completion(self?.currentURL)
|
||||
return
|
||||
}
|
||||
|
||||
let item = DispatchWorkItem { [weak self] in
|
||||
guard let self = self else { return }
|
||||
recorder.stop()
|
||||
self.cleanupSession()
|
||||
let url = self.currentURL
|
||||
self.recorder = nil
|
||||
self.currentURL = url
|
||||
completion(url)
|
||||
}
|
||||
self.stopWorkItem = item
|
||||
self.queue.asyncAfter(deadline: .now() + self.paddingInterval, execute: item)
|
||||
func stopRecording() async -> URL? {
|
||||
guard let recorder, recorder.isRecording else {
|
||||
return currentURL
|
||||
}
|
||||
|
||||
let sessionURL = currentURL
|
||||
|
||||
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
|
||||
|
||||
recorder.stop()
|
||||
|
||||
// A new session may have started during the sleep — don't touch its state
|
||||
if self.recorder === recorder {
|
||||
cleanupSession()
|
||||
self.recorder = nil
|
||||
currentURL = nil
|
||||
}
|
||||
|
||||
return sessionURL
|
||||
}
|
||||
|
||||
func cancelRecording() {
|
||||
queue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.stopWorkItem?.cancel()
|
||||
self.stopWorkItem = nil
|
||||
if let recorder = self.recorder, recorder.isRecording {
|
||||
recorder.stop()
|
||||
}
|
||||
self.cleanupSession()
|
||||
if let url = self.currentURL {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
self.recorder = nil
|
||||
self.currentURL = nil
|
||||
if let recorder, recorder.isRecording {
|
||||
recorder.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Metering
|
||||
|
||||
func currentAveragePower() -> Float {
|
||||
queue.sync {
|
||||
recorder?.updateMeters()
|
||||
return recorder?.averagePower(forChannel: 0) ?? -160
|
||||
cleanupSession()
|
||||
if let currentURL {
|
||||
try? FileManager.default.removeItem(at: currentURL)
|
||||
}
|
||||
recorder = nil
|
||||
currentURL = nil
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
///
|
||||
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
|
||||
// MARK: - Three-Layer Identity Model
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
///
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
@@ -331,16 +332,15 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
func updateSocialIdentity(_ identity: SocialIdentity) {
|
||||
queue.async(flags: .barrier) {
|
||||
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
|
||||
self.cache.socialIdentities[identity.fingerprint] = identity
|
||||
|
||||
// Update nickname index
|
||||
if let existingIdentity = self.cache.socialIdentities[identity.fingerprint] {
|
||||
// Remove old nickname from index if changed
|
||||
if existingIdentity.claimedNickname != identity.claimedNickname {
|
||||
self.cache.nicknameIndex[existingIdentity.claimedNickname]?.remove(identity.fingerprint)
|
||||
if self.cache.nicknameIndex[existingIdentity.claimedNickname]?.isEmpty == true {
|
||||
self.cache.nicknameIndex.removeValue(forKey: existingIdentity.claimedNickname)
|
||||
}
|
||||
if let previousClaimedNickname,
|
||||
previousClaimedNickname != identity.claimedNickname {
|
||||
self.cache.nicknameIndex[previousClaimedNickname]?.remove(identity.fingerprint)
|
||||
if self.cache.nicknameIndex[previousClaimedNickname]?.isEmpty == true {
|
||||
self.cache.nicknameIndex.removeValue(forKey: previousClaimedNickname)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,4 +532,16 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
return cache.verifiedFingerprints
|
||||
}
|
||||
}
|
||||
|
||||
var debugNicknameIndex: [String: Set<String>] {
|
||||
queue.sync { cache.nicknameIndex }
|
||||
}
|
||||
|
||||
func debugEphemeralSession(for peerID: PeerID) -> EphemeralIdentity? {
|
||||
queue.sync { ephemeralSessions[peerID] }
|
||||
}
|
||||
|
||||
func debugLastInteraction(for fingerprint: String) -> Date? {
|
||||
queue.sync { cache.lastInteractions[fingerprint] }
|
||||
}
|
||||
}
|
||||
|
||||
+6
-4
@@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>AppGroupID</key>
|
||||
<string>$(APP_GROUP_ID)</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
@@ -37,12 +39,12 @@
|
||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</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>NSMicrophoneUsageDescription</key>
|
||||
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-central</string>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//
|
||||
// BitchatMessage+Media.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension BitchatMessage {
|
||||
enum Media {
|
||||
case voice(URL)
|
||||
case image(URL)
|
||||
|
||||
var url: URL {
|
||||
switch self {
|
||||
case .voice(let url), .image(let url):
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
|
||||
private struct Cache {
|
||||
let filesDir: URL?
|
||||
|
||||
static let shared = Cache()
|
||||
private init() {
|
||||
do {
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let filesDir = base.appendingPathComponent("files", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||
self.filesDir = filesDir
|
||||
} catch {
|
||||
filesDir = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mediaAttachment(for nickname: String) -> Media? {
|
||||
guard let baseDirectory = Cache.shared.filesDir else { return nil }
|
||||
|
||||
func url(for category: MimeType.Category) -> URL? {
|
||||
guard content.hasPrefix(category.messagePrefix),
|
||||
let filename = String(content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check outgoing first for sent messages, incoming for received
|
||||
let subdir = sender == nickname ? "\(category.mediaDir)/outgoing" : "\(category.mediaDir)/incoming"
|
||||
|
||||
// Construct URL directly without fileExists check (avoids blocking disk I/O in view body)
|
||||
// Files are checked during playback/display, so missing files fail gracefully
|
||||
let directory = baseDirectory.appendingPathComponent(subdir, isDirectory: true)
|
||||
return directory.appendingPathComponent(filename)
|
||||
}
|
||||
|
||||
if let url = url(for: .audio) {
|
||||
return .voice(url)
|
||||
}
|
||||
if let url = url(for: .image) {
|
||||
return .image(url)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
|
||||
/// Represents a user-visible message in the BitChat system.
|
||||
/// Handles both broadcast messages and private encrypted messages,
|
||||
@@ -338,7 +339,7 @@ extension BitchatMessage {
|
||||
extension Array where Element == BitchatMessage {
|
||||
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
|
||||
func cleanedAndDeduped() -> [Element] {
|
||||
let arr = filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false }
|
||||
let arr = filter { $0.content.trimmed.isEmpty == false }
|
||||
guard arr.count > 1 else {
|
||||
return arr
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
|
||||
/// The core packet structure for all BitChat protocol messages.
|
||||
/// Encapsulates all data needed for routing through the mesh network,
|
||||
@@ -22,8 +23,9 @@ struct BitchatPacket: Codable {
|
||||
var signature: Data?
|
||||
var ttl: UInt8
|
||||
var route: [Data]?
|
||||
var isRSR: Bool
|
||||
|
||||
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil) {
|
||||
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil, isRSR: Bool = false) {
|
||||
self.version = version
|
||||
self.type = type
|
||||
self.senderID = senderID
|
||||
@@ -33,10 +35,11 @@ struct BitchatPacket: Codable {
|
||||
self.signature = signature
|
||||
self.ttl = ttl
|
||||
self.route = route
|
||||
self.isRSR = isRSR
|
||||
}
|
||||
|
||||
// Convenience initializer for new binary format
|
||||
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data) {
|
||||
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data, isRSR: Bool = false) {
|
||||
self.version = 1
|
||||
self.type = type
|
||||
// Convert hex string peer ID to binary data (8 bytes)
|
||||
@@ -56,6 +59,7 @@ struct BitchatPacket: Codable {
|
||||
self.signature = nil
|
||||
self.ttl = ttl
|
||||
self.route = nil
|
||||
self.isRSR = isRSR
|
||||
}
|
||||
|
||||
var data: Data? {
|
||||
@@ -85,7 +89,8 @@ struct BitchatPacket: Codable {
|
||||
signature: nil, // Remove signature for signing
|
||||
ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility
|
||||
version: version,
|
||||
route: route
|
||||
route: route,
|
||||
isRSR: false // RSR flag is mutable and not part of the signature
|
||||
)
|
||||
return BinaryProtocol.encode(unsignedPacket)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import CoreBluetooth
|
||||
import BitFoundation
|
||||
|
||||
/// Represents a peer in the BitChat network with all associated metadata
|
||||
struct BitchatPeer: Equatable {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
|
||||
struct ReadReceipt: Codable {
|
||||
let originalMessageID: String
|
||||
|
||||
@@ -9,12 +9,16 @@ struct RequestSyncPacket {
|
||||
let m: UInt32
|
||||
let data: Data
|
||||
let types: SyncTypeFlags?
|
||||
let sinceTimestamp: UInt64?
|
||||
let fragmentIdFilter: String?
|
||||
|
||||
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil) {
|
||||
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) {
|
||||
self.p = p
|
||||
self.m = m
|
||||
self.data = data
|
||||
self.types = types
|
||||
self.sinceTimestamp = sinceTimestamp
|
||||
self.fragmentIdFilter = fragmentIdFilter
|
||||
}
|
||||
|
||||
func encode() -> Data {
|
||||
@@ -36,15 +40,24 @@ struct RequestSyncPacket {
|
||||
if let typesData = types?.toData() {
|
||||
putTLV(0x04, typesData)
|
||||
}
|
||||
if let ts = sinceTimestamp {
|
||||
var tsBE = ts.bigEndian
|
||||
putTLV(0x05, withUnsafeBytes(of: &tsBE) { Data($0) })
|
||||
}
|
||||
if let fid = fragmentIdFilter, let fidData = fid.data(using: .utf8) {
|
||||
putTLV(0x06, fidData)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
static func decode(from data: Data, maxAcceptBytes: Int = 1024) -> RequestSyncPacket? {
|
||||
var off = 0
|
||||
var p: Int? = nil
|
||||
var m: UInt32? = nil
|
||||
var payload: Data? = nil
|
||||
var types: SyncTypeFlags? = nil
|
||||
var sinceTimestamp: UInt64? = nil
|
||||
var fragmentIdFilter: String? = nil
|
||||
|
||||
while off + 3 <= data.count {
|
||||
let t = Int(data[off]); off += 1
|
||||
@@ -68,12 +81,22 @@ struct RequestSyncPacket {
|
||||
if let decoded = SyncTypeFlags.decode(v) {
|
||||
types = decoded
|
||||
}
|
||||
case 0x05:
|
||||
if v.count == 8 {
|
||||
var ts: UInt64 = 0
|
||||
for b in v { ts = (ts << 8) | UInt64(b) }
|
||||
sinceTimestamp = ts
|
||||
}
|
||||
case 0x06:
|
||||
if let fid = String(data: v, encoding: .utf8) {
|
||||
fragmentIdFilter = fid
|
||||
}
|
||||
default:
|
||||
break // forward compatible; ignore unknown TLVs
|
||||
}
|
||||
}
|
||||
|
||||
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
|
||||
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types)
|
||||
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,345 +0,0 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import Tor
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
||||
@MainActor
|
||||
final class GeoRelayDirectory {
|
||||
struct Entry: Hashable {
|
||||
let host: String
|
||||
let lat: Double
|
||||
let lon: Double
|
||||
}
|
||||
|
||||
static let shared = GeoRelayDirectory()
|
||||
|
||||
private(set) var entries: [Entry] = []
|
||||
private let cacheFileName = "georelays_cache.csv"
|
||||
private let lastFetchKey = "georelay.lastFetchAt"
|
||||
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
||||
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds
|
||||
|
||||
private var refreshTimer: Timer?
|
||||
private var retryTask: Task<Void, Never>?
|
||||
private var retryAttempt: Int = 0
|
||||
private var isFetching: Bool = false
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
|
||||
private init() {
|
||||
entries = loadLocalEntries()
|
||||
registerObservers()
|
||||
startRefreshTimer()
|
||||
prefetchIfNeeded()
|
||||
}
|
||||
|
||||
deinit {
|
||||
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
||||
refreshTimer?.invalidate()
|
||||
retryTask?.cancel()
|
||||
}
|
||||
|
||||
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
||||
func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
|
||||
let center = Geohash.decodeCenter(geohash)
|
||||
return closestRelays(toLat: center.lat, lon: center.lon, count: count)
|
||||
}
|
||||
|
||||
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
|
||||
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
|
||||
guard !entries.isEmpty, count > 0 else { return [] }
|
||||
|
||||
if entries.count <= count {
|
||||
return entries
|
||||
.sorted { a, b in
|
||||
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
|
||||
}
|
||||
.map { "wss://\($0.host)" }
|
||||
}
|
||||
|
||||
var best: [(entry: Entry, distance: Double)] = []
|
||||
best.reserveCapacity(count)
|
||||
|
||||
for entry in entries {
|
||||
let distance = haversineKm(lat, lon, entry.lat, entry.lon)
|
||||
if best.count < count {
|
||||
let idx = best.firstIndex { $0.distance > distance } ?? best.count
|
||||
best.insert((entry, distance), at: idx)
|
||||
} else if let worstDistance = best.last?.distance, distance < worstDistance {
|
||||
let idx = best.firstIndex { $0.distance > distance } ?? best.count
|
||||
best.insert((entry, distance), at: idx)
|
||||
best.removeLast()
|
||||
}
|
||||
}
|
||||
|
||||
return best.map { "wss://\($0.entry.host)" }
|
||||
}
|
||||
|
||||
// MARK: - Remote Fetch
|
||||
func prefetchIfNeeded(force: Bool = false) {
|
||||
guard !isFetching else { return }
|
||||
|
||||
let now = Date()
|
||||
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
||||
|
||||
if !force {
|
||||
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
||||
} else if last != .distantPast,
|
||||
now.timeIntervalSince(last) < TransportConfig.geoRelayRetryInitialSeconds {
|
||||
// Skip forced fetches if we just refreshed moments ago.
|
||||
return
|
||||
}
|
||||
|
||||
cancelRetry()
|
||||
fetchRemote()
|
||||
}
|
||||
|
||||
private func fetchRemote() {
|
||||
guard !isFetching else { return }
|
||||
isFetching = true
|
||||
|
||||
let request = URLRequest(
|
||||
url: remoteURL,
|
||||
cachePolicy: .reloadIgnoringLocalCacheData,
|
||||
timeoutInterval: 15
|
||||
)
|
||||
|
||||
Task.detached { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
if !ready {
|
||||
await self.handleFetchFailure(.torNotReady)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let (data, _) = try await TorURLSession.shared.session.data(for: request)
|
||||
guard let text = String(data: data, encoding: .utf8) else {
|
||||
await self.handleFetchFailure(.invalidData)
|
||||
return
|
||||
}
|
||||
|
||||
let parsed = GeoRelayDirectory.parseCSV(text)
|
||||
guard !parsed.isEmpty else {
|
||||
await self.handleFetchFailure(.invalidData)
|
||||
return
|
||||
}
|
||||
|
||||
await self.handleFetchSuccess(entries: parsed, csv: text)
|
||||
} catch {
|
||||
await self.handleFetchFailure(.network(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum FetchFailure {
|
||||
case torNotReady
|
||||
case invalidData
|
||||
case network(Error)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
|
||||
entries = parsed
|
||||
persistCache(csv)
|
||||
UserDefaults.standard.set(Date(), forKey: lastFetchKey)
|
||||
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
||||
isFetching = false
|
||||
retryAttempt = 0
|
||||
cancelRetry()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleFetchFailure(_ reason: FetchFailure) {
|
||||
switch reason {
|
||||
case .torNotReady:
|
||||
SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session)
|
||||
case .invalidData:
|
||||
SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session)
|
||||
case .network(let error):
|
||||
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(error.localizedDescription)", category: .session)
|
||||
}
|
||||
isFetching = false
|
||||
scheduleRetry()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func scheduleRetry() {
|
||||
retryAttempt = min(retryAttempt + 1, 10)
|
||||
let base = TransportConfig.geoRelayRetryInitialSeconds
|
||||
let maxDelay = TransportConfig.geoRelayRetryMaxSeconds
|
||||
let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))
|
||||
let calculated = base * multiplier
|
||||
let delay = min(maxDelay, max(base, calculated))
|
||||
|
||||
cancelRetry()
|
||||
retryTask = Task { [weak self] in
|
||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||
await MainActor.run {
|
||||
self?.prefetchIfNeeded(force: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func cancelRetry() {
|
||||
retryTask?.cancel()
|
||||
retryTask = nil
|
||||
}
|
||||
|
||||
private func persistCache(_ text: String) {
|
||||
guard let url = cacheURL() else { return }
|
||||
do {
|
||||
try text.data(using: .utf8)?.write(to: url, options: .atomic)
|
||||
} catch {
|
||||
SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Loading
|
||||
private func loadLocalEntries() -> [Entry] {
|
||||
// Prefer cached file if present
|
||||
if let cache = cacheURL(),
|
||||
let data = try? Data(contentsOf: cache),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
let arr = Self.parseCSV(text)
|
||||
if !arr.isEmpty { return arr }
|
||||
}
|
||||
|
||||
// Try bundled resource(s)
|
||||
let bundleCandidates = [
|
||||
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
||||
].compactMap { $0 }
|
||||
|
||||
for url in bundleCandidates {
|
||||
if let data = try? Data(contentsOf: url),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
let arr = Self.parseCSV(text)
|
||||
if !arr.isEmpty { return arr }
|
||||
}
|
||||
}
|
||||
|
||||
// Try filesystem path (development/test)
|
||||
if let cwd = FileManager.default.currentDirectoryPath as String?,
|
||||
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
return Self.parseCSV(text)
|
||||
}
|
||||
|
||||
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
||||
return []
|
||||
}
|
||||
|
||||
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
||||
var result: Set<Entry> = []
|
||||
let lines = text.split(whereSeparator: { $0.isNewline })
|
||||
for (idx, raw) in lines.enumerated() {
|
||||
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if line.isEmpty { continue }
|
||||
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
||||
let parts = line.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }
|
||||
guard parts.count >= 3 else { continue }
|
||||
var host = parts[0]
|
||||
host = host.replacingOccurrences(of: "https://", with: "")
|
||||
host = host.replacingOccurrences(of: "http://", with: "")
|
||||
host = host.replacingOccurrences(of: "wss://", with: "")
|
||||
host = host.replacingOccurrences(of: "ws://", with: "")
|
||||
host = host.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
|
||||
result.insert(Entry(host: host, lat: lat, lon: lon))
|
||||
}
|
||||
return Array(result)
|
||||
}
|
||||
|
||||
private func cacheURL() -> URL? {
|
||||
do {
|
||||
let base = try FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir.appendingPathComponent(cacheFileName)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Observers & Timers
|
||||
private func registerObservers() {
|
||||
let center = NotificationCenter.default
|
||||
|
||||
let torReady = center.addObserver(
|
||||
forName: .TorDidBecomeReady,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded(force: true)
|
||||
}
|
||||
}
|
||||
observers.append(torReady)
|
||||
|
||||
#if os(iOS)
|
||||
let didBecomeActive = center.addObserver(
|
||||
forName: UIApplication.didBecomeActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
observers.append(didBecomeActive)
|
||||
#elseif os(macOS)
|
||||
let didBecomeActive = center.addObserver(
|
||||
forName: NSApplication.didBecomeActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
observers.append(didBecomeActive)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func startRefreshTimer() {
|
||||
refreshTimer?.invalidate()
|
||||
let interval = TransportConfig.geoRelayRefreshCheckIntervalSeconds
|
||||
guard interval > 0 else { return }
|
||||
|
||||
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
refreshTimer = timer
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Distance
|
||||
private func haversineKm(_ lat1: Double, _ lon1: Double, _ lat2: Double, _ lon2: Double) -> Double {
|
||||
let r = 6371.0 // Earth radius in km
|
||||
let dLat = (lat2 - lat1) * .pi / 180
|
||||
let dLon = (lon2 - lon1) * .pi / 180
|
||||
let a = sin(dLat/2) * sin(dLat/2) + cos(lat1 * .pi/180) * cos(lat2 * .pi/180) * sin(dLon/2) * sin(dLon/2)
|
||||
let c = 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||
return r * c
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
|
||||
// MARK: - BitChat-over-Nostr Adapter
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import Nostr
|
||||
import Combine
|
||||
import Foundation
|
||||
import Tor
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
// MARK: - GeoRelayDirectory Live Dependencies
|
||||
|
||||
extension GeoRelayDirectoryDependencies {
|
||||
@MainActor
|
||||
static func live() -> Self {
|
||||
#if os(iOS)
|
||||
let activeNotificationName: Notification.Name? = UIApplication.didBecomeActiveNotification
|
||||
#elseif os(macOS)
|
||||
let activeNotificationName: Notification.Name? = NSApplication.didBecomeActiveNotification
|
||||
#else
|
||||
let activeNotificationName: Notification.Name? = nil
|
||||
#endif
|
||||
|
||||
return Self(
|
||||
userDefaults: .standard,
|
||||
notificationCenter: .default,
|
||||
now: Date.init,
|
||||
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!,
|
||||
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
|
||||
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
|
||||
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
|
||||
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
|
||||
awaitTorReady: { await TorManager.shared.awaitReady() },
|
||||
makeFetchData: {
|
||||
let session = TorURLSession.shared.session
|
||||
return { request in
|
||||
let (data, _) = try await session.data(for: request)
|
||||
return data
|
||||
}
|
||||
},
|
||||
readData: { try? Data(contentsOf: $0) },
|
||||
writeData: { data, url in
|
||||
try data.write(to: url, options: .atomic)
|
||||
},
|
||||
cacheURL: {
|
||||
do {
|
||||
let base = try FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir.appendingPathComponent("georelays_cache.csv")
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
},
|
||||
bundledCSVURLs: {
|
||||
[
|
||||
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
||||
].compactMap { $0 }
|
||||
},
|
||||
currentDirectoryPath: { FileManager.default.currentDirectoryPath },
|
||||
retrySleep: { delay in
|
||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||
},
|
||||
torReadyNotificationName: .TorDidBecomeReady,
|
||||
activeNotificationName: activeNotificationName,
|
||||
autoStart: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NostrRelayManager Live Dependencies
|
||||
|
||||
extension NostrRelayManagerDependencies {
|
||||
@MainActor
|
||||
static func live() -> Self {
|
||||
Self(
|
||||
activationAllowed: { NetworkActivationService.shared.activationAllowed },
|
||||
userTorEnabled: { NetworkActivationService.shared.userTorEnabled },
|
||||
hasMutualFavorites: { !FavoritesPersistenceService.shared.mutualFavorites.isEmpty },
|
||||
hasLocationPermission: { LocationChannelManager.shared.permissionState == .authorized },
|
||||
mutualFavoritesPublisher: FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher(),
|
||||
locationPermissionPublisher: LocationChannelManager.shared.$permissionState
|
||||
.map { state -> LocationPermissionState in
|
||||
switch state {
|
||||
case .notDetermined:.notDetermined
|
||||
case .authorized: .authorized
|
||||
case .denied: .denied
|
||||
case .restricted: .denied
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher(),
|
||||
torEnforced: { TorManager.shared.torEnforced },
|
||||
torIsReady: { TorManager.shared.isReady },
|
||||
torIsForeground: { TorManager.shared.isForeground() },
|
||||
awaitTorReady: { completion in
|
||||
Task.detached {
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
await MainActor.run {
|
||||
completion(ready)
|
||||
}
|
||||
}
|
||||
},
|
||||
makeSession: { NostrRelayManager.makeURLSession(TorURLSession.shared.session) },
|
||||
scheduleAfter: { delay, action in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||
},
|
||||
now: Date.init
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,62 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Hex Encoding/Decoding
|
||||
|
||||
extension Data {
|
||||
func hexEncodedString() -> String {
|
||||
if self.isEmpty {
|
||||
return ""
|
||||
}
|
||||
return self.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
func sha256Hex() -> String {
|
||||
let digest = SHA256.hash(data: self)
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
/// Initialize Data from a hex string.
|
||||
/// - Parameter hexString: A hex string, optionally prefixed with "0x" or "0X".
|
||||
/// Whitespace is trimmed. Must have even length after prefix removal.
|
||||
/// - Returns: nil if the string has odd length or contains invalid hex characters.
|
||||
init?(hexString: String) {
|
||||
var hex = hexString.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
// Remove optional 0x prefix
|
||||
if hex.hasPrefix("0x") || hex.hasPrefix("0X") {
|
||||
hex = String(hex.dropFirst(2))
|
||||
}
|
||||
|
||||
// Reject odd-length strings
|
||||
guard hex.count % 2 == 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reject empty strings
|
||||
guard !hex.isEmpty else {
|
||||
self = Data()
|
||||
return
|
||||
}
|
||||
|
||||
let len = hex.count / 2
|
||||
var data = Data(capacity: len)
|
||||
var index = hex.startIndex
|
||||
|
||||
for _ in 0..<len {
|
||||
let nextIndex = hex.index(index, offsetBy: 2)
|
||||
guard let byte = UInt8(String(hex[index..<nextIndex]), radix: 16) else {
|
||||
return nil
|
||||
}
|
||||
data.append(byte)
|
||||
index = nextIndex
|
||||
}
|
||||
|
||||
self = data
|
||||
}
|
||||
}
|
||||
import BitFoundation
|
||||
|
||||
// MARK: - Binary Encoding Utilities
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ struct BinaryProtocol {
|
||||
static let hasSignature: UInt8 = 0x02
|
||||
static let isCompressed: UInt8 = 0x04
|
||||
static let hasRoute: UInt8 = 0x08
|
||||
static let isRSR: UInt8 = 0x10
|
||||
}
|
||||
|
||||
// Encode BitchatPacket to binary format
|
||||
@@ -204,8 +205,9 @@ struct BinaryProtocol {
|
||||
if isCompressed { flags |= Flags.isCompressed }
|
||||
// HAS_ROUTE is only valid for v2+ packets
|
||||
if hasRoute && version >= 2 { flags |= Flags.hasRoute }
|
||||
if packet.isRSR { flags |= Flags.isRSR }
|
||||
data.append(flags)
|
||||
|
||||
|
||||
if version == 2 {
|
||||
let length = UInt32(payloadDataSize)
|
||||
for shift in stride(from: 24, through: 0, by: -8) {
|
||||
@@ -329,7 +331,8 @@ struct BinaryProtocol {
|
||||
let isCompressed = (flags & Flags.isCompressed) != 0
|
||||
// HAS_ROUTE is only valid for v2+ packets; ignore the flag for v1
|
||||
let hasRoute = (version >= 2) && (flags & Flags.hasRoute) != 0
|
||||
|
||||
let isRSR = (flags & Flags.isRSR) != 0
|
||||
|
||||
let payloadLength: Int
|
||||
if version == 2 {
|
||||
guard let len = read32() else { return nil }
|
||||
@@ -340,6 +343,7 @@ struct BinaryProtocol {
|
||||
}
|
||||
|
||||
guard payloadLength >= 0 else { return nil }
|
||||
guard payloadLength <= FileTransferLimits.maxFramedFileBytes else { return nil }
|
||||
|
||||
guard let senderID = readData(senderIDSize) else { return nil }
|
||||
|
||||
@@ -410,7 +414,8 @@ struct BinaryProtocol {
|
||||
signature: signature,
|
||||
ttl: ttl,
|
||||
version: version,
|
||||
route: route
|
||||
route: route,
|
||||
isRSR: isRSR
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
|
||||
import Foundation
|
||||
import CoreBluetooth
|
||||
import BitFoundation
|
||||
|
||||
// MARK: - Message Types
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -191,5 +191,22 @@ enum MimeType: CaseIterable, Hashable {
|
||||
extension MimeType {
|
||||
enum Category: String {
|
||||
case audio, image, file
|
||||
|
||||
/// Ends with a space
|
||||
var messagePrefix: String {
|
||||
switch self {
|
||||
case .audio: "[voice] "
|
||||
case .image: "[image] "
|
||||
case .file: "[file] "
|
||||
}
|
||||
}
|
||||
|
||||
var mediaDir: String {
|
||||
switch self {
|
||||
case .audio: "voicenotes"
|
||||
case .image: "images"
|
||||
case .file: "files"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Nostr
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
|
||||
/// Result of command processing
|
||||
enum CommandResult {
|
||||
@@ -59,22 +61,11 @@ final class CommandProcessor {
|
||||
weak var meshService: Transport?
|
||||
private let identityManager: SecureIdentityStateManagerProtocol
|
||||
|
||||
/// Backward-compatible property for existing code
|
||||
weak var chatViewModel: CommandContextProvider? {
|
||||
get { contextProvider }
|
||||
set { contextProvider = newValue }
|
||||
}
|
||||
|
||||
init(contextProvider: CommandContextProvider? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
|
||||
self.contextProvider = contextProvider
|
||||
self.meshService = meshService
|
||||
self.identityManager = identityManager
|
||||
}
|
||||
|
||||
/// Backward-compatible initializer
|
||||
convenience init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
|
||||
self.init(contextProvider: chatViewModel, meshService: meshService, identityManager: identityManager)
|
||||
}
|
||||
|
||||
/// Process a command string
|
||||
@MainActor
|
||||
@@ -177,7 +168,7 @@ final class CommandProcessor {
|
||||
}
|
||||
|
||||
private func handleEmote(_ args: String, command: String, action: String, emoji: String, suffix: String = "") -> CommandResult {
|
||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||
let targetName = args.trimmed
|
||||
guard !targetName.isEmpty else {
|
||||
return .error(message: "usage: /\(command) <nickname>")
|
||||
}
|
||||
@@ -220,7 +211,7 @@ final class CommandProcessor {
|
||||
}
|
||||
|
||||
private func handleBlock(_ args: String) -> CommandResult {
|
||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||
let targetName = args.trimmed
|
||||
|
||||
if targetName.isEmpty {
|
||||
// List blocked users (mesh) and geohash (Nostr) blocks
|
||||
@@ -295,7 +286,7 @@ final class CommandProcessor {
|
||||
}
|
||||
|
||||
private func handleUnblock(_ args: String) -> CommandResult {
|
||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||
let targetName = args.trimmed
|
||||
guard !targetName.isEmpty else {
|
||||
return .error(message: "usage: /unblock <nickname>")
|
||||
}
|
||||
@@ -322,7 +313,7 @@ final class CommandProcessor {
|
||||
}
|
||||
|
||||
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
|
||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||
let targetName = args.trimmed
|
||||
guard !targetName.isEmpty else {
|
||||
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
@@ -31,9 +32,6 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
||||
@Published private(set) var mutualFavorites: Set<Data> = []
|
||||
|
||||
private let userDefaults = UserDefaults.standard
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
static let shared = FavoritesPersistenceService()
|
||||
|
||||
init(keychain: KeychainManagerProtocol = KeychainManager()) {
|
||||
|
||||
@@ -8,11 +8,31 @@
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Nostr
|
||||
import Foundation
|
||||
import Combine
|
||||
import BitLogger
|
||||
import Tor
|
||||
|
||||
protocol GeohashPresenceTimerProtocol: AnyObject {
|
||||
var isValid: Bool { get }
|
||||
func invalidate()
|
||||
}
|
||||
|
||||
private final class GeohashPresenceTimerAdapter: GeohashPresenceTimerProtocol {
|
||||
private let base: Timer
|
||||
|
||||
init(base: Timer) {
|
||||
self.base = base
|
||||
}
|
||||
|
||||
var isValid: Bool { base.isValid }
|
||||
|
||||
func invalidate() {
|
||||
base.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
/// Service that coordinates the broadcasting of presence heartbeats.
|
||||
///
|
||||
/// Behavior:
|
||||
@@ -25,18 +45,27 @@ final class GeohashPresenceService: ObservableObject {
|
||||
static let shared = GeohashPresenceService()
|
||||
|
||||
private var subscriptions = Set<AnyCancellable>()
|
||||
private var heartbeatTimer: Timer?
|
||||
private let idBridge = NostrIdentityBridge()
|
||||
private var heartbeatTimer: GeohashPresenceTimerProtocol?
|
||||
private let availableChannelsProvider: () -> [GeohashChannel]
|
||||
private let locationChanges: AnyPublisher<[GeohashChannel], Never>
|
||||
private let torReadyPublisher: AnyPublisher<Void, Never>
|
||||
private let torIsReady: () -> Bool
|
||||
private let torIsForeground: () -> Bool
|
||||
private let deriveIdentity: (String) throws -> NostrIdentity
|
||||
private let relayLookup: (String, Int) -> [String]
|
||||
private let relaySender: (NostrEvent, [String]) -> Void
|
||||
private let sleeper: (UInt64) async -> Void
|
||||
private let scheduleTimer: (TimeInterval, @escaping () -> Void) -> GeohashPresenceTimerProtocol
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
// Loop interval range in seconds
|
||||
private let loopMinInterval: TimeInterval = 40.0
|
||||
private let loopMaxInterval: TimeInterval = 80.0
|
||||
private let loopMinInterval: TimeInterval
|
||||
private let loopMaxInterval: TimeInterval
|
||||
|
||||
// Per-broadcast decorrelation delay range in seconds
|
||||
private let burstMinDelay: TimeInterval = 2.0
|
||||
private let burstMaxDelay: TimeInterval = 5.0
|
||||
private let burstMinDelay: TimeInterval
|
||||
private let burstMaxDelay: TimeInterval
|
||||
|
||||
// Privacy: Only broadcast to these levels
|
||||
private let allowedPrecisions: Set<Int> = [
|
||||
@@ -46,6 +75,74 @@ final class GeohashPresenceService: ObservableObject {
|
||||
]
|
||||
|
||||
private init() {
|
||||
let idBridge = NostrIdentityBridge(keychain: KeychainManager())
|
||||
self.availableChannelsProvider = { LocationStateManager.shared.availableChannels }
|
||||
self.locationChanges = LocationStateManager.shared.$availableChannels.eraseToAnyPublisher()
|
||||
self.torReadyPublisher = NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
||||
.map { _ in () }
|
||||
.eraseToAnyPublisher()
|
||||
self.torIsReady = { TorManager.shared.isReady }
|
||||
self.torIsForeground = { TorManager.shared.isForeground() }
|
||||
self.deriveIdentity = { try idBridge.deriveIdentity(forGeohash: $0) }
|
||||
self.relayLookup = { geohash, count in
|
||||
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
|
||||
}
|
||||
self.relaySender = { event, relays in
|
||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
||||
}
|
||||
self.sleeper = { nanoseconds in
|
||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||
}
|
||||
self.scheduleTimer = { interval, action in
|
||||
GeohashPresenceTimerAdapter(
|
||||
base: Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in
|
||||
action()
|
||||
}
|
||||
)
|
||||
}
|
||||
self.loopMinInterval = 40.0
|
||||
self.loopMaxInterval = 80.0
|
||||
self.burstMinDelay = 2.0
|
||||
self.burstMaxDelay = 5.0
|
||||
setupObservers()
|
||||
}
|
||||
|
||||
internal init(
|
||||
availableChannelsProvider: @escaping () -> [GeohashChannel],
|
||||
locationChanges: AnyPublisher<[GeohashChannel], Never>,
|
||||
torReadyPublisher: AnyPublisher<Void, Never>,
|
||||
torIsReady: @escaping () -> Bool,
|
||||
torIsForeground: @escaping () -> Bool,
|
||||
deriveIdentity: @escaping (String) throws -> NostrIdentity,
|
||||
relayLookup: @escaping (String, Int) -> [String],
|
||||
relaySender: @escaping (NostrEvent, [String]) -> Void,
|
||||
sleeper: @escaping (UInt64) async -> Void = { nanoseconds in try? await Task.sleep(nanoseconds: nanoseconds) },
|
||||
scheduleTimer: @escaping (TimeInterval, @escaping () -> Void) -> GeohashPresenceTimerProtocol = { interval, action in
|
||||
GeohashPresenceTimerAdapter(
|
||||
base: Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in
|
||||
action()
|
||||
}
|
||||
)
|
||||
},
|
||||
loopMinInterval: TimeInterval = 40.0,
|
||||
loopMaxInterval: TimeInterval = 80.0,
|
||||
burstMinDelay: TimeInterval = 2.0,
|
||||
burstMaxDelay: TimeInterval = 5.0
|
||||
) {
|
||||
self.availableChannelsProvider = availableChannelsProvider
|
||||
self.locationChanges = locationChanges
|
||||
self.torReadyPublisher = torReadyPublisher
|
||||
self.torIsReady = torIsReady
|
||||
self.torIsForeground = torIsForeground
|
||||
self.deriveIdentity = deriveIdentity
|
||||
self.relayLookup = relayLookup
|
||||
self.relaySender = relaySender
|
||||
self.sleeper = sleeper
|
||||
self.scheduleTimer = scheduleTimer
|
||||
self.loopMinInterval = loopMinInterval
|
||||
self.loopMaxInterval = loopMaxInterval
|
||||
self.burstMinDelay = burstMinDelay
|
||||
self.burstMaxDelay = burstMaxDelay
|
||||
setupObservers()
|
||||
}
|
||||
|
||||
@@ -57,7 +154,7 @@ final class GeohashPresenceService: ObservableObject {
|
||||
|
||||
private func setupObservers() {
|
||||
// Monitor location channel changes
|
||||
LocationStateManager.shared.$availableChannels
|
||||
locationChanges
|
||||
.dropFirst()
|
||||
.sink { [weak self] _ in
|
||||
self?.handleLocationChange()
|
||||
@@ -65,28 +162,28 @@ final class GeohashPresenceService: ObservableObject {
|
||||
.store(in: &subscriptions)
|
||||
|
||||
// Monitor Tor readiness to kick off heartbeat if it was stalled
|
||||
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
||||
torReadyPublisher
|
||||
.sink { [weak self] _ in
|
||||
self?.handleConnectivityChange()
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
private func handleLocationChange() {
|
||||
func handleLocationChange() {
|
||||
// When location changes, we trigger an immediate (but slightly delayed) heartbeat
|
||||
// to announce presence in the new zone, then reset the loop.
|
||||
SecureLogger.debug("Presence: location changed, scheduling update", category: .session)
|
||||
heartbeatTimer?.invalidate()
|
||||
|
||||
// Small delay to allow location state to settle
|
||||
heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { [weak self] _ in
|
||||
heartbeatTimer = scheduleTimer(5.0) { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.performHeartbeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleConnectivityChange() {
|
||||
func handleConnectivityChange() {
|
||||
SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session)
|
||||
// If we were waiting for network, do it now
|
||||
if heartbeatTimer == nil || !heartbeatTimer!.isValid {
|
||||
@@ -94,33 +191,33 @@ final class GeohashPresenceService: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleNextHeartbeat() {
|
||||
func scheduleNextHeartbeat() {
|
||||
heartbeatTimer?.invalidate()
|
||||
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
|
||||
heartbeatTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
|
||||
heartbeatTimer = scheduleTimer(interval) { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.performHeartbeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func performHeartbeat() {
|
||||
func performHeartbeat() {
|
||||
// Always schedule next loop first ensures continuity even if this one fails/skips
|
||||
defer { scheduleNextHeartbeat() }
|
||||
|
||||
// 1. Check preconditions
|
||||
guard TorManager.shared.isReady else {
|
||||
guard torIsReady() else {
|
||||
SecureLogger.debug("Presence: skipping heartbeat (Tor not ready)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
// App must be active (or at least we shouldn't broadcast if in background, usually)
|
||||
if !TorManager.shared.isForeground() {
|
||||
if !torIsForeground() {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Get channels
|
||||
let channels = LocationStateManager.shared.availableChannels
|
||||
let channels = availableChannelsProvider()
|
||||
guard !channels.isEmpty else { return }
|
||||
|
||||
// 3. Filter and broadcast
|
||||
@@ -136,16 +233,16 @@ final class GeohashPresenceService: ObservableObject {
|
||||
// Random delay for decorrelation
|
||||
let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay)
|
||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||
await self.sleeper(nanoseconds)
|
||||
|
||||
self.broadcastPresence(for: channel.geohash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func broadcastPresence(for geohash: String) {
|
||||
func broadcastPresence(for geohash: String) {
|
||||
do {
|
||||
guard let identity = try? idBridge.deriveIdentity(forGeohash: geohash) else {
|
||||
guard let identity = try? deriveIdentity(geohash) else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -155,13 +252,10 @@ final class GeohashPresenceService: ObservableObject {
|
||||
)
|
||||
|
||||
// Send via RelayManager
|
||||
let targetRelays = GeoRelayDirectory.shared.closestRelays(
|
||||
toGeohash: geohash,
|
||||
count: TransportConfig.nostrGeoRelayCount
|
||||
)
|
||||
let targetRelays = relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||
|
||||
if !targetRelays.isEmpty {
|
||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||
relaySender(event, targetRelays)
|
||||
SecureLogger.debug("Presence: sent heartbeat for \(geohash) (pub=\(identity.publicKeyHex.prefix(6))...)", category: .session)
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import protocol Nostr.NostrKeychainStoring
|
||||
import protocol Noise.SecureMemoryCleaner
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
@@ -51,7 +53,7 @@ enum KeychainSaveResult {
|
||||
}
|
||||
}
|
||||
|
||||
protocol KeychainManagerProtocol {
|
||||
protocol KeychainManagerProtocol: SecureMemoryCleaner, NostrKeychainStoring {
|
||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
|
||||
func getIdentityKey(forKey key: String) -> Data?
|
||||
func deleteIdentityKey(forKey key: String) -> Bool
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import BitLogger
|
||||
import Nostr
|
||||
import Foundation
|
||||
|
||||
/// Dependencies for location notes, allowing tests to stub relay/identity behavior.
|
||||
@@ -15,7 +16,7 @@ struct LocationNotesDependencies {
|
||||
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
|
||||
var now: () -> Date
|
||||
|
||||
private static let idBridge = NostrIdentityBridge()
|
||||
private static let idBridge = NostrIdentityBridge(keychain: KeychainManager())
|
||||
|
||||
static let live = LocationNotesDependencies(
|
||||
relayLookup: { geohash, count in
|
||||
@@ -63,7 +64,7 @@ final class LocationNotesManager: ObservableObject {
|
||||
|
||||
var displayName: String {
|
||||
let suffix = String(pubkey.suffix(4))
|
||||
if let nick = nickname, !nick.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
if let nick = nickname?.trimmedOrNilIfEmpty {
|
||||
return "\(nick)#\(suffix)"
|
||||
}
|
||||
return "anon#\(suffix)"
|
||||
@@ -199,8 +200,7 @@ final class LocationNotesManager: ObservableObject {
|
||||
|
||||
/// Send a location note for the current geohash using the per-geohash identity.
|
||||
func send(content: String, nickname: String) {
|
||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
|
||||
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||
guard !relays.isEmpty else {
|
||||
state = .noRelays
|
||||
|
||||
@@ -5,6 +5,79 @@ import Combine
|
||||
#if os(iOS) || os(macOS)
|
||||
import CoreLocation
|
||||
|
||||
protocol LocationStateManaging: AnyObject {
|
||||
var delegate: CLLocationManagerDelegate? { get set }
|
||||
var desiredAccuracy: CLLocationAccuracy { get set }
|
||||
var distanceFilter: CLLocationDistance { get set }
|
||||
var authorizationStatus: CLAuthorizationStatus { get }
|
||||
func requestWhenInUseAuthorization()
|
||||
func requestLocation()
|
||||
func startUpdatingLocation()
|
||||
func stopUpdatingLocation()
|
||||
}
|
||||
|
||||
protocol LocationStateGeocoding: AnyObject {
|
||||
func cancelGeocode()
|
||||
func reverseGeocodeLocation(
|
||||
_ location: CLLocation,
|
||||
completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void
|
||||
)
|
||||
}
|
||||
|
||||
private final class CLLocationManagerAdapter: NSObject, LocationStateManaging {
|
||||
private let base = CLLocationManager()
|
||||
|
||||
var delegate: CLLocationManagerDelegate? {
|
||||
get { base.delegate }
|
||||
set { base.delegate = newValue }
|
||||
}
|
||||
|
||||
var desiredAccuracy: CLLocationAccuracy {
|
||||
get { base.desiredAccuracy }
|
||||
set { base.desiredAccuracy = newValue }
|
||||
}
|
||||
|
||||
var distanceFilter: CLLocationDistance {
|
||||
get { base.distanceFilter }
|
||||
set { base.distanceFilter = newValue }
|
||||
}
|
||||
|
||||
var authorizationStatus: CLAuthorizationStatus {
|
||||
base.authorizationStatus
|
||||
}
|
||||
|
||||
func requestWhenInUseAuthorization() {
|
||||
base.requestWhenInUseAuthorization()
|
||||
}
|
||||
|
||||
func requestLocation() {
|
||||
base.requestLocation()
|
||||
}
|
||||
|
||||
func startUpdatingLocation() {
|
||||
base.startUpdatingLocation()
|
||||
}
|
||||
|
||||
func stopUpdatingLocation() {
|
||||
base.stopUpdatingLocation()
|
||||
}
|
||||
}
|
||||
|
||||
private final class CLGeocoderAdapter: LocationStateGeocoding {
|
||||
private let base = CLGeocoder()
|
||||
|
||||
func cancelGeocode() {
|
||||
base.cancelGeocode()
|
||||
}
|
||||
|
||||
func reverseGeocodeLocation(
|
||||
_ location: CLLocation,
|
||||
completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void
|
||||
) {
|
||||
base.reverseGeocodeLocation(location, completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
|
||||
/// Unified manager for location-based channel state including:
|
||||
/// - CoreLocation permissions and one-shot location retrieval
|
||||
/// - Geohash channel computation from coordinates
|
||||
@@ -26,8 +99,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
||||
|
||||
// MARK: - Private Properties (CoreLocation)
|
||||
|
||||
private let cl = CLLocationManager()
|
||||
private let geocoder = CLGeocoder()
|
||||
private let cl: LocationStateManaging
|
||||
private let geocoder: LocationStateGeocoding
|
||||
private var lastLocation: CLLocation?
|
||||
private var refreshTimer: Timer?
|
||||
private var isGeocoding: Bool = false
|
||||
@@ -73,6 +146,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
||||
|
||||
private override init() {
|
||||
self.storage = .standard
|
||||
self.cl = CLLocationManagerAdapter()
|
||||
self.geocoder = CLGeocoderAdapter()
|
||||
super.init()
|
||||
|
||||
// Skip CoreLocation setup in test environments
|
||||
@@ -92,10 +167,30 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
||||
/// Internal initializer for testing with custom storage
|
||||
init(storage: UserDefaults) {
|
||||
self.storage = storage
|
||||
self.cl = CLLocationManagerAdapter()
|
||||
self.geocoder = CLGeocoderAdapter()
|
||||
super.init()
|
||||
loadPersistedState()
|
||||
}
|
||||
|
||||
internal init(
|
||||
storage: UserDefaults,
|
||||
locationManager: LocationStateManaging,
|
||||
geocoder: LocationStateGeocoding,
|
||||
shouldInitializeCoreLocation: Bool
|
||||
) {
|
||||
self.storage = storage
|
||||
self.cl = locationManager
|
||||
self.geocoder = geocoder
|
||||
super.init()
|
||||
loadPersistedState()
|
||||
guard shouldInitializeCoreLocation else { return }
|
||||
cl.delegate = self
|
||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
|
||||
initializePermissionState()
|
||||
}
|
||||
|
||||
private func loadPersistedState() {
|
||||
// Load selected channel
|
||||
if let data = storage.data(forKey: selectedChannelKey),
|
||||
@@ -132,12 +227,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
||||
}
|
||||
|
||||
private func initializePermissionState() {
|
||||
let status: CLAuthorizationStatus
|
||||
if #available(iOS 14.0, macOS 11.0, *) {
|
||||
status = cl.authorizationStatus
|
||||
} else {
|
||||
status = CLLocationManager.authorizationStatus()
|
||||
}
|
||||
let status = cl.authorizationStatus
|
||||
updatePermissionState(from: status)
|
||||
|
||||
// Fall back to persisted teleport state if no location authorization
|
||||
@@ -156,12 +246,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
||||
// MARK: - Public API (Permissions & Location)
|
||||
|
||||
func enableLocationChannels() {
|
||||
let status: CLAuthorizationStatus
|
||||
if #available(iOS 14.0, macOS 11.0, *) {
|
||||
status = cl.authorizationStatus
|
||||
} else {
|
||||
status = CLLocationManager.authorizationStatus()
|
||||
}
|
||||
let status = cl.authorizationStatus
|
||||
switch status {
|
||||
case .notDetermined:
|
||||
cl.requestWhenInUseAuthorization()
|
||||
@@ -512,7 +597,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
||||
private static func normalizeGeohash(_ s: String) -> String {
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
return s
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.trimmed
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: "#", with: "")
|
||||
.filter { allowed.contains($0) }
|
||||
|
||||
@@ -11,6 +11,10 @@ final class MeshTopologyTracker {
|
||||
// Last time we received an update from a node
|
||||
private var lastSeen: [RoutingID: Date] = [:]
|
||||
|
||||
// Maximum age for topology claims to be considered fresh for routing
|
||||
// Routes computed using stale topology can fail when the network has changed
|
||||
private static let routeFreshnessThreshold: TimeInterval = 60 // 60 seconds
|
||||
|
||||
func reset() {
|
||||
queue.sync(flags: .barrier) {
|
||||
self.claims.removeAll()
|
||||
@@ -55,25 +59,33 @@ final class MeshTopologyTracker {
|
||||
if source == target { return [] } // Direct connection, no intermediate hops
|
||||
|
||||
return queue.sync {
|
||||
let now = Date()
|
||||
let freshnessDeadline = now.addingTimeInterval(-Self.routeFreshnessThreshold)
|
||||
|
||||
// BFS
|
||||
var visited: Set<RoutingID> = [source]
|
||||
// Queue stores paths: [Start, Hop1, Hop2, ..., Current]
|
||||
var queuePaths: [[RoutingID]] = [[source]]
|
||||
|
||||
|
||||
while !queuePaths.isEmpty {
|
||||
let path = queuePaths.removeFirst()
|
||||
// Limit path length (path contains source + maxHops + target) -> maxHops intermediate
|
||||
// If maxHops = 10, max edges = 11, max nodes = 12.
|
||||
if path.count > maxHops + 1 { continue }
|
||||
|
||||
|
||||
guard let last = path.last else { continue }
|
||||
|
||||
|
||||
// Get neighbors that 'last' claims to see
|
||||
guard let neighbors = claims[last] else { continue }
|
||||
|
||||
// Check if 'last' node's topology info is fresh
|
||||
guard let lastSeenTime = lastSeen[last], lastSeenTime > freshnessDeadline else {
|
||||
continue // Skip stale nodes
|
||||
}
|
||||
|
||||
for neighbor in neighbors {
|
||||
if visited.contains(neighbor) { continue }
|
||||
|
||||
|
||||
// CONFIRMED EDGE CHECK:
|
||||
// 'last' claims 'neighbor' (checked above)
|
||||
// Does 'neighbor' claim 'last'?
|
||||
@@ -81,16 +93,21 @@ final class MeshTopologyTracker {
|
||||
neighborClaims.contains(last) else {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
// Check if 'neighbor' node's topology info is fresh
|
||||
guard let neighborSeenTime = lastSeen[neighbor], neighborSeenTime > freshnessDeadline else {
|
||||
continue // Skip edges to stale nodes
|
||||
}
|
||||
|
||||
var nextPath = path
|
||||
nextPath.append(neighbor)
|
||||
|
||||
|
||||
if neighbor == target {
|
||||
// Return only intermediate hops
|
||||
// Path: [Source, I1, I2, Target] -> [I1, I2]
|
||||
return Array(nextPath.dropFirst().dropLast())
|
||||
}
|
||||
|
||||
|
||||
visited.insert(neighbor)
|
||||
queuePaths.append(nextPath)
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ enum ContentNormalizer {
|
||||
}
|
||||
|
||||
// Trim and collapse whitespace
|
||||
let trimmed = simplified.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmed = simplified.trimmed
|
||||
let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||
|
||||
// Take prefix and hash
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// Routes messages using available transports (Mesh, Nostr, etc.)
|
||||
@MainActor
|
||||
final class MessageRouter {
|
||||
private let transports: [Transport]
|
||||
private var outbox: [PeerID: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
|
||||
|
||||
// Outbox entry with timestamp for TTL-based eviction
|
||||
private struct QueuedMessage {
|
||||
let content: String
|
||||
let nickname: String
|
||||
let messageID: String
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
private var outbox: [PeerID: [QueuedMessage]] = [:]
|
||||
|
||||
// Outbox limits to prevent unbounded memory growth
|
||||
private static let maxMessagesPerPeer = 100
|
||||
private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours
|
||||
|
||||
init(transports: [Transport]) {
|
||||
self.transports = transports
|
||||
@@ -34,52 +48,60 @@ final class MessageRouter {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Transport Selection
|
||||
|
||||
private func reachableTransport(for peerID: PeerID) -> Transport? {
|
||||
transports.first { $0.isPeerReachable(peerID) }
|
||||
}
|
||||
|
||||
private func connectedTransport(for peerID: PeerID) -> Transport? {
|
||||
transports.first { $0.isPeerConnected(peerID) }
|
||||
}
|
||||
|
||||
// MARK: - Message Sending
|
||||
|
||||
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
// Try to find a reachable transport
|
||||
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
||||
if let transport = reachableTransport(for: peerID) {
|
||||
SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
} else {
|
||||
// Queue for later
|
||||
// Queue for later with timestamp for TTL tracking
|
||||
if outbox[peerID] == nil { outbox[peerID] = [] }
|
||||
outbox[peerID]?.append((content, recipientNickname, messageID))
|
||||
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))…", category: .session)
|
||||
|
||||
let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: Date())
|
||||
outbox[peerID]?.append(message)
|
||||
|
||||
// Enforce per-peer size limit with FIFO eviction
|
||||
if let count = outbox[peerID]?.count, count > Self.maxMessagesPerPeer {
|
||||
let evicted = outbox[peerID]?.removeFirst()
|
||||
SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted?.messageID.prefix(8) ?? "?")…", category: .session)
|
||||
}
|
||||
|
||||
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
||||
if let transport = reachableTransport(for: peerID) {
|
||||
SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||
transport.sendReadReceipt(receipt, to: peerID)
|
||||
} else if !transports.isEmpty {
|
||||
// Fallback to last transport (usually Nostr) if neither is explicitly reachable?
|
||||
// Or better: just try the first one that supports it?
|
||||
// Existing logic preferred mesh, then nostr.
|
||||
// If neither reachable, existing logic queued it (via mesh usually) or sent via nostr.
|
||||
// Let's stick to "try reachable". If none, maybe pick the first one to queue?
|
||||
// Actually, for READ receipts, we might want to just fire-and-forget on the "best effort" transport.
|
||||
// But let's stick to the reachable check.
|
||||
SecureLogger.debug("No reachable transport for READ ack to \(peerID.id.prefix(8))…", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
|
||||
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
||||
if let transport = reachableTransport(for: peerID) {
|
||||
SecureLogger.debug("Routing DELIVERED ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
transport.sendDeliveryAck(for: messageID, to: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||
if let transport = transports.first(where: { $0.isPeerConnected(peerID) }) {
|
||||
if let transport = connectedTransport(for: peerID) {
|
||||
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
} else if let transport = reachableTransport(for: peerID) {
|
||||
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
} else if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
||||
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
} else {
|
||||
// Fallback: try all? or just the last one?
|
||||
// Old logic: if mesh connected, mesh. Else nostr.
|
||||
// Note: NostrTransport.isPeerReachable now returns true if mapped.
|
||||
// If not mapped, we can't send via Nostr anyway.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,17 +110,25 @@ final class MessageRouter {
|
||||
func flushOutbox(for peerID: PeerID) {
|
||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
||||
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
||||
var remaining: [(content: String, nickname: String, messageID: String)] = []
|
||||
|
||||
for (content, nickname, messageID) in queued {
|
||||
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
||||
SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||
|
||||
let now = Date()
|
||||
var remaining: [QueuedMessage] = []
|
||||
|
||||
for message in queued {
|
||||
// Skip expired messages (TTL exceeded)
|
||||
if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
|
||||
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session)
|
||||
continue
|
||||
}
|
||||
|
||||
if let transport = reachableTransport(for: peerID) {
|
||||
SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
||||
} else {
|
||||
remaining.append((content, nickname, messageID))
|
||||
remaining.append(message)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if remaining.isEmpty {
|
||||
outbox.removeValue(forKey: peerID)
|
||||
} else {
|
||||
@@ -109,4 +139,15 @@ final class MessageRouter {
|
||||
func flushAllOutbox() {
|
||||
for key in Array(outbox.keys) { flushOutbox(for: key) }
|
||||
}
|
||||
|
||||
/// Periodically clean up expired messages from all outboxes
|
||||
func cleanupExpiredMessages() {
|
||||
let now = Date()
|
||||
for peerID in Array(outbox.keys) {
|
||||
outbox[peerID]?.removeAll { now.timeIntervalSince($0.timestamp) > Self.messageTTLSeconds }
|
||||
if outbox[peerID]?.isEmpty == true {
|
||||
outbox.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
import Nostr
|
||||
import Foundation
|
||||
import BitLogger
|
||||
import Combine
|
||||
import Tor
|
||||
|
||||
@MainActor
|
||||
protocol NetworkActivationTorControlling: AnyObject {
|
||||
func setAutoStartAllowed(_ allowed: Bool)
|
||||
func startIfNeeded()
|
||||
func shutdownCompletely()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
protocol NetworkActivationRelayControlling: AnyObject {
|
||||
func connect()
|
||||
func disconnect()
|
||||
}
|
||||
|
||||
protocol NetworkActivationProxyControlling: AnyObject {
|
||||
func setProxyMode(useTor: Bool)
|
||||
}
|
||||
|
||||
extension TorManager: NetworkActivationTorControlling {}
|
||||
extension NostrRelayManager: NetworkActivationRelayControlling {}
|
||||
extension TorURLSession: NetworkActivationProxyControlling {}
|
||||
|
||||
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
|
||||
/// Policy: permit start when either location permissions are authorized OR
|
||||
/// there exists at least one mutual favorite. Otherwise, do not start.
|
||||
@@ -17,14 +39,55 @@ final class NetworkActivationService: ObservableObject {
|
||||
private var started = false
|
||||
private let torPreferenceKey = "networkActivationService.userTorEnabled"
|
||||
private var torAutoStartDesired: Bool = false
|
||||
private let storage: UserDefaults
|
||||
private let locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
|
||||
private let mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
|
||||
private let permissionProvider: () -> LocationChannelManager.PermissionState
|
||||
private let mutualFavoritesProvider: () -> Set<Data>
|
||||
private let torController: NetworkActivationTorControlling
|
||||
private let relayController: NetworkActivationRelayControlling
|
||||
private let proxyController: NetworkActivationProxyControlling
|
||||
private let notificationCenter: NotificationCenter
|
||||
|
||||
private init() {}
|
||||
private init() {
|
||||
storage = .standard
|
||||
locationPermissionPublisher = LocationChannelManager.shared.$permissionState.eraseToAnyPublisher()
|
||||
mutualFavoritesPublisher = FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher()
|
||||
permissionProvider = { LocationChannelManager.shared.permissionState }
|
||||
mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites }
|
||||
torController = TorManager.shared
|
||||
relayController = NostrRelayManager.shared
|
||||
proxyController = TorURLSession.shared
|
||||
notificationCenter = .default
|
||||
}
|
||||
|
||||
internal init(
|
||||
storage: UserDefaults,
|
||||
locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>,
|
||||
mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>,
|
||||
permissionProvider: @escaping () -> LocationChannelManager.PermissionState,
|
||||
mutualFavoritesProvider: @escaping () -> Set<Data>,
|
||||
torController: NetworkActivationTorControlling,
|
||||
relayController: NetworkActivationRelayControlling,
|
||||
proxyController: NetworkActivationProxyControlling,
|
||||
notificationCenter: NotificationCenter = .default
|
||||
) {
|
||||
self.storage = storage
|
||||
self.locationPermissionPublisher = locationPermissionPublisher
|
||||
self.mutualFavoritesPublisher = mutualFavoritesPublisher
|
||||
self.permissionProvider = permissionProvider
|
||||
self.mutualFavoritesProvider = mutualFavoritesProvider
|
||||
self.torController = torController
|
||||
self.relayController = relayController
|
||||
self.proxyController = proxyController
|
||||
self.notificationCenter = notificationCenter
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard !started else { return }
|
||||
started = true
|
||||
|
||||
if let stored = UserDefaults.standard.object(forKey: torPreferenceKey) as? Bool {
|
||||
if let stored = storage.object(forKey: torPreferenceKey) as? Bool {
|
||||
userTorEnabled = stored
|
||||
} else {
|
||||
userTorEnabled = true
|
||||
@@ -34,16 +97,16 @@ final class NetworkActivationService: ObservableObject {
|
||||
let allowed = basePolicyAllowed()
|
||||
activationAllowed = allowed
|
||||
torAutoStartDesired = allowed && userTorEnabled
|
||||
TorManager.shared.setAutoStartAllowed(torAutoStartDesired)
|
||||
torController.setAutoStartAllowed(torAutoStartDesired)
|
||||
applyTorState(torDesired: torAutoStartDesired)
|
||||
if allowed {
|
||||
NostrRelayManager.shared.connect()
|
||||
relayController.connect()
|
||||
} else {
|
||||
NostrRelayManager.shared.disconnect()
|
||||
relayController.disconnect()
|
||||
}
|
||||
|
||||
// React to location permission changes
|
||||
LocationChannelManager.shared.$permissionState
|
||||
locationPermissionPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.reevaluate()
|
||||
@@ -51,7 +114,7 @@ final class NetworkActivationService: ObservableObject {
|
||||
.store(in: &cancellables)
|
||||
|
||||
// React to mutual favorites changes
|
||||
FavoritesPersistenceService.shared.$mutualFavorites
|
||||
mutualFavoritesPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.reevaluate()
|
||||
@@ -62,8 +125,8 @@ final class NetworkActivationService: ObservableObject {
|
||||
func setUserTorEnabled(_ enabled: Bool) {
|
||||
guard enabled != userTorEnabled else { return }
|
||||
userTorEnabled = enabled
|
||||
UserDefaults.standard.set(enabled, forKey: torPreferenceKey)
|
||||
NotificationCenter.default.post(
|
||||
storage.set(enabled, forKey: torPreferenceKey)
|
||||
notificationCenter.post(
|
||||
name: .TorUserPreferenceChanged,
|
||||
object: nil,
|
||||
userInfo: ["enabled": enabled]
|
||||
@@ -82,33 +145,33 @@ final class NetworkActivationService: ObservableObject {
|
||||
}
|
||||
if statusChanged || torChanged {
|
||||
torAutoStartDesired = torDesired
|
||||
TorManager.shared.setAutoStartAllowed(torDesired)
|
||||
torController.setAutoStartAllowed(torDesired)
|
||||
applyTorState(torDesired: torDesired)
|
||||
}
|
||||
|
||||
if allowed {
|
||||
if torChanged {
|
||||
// Reset relay sockets when switching transport path (Tor ↔︎ direct)
|
||||
NostrRelayManager.shared.disconnect()
|
||||
relayController.disconnect()
|
||||
}
|
||||
NostrRelayManager.shared.connect()
|
||||
relayController.connect()
|
||||
} else if statusChanged {
|
||||
NostrRelayManager.shared.disconnect()
|
||||
relayController.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private func basePolicyAllowed() -> Bool {
|
||||
let permOK = LocationChannelManager.shared.permissionState == .authorized
|
||||
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
||||
let permOK = permissionProvider() == .authorized
|
||||
let hasMutual = !mutualFavoritesProvider().isEmpty
|
||||
return permOK || hasMutual
|
||||
}
|
||||
|
||||
private func applyTorState(torDesired: Bool) {
|
||||
TorURLSession.shared.setProxyMode(useTor: torDesired)
|
||||
proxyController.setProxyMode(useTor: torDesired)
|
||||
if torDesired {
|
||||
TorManager.shared.startIfNeeded()
|
||||
torController.startIfNeeded()
|
||||
} else {
|
||||
TorManager.shared.shutdownCompletely()
|
||||
torController.shutdownCompletely()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,8 @@
|
||||
///
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Noise
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
@@ -616,6 +618,17 @@ final class NoiseEncryptionService {
|
||||
}
|
||||
rateLimiter.resetAll()
|
||||
}
|
||||
|
||||
/// Clear session for a specific peer (e.g., on decryption failure to allow re-handshake)
|
||||
func clearSession(for peerID: PeerID) {
|
||||
sessionManager.removeSession(for: peerID)
|
||||
serviceQueue.sync(flags: .barrier) {
|
||||
if let fingerprint = peerFingerprints.removeValue(forKey: peerID) {
|
||||
fingerprintToPeerID.removeValue(forKey: fingerprint)
|
||||
}
|
||||
}
|
||||
SecureLogger.debug("🔓 Cleared Noise session for \(peerID)", category: .session)
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
|
||||
@@ -1,9 +1,37 @@
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Nostr
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
// Minimal Nostr transport conforming to Transport for offline sending
|
||||
final class NostrTransport: Transport, @unchecked Sendable {
|
||||
struct Dependencies {
|
||||
let notificationCenter: NotificationCenter
|
||||
let loadFavorites: @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship]
|
||||
let favoriteStatusForNoiseKey: @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||
let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||
let currentIdentity: @MainActor () throws -> NostrIdentity?
|
||||
let registerPendingGiftWrap: @MainActor (String) -> Void
|
||||
let sendEvent: @MainActor (NostrEvent) -> Void
|
||||
let scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
|
||||
|
||||
static func live(idBridge: NostrIdentityBridge) -> Dependencies {
|
||||
Dependencies(
|
||||
notificationCenter: .default,
|
||||
loadFavorites: { FavoritesPersistenceService.shared.favorites },
|
||||
favoriteStatusForNoiseKey: { FavoritesPersistenceService.shared.getFavoriteStatus(for: $0) },
|
||||
favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) },
|
||||
currentIdentity: { try idBridge.getCurrentNostrIdentity() },
|
||||
registerPendingGiftWrap: { NostrRelayManager.registerPendingGiftWrap(id: $0) },
|
||||
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
|
||||
scheduleAfter: { delay, action in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Provide BLE short peer ID for BitChat embedding
|
||||
var senderPeerID = PeerID(str: "")
|
||||
|
||||
@@ -17,20 +45,27 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private let idBridge: NostrIdentityBridge
|
||||
private let dependencies: Dependencies
|
||||
private var favoriteStatusObserver: NSObjectProtocol?
|
||||
|
||||
// Reachability Cache (thread-safe)
|
||||
private var reachablePeers: Set<PeerID> = []
|
||||
private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent)
|
||||
|
||||
@MainActor
|
||||
init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) {
|
||||
init(
|
||||
keychain: KeychainManagerProtocol,
|
||||
idBridge: NostrIdentityBridge,
|
||||
dependencies: Dependencies? = nil
|
||||
) {
|
||||
self.keychain = keychain
|
||||
self.idBridge = idBridge
|
||||
self.dependencies = dependencies ?? .live(idBridge: idBridge)
|
||||
|
||||
setupObservers()
|
||||
|
||||
// Synchronously warm the cache to avoid startup race
|
||||
let favorites = FavoritesPersistenceService.shared.favorites
|
||||
let favorites = self.dependencies.loadFavorites()
|
||||
let reachable = favorites.values
|
||||
.filter { $0.peerNostrPublicKey != nil }
|
||||
.map { PeerID(publicKey: $0.peerNoisePublicKey) }
|
||||
@@ -40,8 +75,14 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let favoriteStatusObserver {
|
||||
dependencies.notificationCenter.removeObserver(favoriteStatusObserver)
|
||||
}
|
||||
}
|
||||
|
||||
private func setupObservers() {
|
||||
NotificationCenter.default.addObserver(
|
||||
favoriteStatusObserver = dependencies.notificationCenter.addObserver(
|
||||
forName: .favoriteStatusChanged,
|
||||
object: nil,
|
||||
queue: nil
|
||||
@@ -52,7 +93,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
|
||||
private func refreshReachablePeers() {
|
||||
Task { @MainActor in
|
||||
let favorites = FavoritesPersistenceService.shared.favorites
|
||||
let favorites = dependencies.loadFavorites()
|
||||
let reachable = favorites.values
|
||||
.filter { $0.peerNostrPublicKey != nil }
|
||||
.map { PeerID(publicKey: $0.peerNoisePublicKey) }
|
||||
@@ -118,32 +159,15 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
// Convert recipient npub -> hex (x-only)
|
||||
let recipientHex: String
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||
guard hrp == "npub" else {
|
||||
SecureLogger.error("NostrTransport: recipient key not npub (hrp=\(hrp))", category: .session)
|
||||
return
|
||||
}
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch {
|
||||
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
||||
return
|
||||
}
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||
let recipientHex = npubToHex(recipientNpub),
|
||||
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event for PM", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("NostrTransport: sending PM giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,58 +182,31 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||
let recipientHex = npubToHex(recipientNpub),
|
||||
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
||||
// Convert recipient npub -> hex
|
||||
let recipientHex: String
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||
guard hrp == "npub" else { return }
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch {
|
||||
SecureLogger.error("NostrTransport: failed to decode recipient npub for favorite notification: \(error.localizedDescription)", category: .session)
|
||||
return
|
||||
}
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event for favorite notification", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||
let recipientHex: String
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||
guard hrp == "npub" else { return }
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch {
|
||||
SecureLogger.error("NostrTransport: failed to decode recipient npub for delivery ack: \(error.localizedDescription)", category: .session)
|
||||
return
|
||||
}
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||
let recipientHex = npubToHex(recipientNpub),
|
||||
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))…", category: .session)
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event for DELIVERED ack", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,21 +218,17 @@ extension NostrTransport {
|
||||
// MARK: Geohash ACK helpers
|
||||
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
Task { @MainActor in
|
||||
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||
}
|
||||
}
|
||||
|
||||
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
Task { @MainActor in
|
||||
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,19 +236,12 @@ extension NostrTransport {
|
||||
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
||||
Task { @MainActor in
|
||||
guard !recipientHex.isEmpty else { return }
|
||||
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||
// Build embedded BitChat packet without recipient peer ID
|
||||
SecureLogger.debug("GeoDM: send PM mid=\(messageID.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event for geohash PM", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -263,6 +249,32 @@ extension NostrTransport {
|
||||
// MARK: - Private Helpers
|
||||
|
||||
extension NostrTransport {
|
||||
/// Converts npub bech32 string to hex pubkey
|
||||
@MainActor
|
||||
private func npubToHex(_ npub: String) -> String? {
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(npub)
|
||||
guard hrp == "npub" else { return nil }
|
||||
return data.hexEncodedString()
|
||||
} catch {
|
||||
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates and sends a gift-wrapped private message event
|
||||
@MainActor
|
||||
private func sendWrappedMessage(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) {
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: content, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event", category: .session)
|
||||
return
|
||||
}
|
||||
if registerPending {
|
||||
dependencies.registerPendingGiftWrap(event.id)
|
||||
}
|
||||
dependencies.sendEvent(event)
|
||||
}
|
||||
|
||||
/// Must be called within a barrier on `queue`
|
||||
private func processReadQueueIfNeeded() {
|
||||
guard !isSendingReadAcks else { return }
|
||||
@@ -275,36 +287,21 @@ extension NostrTransport {
|
||||
/// Sends a single read ack item (called after extraction from queue within barrier)
|
||||
private func sendReadAckItem(_ item: QueuedRead) {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
|
||||
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
|
||||
SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||
// Convert recipient npub -> hex
|
||||
let recipientHex: String
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||
guard hrp == "npub" else { scheduleNextReadAck(); return }
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch {
|
||||
SecureLogger.error("NostrTransport: failed to decode recipient npub for read ack: \(error.localizedDescription)", category: .session)
|
||||
scheduleNextReadAck()
|
||||
return
|
||||
}
|
||||
defer { scheduleNextReadAck() }
|
||||
guard let recipientNpub = resolveRecipientNpub(for: item.peerID),
|
||||
let recipientHex = npubToHex(recipientNpub),
|
||||
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing READ ack id=\(item.receipt.originalMessageID.prefix(8))…", category: .session)
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||
scheduleNextReadAck(); return
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event for READ ack", category: .session)
|
||||
scheduleNextReadAck(); return
|
||||
}
|
||||
SecureLogger.debug("NostrTransport: sending READ ack giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
scheduleNextReadAck()
|
||||
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleNextReadAck() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + readAckInterval) { [weak self] in
|
||||
dependencies.scheduleAfter(readAckInterval) { [weak self] in
|
||||
self?.queue.async(flags: .barrier) { [weak self] in
|
||||
self?.isSendingReadAcks = false
|
||||
self?.processReadQueueIfNeeded()
|
||||
@@ -315,12 +312,12 @@ extension NostrTransport {
|
||||
@MainActor
|
||||
private func resolveRecipientNpub(for peerID: PeerID) -> String? {
|
||||
if let noiseKey = Data(hexString: peerID.id),
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
let fav = dependencies.favoriteStatusForNoiseKey(noiseKey),
|
||||
let npub = fav.peerNostrPublicKey {
|
||||
return npub
|
||||
}
|
||||
if peerID.id.count == 16,
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||
let fav = dependencies.favoriteStatusForPeerID(peerID),
|
||||
let npub = fav.peerNostrPublicKey {
|
||||
return npub
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
#if os(iOS)
|
||||
@@ -14,24 +15,103 @@ import UIKit
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
protocol NotificationAuthorizing {
|
||||
func requestAuthorization(
|
||||
options: UNAuthorizationOptions,
|
||||
completionHandler: @escaping (Bool, Error?) -> Void
|
||||
)
|
||||
}
|
||||
|
||||
protocol NotificationRequestDelivering {
|
||||
func add(_ request: UNNotificationRequest)
|
||||
}
|
||||
|
||||
private final class NotificationCenterAuthorizerAdapter: NotificationAuthorizing {
|
||||
private let center: UNUserNotificationCenter
|
||||
|
||||
init(center: UNUserNotificationCenter) {
|
||||
self.center = center
|
||||
}
|
||||
|
||||
func requestAuthorization(
|
||||
options: UNAuthorizationOptions,
|
||||
completionHandler: @escaping (Bool, Error?) -> Void
|
||||
) {
|
||||
center.requestAuthorization(options: options, completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
|
||||
private final class NotificationCenterRequestDelivererAdapter: NotificationRequestDelivering {
|
||||
private let center: UNUserNotificationCenter
|
||||
|
||||
init(center: UNUserNotificationCenter) {
|
||||
self.center = center
|
||||
}
|
||||
|
||||
func add(_ request: UNNotificationRequest) {
|
||||
Task {
|
||||
try? await center.add(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NoopNotificationAuthorizer: NotificationAuthorizing {
|
||||
func requestAuthorization(
|
||||
options: UNAuthorizationOptions,
|
||||
completionHandler: @escaping (Bool, Error?) -> Void
|
||||
) {
|
||||
completionHandler(false, nil)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NoopNotificationRequestDeliverer: NotificationRequestDelivering {
|
||||
func add(_ request: UNNotificationRequest) {}
|
||||
}
|
||||
|
||||
final class NotificationService {
|
||||
static let shared = NotificationService()
|
||||
|
||||
private let isRunningTestsProvider: () -> Bool
|
||||
private let authorizer: NotificationAuthorizing
|
||||
private let requestDeliverer: NotificationRequestDelivering
|
||||
|
||||
/// Returns true if running in test environment (XCTest, Swift Testing, or CI)
|
||||
private var isRunningTests: Bool {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
return NSClassFromString("XCTestCase") != nil ||
|
||||
env["XCTestConfigurationFilePath"] != nil ||
|
||||
env["XCTestBundlePath"] != nil ||
|
||||
env["GITHUB_ACTIONS"] != nil ||
|
||||
env["CI"] != nil
|
||||
isRunningTestsProvider()
|
||||
}
|
||||
|
||||
private init() {}
|
||||
private init() {
|
||||
self.isRunningTestsProvider = {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
return NSClassFromString("XCTestCase") != nil ||
|
||||
env["XCTestConfigurationFilePath"] != nil ||
|
||||
env["XCTestBundlePath"] != nil ||
|
||||
env["GITHUB_ACTIONS"] != nil ||
|
||||
env["CI"] != nil
|
||||
}
|
||||
if isRunningTestsProvider() {
|
||||
self.authorizer = NoopNotificationAuthorizer()
|
||||
self.requestDeliverer = NoopNotificationRequestDeliverer()
|
||||
} else {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
self.authorizer = NotificationCenterAuthorizerAdapter(center: center)
|
||||
self.requestDeliverer = NotificationCenterRequestDelivererAdapter(center: center)
|
||||
}
|
||||
}
|
||||
|
||||
internal init(
|
||||
isRunningTestsProvider: @escaping () -> Bool,
|
||||
authorizer: NotificationAuthorizing,
|
||||
requestDeliverer: NotificationRequestDelivering
|
||||
) {
|
||||
self.isRunningTestsProvider = isRunningTestsProvider
|
||||
self.authorizer = authorizer
|
||||
self.requestDeliverer = requestDeliverer
|
||||
}
|
||||
|
||||
func requestAuthorization() {
|
||||
guard !isRunningTests else { return }
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||
if granted {
|
||||
// Permission granted
|
||||
} else {
|
||||
@@ -64,7 +144,7 @@ final class NotificationService {
|
||||
trigger: nil // Deliver immediately
|
||||
)
|
||||
|
||||
UNUserNotificationCenter.current().add(request)
|
||||
requestDeliverer.add(request)
|
||||
}
|
||||
|
||||
func sendMentionNotification(from sender: String, message: String) {
|
||||
@@ -96,7 +176,8 @@ final class NotificationService {
|
||||
func sendNetworkAvailableNotification(peerCount: Int) {
|
||||
let title = "👥 bitchatters nearby!"
|
||||
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
|
||||
let identifier = "network-available-\(Date().timeIntervalSince1970)"
|
||||
// Fixed identifier so iOS updates the existing notification instead of creating new ones
|
||||
let identifier = "network-available"
|
||||
|
||||
sendLocalNotification(
|
||||
title: title,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ enum TransportConfig {
|
||||
|
||||
// Timers
|
||||
static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes
|
||||
static let networkNotificationCooldownSeconds: TimeInterval = 300 // 5 minutes
|
||||
static let basePublicFlushInterval: TimeInterval = 0.08 // ~12.5 fps batching
|
||||
|
||||
// BLE duty/announce/connect
|
||||
@@ -96,11 +97,12 @@ enum TransportConfig {
|
||||
// Keep scanning fully ON when we saw traffic very recently
|
||||
static let bleRecentTrafficForceScanSeconds: TimeInterval = 10.0
|
||||
static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05
|
||||
static let bleExpectedWritePerFragmentMs: Int = 8
|
||||
static let bleExpectedWriteMaxMs: Int = 2000
|
||||
// Faster fragment pacing; use slightly tighter spacing for directed trains
|
||||
static let bleFragmentSpacingMs: Int = 5
|
||||
static let bleFragmentSpacingDirectedMs: Int = 4
|
||||
static let bleExpectedWritePerFragmentMs: Int = 20
|
||||
static let bleExpectedWriteMaxMs: Int = 5000
|
||||
// Fragment pacing: Conservative spacing to prevent BLE buffer overflow
|
||||
// Aggressive pacing causes packet loss; needs 25-30ms between fragments for reliable delivery
|
||||
static let bleFragmentSpacingMs: Int = 30
|
||||
static let bleFragmentSpacingDirectedMs: Int = 25
|
||||
static let bleAnnounceIntervalSeconds: TimeInterval = 4.0
|
||||
static let bleDutyOnDurationDense: TimeInterval = 3.0
|
||||
static let bleDutyOffDurationDense: TimeInterval = 15.0
|
||||
@@ -211,4 +213,18 @@ enum TransportConfig {
|
||||
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
|
||||
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
|
||||
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
|
||||
|
||||
// Gossip Sync Configuration
|
||||
static let syncSeenCapacity: Int = 1000
|
||||
static let syncGCSMaxBytes: Int = 400
|
||||
static let syncGCSTargetFpr: Double = 0.01
|
||||
static let syncMaxMessageAgeSeconds: TimeInterval = 900
|
||||
static let syncMaintenanceIntervalSeconds: TimeInterval = 30.0
|
||||
static let syncStalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
||||
static let syncStalePeerTimeoutSeconds: TimeInterval = 60.0
|
||||
static let syncFragmentCapacity: Int = 600
|
||||
static let syncFileTransferCapacity: Int = 200
|
||||
static let syncFragmentIntervalSeconds: TimeInterval = 30.0
|
||||
static let syncFileTransferIntervalSeconds: TimeInterval = 60.0
|
||||
static let syncMessageIntervalSeconds: TimeInterval = 15.0
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Nostr
|
||||
import Foundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import Foundation
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
|
||||
// Gossip-based sync manager using on-demand GCS filters
|
||||
final class GossipSyncManager {
|
||||
@@ -6,6 +8,7 @@ final class GossipSyncManager {
|
||||
func sendPacket(_ packet: BitchatPacket)
|
||||
func sendPacket(to peerID: PeerID, packet: BitchatPacket)
|
||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
|
||||
func getConnectedPeers() -> [PeerID]
|
||||
}
|
||||
|
||||
private struct PacketStore {
|
||||
@@ -74,6 +77,7 @@ final class GossipSyncManager {
|
||||
|
||||
private let myPeerID: PeerID
|
||||
private let config: Config
|
||||
private let requestSyncManager: RequestSyncManager
|
||||
weak var delegate: Delegate?
|
||||
|
||||
// Storage: broadcast packets by type, and latest announce per sender
|
||||
@@ -88,9 +92,10 @@ final class GossipSyncManager {
|
||||
private var lastStalePeerCleanup: Date = .distantPast
|
||||
private var syncSchedules: [SyncSchedule] = []
|
||||
|
||||
init(myPeerID: PeerID, config: Config = Config()) {
|
||||
init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager) {
|
||||
self.myPeerID = myPeerID
|
||||
self.config = config
|
||||
self.requestSyncManager = requestSyncManager
|
||||
var schedules: [SyncSchedule] = []
|
||||
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
|
||||
schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
|
||||
@@ -202,6 +207,19 @@ final class GossipSyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
private func sendPeriodicSync(for types: SyncTypeFlags) {
|
||||
// Unicast sync to connected peers to allow RSR attribution
|
||||
if let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty {
|
||||
SecureLogger.debug("Sending periodic sync to \(connectedPeers.count) connected peers", category: .sync)
|
||||
for peerID in connectedPeers {
|
||||
sendRequestSync(to: peerID, types: types)
|
||||
}
|
||||
} else {
|
||||
// Fallback to broadcast (discovery phase)
|
||||
sendRequestSync(for: types)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendRequestSync(for types: SyncTypeFlags) {
|
||||
let payload = buildGcsPayload(for: types)
|
||||
let pkt = BitchatPacket(
|
||||
@@ -218,6 +236,9 @@ final class GossipSyncManager {
|
||||
}
|
||||
|
||||
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) {
|
||||
// Register the request for RSR validation
|
||||
requestSyncManager.registerRequest(to: peerID)
|
||||
|
||||
let payload = buildGcsPayload(for: types)
|
||||
var recipient = Data()
|
||||
var temp = peerID.id
|
||||
@@ -262,6 +283,7 @@ final class GossipSyncManager {
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
@@ -274,6 +296,7 @@ final class GossipSyncManager {
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
@@ -286,6 +309,7 @@ final class GossipSyncManager {
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
@@ -298,6 +322,7 @@ final class GossipSyncManager {
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
@@ -366,11 +391,13 @@ final class GossipSyncManager {
|
||||
private func performPeriodicMaintenance(now: Date = Date()) {
|
||||
cleanupExpiredMessages()
|
||||
cleanupStaleAnnouncementsIfNeeded(now: now)
|
||||
requestSyncManager.cleanup() // Cleanup expired sync requests
|
||||
|
||||
for index in syncSchedules.indices {
|
||||
guard syncSchedules[index].interval > 0 else { continue }
|
||||
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
||||
syncSchedules[index].lastSent = now
|
||||
sendRequestSync(for: syncSchedules[index].types)
|
||||
sendPeriodicSync(for: syncSchedules[index].types)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//
|
||||
// RequestSyncManager.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
|
||||
/// Manages outgoing sync requests and validates incoming responses.
|
||||
///
|
||||
/// Allows attributing RSR (Request-Sync Response) packets to specific peers
|
||||
/// that we have actively requested sync from.
|
||||
final class RequestSyncManager {
|
||||
|
||||
private let queue = DispatchQueue(label: "request.sync.manager", attributes: .concurrent)
|
||||
private var pendingRequests: [PeerID: TimeInterval] = [:]
|
||||
private let responseWindow: TimeInterval
|
||||
private let now: () -> TimeInterval
|
||||
|
||||
init(
|
||||
responseWindow: TimeInterval = 30.0,
|
||||
now: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 }
|
||||
) {
|
||||
self.responseWindow = responseWindow
|
||||
self.now = now
|
||||
}
|
||||
|
||||
/// Register that we are sending a sync request to a peer.
|
||||
/// - Parameter peerID: The peer we are requesting sync from
|
||||
func registerRequest(to peerID: PeerID) {
|
||||
let now = self.now()
|
||||
queue.async(flags: .barrier) {
|
||||
SecureLogger.debug("Registering sync request to \(peerID.id.prefix(8))…", category: .sync)
|
||||
self.pendingRequests[peerID] = now
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a packet from a peer is a valid response to a sync request.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - peerID: The sender of the packet
|
||||
/// - isRSR: Whether the packet is marked as a Request-Sync Response
|
||||
/// - Returns: true if we have a pending request for this peer and the window is open
|
||||
func isValidResponse(from peerID: PeerID, isRSR: Bool) -> Bool {
|
||||
guard isRSR else { return false }
|
||||
|
||||
return queue.sync {
|
||||
guard let requestTime = pendingRequests[peerID] else {
|
||||
SecureLogger.warning("Received unsolicited RSR packet from \(peerID.id.prefix(8))…", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
let now = self.now()
|
||||
if now - requestTime > responseWindow {
|
||||
SecureLogger.warning("Received RSR packet from \(peerID.id.prefix(8))… outside of response window", category: .security)
|
||||
// We don't remove here because we might receive multiple packets for one request
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Periodic cleanup of expired requests
|
||||
func cleanup() {
|
||||
let now = self.now()
|
||||
queue.async(flags: .barrier) {
|
||||
let originalCount = self.pendingRequests.count
|
||||
self.pendingRequests = self.pendingRequests.filter { _, timestamp in
|
||||
now - timestamp <= self.responseWindow
|
||||
}
|
||||
let removed = originalCount - self.pendingRequests.count
|
||||
if removed > 0 {
|
||||
SecureLogger.debug("Cleaned up \(removed) expired sync requests", category: .sync)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var debugPendingRequestCount: Int {
|
||||
queue.sync { pendingRequests.count }
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
//
|
||||
// Data+SHA256.swift
|
||||
// bitchat
|
||||
//
|
||||
// Created by Islam on 26/09/2025.
|
||||
//
|
||||
|
||||
import struct Foundation.Data
|
||||
import struct CryptoKit.SHA256
|
||||
|
||||
extension Data {
|
||||
/// Returns the hex representation of SHA256 hash
|
||||
func sha256Fingerprint() -> String {
|
||||
// Implementation matches existing fingerprint generation in NoiseEncryptionService
|
||||
sha256Hash().hexEncodedString()
|
||||
}
|
||||
|
||||
/// Returns the SHA256 hash wrapped in Data
|
||||
func sha256Hash() -> Data {
|
||||
Data(SHA256.hash(data: self))
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,7 @@ struct InputValidator {
|
||||
/// Rejects strings containing control characters to prevent potential security issues
|
||||
/// and UI rendering problems. This strict approach ensures data integrity at input time.
|
||||
static func validateUserString(_ string: String, maxLength: Int) -> String? {
|
||||
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
guard trimmed.count <= maxLength else { return nil }
|
||||
guard let trimmed = string.trimmedOrNilIfEmpty, trimmed.count <= maxLength else { return nil }
|
||||
|
||||
// Reject control characters outright instead of rewriting the string.
|
||||
// This prevents injection attacks and ensures consistent UI rendering.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
|
||||
/// Resolves a stable display name for peers, adding a short suffix when collisions exist.
|
||||
struct PeerDisplayNameResolver {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// SystemSettings.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
enum SystemSettings {
|
||||
case bluetooth
|
||||
case location
|
||||
case microphone
|
||||
|
||||
#if os(macOS)
|
||||
private static let baseURL = "x-apple.systempreferences:com.apple.preference.security"
|
||||
|
||||
private var macPrivacyAnchor: String {
|
||||
switch self {
|
||||
case .bluetooth: "Privacy_Bluetooth"
|
||||
case .location: "Privacy_LocationServices"
|
||||
case .microphone: "Privacy_Microphone"
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
func open() {
|
||||
#if os(iOS)
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
let urlString = "\(Self.baseURL)?\(macPrivacyAnchor)"
|
||||
if let url = URL(string: urlString) {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,8 @@
|
||||
///
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Nostr
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Combine
|
||||
@@ -144,10 +146,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects
|
||||
@Published var nickname: String = "" {
|
||||
didSet {
|
||||
// Trim whitespace whenever nickname is set
|
||||
let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
// Trim whitespace whenever nickname is set; whitespace-only becomes ""
|
||||
let trimmed = nickname.trimmedOrNilIfEmpty ?? ""
|
||||
if trimmed != nickname {
|
||||
nickname = trimmed
|
||||
return
|
||||
}
|
||||
// Update mesh service nickname if it's initialized
|
||||
if !meshService.myPeerID.isEmpty {
|
||||
@@ -279,8 +282,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname
|
||||
// Show Tor status once per app launch
|
||||
var torStatusAnnounced = false
|
||||
private var torProgressCancellable: AnyCancellable?
|
||||
private var lastTorProgressAnnounced = -1
|
||||
// Track whether a Tor restart is pending so we only announce
|
||||
// "tor restarted" after an actual restart, not the first launch.
|
||||
var torRestartPending: Bool = false
|
||||
@@ -323,8 +324,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
)
|
||||
// Channel activity tracking for background nudges
|
||||
var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time
|
||||
private var lastPublicActivityNotifyAt: [String: Date] = [:]
|
||||
private let channelInactivityThreshold: TimeInterval = TransportConfig.uiChannelInactivityThresholdSeconds
|
||||
// Geohash participant tracker
|
||||
let participantTracker = GeohashParticipantTracker(activityCutoff: -TransportConfig.uiRecentCutoffFiveMinutesSeconds)
|
||||
// Participants who indicated they teleported (by tag in their events)
|
||||
@@ -448,7 +447,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
self.deduplicationService = MessageDeduplicationService()
|
||||
|
||||
// Wire up dependencies
|
||||
self.commandProcessor.chatViewModel = self
|
||||
self.commandProcessor.contextProvider = self
|
||||
self.participantTracker.configure(context: self)
|
||||
|
||||
// Subscribe to privateChatManager changes to trigger UI updates
|
||||
@@ -503,8 +502,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
addGeohashOnlySystemMessage(
|
||||
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
|
||||
)
|
||||
// Suppress incremental Tor progress messages
|
||||
torProgressCancellable = nil
|
||||
} else if !TorManager.shared.torEnforced && !torStatusAnnounced {
|
||||
torStatusAnnounced = true
|
||||
addGeohashOnlySystemMessage(
|
||||
@@ -631,8 +628,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
object: nil
|
||||
)
|
||||
|
||||
// Listen for delivery acknowledgments
|
||||
|
||||
// When app becomes active, send read receipts for visible messages
|
||||
#if os(macOS)
|
||||
NotificationCenter.default.addObserver(
|
||||
@@ -755,8 +750,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
|
||||
private func loadNickname() {
|
||||
if let savedNickname = userDefaults.string(forKey: nicknameKey) {
|
||||
// Trim whitespace when loading
|
||||
nickname = savedNickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
nickname = savedNickname.trimmed
|
||||
} else {
|
||||
nickname = "anon\(Int.random(in: 1000...9999))"
|
||||
saveNickname()
|
||||
@@ -772,20 +766,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
}
|
||||
|
||||
func validateAndSaveNickname() {
|
||||
// Trim whitespace from nickname
|
||||
let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// Check if nickname is empty after trimming
|
||||
if trimmed.isEmpty {
|
||||
nickname = "anon\(Int.random(in: 1000...9999))"
|
||||
} else {
|
||||
nickname = trimmed
|
||||
}
|
||||
nickname = nickname.trimmedOrNilIfEmpty ?? "anon\(Int.random(in: 1000...9999))"
|
||||
saveNickname()
|
||||
}
|
||||
|
||||
// MARK: - Favorites Management
|
||||
|
||||
|
||||
// MARK: - Blocked Users Management (Delegated to PeerStateManager)
|
||||
|
||||
|
||||
@@ -1006,8 +990,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
@MainActor
|
||||
func sendMessage(_ content: String) {
|
||||
// Ignore messages that are empty or whitespace-only to prevent blank lines
|
||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
|
||||
|
||||
// Check for commands
|
||||
if content.hasPrefix("/") {
|
||||
@@ -1468,56 +1451,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
selectedPrivateChatFingerprint = nil
|
||||
}
|
||||
|
||||
// MARK: - Nostr Message Handling
|
||||
|
||||
@MainActor
|
||||
@objc private func handleNostrMessage(_ notification: Notification) {
|
||||
guard let message = notification.userInfo?["message"] as? BitchatMessage else { return }
|
||||
|
||||
// Store the Nostr pubkey if provided (for messages from unknown senders)
|
||||
if let nostrPubkey = notification.userInfo?["nostrPubkey"] as? String,
|
||||
let senderPeerID = message.senderPeerID {
|
||||
// Store mapping for read receipts
|
||||
nostrKeyMapping[senderPeerID] = nostrPubkey
|
||||
}
|
||||
|
||||
// Process the Nostr message through the same flow as Bluetooth messages
|
||||
didReceiveMessage(message)
|
||||
}
|
||||
|
||||
@objc private func handleDeliveryAcknowledgment(_ notification: Notification) {
|
||||
guard let messageId = notification.userInfo?["messageId"] as? String else { return }
|
||||
|
||||
|
||||
|
||||
// Update the delivery status for the message
|
||||
if let index = messages.firstIndex(where: { $0.id == messageId }) {
|
||||
// Update delivery status to delivered
|
||||
messages[index].deliveryStatus = DeliveryStatus.delivered(to: "nostr", at: Date())
|
||||
|
||||
// Schedule UI update for delivery status
|
||||
// UI will update automatically
|
||||
}
|
||||
|
||||
// Also update in private chats if it's a private message
|
||||
for (peerID, chatMessages) in privateChats {
|
||||
if let index = chatMessages.firstIndex(where: { $0.id == messageId }) {
|
||||
privateChats[peerID]?[index].deliveryStatus = DeliveryStatus.delivered(to: "nostr", at: Date())
|
||||
// UI will update automatically
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleNostrReadReceipt(_ notification: Notification) {
|
||||
guard let receipt = notification.userInfo?["receipt"] as? ReadReceipt else { return }
|
||||
|
||||
SecureLogger.info("📖 Handling read receipt for message \(receipt.originalMessageID) from Nostr", category: .session)
|
||||
|
||||
// Process the read receipt through the same flow as Bluetooth read receipts
|
||||
didReceiveReadReceipt(receipt)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@objc private func handlePeerStatusUpdate(_ notification: Notification) {
|
||||
// Update private chat peer if needed when peer status changes
|
||||
@@ -1868,7 +1801,15 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func getMessages(for peerID: PeerID?) -> [BitchatMessage] {
|
||||
if let peerID {
|
||||
return getPrivateChatMessages(for: peerID)
|
||||
} else {
|
||||
return messages
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] {
|
||||
var combined: [BitchatMessage] = []
|
||||
@@ -2027,7 +1968,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds
|
||||
|
||||
// Reinitialize Nostr relay manager with new identity
|
||||
nostrRelayManager = NostrRelayManager()
|
||||
nostrRelayManager = NostrRelayManager(dependencies: .live())
|
||||
setupNostrMessageHandling()
|
||||
nostrRelayManager?.connect()
|
||||
}
|
||||
@@ -3060,7 +3001,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
Task { @MainActor in
|
||||
// Early validation
|
||||
guard !isMessageBlocked(message) else { return }
|
||||
guard !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || message.isPrivate else { return }
|
||||
guard !message.content.trimmed.isEmpty || message.isPrivate else { return }
|
||||
|
||||
// Route to appropriate handler
|
||||
if message.isPrivate {
|
||||
@@ -3233,7 +3174,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||
Task { @MainActor in
|
||||
let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalized = content.trimmed
|
||||
let publicMentions = parseMentions(from: normalized)
|
||||
let msg = BitchatMessage(
|
||||
id: messageID,
|
||||
@@ -3406,18 +3347,25 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
self.scheduleNetworkEmptyTimer()
|
||||
} else {
|
||||
self.invalidateNetworkEmptyTimer()
|
||||
// Trim out peers we no longer observe before comparing for new arrivals
|
||||
self.recentlySeenPeers.formIntersection(meshPeerSet)
|
||||
// Don't trim recentlySeenPeers here - let timers handle cleanup.
|
||||
// Trimming immediately causes peers to be treated as "new" when they
|
||||
// briefly drop and reconnect, triggering notification floods.
|
||||
let newPeers = meshPeerSet.subtracting(self.recentlySeenPeers)
|
||||
|
||||
|
||||
if !newPeers.isEmpty {
|
||||
self.lastNetworkNotificationTime = Date()
|
||||
self.recentlySeenPeers.formUnion(newPeers)
|
||||
NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count)
|
||||
SecureLogger.info(
|
||||
"👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))",
|
||||
category: .session
|
||||
)
|
||||
// Rate limit: max one notification per 5 minutes
|
||||
let cooldown = TransportConfig.networkNotificationCooldownSeconds
|
||||
if Date().timeIntervalSince(self.lastNetworkNotificationTime) >= cooldown {
|
||||
// Only mark peers as seen when we actually notify about them
|
||||
// This ensures peers arriving during cooldown will be included in the next notification
|
||||
self.recentlySeenPeers.formUnion(newPeers)
|
||||
self.lastNetworkNotificationTime = Date()
|
||||
NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count)
|
||||
SecureLogger.info(
|
||||
"👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
self.scheduleNetworkResetTimer()
|
||||
}
|
||||
}
|
||||
@@ -3834,10 +3782,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
// Removed background nudge notification for generic "new chats!"
|
||||
|
||||
// Append via batching buffer (skip empty content) with simple dedup by ID
|
||||
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
if !messages.contains(where: { $0.id == finalMessage.id }) {
|
||||
publicMessagePipeline.enqueue(finalMessage)
|
||||
}
|
||||
if !finalMessage.content.trimmed.isEmpty, !messages.contains(where: { $0.id == finalMessage.id }) {
|
||||
publicMessagePipeline.enqueue(finalMessage)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
// Geohash and Nostr logic for ChatViewModel
|
||||
//
|
||||
|
||||
import Nostr
|
||||
import Foundation
|
||||
import Combine
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import SwiftUI
|
||||
import Tor
|
||||
|
||||
@@ -56,6 +58,7 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
func subscribeNostrEvent(_ event: NostrEvent) {
|
||||
guard event.isValidSignature() else { return }
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
||||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
|
||||
!deduplicationService.hasProcessedNostrEvent(event.id)
|
||||
@@ -76,7 +79,7 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
||||
let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let nick = nickTag[1].trimmed
|
||||
geoNicknames[event.pubkey.lowercased()] = nick
|
||||
}
|
||||
|
||||
@@ -114,8 +117,8 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
let senderName = displayNameForNostrPubkey(event.pubkey)
|
||||
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let content = event.content.trimmed
|
||||
|
||||
// Clamp future timestamps to now to avoid future-dated messages skewing order
|
||||
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
let timestamp = min(rawTs, Date())
|
||||
@@ -146,13 +149,12 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
guard !deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return }
|
||||
deduplicationService.recordNostrEvent(giftWrap.id)
|
||||
|
||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id),
|
||||
content.hasPrefix("bitchat1:"),
|
||||
let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
|
||||
let packet = BitchatPacket.from(packetData),
|
||||
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
||||
packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
let noisePayload = NoisePayload.decode(packet.payload)
|
||||
else {
|
||||
@@ -192,7 +194,7 @@ extension ChatViewModel {
|
||||
case .mesh:
|
||||
refreshVisibleMessages(from: .mesh)
|
||||
// Debug: log if any empty messages are present
|
||||
let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
|
||||
let emptyMesh = messages.filter { $0.content.trimmed.isEmpty }.count
|
||||
if emptyMesh > 0 {
|
||||
SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session)
|
||||
}
|
||||
@@ -254,6 +256,7 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
func handleNostrEvent(_ event: NostrEvent) {
|
||||
guard event.isValidSignature() else { return }
|
||||
// Only handle ephemeral kind 20000 or presence kind 20001 with matching tag
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
||||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
|
||||
@@ -307,8 +310,7 @@ extension ChatViewModel {
|
||||
|
||||
// Cache nickname from tag if present
|
||||
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
||||
let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
geoNicknames[event.pubkey.lowercased()] = nick
|
||||
geoNicknames[event.pubkey.lowercased()] = nickTag[1].trimmed
|
||||
}
|
||||
|
||||
// Store mapping for geohash DM initiation
|
||||
@@ -324,8 +326,10 @@ extension ChatViewModel {
|
||||
let content = event.content
|
||||
|
||||
// If this is a teleport presence event (no content), don't add to timeline
|
||||
if let teleTag = event.tags.first(where: { $0.first == "t" }), teleTag.count >= 2, (teleTag[1] == "teleport"),
|
||||
content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
if let teleTag = event.tags.first(where: { $0.first == "t" }),
|
||||
teleTag.count >= 2,
|
||||
teleTag[1] == "teleport",
|
||||
content.trimmed.isEmpty {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -367,6 +371,7 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
if deduplicationService.hasProcessedNostrEvent(giftWrap.id) {
|
||||
return
|
||||
}
|
||||
@@ -380,9 +385,7 @@ extension ChatViewModel {
|
||||
|
||||
SecureLogger.debug("GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", category: .session)
|
||||
|
||||
guard content.hasPrefix("bitchat1:"),
|
||||
let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
|
||||
let packet = BitchatPacket.from(packetData),
|
||||
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
||||
packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
let payload = NoisePayload.decode(packet.payload)
|
||||
else {
|
||||
@@ -485,6 +488,7 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
|
||||
guard event.isValidSignature() else { return }
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
||||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
|
||||
|
||||
@@ -495,9 +499,8 @@ extension ChatViewModel {
|
||||
participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh)
|
||||
|
||||
// Notify only on rising-edge: previously zero people, now someone sends a chat
|
||||
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !content.isEmpty else { return }
|
||||
|
||||
guard let content = event.content.trimmedOrNilIfEmpty else { return }
|
||||
|
||||
// Respect geohash blocks
|
||||
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
|
||||
|
||||
@@ -602,6 +605,7 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
|
||||
do {
|
||||
@@ -619,8 +623,7 @@ extension ChatViewModel {
|
||||
|
||||
// Check if it's a BitChat packet embedded in the content (bitchat1:...)
|
||||
if content.hasPrefix("bitchat1:") {
|
||||
guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
|
||||
let packet = BitchatPacket.from(packetData) else {
|
||||
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content) else {
|
||||
SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session)
|
||||
return
|
||||
}
|
||||
@@ -631,24 +634,23 @@ extension ChatViewModel {
|
||||
// Stable target ID if we know Noise key; otherwise temporary Nostr-based peer
|
||||
let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey)
|
||||
|
||||
if packet.type == MessageType.noiseEncrypted.rawValue {
|
||||
if let payload = NoisePayload.decode(packet.payload) {
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
||||
// Store Nostr mapping
|
||||
await MainActor.run {
|
||||
nostrKeyMapping[targetPeerID] = senderPubkey
|
||||
|
||||
// Handle packet types
|
||||
switch payload.type {
|
||||
case .privateMessage:
|
||||
handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: targetPeerID, id: currentIdentity, messageTimestamp: messageTimestamp)
|
||||
case .delivered:
|
||||
handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .readReceipt:
|
||||
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
break
|
||||
}
|
||||
if packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
let payload = NoisePayload.decode(packet.payload) {
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
||||
// Store Nostr mapping
|
||||
await MainActor.run {
|
||||
nostrKeyMapping[targetPeerID] = senderPubkey
|
||||
|
||||
// Handle packet types
|
||||
switch payload.type {
|
||||
case .privateMessage:
|
||||
handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: targetPeerID, id: currentIdentity, messageTimestamp: messageTimestamp)
|
||||
case .delivered:
|
||||
handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .readReceipt:
|
||||
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -801,6 +803,19 @@ extension ChatViewModel {
|
||||
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
}
|
||||
|
||||
private static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
|
||||
guard content.hasPrefix("bitchat1:") else { return nil }
|
||||
let encoded = String(content.dropFirst("bitchat1:".count))
|
||||
let maxBytes = FileTransferLimits.maxFramedFileBytes
|
||||
// Base64url length upper bound for maxBytes (padded length; unpadded is <= this).
|
||||
let maxEncoded = ((maxBytes + 2) / 3) * 4
|
||||
guard encoded.count <= maxEncoded else { return nil }
|
||||
guard let packetData = Self.base64URLDecode(encoded),
|
||||
packetData.count <= maxBytes
|
||||
else { return nil }
|
||||
return BitchatPacket.from(packetData)
|
||||
}
|
||||
|
||||
// MARK: - Geohash Nickname Resolution (for /block in geohash)
|
||||
|
||||
func nostrPubkeyForDisplayName(_ name: String) -> String? {
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
// Private chat and media transfer logic for ChatViewModel
|
||||
//
|
||||
|
||||
import Nostr
|
||||
import Foundation
|
||||
import Combine
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import SwiftUI
|
||||
|
||||
extension ChatViewModel {
|
||||
@@ -319,7 +321,7 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
let targetPeer = selectedPrivateChatPeer
|
||||
let message = enqueueMediaMessage(content: "[voice] \(url.lastPathComponent)", targetPeer: targetPeer)
|
||||
let message = enqueueMediaMessage(content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)", targetPeer: targetPeer)
|
||||
let messageID = message.id
|
||||
let transferId = makeTransferID(messageID: messageID)
|
||||
|
||||
@@ -364,6 +366,36 @@ extension ChatViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
func processThenSendImage(_ image: UIImage?) {
|
||||
guard let image else { return }
|
||||
Task.detached {
|
||||
do {
|
||||
let processedURL = try ImageUtils.processImage(image)
|
||||
await MainActor.run {
|
||||
self.sendImage(from: processedURL)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
func processThenSendImage(from url: URL?) {
|
||||
guard let url else { return }
|
||||
Task.detached {
|
||||
do {
|
||||
let processedURL = try ImageUtils.processImage(at: url)
|
||||
await MainActor.run {
|
||||
self.sendImage(from: processedURL)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) {
|
||||
guard canSendMediaInCurrentContext else {
|
||||
@@ -398,7 +430,7 @@ extension ChatViewModel {
|
||||
)
|
||||
guard packet.encode() != nil else { throw MediaSendError.encodingFailed }
|
||||
await MainActor.run {
|
||||
let message = self.enqueueMediaMessage(content: "[image] \(outputURL.lastPathComponent)", targetPeer: targetPeer)
|
||||
let message = self.enqueueMediaMessage(content: "\(MimeType.Category.image.messagePrefix)\(outputURL.lastPathComponent)", targetPeer: targetPeer)
|
||||
let messageID = message.id
|
||||
let transferId = self.makeTransferID(messageID: messageID)
|
||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||
@@ -517,20 +549,19 @@ extension ChatViewModel {
|
||||
|
||||
func cleanupLocalFile(forMessage message: BitchatMessage) {
|
||||
// Check both outgoing and incoming directories for thorough cleanup
|
||||
let prefixes = ["[voice] ", "[image] ", "[file] "]
|
||||
let subdirs = ["voicenotes/outgoing", "voicenotes/incoming",
|
||||
"images/outgoing", "images/incoming",
|
||||
"files/outgoing", "files/incoming"]
|
||||
|
||||
guard let prefix = prefixes.first(where: { message.content.hasPrefix($0) }) else { return }
|
||||
let rawFilename = String(message.content.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !rawFilename.isEmpty, let base = try? applicationFilesDirectory() else { return }
|
||||
|
||||
// Security: Extract only the last path component to prevent directory traversal
|
||||
let safeFilename = (rawFilename as NSString).lastPathComponent
|
||||
guard !safeFilename.isEmpty && safeFilename != "." && safeFilename != ".." else { return }
|
||||
let categories: [MimeType.Category] = [.audio, .image, .file]
|
||||
guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }),
|
||||
let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty,
|
||||
let base = try? applicationFilesDirectory(),
|
||||
// Security: Extract only the last path component to prevent directory traversal
|
||||
let safeFilename = (rawFilename as NSString).lastPathComponent.nilIfEmpty,
|
||||
safeFilename != "." && safeFilename != ".."
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
// Try all possible locations (outgoing and incoming)
|
||||
let subdirs = categories.flatMap { ["\($0.mediaDir)/outgoing", "\($0.mediaDir)/incoming"] }
|
||||
for subdir in subdirs {
|
||||
let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename)
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
//
|
||||
// VoiceRecordingViewModel.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class VoiceRecordingViewModel: ObservableObject {
|
||||
enum State: Equatable {
|
||||
case idle
|
||||
case requestingPermission
|
||||
case permissionDenied
|
||||
case preparing
|
||||
case recording(startDate: Date)
|
||||
case error(message: String)
|
||||
|
||||
var isActive: Bool {
|
||||
switch self {
|
||||
case .preparing, .recording: true
|
||||
case .idle, .requestingPermission, .permissionDenied, .error: false
|
||||
}
|
||||
}
|
||||
|
||||
var alertMessage: String {
|
||||
switch self {
|
||||
case .error(let message): message
|
||||
case .permissionDenied: "Microphone access is required to record voice notes."
|
||||
case .idle, .requestingPermission, .preparing, .recording: ""
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func duration(for date: Date) -> TimeInterval {
|
||||
switch self {
|
||||
case .idle, .requestingPermission, .preparing, .permissionDenied, .error: 0
|
||||
case .recording(let startDate): date.timeIntervalSince(startDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var showAlert: Bool {
|
||||
get {
|
||||
switch state {
|
||||
case .permissionDenied, .error: true
|
||||
case .idle, .requestingPermission, .preparing, .recording: false
|
||||
}
|
||||
}
|
||||
set {
|
||||
if !newValue { state = .idle }
|
||||
}
|
||||
}
|
||||
|
||||
@Published private(set) var state = State.idle
|
||||
|
||||
func formattedDuration(for date: Date) -> String {
|
||||
let clamped = max(0, state.duration(for: date))
|
||||
let totalMilliseconds = Int(clamped * 1000)
|
||||
let minutes = totalMilliseconds / 60_000
|
||||
let seconds = (totalMilliseconds % 60_000) / 1_000
|
||||
let centiseconds = (totalMilliseconds % 1_000) / 10
|
||||
return String(format: "%02d:%02d.%02d", minutes, seconds, centiseconds)
|
||||
}
|
||||
|
||||
func start(shouldShow: Bool) {
|
||||
guard shouldShow, state == .idle else { return }
|
||||
state = .requestingPermission
|
||||
Task {
|
||||
let granted = await VoiceRecorder.shared.requestPermission()
|
||||
guard state == .requestingPermission else { return }
|
||||
guard granted else {
|
||||
state = .permissionDenied
|
||||
return
|
||||
}
|
||||
state = .preparing
|
||||
do {
|
||||
try await VoiceRecorder.shared.startRecording()
|
||||
guard state == .preparing else {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
state = .recording(startDate: Date())
|
||||
} catch {
|
||||
SecureLogger.error("Voice recording failed to start: \(error)", category: .session)
|
||||
await VoiceRecorder.shared.cancelRecording()
|
||||
guard state == .preparing else { return }
|
||||
state = .error(message: "Could not start recording.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func finish(completion: ((URL) -> Void)?) {
|
||||
let previousState = state
|
||||
|
||||
switch previousState {
|
||||
case .permissionDenied, .error:
|
||||
return
|
||||
case .idle, .requestingPermission, .preparing, .recording:
|
||||
break
|
||||
}
|
||||
|
||||
state = .idle
|
||||
|
||||
guard case .recording(let startDate) = previousState, let completion else {
|
||||
Task { await VoiceRecorder.shared.cancelRecording() }
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
let finalDuration = Date().timeIntervalSince(startDate)
|
||||
if let url = await VoiceRecorder.shared.stopRecording(),
|
||||
isValidRecording(at: url, duration: finalDuration) {
|
||||
completion(url)
|
||||
} else {
|
||||
guard state == .idle else { return }
|
||||
state = .error(
|
||||
message: finalDuration < VoiceRecorder.minRecordingDuration
|
||||
? "Recording is too short."
|
||||
: "Recording failed to save."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
finish(completion: nil)
|
||||
}
|
||||
|
||||
private func isValidRecording(at url: URL, duration: TimeInterval) -> Bool {
|
||||
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||
let fileSize = attributes[.size] as? NSNumber,
|
||||
fileSize.intValue > 0,
|
||||
duration >= VoiceRecorder.minRecordingDuration {
|
||||
return true
|
||||
}
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Islam on 29/10/2025.
|
||||
//
|
||||
|
||||
import Nostr
|
||||
import SwiftUI
|
||||
|
||||
struct CommandSuggestionsView: View {
|
||||
@@ -76,7 +77,7 @@ struct CommandSuggestionsView: View {
|
||||
let keychain = KeychainManager()
|
||||
let viewModel = ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: NostrIdentityBridge(),
|
||||
idBridge: NostrIdentityBridge(keychain: keychain),
|
||||
identityManager: SecureIdentityStateManager(keychain)
|
||||
)
|
||||
|
||||
|
||||
@@ -15,10 +15,22 @@ struct PaymentChipView: View {
|
||||
enum PaymentType {
|
||||
case cashu(String)
|
||||
case lightning(String)
|
||||
|
||||
|
||||
private static let cashuAllowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
||||
|
||||
private static func cashuURL(from link: String) -> URL? {
|
||||
if let url = URL(string: link), url.scheme != nil {
|
||||
return url
|
||||
}
|
||||
let enc = link.addingPercentEncoding(withAllowedCharacters: cashuAllowedCharacters) ?? link
|
||||
return URL(string: "cashu:\(enc)")
|
||||
}
|
||||
|
||||
var url: URL? {
|
||||
switch self {
|
||||
case .cashu(let link), .lightning(let link):
|
||||
case .cashu(let link):
|
||||
return Self.cashuURL(from: link)
|
||||
case .lightning(let link):
|
||||
return URL(string: link)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Nostr
|
||||
import SwiftUI
|
||||
|
||||
struct TextMessageView: View {
|
||||
@@ -13,7 +14,7 @@ struct TextMessageView: View {
|
||||
@EnvironmentObject private var viewModel: ChatViewModel
|
||||
|
||||
let message: BitchatMessage
|
||||
@Binding var expandedMessageIDs: Set<String>
|
||||
@State private var expandedMessageIDs: Set<String> = []
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
@@ -66,14 +67,12 @@ struct TextMessageView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 14, iOS 17, *)
|
||||
#Preview {
|
||||
@Previewable @State var ids: Set<String> = []
|
||||
let keychain = PreviewKeychainManager()
|
||||
|
||||
Group {
|
||||
List {
|
||||
TextMessageView(message: .preview, expandedMessageIDs: $ids)
|
||||
TextMessageView(message: .preview)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(EmptyView())
|
||||
@@ -81,7 +80,7 @@ struct TextMessageView: View {
|
||||
.environment(\.colorScheme, .light)
|
||||
|
||||
List {
|
||||
TextMessageView(message: .preview, expandedMessageIDs: $ids)
|
||||
TextMessageView(message: .preview)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(EmptyView())
|
||||
@@ -91,7 +90,7 @@ struct TextMessageView: View {
|
||||
.environmentObject(
|
||||
ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: NostrIdentityBridge(),
|
||||
idBridge: NostrIdentityBridge(keychain: keychain),
|
||||
identityManager: SecureIdentityStateManager(keychain)
|
||||
)
|
||||
)
|
||||
|
||||
+99
-1076
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import BitFoundation
|
||||
|
||||
struct FingerprintView: View {
|
||||
@ObservedObject var viewModel: ChatViewModel
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//
|
||||
// ImagePickerView.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Camera or Photo Library
|
||||
struct ImagePickerView: UIViewControllerRepresentable {
|
||||
let sourceType: UIImagePickerController.SourceType
|
||||
let completion: (UIImage?) -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> UIImagePickerController {
|
||||
let picker = UIImagePickerController()
|
||||
picker.sourceType = sourceType
|
||||
picker.delegate = context.coordinator
|
||||
picker.allowsEditing = false
|
||||
|
||||
// Use standard full screen - iOS handles safe areas automatically
|
||||
picker.modalPresentationStyle = .fullScreen
|
||||
|
||||
// Force dark mode to make safe area bars black instead of white
|
||||
picker.overrideUserInterfaceStyle = .dark
|
||||
|
||||
return picker
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(completion: completion)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||
let completion: (UIImage?) -> Void
|
||||
|
||||
init(completion: @escaping (UIImage?) -> Void) {
|
||||
self.completion = completion
|
||||
}
|
||||
|
||||
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
||||
let image = info[.originalImage] as? UIImage
|
||||
completion(image)
|
||||
}
|
||||
|
||||
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||
completion(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17, *)
|
||||
#Preview {
|
||||
@Previewable @State var isPresented = true
|
||||
@Previewable @State var selectedImage: UIImage?
|
||||
VStack {
|
||||
if let selectedImage {
|
||||
Image(uiImage: selectedImage)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
} else {
|
||||
Text("No image selected")
|
||||
}
|
||||
Button("Show") { isPresented = true }
|
||||
}
|
||||
.sheet(isPresented: $isPresented) {
|
||||
ImagePickerView(sourceType: .photoLibrary) { image in
|
||||
selectedImage = image
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,147 @@
|
||||
//
|
||||
// ImagePreviewView.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
#if os(macOS)
|
||||
import BitLogger
|
||||
#endif
|
||||
|
||||
struct ImagePreviewView: View {
|
||||
let url: URL
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
#if os(iOS)
|
||||
@State private var showExporter = false
|
||||
@State private var platformImage: UIImage?
|
||||
#else
|
||||
@State private var platformImage: NSImage?
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
VStack {
|
||||
Spacer()
|
||||
if let image = platformImage {
|
||||
#if os(iOS)
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.padding()
|
||||
#else
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.padding()
|
||||
#endif
|
||||
} else {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.tint(.white)
|
||||
}
|
||||
Spacer()
|
||||
HStack {
|
||||
Button(action: { dismiss() }) {
|
||||
Text("close", comment: "Button to dismiss fullscreen media viewer")
|
||||
.font(.bitchatSystem(size: 15, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(RoundedRectangle(cornerRadius: 12).stroke(Color.white.opacity(0.5), lineWidth: 1))
|
||||
}
|
||||
Spacer()
|
||||
Button(action: saveCopy) {
|
||||
Text("save", comment: "Button to save media to device")
|
||||
.font(.bitchatSystem(size: 15, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(RoundedRectangle(cornerRadius: 12).fill(Color.blue.opacity(0.6)))
|
||||
}
|
||||
}
|
||||
.padding([.horizontal, .bottom], 24)
|
||||
}
|
||||
}
|
||||
.onAppear(perform: loadImage)
|
||||
#if os(iOS)
|
||||
.sheet(isPresented: $showExporter) {
|
||||
FileExportWrapper(url: url)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func loadImage() {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
#if os(iOS)
|
||||
guard let image = UIImage(contentsOfFile: url.path) else { return }
|
||||
#else
|
||||
guard let image = NSImage(contentsOf: url) else { return }
|
||||
#endif
|
||||
DispatchQueue.main.async {
|
||||
self.platformImage = image
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveCopy() {
|
||||
#if os(iOS)
|
||||
showExporter = true
|
||||
#else
|
||||
Task { @MainActor in
|
||||
let panel = NSSavePanel()
|
||||
panel.canCreateDirectories = true
|
||||
panel.nameFieldStringValue = url.lastPathComponent
|
||||
panel.prompt = "save"
|
||||
if panel.runModal() == .OK, let destination = panel.url {
|
||||
do {
|
||||
if FileManager.default.fileExists(atPath: destination.path) {
|
||||
try FileManager.default.removeItem(at: destination)
|
||||
}
|
||||
try FileManager.default.copyItem(at: url, to: destination)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to save image preview copy: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private struct FileExportWrapper: UIViewControllerRepresentable {
|
||||
let url: URL
|
||||
|
||||
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
||||
let controller = UIDocumentPickerViewController(forExporting: [url])
|
||||
controller.shouldShowFileExtensions = true
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("dummy.jpg")
|
||||
if !FileManager.default.fileExists(atPath: tempURL.path(percentEncoded: false)) {
|
||||
#if os(iOS)
|
||||
let image = UIImage(named: "dummy")
|
||||
let data = image?.jpegData(compressionQuality: 0.8)
|
||||
let _ = try? data?.write(to: tempURL)
|
||||
#elseif os(macOS)
|
||||
let image = NSImage(named: "dummy")
|
||||
var rect = NSRect(origin: .zero, size: image?.size ?? .zero)
|
||||
if let cgImage = image?.cgImage(forProposedRect: &rect, context: nil, hints: nil) {
|
||||
let rep = NSBitmapImageRep(cgImage: cgImage)
|
||||
let jpegData = rep.representation(using: .jpeg, properties: [.compressionFactor: 0.8])
|
||||
let _ = try? jpegData?.write(to: tempURL)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
ImagePreviewView(url: tempURL)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//
|
||||
// MacImagePickerView.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MacImagePickerView: View {
|
||||
let completion: (URL?) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Text("Choose an image")
|
||||
.font(.headline)
|
||||
|
||||
Button("Select Image") {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.canChooseDirectories = false
|
||||
panel.canChooseFiles = true
|
||||
panel.allowedContentTypes = [.image, .png, .jpeg, .heic]
|
||||
panel.message = "Choose an image to send"
|
||||
|
||||
if panel.runModal() == .OK {
|
||||
completion(panel.url)
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button("Cancel") {
|
||||
completion(nil)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(40)
|
||||
.frame(minWidth: 300, minHeight: 150)
|
||||
}
|
||||
}
|
||||
|
||||
@available(OSX 14, *)
|
||||
#Preview {
|
||||
@Previewable @State var isPresented = true
|
||||
@Previewable @State var selectedImage: NSImage?
|
||||
|
||||
VStack {
|
||||
if let selectedImage {
|
||||
Image(nsImage: selectedImage)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
} else {
|
||||
Text("No image selected")
|
||||
}
|
||||
Button("Show") { isPresented = true }
|
||||
}
|
||||
.sheet(isPresented: $isPresented) {
|
||||
MacImagePickerView { url in
|
||||
selectedImage = url.map(NSImage.init)
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -125,7 +125,7 @@ struct LocationChannelsSheet: View {
|
||||
Text(Strings.permissionDenied)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
Button(Strings.openSettings) { openSystemLocationSettings() }
|
||||
Button(Strings.openSettings, action: SystemSettings.location.open)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
case LocationChannelManager.PermissionState.authorized:
|
||||
@@ -246,9 +246,7 @@ struct LocationChannelsSheet: View {
|
||||
sectionDivider
|
||||
torToggleSection
|
||||
.padding(.top, 12)
|
||||
Button(action: {
|
||||
openSystemLocationSettings()
|
||||
}) {
|
||||
Button(action: SystemSettings.location.open) {
|
||||
Text(Strings.removeAccess)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
|
||||
@@ -304,7 +302,7 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
}
|
||||
let normalized = customGeohash
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.trimmed
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: "#", with: "")
|
||||
let isValid = validateGeohash(normalized)
|
||||
@@ -451,7 +449,7 @@ struct LocationChannelsSheet: View {
|
||||
// Split a title like "#mesh [3 people]" into base and suffix "[3 people]"
|
||||
private func splitTitleAndCount(_ s: String) -> (base: String, countSuffix: String?) {
|
||||
guard let idx = s.lastIndex(of: "[") else { return (s, nil) }
|
||||
let prefix = String(s[..<idx]).trimmingCharacters(in: .whitespaces)
|
||||
let prefix = String(s[..<idx]).trimmed
|
||||
let suffix = String(s[idx...])
|
||||
return (prefix, suffix)
|
||||
}
|
||||
@@ -622,18 +620,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -12,11 +12,15 @@ struct LocationNotesView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var draft: String = ""
|
||||
|
||||
init(geohash: String, onNotesCountChanged: ((Int) -> Void)? = nil) {
|
||||
init(
|
||||
geohash: String,
|
||||
onNotesCountChanged: ((Int) -> Void)? = nil,
|
||||
manager: LocationNotesManager? = nil
|
||||
) {
|
||||
let gh = geohash.lowercased()
|
||||
self.geohash = gh
|
||||
self.onNotesCountChanged = onNotesCountChanged
|
||||
_manager = StateObject(wrappedValue: LocationNotesManager(geohash: gh))
|
||||
_manager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh))
|
||||
}
|
||||
|
||||
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
|
||||
@@ -260,14 +264,13 @@ struct LocationNotesView: View {
|
||||
}
|
||||
|
||||
private func send() {
|
||||
let content = draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !content.isEmpty else { return }
|
||||
guard let content = draft.trimmedOrNilIfEmpty else { return }
|
||||
manager.send(content: content, nickname: viewModel.nickname)
|
||||
draft = ""
|
||||
}
|
||||
|
||||
private var sendButtonEnabled: Bool {
|
||||
!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && manager.state != .noRelays
|
||||
!draft.trimmed.isEmpty && manager.state != .noRelays
|
||||
}
|
||||
|
||||
// MARK: - Timestamp Formatting
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//
|
||||
// MediaMessageView.swift
|
||||
// bitchat
|
||||
//
|
||||
// Created by Islam on 30/03/2026.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MediaMessageView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
let message: BitchatMessage
|
||||
let media: BitchatMessage.Media
|
||||
|
||||
@Binding var imagePreviewURL: URL?
|
||||
|
||||
var body: some View {
|
||||
let state = mediaSendState(for: message)
|
||||
let isFromMe = message.sender == viewModel.nickname || message.senderPeerID == viewModel.meshService.myPeerID
|
||||
let cancelAction: (() -> Void)? = state.canCancel ? { viewModel.cancelMediaSend(messageID: message.id) } : nil
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
Text(viewModel.formatMessageHeader(message, colorScheme: colorScheme))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if message.isPrivate && message.sender == viewModel.nickname,
|
||||
let status = message.deliveryStatus {
|
||||
DeliveryStatusView(status: status)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
}
|
||||
|
||||
Group {
|
||||
switch media {
|
||||
case .voice(let url):
|
||||
VoiceNoteView(
|
||||
url: url,
|
||||
isSending: state.isSending,
|
||||
sendProgress: state.progress,
|
||||
onCancel: cancelAction
|
||||
)
|
||||
case .image(let url):
|
||||
BlockRevealImageView(
|
||||
url: url,
|
||||
revealProgress: state.progress,
|
||||
isSending: state.isSending,
|
||||
onCancel: cancelAction,
|
||||
initiallyBlurred: !isFromMe,
|
||||
onOpen: {
|
||||
if !state.isSending {
|
||||
imagePreviewURL = url
|
||||
}
|
||||
},
|
||||
onDelete: !isFromMe ? { viewModel.deleteMediaMessage(messageID: message.id) } : nil
|
||||
)
|
||||
.frame(maxWidth: 280)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func mediaSendState(for message: BitchatMessage) -> (isSending: Bool, progress: Double?, canCancel: Bool) {
|
||||
var isSending = false
|
||||
var progress: Double?
|
||||
if let status = message.deliveryStatus {
|
||||
switch status {
|
||||
case .sending:
|
||||
isSending = true
|
||||
progress = 0
|
||||
case .partiallyDelivered(let reached, let total):
|
||||
if total > 0 {
|
||||
isSending = true
|
||||
progress = Double(reached) / Double(total)
|
||||
}
|
||||
case .sent, .read, .delivered, .failed:
|
||||
break
|
||||
}
|
||||
}
|
||||
let canCancel = isSending && message.sender == viewModel.nickname
|
||||
let clamped = progress.map { max(0, min(1, $0)) }
|
||||
return (isSending, isSending ? clamped : nil, canCancel)
|
||||
}
|
||||
}
|
||||
@@ -34,24 +34,10 @@ struct VoiceNoteView: View {
|
||||
colorScheme == .dark ? Color.green.opacity(0.3) : Color.green.opacity(0.2)
|
||||
}
|
||||
|
||||
private var durationText: String {
|
||||
let duration = playback.duration
|
||||
guard duration.isFinite, duration > 0 else { return "--:--" }
|
||||
let minutes = Int(duration) / 60
|
||||
let seconds = Int(duration) % 60
|
||||
return String(format: "%02d:%02d", minutes, seconds)
|
||||
}
|
||||
|
||||
private var currentText: String {
|
||||
let current = playback.currentTime
|
||||
guard current.isFinite, current > 0 else { return "00:00" }
|
||||
let minutes = Int(current) / 60
|
||||
let seconds = Int(current) % 60
|
||||
return String(format: "%02d:%02d", minutes, seconds)
|
||||
}
|
||||
|
||||
private var playbackLabel: String {
|
||||
playback.isPlaying ? currentText + "/" + durationText : durationText
|
||||
guard playback.duration.isFinite else { return "--:--" }
|
||||
let seconds = playback.isPlaying ? playback.remainingSeconds : playback.roundedDuration
|
||||
return String(format: "%02d:%02d", seconds / 60, seconds % 60)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import BitFoundation
|
||||
|
||||
struct MeshPeerList: View {
|
||||
@ObservedObject var viewModel: ChatViewModel
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
//
|
||||
// MessageListView.swift
|
||||
// bitchat
|
||||
//
|
||||
// Created by Islam on 30/03/2026.
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import SwiftUI
|
||||
|
||||
private struct MessageDisplayItem: Identifiable {
|
||||
let id: String
|
||||
let message: BitchatMessage
|
||||
}
|
||||
|
||||
struct MessageListView: View {
|
||||
@EnvironmentObject private var viewModel: ChatViewModel
|
||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let privatePeer: PeerID?
|
||||
@Binding var isAtBottom: Bool
|
||||
@Binding var messageText: String
|
||||
@Binding var selectedMessageSender: String?
|
||||
@Binding var selectedMessageSenderID: PeerID?
|
||||
@Binding var imagePreviewURL: URL?
|
||||
@Binding var windowCountPublic: Int
|
||||
@Binding var windowCountPrivate: [PeerID: Int]
|
||||
@Binding var showSidebar: Bool
|
||||
|
||||
var isTextFieldFocused: FocusState<Bool>.Binding
|
||||
|
||||
@State private var showMessageActions = false
|
||||
@State private var lastScrollTime: Date = .distantPast
|
||||
@State private var scrollThrottleTimer: Timer?
|
||||
|
||||
var body: some View {
|
||||
let currentWindowCount: Int = {
|
||||
if let peer = privatePeer {
|
||||
return windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate
|
||||
}
|
||||
return windowCountPublic
|
||||
}()
|
||||
|
||||
let messages = viewModel.getMessages(for: privatePeer)
|
||||
let windowedMessages = Array(messages.suffix(currentWindowCount))
|
||||
|
||||
let contextKey: String = {
|
||||
if let peer = privatePeer {
|
||||
"dm:\(peer)"
|
||||
} else {
|
||||
locationManager.selectedChannel.contextKey
|
||||
}
|
||||
}()
|
||||
|
||||
let messageItems: [MessageDisplayItem] = windowedMessages.compactMap { message in
|
||||
guard !message.content.trimmed.isEmpty else { return nil }
|
||||
return MessageDisplayItem(id: "\(contextKey)|\(message.id)", message: message)
|
||||
}
|
||||
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(messageItems) { item in
|
||||
let message = item.message
|
||||
messageRow(for: message)
|
||||
.onAppear {
|
||||
if message.id == windowedMessages.last?.id {
|
||||
isAtBottom = true
|
||||
}
|
||||
if message.id == windowedMessages.first?.id,
|
||||
messages.count > windowedMessages.count {
|
||||
expandWindow(
|
||||
ifNeededFor: message,
|
||||
allMessages: messages,
|
||||
privatePeer: privatePeer,
|
||||
proxy: proxy
|
||||
)
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
if message.id == windowedMessages.last?.id {
|
||||
isAtBottom = false
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if message.sender != "system" {
|
||||
messageText = "@\(message.sender) "
|
||||
isTextFieldFocused.wrappedValue = true
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
Button("content.message.copy") {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string = message.content
|
||||
#else
|
||||
let pb = NSPasteboard.general
|
||||
pb.clearContents()
|
||||
pb.setString(message.content, forType: .string)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 1)
|
||||
}
|
||||
}
|
||||
.transaction { tx in if viewModel.isBatchingPublic { tx.disablesAnimations = true } }
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.onOpenURL(perform: handleOpenURL)
|
||||
.onTapGesture(count: 3) {
|
||||
viewModel.sendMessage("/clear")
|
||||
}
|
||||
.onAppear {
|
||||
scrollToBottom(on: proxy)
|
||||
}
|
||||
.onChange(of: privatePeer) { _ in
|
||||
scrollToBottom(on: proxy)
|
||||
}
|
||||
.onChange(of: viewModel.messages.count) { _ in
|
||||
onMessagesChange(proxy: proxy)
|
||||
}
|
||||
.onChange(of: viewModel.privateChats) { _ in
|
||||
onPrivateChatsChange(proxy: proxy)
|
||||
}
|
||||
.onChange(of: locationManager.selectedChannel) { newChannel in
|
||||
onSelectedChannelChange(newChannel, proxy: proxy)
|
||||
}
|
||||
.confirmationDialog(
|
||||
selectedMessageSender.map { "@\($0)" } ?? String(localized: "content.actions.title", comment: "Fallback title for the message action sheet"),
|
||||
isPresented: $showMessageActions,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("content.actions.mention") {
|
||||
if let sender = selectedMessageSender {
|
||||
// Pre-fill the input with an @mention and focus the field
|
||||
messageText = "@\(sender) "
|
||||
isTextFieldFocused.wrappedValue = true
|
||||
}
|
||||
}
|
||||
|
||||
Button("content.actions.direct_message") {
|
||||
if let peerID = selectedMessageSenderID {
|
||||
if peerID.isGeoChat {
|
||||
if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
|
||||
viewModel.startGeohashDM(withPubkeyHex: full)
|
||||
}
|
||||
} else {
|
||||
viewModel.startPrivateChat(with: peerID)
|
||||
}
|
||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||
showSidebar = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button("content.actions.hug") {
|
||||
if let sender = selectedMessageSender {
|
||||
viewModel.sendMessage("/hug @\(sender)")
|
||||
}
|
||||
}
|
||||
|
||||
Button("content.actions.slap") {
|
||||
if let sender = selectedMessageSender {
|
||||
viewModel.sendMessage("/slap @\(sender)")
|
||||
}
|
||||
}
|
||||
|
||||
Button("content.actions.block", role: .destructive) {
|
||||
// Prefer direct geohash block when we have a Nostr sender ID
|
||||
if let peerID = selectedMessageSenderID, peerID.isGeoChat,
|
||||
let full = viewModel.fullNostrHex(forSenderPeerID: peerID),
|
||||
let sender = selectedMessageSender {
|
||||
viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender)
|
||||
} else if let sender = selectedMessageSender {
|
||||
viewModel.sendMessage("/block \(sender)")
|
||||
}
|
||||
}
|
||||
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
}
|
||||
.onAppear {
|
||||
// Also check when view appears
|
||||
if let peerID = privatePeer {
|
||||
// Try multiple times to ensure read receipts are sent
|
||||
viewModel.markPrivateMessagesAsRead(from: peerID)
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) {
|
||||
viewModel.markPrivateMessagesAsRead(from: peerID)
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryLongSeconds) {
|
||||
viewModel.markPrivateMessagesAsRead(from: peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
scrollThrottleTimer?.invalidate()
|
||||
}
|
||||
}
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
// Intercept custom cashu: links created in attributed text
|
||||
if let scheme = url.scheme?.lowercased(), scheme == "cashu" || scheme == "lightning" {
|
||||
#if os(iOS)
|
||||
UIApplication.shared.open(url)
|
||||
return .handled
|
||||
#else
|
||||
// On non-iOS platforms, let the system handle or ignore
|
||||
return .systemAction
|
||||
#endif
|
||||
}
|
||||
return .systemAction
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private extension MessageListView {
|
||||
@ViewBuilder
|
||||
func messageRow(for message: BitchatMessage) -> some View {
|
||||
Group {
|
||||
if message.sender == "system" {
|
||||
systemMessageRow(message)
|
||||
} else if let media = message.mediaAttachment(for: viewModel.nickname) {
|
||||
MediaMessageView(message: message, media: media, imagePreviewURL: $imagePreviewURL)
|
||||
} else {
|
||||
TextMessageView(message: message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func systemMessageRow(_ message: BitchatMessage) -> some View {
|
||||
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
func expandWindow(ifNeededFor message: BitchatMessage,
|
||||
allMessages: [BitchatMessage],
|
||||
privatePeer: PeerID?,
|
||||
proxy: ScrollViewProxy) {
|
||||
let step = TransportConfig.uiWindowStepCount
|
||||
let contextKey: String = {
|
||||
if let peer = privatePeer {
|
||||
"dm:\(peer)"
|
||||
} else {
|
||||
locationManager.selectedChannel.contextKey
|
||||
}
|
||||
}()
|
||||
let preserveID = "\(contextKey)|\(message.id)"
|
||||
|
||||
if let peer = privatePeer {
|
||||
let current = windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate
|
||||
let newCount = min(allMessages.count, current + step)
|
||||
guard newCount != current else { return }
|
||||
windowCountPrivate[peer] = newCount
|
||||
DispatchQueue.main.async {
|
||||
proxy.scrollTo(preserveID, anchor: .top)
|
||||
}
|
||||
} else {
|
||||
let current = windowCountPublic
|
||||
let newCount = min(allMessages.count, current + step)
|
||||
guard newCount != current else { return }
|
||||
windowCountPublic = newCount
|
||||
DispatchQueue.main.async {
|
||||
proxy.scrollTo(preserveID, anchor: .top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleOpenURL(_ url: URL) {
|
||||
guard url.scheme == "bitchat" else { return }
|
||||
switch url.host {
|
||||
case "user":
|
||||
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
let peerID = PeerID(str: id.removingPercentEncoding ?? id)
|
||||
selectedMessageSenderID = peerID
|
||||
|
||||
if peerID.isGeoDM || peerID.isGeoChat {
|
||||
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
|
||||
} else if let name = viewModel.meshService.peerNickname(peerID: peerID) {
|
||||
selectedMessageSender = name
|
||||
} else {
|
||||
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
|
||||
}
|
||||
|
||||
if viewModel.isSelfSender(peerID: peerID, displayName: selectedMessageSender) {
|
||||
selectedMessageSender = nil
|
||||
selectedMessageSenderID = nil
|
||||
} else {
|
||||
showMessageActions = true
|
||||
}
|
||||
|
||||
case "geohash":
|
||||
let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return }
|
||||
|
||||
func levelForLength(_ len: Int) -> GeohashChannelLevel {
|
||||
switch len {
|
||||
case 0...2: return .region
|
||||
case 3...4: return .province
|
||||
case 5: return .city
|
||||
case 6: return .neighborhood
|
||||
case 7: return .block
|
||||
default: return .block
|
||||
}
|
||||
}
|
||||
|
||||
let level = levelForLength(gh.count)
|
||||
let channel = GeohashChannel(level: level, geohash: gh)
|
||||
|
||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == gh }
|
||||
if !inRegional && !LocationChannelManager.shared.availableChannels.isEmpty {
|
||||
LocationChannelManager.shared.markTeleported(for: gh, true)
|
||||
}
|
||||
LocationChannelManager.shared.select(ChannelID.location(channel))
|
||||
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func scrollToBottom(on proxy: ScrollViewProxy) {
|
||||
isAtBottom = true
|
||||
if let targetPeerID {
|
||||
proxy.scrollTo(targetPeerID, anchor: .bottom)
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
if let secondTarget = self.targetPeerID {
|
||||
proxy.scrollTo(secondTarget, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var targetPeerID: String? {
|
||||
if let peer = privatePeer,
|
||||
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
||||
return "dm:\(peer)|\(last)"
|
||||
}
|
||||
if let last = viewModel.messages.suffix(300).last?.id {
|
||||
return "\(locationManager.selectedChannel.contextKey)|\(last)"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func onMessagesChange(proxy: ScrollViewProxy) {
|
||||
guard privatePeer == nil, let lastMsg = viewModel.messages.last else { return }
|
||||
|
||||
// If the newest message is from me, always scroll to bottom
|
||||
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
|
||||
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
|
||||
return
|
||||
} else { // Ensure we consider ourselves at bottom for subsequent messages
|
||||
isAtBottom = true
|
||||
}
|
||||
|
||||
func scrollIfNeeded(date: Date) {
|
||||
lastScrollTime = date
|
||||
let contextKey = locationManager.selectedChannel.contextKey
|
||||
if let target = viewModel.messages.suffix(windowCountPublic).last.map({ "\(contextKey)|\($0.id)" }) {
|
||||
proxy.scrollTo(target, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
// Throttle scroll animations to prevent excessive UI updates
|
||||
let now = Date()
|
||||
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
|
||||
// Immediate scroll if enough time has passed
|
||||
scrollIfNeeded(date: now)
|
||||
} else {
|
||||
// Schedule a delayed scroll
|
||||
scrollThrottleTimer?.invalidate()
|
||||
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
|
||||
Task { @MainActor in
|
||||
scrollIfNeeded(date: Date())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func onPrivateChatsChange(proxy: ScrollViewProxy) {
|
||||
guard let peerID = privatePeer, let messages = viewModel.privateChats[peerID], let lastMsg = messages.last else {
|
||||
return
|
||||
}
|
||||
|
||||
// If the newest private message is from me, always scroll
|
||||
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
|
||||
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
|
||||
return
|
||||
} else {
|
||||
isAtBottom = true
|
||||
}
|
||||
|
||||
func scrollIfNeeded(date: Date) {
|
||||
lastScrollTime = date
|
||||
let contextKey = "dm:\(peerID)"
|
||||
let count = windowCountPrivate[peerID] ?? 300
|
||||
if let target = messages.suffix(count).last.map({ "\(contextKey)|\($0.id)" }){
|
||||
proxy.scrollTo(target, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
// Same throttling for private chats
|
||||
let now = Date()
|
||||
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
|
||||
scrollIfNeeded(date: now)
|
||||
} else {
|
||||
scrollThrottleTimer?.invalidate()
|
||||
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
|
||||
Task { @MainActor in
|
||||
scrollIfNeeded(date: Date())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func onSelectedChannelChange(_ channel: ChannelID, proxy: ScrollViewProxy) {
|
||||
// When switching to a new geohash channel, scroll to the bottom
|
||||
guard privatePeer == nil else { return }
|
||||
switch channel {
|
||||
case .mesh:
|
||||
break
|
||||
case .location(let ch):
|
||||
// Reset window size
|
||||
isAtBottom = true
|
||||
windowCountPublic = TransportConfig.uiWindowInitialCountPublic
|
||||
let contextKey = "geo:\(ch.geohash)"
|
||||
if let target = viewModel.messages.suffix(windowCountPublic).last?.id.map({ "\(contextKey)|\($0)" }) {
|
||||
proxy.scrollTo(target, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ChannelID {
|
||||
var contextKey: String {
|
||||
switch self {
|
||||
case .mesh: "mesh"
|
||||
case .location(let ch): "geo:\(ch.geohash)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//#Preview {
|
||||
// MessageListView()
|
||||
//}
|
||||
@@ -5,21 +5,6 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
private enum RegexCache {
|
||||
static let cashu: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
||||
}()
|
||||
static let lightningScheme: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
|
||||
}()
|
||||
static let bolt11: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
|
||||
}()
|
||||
static let lnurl: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
|
||||
}()
|
||||
}
|
||||
|
||||
extension String {
|
||||
// Detect if there is an extremely long token (no whitespace/newlines) that could break layout
|
||||
func hasVeryLongToken(threshold: Int) -> Bool {
|
||||
@@ -38,7 +23,7 @@ extension String {
|
||||
|
||||
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
|
||||
func extractCashuLinks(max: Int = 3) -> [String] {
|
||||
let regex = RegexCache.cashu
|
||||
let regex = MessageFormattingEngine.Patterns.cashu
|
||||
let ns = self as NSString
|
||||
let range = NSRange(location: 0, length: ns.length)
|
||||
var found: [String] = []
|
||||
@@ -59,19 +44,19 @@ extension String {
|
||||
let ns = self as NSString
|
||||
let full = NSRange(location: 0, length: ns.length)
|
||||
// lightning: scheme
|
||||
for m in RegexCache.lightningScheme.matches(in: self, range: full) {
|
||||
for m in MessageFormattingEngine.Patterns.lightningScheme.matches(in: self, range: full) {
|
||||
let s = ns.substring(with: m.range(at: 0))
|
||||
results.append(s)
|
||||
if results.count >= max { return results }
|
||||
}
|
||||
// BOLT11
|
||||
for m in RegexCache.bolt11.matches(in: self, range: full) {
|
||||
for m in MessageFormattingEngine.Patterns.bolt11.matches(in: self, range: full) {
|
||||
let s = ns.substring(with: m.range(at: 0))
|
||||
results.append("lightning:\(s)")
|
||||
if results.count >= max { return results }
|
||||
}
|
||||
// LNURL bech32
|
||||
for m in RegexCache.lnurl.matches(in: self, range: full) {
|
||||
for m in MessageFormattingEngine.Patterns.lnurl.matches(in: self, range: full) {
|
||||
let s = ns.substring(with: m.range(at: 0))
|
||||
results.append("lightning:\(s)")
|
||||
if results.count >= max { return results }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Nostr
|
||||
import SwiftUI
|
||||
import CoreImage
|
||||
import CoreImage.CIFilterBuiltins
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "dummy.jpg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -6,7 +6,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.chat.bitchat</string>
|
||||
<string>$(APP_GROUP_ID)</string>
|
||||
</array>
|
||||
<key>com.apple.security.device.bluetooth</key>
|
||||
<true/>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.chat.bitchat</string>
|
||||
<string>$(APP_GROUP_ID)</string>
|
||||
</array>
|
||||
<key>com.apple.security.device.bluetooth</key>
|
||||
<true/>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>AppGroupID</key>
|
||||
<string>$(APP_GROUP_ID)</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
|
||||
@@ -13,7 +13,7 @@ import UniformTypeIdentifiers
|
||||
/// Avoids deprecated Social framework and SLComposeServiceViewController.
|
||||
final class ShareViewController: UIViewController {
|
||||
// Bundle.main.bundleIdentifier would get the extension's bundleID
|
||||
private static let groupID = "group.chat.bitchat"
|
||||
private static let groupID = Bundle.main.object(forInfoDictionaryKey: "AppGroupID") as? String ?? "group.chat.bitchat"
|
||||
|
||||
private enum Strings {
|
||||
static let nothingToShare = String(localized: "share.status.nothing_to_share", comment: "Shown when the share extension receives no content")
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.chat.bitchat</string>
|
||||
<string>$(APP_GROUP_ID)</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,155 @@
|
||||
//
|
||||
// BLEServiceCoreTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Focused BLEService tests for packet handling behavior.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Nostr
|
||||
import Foundation
|
||||
import CoreBluetooth
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEServiceCoreTests {
|
||||
|
||||
@Test
|
||||
func duplicatePacket_isDeduped() async {
|
||||
let ble = makeService()
|
||||
let delegate = PublicCaptureDelegate()
|
||||
ble.delegate = delegate
|
||||
|
||||
let sender = PeerID(str: "1122334455667788")
|
||||
let timestamp = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
let packet = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp)
|
||||
|
||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
||||
let receivedFirst = await TestHelpers.waitUntil(
|
||||
{ delegate.publicMessagesSnapshot().count == 1 },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(receivedFirst)
|
||||
|
||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
||||
let receivedDuplicate = await TestHelpers.waitUntil(
|
||||
{ delegate.publicMessagesSnapshot().count > 1 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!receivedDuplicate)
|
||||
|
||||
let messages = delegate.publicMessagesSnapshot()
|
||||
#expect(messages.count == 1)
|
||||
#expect(messages.first?.content == "Hello")
|
||||
}
|
||||
|
||||
@Test
|
||||
func staleBroadcast_isIgnored() async {
|
||||
let ble = makeService()
|
||||
let delegate = PublicCaptureDelegate()
|
||||
ble.delegate = delegate
|
||||
|
||||
let sender = PeerID(str: "A1B2C3D4E5F60708")
|
||||
let oldTimestamp = UInt64(Date().addingTimeInterval(-901).timeIntervalSince1970 * 1000)
|
||||
let packet = makePublicPacket(content: "Old", sender: sender, timestamp: oldTimestamp)
|
||||
|
||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
||||
|
||||
let didReceive = await TestHelpers.waitUntil({ !delegate.publicMessagesSnapshot().isEmpty }, timeout: 0.3)
|
||||
#expect(!didReceive)
|
||||
#expect(delegate.publicMessagesSnapshot().isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func announceSenderMismatch_isRejected() async throws {
|
||||
let ble = makeService()
|
||||
|
||||
let signer = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let announcement = AnnouncementPacket(
|
||||
nickname: "Spoof",
|
||||
noisePublicKey: signer.getStaticPublicKeyData(),
|
||||
signingPublicKey: signer.getSigningPublicKeyData(),
|
||||
directNeighbors: nil
|
||||
)
|
||||
let payload = try #require(announcement.encode(), "Failed to encode announcement")
|
||||
|
||||
let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey)
|
||||
let wrongFirst = derivedPeerID.bare.first == "0" ? "1" : "0"
|
||||
let wrongBare = String(wrongFirst) + String(derivedPeerID.bare.dropFirst())
|
||||
let wrongPeerID = PeerID(str: wrongBare)
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.announce.rawValue,
|
||||
senderID: Data(hexString: wrongPeerID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
let signed = try #require(signer.signPacket(packet), "Failed to sign announce packet")
|
||||
|
||||
ble._test_handlePacket(signed, fromPeerID: wrongPeerID, preseedPeer: false)
|
||||
|
||||
_ = await TestHelpers.waitUntil({ !ble.currentPeerSnapshots().isEmpty }, timeout: 0.3)
|
||||
#expect(ble.currentPeerSnapshots().isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeService() -> BLEService {
|
||||
let keychain = MockKeychain()
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
return BLEService(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
initializeBluetoothManagers: false
|
||||
)
|
||||
}
|
||||
|
||||
private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: Data(hexString: sender.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: timestamp,
|
||||
payload: Data(content.utf8),
|
||||
signature: nil,
|
||||
ttl: 3
|
||||
)
|
||||
}
|
||||
|
||||
private final class PublicCaptureDelegate: BitchatDelegate {
|
||||
private let lock = NSLock()
|
||||
private(set) var publicMessages: [BitchatMessage] = []
|
||||
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: nickname,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: peerID,
|
||||
mentions: nil
|
||||
)
|
||||
lock.lock()
|
||||
publicMessages.append(message)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func didReceiveMessage(_ message: BitchatMessage) {}
|
||||
func didConnectToPeer(_ peerID: PeerID) {}
|
||||
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
||||
func didUpdatePeerList(_ peers: [PeerID]) {}
|
||||
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||
|
||||
func publicMessagesSnapshot() -> [BitchatMessage] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return publicMessages
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import Testing
|
||||
import CoreBluetooth
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEServiceTests {
|
||||
@@ -73,7 +74,7 @@ struct BLEServiceTests {
|
||||
service.sendMessage("Hello, world!")
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
try await sleep(1.0)
|
||||
}
|
||||
#expect(service.sentMessages.count == 1)
|
||||
}
|
||||
@@ -97,7 +98,7 @@ struct BLEServiceTests {
|
||||
)
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
try await sleep(1.0)
|
||||
}
|
||||
#expect(service.sentMessages.count == 1)
|
||||
}
|
||||
@@ -113,7 +114,7 @@ struct BLEServiceTests {
|
||||
service.sendMessage("@alice @bob check this out", mentions: ["alice", "bob"])
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
try await sleep(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +147,7 @@ struct BLEServiceTests {
|
||||
service.simulateIncomingMessage(incomingMessage)
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
try await sleep(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +190,7 @@ struct BLEServiceTests {
|
||||
service.simulateIncomingPacket(packet)
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
try await sleep(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,7 +232,7 @@ struct BLEServiceTests {
|
||||
service.sendMessage("Test delivery")
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
try await sleep(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,7 +274,7 @@ struct BLEServiceTests {
|
||||
service.simulateIncomingPacket(packet)
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
try await sleep(1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("BitchatPeer Tests")
|
||||
struct BitchatPeerTests {
|
||||
typealias FavoriteRelationship = FavoritesPersistenceService.FavoriteRelationship
|
||||
|
||||
@Test("Connection state prioritizes bluetooth, mesh, nostr, then offline")
|
||||
func connectionStatePriorityIsCorrect() {
|
||||
let peerID = PeerID(str: "0123456789abcdef")
|
||||
let noiseKey = Data((0..<32).map(UInt8.init))
|
||||
let mutual = makeRelationship(isFavorite: true, theyFavoritedUs: true)
|
||||
|
||||
let bluetooth = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: true, isReachable: true)
|
||||
let mesh = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: true)
|
||||
var nostr = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: false)
|
||||
nostr.favoriteStatus = mutual
|
||||
let offline = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: false)
|
||||
|
||||
#expect(bluetooth.connectionState == .bluetoothConnected)
|
||||
#expect(mesh.connectionState == .meshReachable)
|
||||
#expect(nostr.connectionState == .nostrAvailable)
|
||||
#expect(offline.connectionState == .offline)
|
||||
}
|
||||
|
||||
@Test("Display name falls back to peer prefix and offline icon reflects inbound favorite")
|
||||
func displayNameAndOfflineIconUseDerivedState() {
|
||||
let peerID = PeerID(str: "fedcba9876543210")
|
||||
let noiseKey = Data((32..<64).map(UInt8.init))
|
||||
var peer = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "", isConnected: false, isReachable: false)
|
||||
peer.favoriteStatus = makeRelationship(isFavorite: false, theyFavoritedUs: true)
|
||||
|
||||
#expect(peer.displayName == String(peerID.id.prefix(8)))
|
||||
#expect(peer.statusIcon == "🌙")
|
||||
}
|
||||
|
||||
@Test("Mutual offline peers show Nostr icon")
|
||||
func mutualFavoriteOfflinePeerShowsNostrIcon() {
|
||||
let peerID = PeerID(str: "0011223344556677")
|
||||
let noiseKey = Data((64..<96).map(UInt8.init))
|
||||
var peer = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "Peer", isConnected: false, isReachable: false)
|
||||
peer.favoriteStatus = makeRelationship(isFavorite: true, theyFavoritedUs: true)
|
||||
|
||||
#expect(peer.statusIcon == "🌐")
|
||||
#expect(peer.isFavorite)
|
||||
#expect(peer.isMutualFavorite)
|
||||
#expect(peer.theyFavoritedUs)
|
||||
}
|
||||
|
||||
@Test("Equality is based only on peer ID")
|
||||
func equalityUsesPeerIDOnly() {
|
||||
let peerID = PeerID(str: "8899aabbccddeeff")
|
||||
let first = BitchatPeer(
|
||||
peerID: peerID,
|
||||
noisePublicKey: Data(repeating: 1, count: 32),
|
||||
nickname: "First",
|
||||
isConnected: false,
|
||||
isReachable: false
|
||||
)
|
||||
let second = BitchatPeer(
|
||||
peerID: peerID,
|
||||
noisePublicKey: Data(repeating: 2, count: 32),
|
||||
nickname: "Second",
|
||||
isConnected: true,
|
||||
isReachable: true
|
||||
)
|
||||
|
||||
#expect(first == second)
|
||||
}
|
||||
|
||||
private func makeRelationship(isFavorite: Bool, theyFavoritedUs: Bool) -> FavoriteRelationship {
|
||||
FavoriteRelationship(
|
||||
peerNoisePublicKey: Data(repeating: 7, count: 32),
|
||||
peerNostrPublicKey: "npub1example",
|
||||
peerNickname: "Peer",
|
||||
isFavorite: isFavorite,
|
||||
theyFavoritedUs: theyFavoritedUs,
|
||||
favoritedAt: Date(timeIntervalSince1970: 1),
|
||||
lastUpdated: Date(timeIntervalSince1970: 2)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//
|
||||
// ChatViewModelDeliveryStatusTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for ChatViewModel delivery status state machine.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Nostr
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
// MARK: - Test Helpers
|
||||
|
||||
@MainActor
|
||||
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
||||
let keychain = MockKeychain()
|
||||
let keychainHelper = MockKeychainHelper()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
let transport = MockTransport()
|
||||
|
||||
let viewModel = ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
transport: transport
|
||||
)
|
||||
|
||||
return (viewModel, transport)
|
||||
}
|
||||
|
||||
// MARK: - Delivery Status Tests
|
||||
|
||||
struct ChatViewModelDeliveryStatusTests {
|
||||
|
||||
// MARK: - Status Transition Tests
|
||||
|
||||
@Test @MainActor
|
||||
func deliveryStatus_noDowngrade_readToDelivered() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
let messageID = "test-msg-1"
|
||||
|
||||
// Setup: create a message with .read status
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Test message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .read(by: "Peer", at: Date())
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
|
||||
// Action: try to downgrade to .delivered
|
||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date()))
|
||||
|
||||
// Assert: status should remain .read (no downgrade)
|
||||
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
||||
#expect({
|
||||
if case .read = currentStatus { return true }
|
||||
return false
|
||||
}())
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func deliveryStatus_upgrade_sentToDelivered() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
let messageID = "test-msg-2"
|
||||
|
||||
// Setup: create a message with .sent status
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Test message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
|
||||
// Action: upgrade to .delivered
|
||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date()))
|
||||
|
||||
// Assert: status should be .delivered
|
||||
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
||||
#expect({
|
||||
if case .delivered = currentStatus { return true }
|
||||
return false
|
||||
}())
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func deliveryStatus_upgrade_deliveredToRead() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
let messageID = "test-msg-3"
|
||||
|
||||
// Setup: create a message with .delivered status
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Test message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .delivered(to: "Peer", at: Date().addingTimeInterval(-60))
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
|
||||
// Action: upgrade to .read
|
||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .read(by: "Peer", at: Date()))
|
||||
|
||||
// Assert: status should be .read
|
||||
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
||||
#expect({
|
||||
if case .read = currentStatus { return true }
|
||||
return false
|
||||
}())
|
||||
}
|
||||
|
||||
// MARK: - Read Receipt Handling
|
||||
|
||||
@Test @MainActor
|
||||
func didReceiveReadReceipt_updatesMessageStatus() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
let messageID = "test-msg-4"
|
||||
|
||||
// Setup: create a message with .sent status
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Test message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
|
||||
// Action: receive read receipt
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: messageID,
|
||||
readerID: peerID,
|
||||
readerNickname: "Peer"
|
||||
)
|
||||
viewModel.didReceiveReadReceipt(receipt)
|
||||
|
||||
// Assert: status should be .read
|
||||
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
||||
#expect({
|
||||
if case .read = currentStatus { return true }
|
||||
return false
|
||||
}())
|
||||
}
|
||||
|
||||
// MARK: - Public Timeline Status Tests
|
||||
|
||||
@Test @MainActor
|
||||
func deliveryStatus_publicTimeline_updatesCorrectly() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let messageID = "public-msg-1"
|
||||
|
||||
// Setup: add a message to public timeline with .sending status
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Public message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: false,
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
viewModel.messages.append(message)
|
||||
|
||||
// Action: update to .sent
|
||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
||||
|
||||
// Assert
|
||||
let updatedMessage = viewModel.messages.first(where: { $0.id == messageID })
|
||||
#expect({
|
||||
if case .sent = updatedMessage?.deliveryStatus { return true }
|
||||
return false
|
||||
}())
|
||||
}
|
||||
|
||||
// MARK: - Status Rank Tests (for deduplication)
|
||||
|
||||
@Test @MainActor
|
||||
func statusRank_orderingIsCorrect() async {
|
||||
// This tests the implicit ordering used in refreshVisibleMessages
|
||||
// failed < sending < sent < partiallyDelivered < delivered < read
|
||||
|
||||
let statuses: [DeliveryStatus] = [
|
||||
.failed(reason: "test"),
|
||||
.sending,
|
||||
.sent,
|
||||
.partiallyDelivered(reached: 1, total: 3),
|
||||
.delivered(to: "B", at: Date()),
|
||||
.read(by: "C", at: Date())
|
||||
]
|
||||
|
||||
// Verify each status has a logical progression
|
||||
// This is more of a documentation test to ensure the ranking logic is understood
|
||||
for (index, status) in statuses.enumerated() {
|
||||
switch status {
|
||||
case .failed: #expect(index == 0)
|
||||
case .sending: #expect(index == 1)
|
||||
case .sent: #expect(index == 2)
|
||||
case .partiallyDelivered: #expect(index == 3)
|
||||
case .delivered: #expect(index == 4)
|
||||
case .read: #expect(index == 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,15 @@
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Nostr
|
||||
import Foundation
|
||||
import Combine
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
// MARK: - Test Helpers
|
||||
@@ -64,6 +71,22 @@ struct ChatViewModelPrivateChatExtensionTests {
|
||||
// MockTransport.sendPrivateMessage is what MessageRouter calls for connected peers
|
||||
// Check MockTransport implementation... it might need update or verification
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivateMessage_unreachable_setsFailedStatus() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let validHex = "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10"
|
||||
let peerID = PeerID(str: validHex)
|
||||
|
||||
viewModel.sendPrivateMessage("Hello", to: peerID)
|
||||
|
||||
#expect(viewModel.privateChats[peerID]?.count == 1)
|
||||
let status = viewModel.privateChats[peerID]?.last?.deliveryStatus
|
||||
#expect({
|
||||
if case .failed = status { return true }
|
||||
return false
|
||||
}())
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handlePrivateMessage_storesMessage() async {
|
||||
@@ -249,37 +272,872 @@ struct ChatViewModelNostrExtensionTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func subscribeNostrEvent_addsToTimeline_ifMatchesGeohash() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
func subscribeNostrEvent_addsToTimeline_ifMatchesGeohash() async throws {
|
||||
let geohash = "u4pruydq"
|
||||
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
|
||||
|
||||
LocationChannelManager.shared.select(channel)
|
||||
defer { LocationChannelManager.shared.select(.mesh) }
|
||||
|
||||
_ = await TestHelpers.waitUntil({ LocationChannelManager.shared.selectedChannel == channel })
|
||||
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
_ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel })
|
||||
|
||||
var event = NostrEvent(
|
||||
pubkey: "pub1",
|
||||
let signer = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: signer.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: [["g", geohash]],
|
||||
content: "Hello Geo"
|
||||
)
|
||||
event.id = "evt1"
|
||||
event.sig = "sig"
|
||||
let signed = try event.sign(with: signer.schnorrSigningKey())
|
||||
viewModel.handleNostrEvent(signed)
|
||||
|
||||
viewModel.handleNostrEvent(event)
|
||||
|
||||
// Allow async processing
|
||||
let didAppend = await TestHelpers.waitUntil({
|
||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||
return viewModel.messages.contains { $0.content == "Hello Geo" }
|
||||
})
|
||||
#expect(didAppend)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleNostrEvent_ignoresRecentSelfEcho() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)
|
||||
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: [["g", geohash]],
|
||||
content: "Self echo"
|
||||
)
|
||||
let signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
viewModel.handleNostrEvent(signed)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Check timeline
|
||||
// This depends on `handlePublicMessage` being called and updating `messages`
|
||||
// Since `handlePublicMessage` delegates to `timelineStore` and updates `messages`...
|
||||
// And we are in the correct channel...
|
||||
|
||||
// However, `handleNostrEvent` in the extension now calls `handlePublicMessage`.
|
||||
// Let's verify if the message appears.
|
||||
// Note: `handleNostrEvent` logic was refactored.
|
||||
// The new logic in `ChatViewModel+Nostr.swift` calls `handlePublicMessage`.
|
||||
|
||||
// We need to ensure `deduplicationService` doesn't block it (new instance, so empty).
|
||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||
|
||||
#expect(!viewModel.messages.contains { $0.content == "Self echo" })
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleNostrEvent_skipsBlockedSender() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
let blockedIdentity = try NostrIdentity.generate()
|
||||
let blockedPubkey = blockedIdentity.publicKeyHex
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
viewModel.identityManager.setNostrBlocked(blockedPubkey, isBlocked: true)
|
||||
|
||||
let event = NostrEvent(
|
||||
pubkey: blockedPubkey,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: [["g", geohash]],
|
||||
content: "Blocked"
|
||||
)
|
||||
let signed = try event.sign(with: blockedIdentity.schnorrSigningKey())
|
||||
viewModel.handleNostrEvent(signed)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||
|
||||
#expect(!viewModel.messages.contains { $0.content == "Blocked" })
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleNostrEvent_rejectsInvalidSignature() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
let identity = try NostrIdentity.generate()
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: [["g", geohash]],
|
||||
content: "Valid"
|
||||
)
|
||||
var signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
signed.id = "deadbeef"
|
||||
|
||||
viewModel.handleNostrEvent(signed)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||
|
||||
#expect(!viewModel.messages.contains { $0.content == "Tampered" })
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func subscribeGiftWrap_rejectsOversizedEmbeddedPacket() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
|
||||
let oversized = Data(repeating: 0x41, count: FileTransferLimits.maxFramedFileBytes + 1)
|
||||
let content = "bitchat1:" + base64URLEncode(oversized)
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
#expect(viewModel.privateChats.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func switchLocationChannel_clearsNostrDedupCache() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
|
||||
viewModel.deduplicationService.recordNostrEvent("evt-cache")
|
||||
#expect(viewModel.deduplicationService.hasProcessedNostrEvent("evt-cache"))
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
|
||||
#expect(!viewModel.deduplicationService.hasProcessedNostrEvent("evt-cache"))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleNostrEvent_presenceTracksParticipantWithoutTimelineMessage() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
let identity = try NostrIdentity.generate()
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .geohashPresence,
|
||||
tags: [["g", geohash]],
|
||||
content: ""
|
||||
)
|
||||
let signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
|
||||
viewModel.handleNostrEvent(signed)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
#expect(viewModel.geohashParticipantCount(for: geohash) >= 1)
|
||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||
#expect(viewModel.messages.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func subscribeGiftWrap_deliveredAckUpdatesExistingMessage() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||
let messageID = "geo-ack-delivered"
|
||||
|
||||
viewModel.privateChats[convKey] = [
|
||||
BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Hello",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Friend",
|
||||
senderPeerID: viewModel.meshService.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
]
|
||||
|
||||
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
|
||||
|
||||
let didUpdate = await TestHelpers.waitUntil(
|
||||
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||
timeout: 0.5
|
||||
)
|
||||
#expect(didUpdate)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func subscribeGiftWrap_readAckUpdatesExistingMessage() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||
let messageID = "geo-ack-read"
|
||||
|
||||
viewModel.privateChats[convKey] = [
|
||||
BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Hello",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Friend",
|
||||
senderPeerID: viewModel.meshService.myPeerID,
|
||||
deliveryStatus: .delivered(to: "Friend", at: Date())
|
||||
)
|
||||
]
|
||||
|
||||
let content = try ackContent(type: .readReceipt, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
|
||||
|
||||
let didUpdate = await TestHelpers.waitUntil(
|
||||
{ isRead(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||
timeout: 0.5
|
||||
)
|
||||
#expect(didUpdate)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleGiftWrap_privateMessageStoresConversationAndMapping() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let messageID = "gift-private"
|
||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||
|
||||
let content = try privateMessageContent(
|
||||
text: "Hello from gift wrap",
|
||||
messageID: messageID,
|
||||
senderPeerID: PeerID(str: "0123456789abcdef")
|
||||
)
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.handleGiftWrap(giftWrap, id: recipient)
|
||||
|
||||
let didStore = await TestHelpers.waitUntil(
|
||||
{ viewModel.privateChats[convKey]?.first?.content == "Hello from gift wrap" },
|
||||
timeout: 0.5
|
||||
)
|
||||
#expect(didStore)
|
||||
#expect(viewModel.nostrKeyMapping[convKey] == sender.publicKeyHex)
|
||||
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleGiftWrap_blockedSenderSkipsMessageStorage() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let messageID = "gift-blocked"
|
||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||
|
||||
viewModel.identityManager.setNostrBlocked(sender.publicKeyHex, isBlocked: true)
|
||||
|
||||
let content = try privateMessageContent(
|
||||
text: "Blocked",
|
||||
messageID: messageID,
|
||||
senderPeerID: PeerID(str: "0123456789abcdef")
|
||||
)
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.handleGiftWrap(giftWrap, id: recipient)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
#expect(viewModel.privateChats[convKey] == nil)
|
||||
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleGiftWrap_deliveredAckUpdatesExistingMessage() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||
let messageID = "gift-delivered"
|
||||
|
||||
viewModel.privateChats[convKey] = [
|
||||
BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Hello",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Friend",
|
||||
senderPeerID: viewModel.meshService.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
]
|
||||
|
||||
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.handleGiftWrap(giftWrap, id: recipient)
|
||||
|
||||
let didUpdate = await TestHelpers.waitUntil(
|
||||
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||
timeout: 0.5
|
||||
)
|
||||
#expect(didUpdate)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func findNoiseKey_matchesFavoriteStoredAsNpub() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let identity = try NostrIdentity.generate()
|
||||
let noiseKey = Data((0..<32).map { UInt8(($0 + 80) & 0xFF) })
|
||||
|
||||
FavoritesPersistenceService.shared.addFavorite(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: identity.npub,
|
||||
peerNickname: "Alice"
|
||||
)
|
||||
defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) }
|
||||
|
||||
#expect(viewModel.findNoiseKey(for: identity.publicKeyHex) == noiseKey)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func findNoiseKey_matchesFavoriteStoredAsHex() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let nostrHex = String(repeating: "ab", count: 32)
|
||||
let noiseKey = Data((0..<32).map { UInt8(($0 + 112) & 0xFF) })
|
||||
|
||||
FavoritesPersistenceService.shared.addFavorite(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: nostrHex,
|
||||
peerNickname: "Bob"
|
||||
)
|
||||
defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) }
|
||||
|
||||
#expect(viewModel.findNoiseKey(for: nostrHex) == noiseKey)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleFavoriteNotification_updatesFavoriteAssociation() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let identity = try NostrIdentity.generate()
|
||||
let noiseKey = Data((0..<32).map { UInt8(($0 + 144) & 0xFF) })
|
||||
|
||||
FavoritesPersistenceService.shared.addFavorite(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: identity.npub,
|
||||
peerNickname: "Before"
|
||||
)
|
||||
defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) }
|
||||
|
||||
viewModel.handleFavoriteNotification(
|
||||
content: "FAVORITE:TRUE|NPUB:\(identity.npub)|Alice",
|
||||
from: identity.publicKeyHex
|
||||
)
|
||||
|
||||
let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
|
||||
#expect(relationship?.peerNickname == "Alice")
|
||||
#expect(relationship?.peerNostrPublicKey == identity.npub)
|
||||
#expect(relationship?.isFavorite == true)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func geohashDMHelpers_exposeMappingAndDisplayName() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let nostrHex = String(repeating: "cd", count: 32)
|
||||
let convKey = PeerID(nostr_: nostrHex)
|
||||
|
||||
viewModel.geoNicknames[nostrHex] = "Alice"
|
||||
viewModel.startGeohashDM(withPubkeyHex: nostrHex)
|
||||
|
||||
#expect(viewModel.selectedPrivateChatPeer == convKey)
|
||||
#expect(viewModel.fullNostrHex(forSenderPeerID: convKey) == nostrHex)
|
||||
#expect(viewModel.geohashDisplayName(for: convKey).hasPrefix("Alice"))
|
||||
#expect(viewModel.nostrPubkeyForDisplayName("Alice") == nostrHex)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geohash Queue Tests
|
||||
|
||||
struct ChatViewModelGeohashQueueTests {
|
||||
|
||||
@Test @MainActor
|
||||
func addGeohashOnlySystemMessage_queuesUntilLocationChannel() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
|
||||
viewModel.addGeohashOnlySystemMessage("Queued system")
|
||||
#expect(!viewModel.messages.contains { $0.content == "Queued system" })
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
|
||||
#expect(viewModel.messages.contains { $0.content == "Queued system" })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - GeoDM Tests
|
||||
|
||||
struct ChatViewModelGeoDMTests {
|
||||
|
||||
@Test @MainActor
|
||||
func handlePrivateMessage_geohash_dedupsAndTracksAck() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
let senderPubkey = "0000000000000000000000000000000000000000000000000000000000000001"
|
||||
let messageID = "pm-1"
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)
|
||||
|
||||
let convKey = PeerID(nostr_: senderPubkey)
|
||||
let packet = PrivateMessagePacket(messageID: messageID, content: "Hello")
|
||||
let payloadData = try #require(packet.encode(), "Failed to encode private message")
|
||||
let payload = NoisePayload(type: .privateMessage, data: payloadData)
|
||||
|
||||
viewModel.handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: identity, messageTimestamp: Date())
|
||||
viewModel.handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: identity, messageTimestamp: Date())
|
||||
|
||||
#expect(viewModel.privateChats[convKey]?.count == 1)
|
||||
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendGeohashDM_requiresActiveLocationChannel() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let convKey = PeerID(nostr_: "0000000000000000000000000000000000000000000000000000000000000001")
|
||||
|
||||
viewModel.sendGeohashDM("hello", to: convKey)
|
||||
|
||||
#expect(viewModel.privateChats[convKey] == nil)
|
||||
#expect(viewModel.messages.count == 1)
|
||||
#expect(viewModel.messages.last?.sender == "system")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendGeohashDM_missingRecipientMapping_marksFailed() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
let convKey = PeerID(nostr_: "0000000000000000000000000000000000000000000000000000000000000002")
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
viewModel.sendGeohashDM("hello", to: convKey)
|
||||
|
||||
#expect(viewModel.privateChats[convKey]?.count == 1)
|
||||
#expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendGeohashDM_blockedRecipient_marksFailedAndAddsSystemMessage() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
let recipientHex = "0000000000000000000000000000000000000000000000000000000000000003"
|
||||
let convKey = PeerID(nostr_: recipientHex)
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
viewModel.nostrKeyMapping[convKey] = recipientHex
|
||||
viewModel.identityManager.setNostrBlocked(recipientHex, isBlocked: true)
|
||||
|
||||
viewModel.sendGeohashDM("hello", to: convKey)
|
||||
|
||||
#expect(viewModel.privateChats[convKey]?.count == 1)
|
||||
#expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus))
|
||||
#expect(viewModel.messages.contains(where: { $0.sender == "system" }))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handlePrivateMessage_geohashViewingConversationRecordsReadReceipt() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
let senderPubkey = "0000000000000000000000000000000000000000000000000000000000000004"
|
||||
let convKey = PeerID(nostr_: senderPubkey)
|
||||
let messageID = "pm-viewing"
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
viewModel.selectedPrivateChatPeer = convKey
|
||||
|
||||
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)
|
||||
let packet = PrivateMessagePacket(messageID: messageID, content: "Hello")
|
||||
let payloadData = try #require(packet.encode(), "Failed to encode private message")
|
||||
let payload = NoisePayload(type: .privateMessage, data: payloadData)
|
||||
|
||||
viewModel.handlePrivateMessage(
|
||||
payload,
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: convKey,
|
||||
id: identity,
|
||||
messageTimestamp: Date()
|
||||
)
|
||||
|
||||
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
|
||||
#expect(viewModel.sentReadReceipts.contains(messageID))
|
||||
#expect(!viewModel.unreadPrivateMessages.contains(convKey))
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatViewModelMediaTransferTests {
|
||||
|
||||
@Test @MainActor
|
||||
func handleTransferEvent_updatesPrivateMessageProgressAndClearsMappingOnCompletion() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10")
|
||||
let message = viewModel.enqueueMediaMessage(content: "[voice] clip.m4a", targetPeer: peerID)
|
||||
let transferID = "transfer-1"
|
||||
|
||||
viewModel.registerTransfer(transferId: transferID, messageID: message.id)
|
||||
viewModel.handleTransferEvent(.started(id: transferID, totalFragments: 4))
|
||||
#expect(isPartiallyDelivered(status: deliveryStatus(in: viewModel, peerID: peerID, messageID: message.id), reached: 0, total: 4))
|
||||
|
||||
viewModel.handleTransferEvent(.updated(id: transferID, sentFragments: 2, totalFragments: 4))
|
||||
#expect(isPartiallyDelivered(status: deliveryStatus(in: viewModel, peerID: peerID, messageID: message.id), reached: 2, total: 4))
|
||||
|
||||
viewModel.handleTransferEvent(.completed(id: transferID, totalFragments: 4))
|
||||
#expect(isSent(status: deliveryStatus(in: viewModel, peerID: peerID, messageID: message.id)))
|
||||
#expect(viewModel.messageIDToTransferId[message.id] == nil)
|
||||
#expect(viewModel.transferIdToMessageIDs[transferID] == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleTransferEvent_cancelledRemovesOutgoingMessage() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "1111111111111111111111111111111111111111111111111111111111111111")
|
||||
let message = viewModel.enqueueMediaMessage(content: "[image] pic.jpg", targetPeer: peerID)
|
||||
let transferID = "transfer-2"
|
||||
|
||||
viewModel.registerTransfer(transferId: transferID, messageID: message.id)
|
||||
viewModel.handleTransferEvent(.cancelled(id: transferID, sentFragments: 1, totalFragments: 3))
|
||||
|
||||
#expect(viewModel.privateChats[peerID]?.contains(where: { $0.id == message.id }) != true)
|
||||
#expect(viewModel.messageIDToTransferId[message.id] == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendVoiceNote_outsideAllowedContextDeletesTempFile() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-\(UUID().uuidString).m4a")
|
||||
|
||||
try Data("voice".utf8).write(to: url)
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
|
||||
viewModel.sendVoiceNote(at: url)
|
||||
|
||||
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||
#expect(viewModel.messages.contains(where: { $0.sender == "system" }))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendImage_outsideAllowedContextRunsCleanup() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
var cleanupCalled = false
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
viewModel.sendImage(from: URL(fileURLWithPath: "/tmp/ignored.jpg")) {
|
||||
cleanupCalled = true
|
||||
}
|
||||
|
||||
#expect(cleanupCalled)
|
||||
#expect(viewModel.messages.contains(where: { $0.sender == "system" }))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendVoiceNote_privateChatUsesPrivateFileTransfer() async throws {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "2222222222222222222222222222222222222222222222222222222222222222")
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-\(UUID().uuidString).m4a")
|
||||
try Data("voice payload".utf8).write(to: url, options: .atomic)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
viewModel.sendVoiceNote(at: url)
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 0.5)
|
||||
#expect(didSend)
|
||||
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
|
||||
#expect(viewModel.privateChats[peerID]?.last?.content.contains("[voice]") == true)
|
||||
#expect(viewModel.messageIDToTransferId.count == 1)
|
||||
#expect(viewModel.transferIdToMessageIDs.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendVoiceNote_oversizedFileFailsAndDeletesTempFile() async throws {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "3333333333333333333333333333333333333333333333333333333333333333")
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-too-large-\(UUID().uuidString).m4a")
|
||||
try Data(repeating: 0x55, count: FileTransferLimits.maxVoiceNoteBytes + 1).write(to: url, options: .atomic)
|
||||
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
viewModel.sendVoiceNote(at: url)
|
||||
|
||||
let didFail = await TestHelpers.waitUntil({
|
||||
isFailed(status: viewModel.privateChats[peerID]?.last?.deliveryStatus)
|
||||
}, timeout: 0.5)
|
||||
#expect(didFail)
|
||||
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||
#expect(transport.sentPrivateFiles.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendImage_privateChatProcessesAndTransfersImage() async throws {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "4444444444444444444444444444444444444444444444444444444444444444")
|
||||
let sourceURL = try makeTemporaryImageURL()
|
||||
defer { try? FileManager.default.removeItem(at: sourceURL) }
|
||||
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
viewModel.sendImage(from: sourceURL)
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 1.0)
|
||||
#expect(didSend)
|
||||
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
|
||||
#expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg")
|
||||
#expect(viewModel.privateChats[peerID]?.last?.content.contains("[image]") == true)
|
||||
#expect(viewModel.messageIDToTransferId.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendImage_invalidSourceAddsFailureSystemMessage() async throws {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "5555555555555555555555555555555555555555555555555555555555555555")
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent("invalid-\(UUID().uuidString).jpg")
|
||||
try Data("not-an-image".utf8).write(to: url, options: .atomic)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
viewModel.sendImage(from: url)
|
||||
|
||||
let didNotify = await TestHelpers.waitUntil({
|
||||
viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") })
|
||||
}, timeout: 2.0)
|
||||
#expect(didNotify)
|
||||
#expect(transport.sentPrivateFiles.isEmpty)
|
||||
#expect(viewModel.privateChats[peerID]?.isEmpty != false)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func clearTransferMapping_promotesQueuedTransferForSameID() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
viewModel.registerTransfer(transferId: "transfer-queue", messageID: "first")
|
||||
viewModel.registerTransfer(transferId: "transfer-queue", messageID: "second")
|
||||
|
||||
viewModel.clearTransferMapping(for: "first")
|
||||
|
||||
#expect(viewModel.messageIDToTransferId["first"] == nil)
|
||||
#expect(viewModel.transferIdToMessageIDs["transfer-queue"] == ["second"])
|
||||
#expect(viewModel.messageIDToTransferId["second"] == "transfer-queue")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func cancelMediaSend_cancelsActiveTransferRemovesMessageAndDeletesFile() async throws {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "6666666666666666666666666666666666666666666666666666666666666666")
|
||||
let fileName = "cancel-\(UUID().uuidString).m4a"
|
||||
let fileURL = try mediaFileURL(subdirectory: "voicenotes/outgoing", fileName: fileName)
|
||||
try Data("cancel me".utf8).write(to: fileURL, options: .atomic)
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: "cancel-msg",
|
||||
sender: viewModel.nickname,
|
||||
content: "[voice] \(fileName)",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
senderPeerID: viewModel.meshService.myPeerID,
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
viewModel.registerTransfer(transferId: "transfer-cancel", messageID: message.id)
|
||||
|
||||
viewModel.cancelMediaSend(messageID: message.id)
|
||||
|
||||
#expect(transport.cancelledTransfers == ["transfer-cancel"])
|
||||
#expect(viewModel.privateChats[peerID] == nil)
|
||||
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func deleteMediaMessage_removesStoredMessageAndCleansImageFile() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "7777777777777777777777777777777777777777777777777777777777777777")
|
||||
let fileName = "delete-\(UUID().uuidString).jpg"
|
||||
let fileURL = try mediaFileURL(subdirectory: "images/outgoing", fileName: fileName)
|
||||
try Data("image bytes".utf8).write(to: fileURL, options: .atomic)
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: "delete-msg",
|
||||
sender: viewModel.nickname,
|
||||
content: "[image] \(fileName)",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
senderPeerID: viewModel.meshService.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
viewModel.registerTransfer(transferId: "transfer-delete", messageID: message.id)
|
||||
|
||||
viewModel.deleteMediaMessage(messageID: message.id)
|
||||
|
||||
#expect(viewModel.privateChats[peerID] == nil)
|
||||
#expect(viewModel.messageIDToTransferId[message.id] == nil)
|
||||
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func makeTransferID_isPrefixedByMessageIDAndUnique() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
let first = viewModel.makeTransferID(messageID: "base")
|
||||
let second = viewModel.makeTransferID(messageID: "base")
|
||||
|
||||
#expect(first.hasPrefix("base-"))
|
||||
#expect(second.hasPrefix("base-"))
|
||||
#expect(first != second)
|
||||
}
|
||||
}
|
||||
|
||||
private func base64URLEncode(_ data: Data) -> String {
|
||||
data.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
private func ackContent(type: NoisePayloadType, messageID: String, senderPeerID: PeerID) throws -> String {
|
||||
if let content = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
|
||||
type: type,
|
||||
messageID: messageID,
|
||||
senderPeerID: senderPeerID
|
||||
) {
|
||||
return content
|
||||
}
|
||||
throw ChatViewModelExtensionsTestError.invalidAckContent
|
||||
}
|
||||
|
||||
private func privateMessageContent(text: String, messageID: String, senderPeerID: PeerID) throws -> String {
|
||||
if let content = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
|
||||
content: text,
|
||||
messageID: messageID,
|
||||
senderPeerID: senderPeerID
|
||||
) {
|
||||
return content
|
||||
}
|
||||
throw ChatViewModelExtensionsTestError.invalidPrivateMessageContent
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func deliveryStatus(in viewModel: ChatViewModel, peerID: PeerID, messageID: String) -> DeliveryStatus? {
|
||||
viewModel.privateChats[peerID]?.first(where: { $0.id == messageID })?.deliveryStatus
|
||||
}
|
||||
|
||||
private func isFailed(status: DeliveryStatus?) -> Bool {
|
||||
if case .failed = status {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func isDelivered(status: DeliveryStatus?) -> Bool {
|
||||
if case .delivered = status {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func isRead(status: DeliveryStatus?) -> Bool {
|
||||
if case .read = status {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func isSent(status: DeliveryStatus?) -> Bool {
|
||||
if case .sent = status {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func isPartiallyDelivered(status: DeliveryStatus?, reached: Int, total: Int) -> Bool {
|
||||
if case .partiallyDelivered(let actualReached, let actualTotal) = status {
|
||||
return actualReached == reached && actualTotal == total
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private enum ChatViewModelExtensionsTestError: Error {
|
||||
case invalidAckContent
|
||||
case invalidPrivateMessageContent
|
||||
}
|
||||
|
||||
private func mediaFileURL(subdirectory: String, fileName: String) throws -> URL {
|
||||
let base = try FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
).appendingPathComponent("files", isDirectory: true)
|
||||
let directory = base.appendingPathComponent(subdirectory, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory.appendingPathComponent(fileName)
|
||||
}
|
||||
|
||||
private func makeTemporaryImageURL() throws -> URL {
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent("image-\(UUID().uuidString).png")
|
||||
let data = try makeImageData()
|
||||
try data.write(to: url, options: .atomic)
|
||||
return url
|
||||
}
|
||||
|
||||
private func makeImageData() throws -> Data {
|
||||
#if os(iOS)
|
||||
let image = UIGraphicsImageRenderer(size: CGSize(width: 64, height: 64)).image { context in
|
||||
UIColor.systemTeal.setFill()
|
||||
context.fill(CGRect(x: 0, y: 0, width: 64, height: 64))
|
||||
}
|
||||
guard let data = image.pngData() else {
|
||||
throw ChatViewModelExtensionsTestError.invalidPrivateMessageContent
|
||||
}
|
||||
return data
|
||||
#else
|
||||
let image = NSImage(size: CGSize(width: 64, height: 64))
|
||||
image.lockFocus()
|
||||
NSColor.systemTeal.setFill()
|
||||
NSBezierPath(rect: CGRect(x: 0, y: 0, width: 64, height: 64)).fill()
|
||||
image.unlockFocus()
|
||||
guard
|
||||
let tiffData = image.tiffRepresentation,
|
||||
let bitmap = NSBitmapImageRep(data: tiffData),
|
||||
let data = bitmap.representation(using: .png, properties: [:])
|
||||
else {
|
||||
throw ChatViewModelExtensionsTestError.invalidPrivateMessageContent
|
||||
}
|
||||
return data
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//
|
||||
// ChatViewModelRefactoringTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Pinning tests to characterize ChatViewModel behavior before refactoring.
|
||||
// These tests act as a safety net to ensure we don't break existing functionality.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Nostr
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
struct ChatViewModelRefactoringTests {
|
||||
|
||||
// Helper to setup the environment
|
||||
@MainActor
|
||||
private func makePinnedViewModel() -> (viewModel: ChatViewModel, transport: MockTransport, identity: MockIdentityManager) {
|
||||
let keychain = MockKeychain()
|
||||
let keychainHelper = MockKeychainHelper()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
let transport = MockTransport()
|
||||
|
||||
let viewModel = ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
transport: transport
|
||||
)
|
||||
|
||||
return (viewModel, transport, identityManager)
|
||||
}
|
||||
|
||||
// MARK: - Command Processor Integration "Pinning"
|
||||
|
||||
@Test @MainActor
|
||||
func command_msg_routesToTransport() async throws {
|
||||
let (viewModel, transport, _) = makePinnedViewModel()
|
||||
|
||||
// Setup: Use simulateConnect so ChatViewModel and UnifiedPeerService are notified
|
||||
let peerID = PeerID(str: "0000000000000001")
|
||||
transport.simulateConnect(peerID, nickname: "alice")
|
||||
|
||||
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("alice") != nil },
|
||||
timeout: TestConstants.shortTimeout)
|
||||
#expect(didResolve)
|
||||
|
||||
// Action: User types /msg command
|
||||
viewModel.sendMessage("/msg @alice Hello Private World")
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateMessages.count == 1 },
|
||||
timeout: TestConstants.shortTimeout)
|
||||
#expect(didSend)
|
||||
|
||||
// Assert:
|
||||
// 1. Should NOT go to public transport
|
||||
#expect(transport.sentMessages.isEmpty, "Command should not be sent as public message")
|
||||
|
||||
// 2. Should go to private transport logic
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
#expect(transport.sentPrivateMessages.first?.content == "Hello Private World")
|
||||
#expect(transport.sentPrivateMessages.first?.peerID == peerID)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func command_block_updatesIdentity() async throws {
|
||||
let (viewModel, transport, identity) = makePinnedViewModel()
|
||||
|
||||
// Setup: Use simulateConnect
|
||||
let peerID = PeerID(str: "0000000000000002")
|
||||
// Mock the fingerprint so the block command finds it
|
||||
transport.peerFingerprints[peerID] = "fingerprint_123"
|
||||
transport.simulateConnect(peerID, nickname: "troll")
|
||||
|
||||
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("troll") != nil },
|
||||
timeout: TestConstants.shortTimeout)
|
||||
#expect(didResolve)
|
||||
|
||||
// Action
|
||||
viewModel.sendMessage("/block @troll")
|
||||
|
||||
// Assert
|
||||
// Verify identity manager was called to block "fingerprint_123"
|
||||
let didBlock = await TestHelpers.waitUntil({ identity.isBlocked(fingerprint: "fingerprint_123") },
|
||||
timeout: TestConstants.shortTimeout)
|
||||
#expect(didBlock)
|
||||
}
|
||||
|
||||
// MARK: - Message Routing Logic
|
||||
|
||||
@Test @MainActor
|
||||
func routing_incomingPrivateMessage_addsToPrivateChats() async {
|
||||
let (viewModel, _, _) = makePinnedViewModel()
|
||||
let senderID = PeerID(str: "sender_1")
|
||||
|
||||
// Setup
|
||||
let message = BitchatMessage(
|
||||
id: "msg_1",
|
||||
sender: "bob",
|
||||
content: "Secret",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: "me",
|
||||
senderPeerID: senderID,
|
||||
mentions: nil
|
||||
)
|
||||
|
||||
// Action: Simulate incoming private message
|
||||
viewModel.didReceiveMessage(message)
|
||||
|
||||
// Wait for async processing with proper timeout
|
||||
let found = await TestHelpers.waitUntil(
|
||||
{ viewModel.privateChats[senderID]?.first?.content == "Secret" },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
|
||||
// Assert
|
||||
#expect(found)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func routing_incomingPublicMessage_addsToPublicTimeline() async {
|
||||
let (viewModel, _, _) = makePinnedViewModel()
|
||||
let senderID = PeerID(str: "sender_2")
|
||||
|
||||
// Action
|
||||
viewModel.didReceivePublicMessage(
|
||||
from: senderID,
|
||||
nickname: "charlie",
|
||||
content: "Public Hi",
|
||||
timestamp: Date(),
|
||||
messageID: "msg_2"
|
||||
)
|
||||
|
||||
// Wait for async processing with proper timeout
|
||||
let found = await TestHelpers.waitUntil(
|
||||
{
|
||||
viewModel.timelineStore.messages(for: .mesh).contains(where: { $0.content == "Public Hi" })
|
||||
},
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
|
||||
// Assert
|
||||
#expect(found)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,9 @@
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Nostr
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
// MARK: - Test Helpers
|
||||
@@ -114,6 +116,44 @@ struct ChatViewModelSendingTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Command Handling Tests
|
||||
|
||||
struct ChatViewModelCommandTests {
|
||||
|
||||
@Test @MainActor
|
||||
func sendMessage_commandsNotSentToTransport() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let commands = ["/nick bob", "/who", "/help", "/clear"]
|
||||
|
||||
for command in commands {
|
||||
transport.resetRecordings()
|
||||
viewModel.sendMessage(command)
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
#expect(transport.sentMessages.isEmpty)
|
||||
#expect(transport.sentPrivateMessages.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Timeline Cap Tests
|
||||
|
||||
struct ChatViewModelTimelineCapTests {
|
||||
|
||||
@Test @MainActor
|
||||
func sendMessage_trimsTimelineToCap() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let total = TransportConfig.meshTimelineCap + 5
|
||||
|
||||
for i in 0..<total {
|
||||
viewModel.sendMessage("cap-msg-\(i)")
|
||||
}
|
||||
|
||||
#expect(viewModel.messages.count == TransportConfig.meshTimelineCap)
|
||||
#expect(viewModel.messages.last?.content == "cap-msg-\(total - 1)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Message Receiving Tests
|
||||
|
||||
struct ChatViewModelReceivingTests {
|
||||
@@ -157,10 +197,45 @@ struct ChatViewModelReceivingTests {
|
||||
messageID: "pub-001"
|
||||
)
|
||||
|
||||
// Give time for async Task and pipeline processing
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
let found = await TestHelpers.waitUntil({
|
||||
viewModel.timelineStore.messages(for: .mesh).contains { $0.content == "Public hello from Bob" }
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
|
||||
#expect(viewModel.messages.contains { $0.content == "Public hello from Bob" })
|
||||
#expect(found)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rate Limiting Tests
|
||||
|
||||
struct ChatViewModelRateLimitingTests {
|
||||
|
||||
@Test @MainActor
|
||||
func handlePublicMessage_rateLimitsBurstBySender() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let senderID = PeerID(str: "1122334455667788")
|
||||
let now = Date()
|
||||
|
||||
for i in 0..<6 {
|
||||
let message = BitchatMessage(
|
||||
id: "rate-\(i)",
|
||||
sender: "Spammer",
|
||||
content: "rate-msg-\(i)",
|
||||
timestamp: now,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: senderID,
|
||||
mentions: nil
|
||||
)
|
||||
viewModel.handlePublicMessage(message)
|
||||
}
|
||||
|
||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||
|
||||
let burstMessages = viewModel.messages.filter { $0.content.hasPrefix("rate-msg-") }
|
||||
#expect(burstMessages.count == 5)
|
||||
#expect(!burstMessages.contains { $0.content == "rate-msg-5" })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +333,94 @@ struct ChatViewModelPrivateChatTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Chat Selection Tests
|
||||
|
||||
struct ChatViewModelPrivateChatSelectionTests {
|
||||
|
||||
@Test @MainActor
|
||||
func openMostRelevantPrivateChat_prefersUnreadMostRecent() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let peerA = PeerID(str: "PEER_A")
|
||||
let peerB = PeerID(str: "PEER_B")
|
||||
|
||||
let older = Date().addingTimeInterval(-120)
|
||||
let newer = Date().addingTimeInterval(-30)
|
||||
|
||||
viewModel.privateChats = [
|
||||
peerA: [
|
||||
BitchatMessage(
|
||||
id: "a-1",
|
||||
sender: "A",
|
||||
content: "Old",
|
||||
timestamp: older,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerA
|
||||
)
|
||||
],
|
||||
peerB: [
|
||||
BitchatMessage(
|
||||
id: "b-1",
|
||||
sender: "B",
|
||||
content: "New",
|
||||
timestamp: newer,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerB
|
||||
)
|
||||
]
|
||||
]
|
||||
viewModel.unreadPrivateMessages = [peerA, peerB]
|
||||
|
||||
viewModel.openMostRelevantPrivateChat()
|
||||
|
||||
#expect(viewModel.selectedPrivateChatPeer == peerB)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func openMostRelevantPrivateChat_fallsBackToMostRecentChat() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let peerA = PeerID(str: "PEER_A")
|
||||
let peerB = PeerID(str: "PEER_B")
|
||||
|
||||
let older = Date().addingTimeInterval(-200)
|
||||
let newer = Date().addingTimeInterval(-20)
|
||||
|
||||
viewModel.privateChats = [
|
||||
peerA: [
|
||||
BitchatMessage(
|
||||
id: "a-1",
|
||||
sender: "A",
|
||||
content: "Old",
|
||||
timestamp: older,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerA
|
||||
)
|
||||
],
|
||||
peerB: [
|
||||
BitchatMessage(
|
||||
id: "b-1",
|
||||
sender: "B",
|
||||
content: "New",
|
||||
timestamp: newer,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerB
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
viewModel.openMostRelevantPrivateChat()
|
||||
|
||||
#expect(viewModel.selectedPrivateChatPeer == peerB)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bluetooth State Tests
|
||||
|
||||
struct ChatViewModelBluetoothTests {
|
||||
@@ -309,11 +472,37 @@ struct ChatViewModelPanicTests {
|
||||
|
||||
// Set up some state
|
||||
transport.connectedPeers.insert(PeerID(str: "PEER1"))
|
||||
viewModel.messages = [
|
||||
BitchatMessage(
|
||||
id: "panic-1",
|
||||
sender: "Tester",
|
||||
content: "Before",
|
||||
timestamp: Date(),
|
||||
isRelay: false
|
||||
)
|
||||
]
|
||||
viewModel.privateChats[PeerID(str: "PEER1")] = [
|
||||
BitchatMessage(
|
||||
id: "pm-1",
|
||||
sender: "Peer",
|
||||
content: "Secret",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: PeerID(str: "PEER1")
|
||||
)
|
||||
]
|
||||
viewModel.unreadPrivateMessages.insert(PeerID(str: "PEER1"))
|
||||
|
||||
viewModel.panicClearAllData()
|
||||
|
||||
// After panic, emergency disconnect should be called
|
||||
#expect(transport.emergencyDisconnectCallCount == 1)
|
||||
#expect(viewModel.messages.isEmpty)
|
||||
#expect(viewModel.privateChats.isEmpty)
|
||||
#expect(viewModel.unreadPrivateMessages.isEmpty)
|
||||
#expect(viewModel.selectedPrivateChatPeer == nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//
|
||||
// ChatViewModelTorTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for ChatViewModel+Tor.swift Tor lifecycle notification handlers.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Nostr
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
// MARK: - Test Helpers
|
||||
|
||||
@MainActor
|
||||
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
||||
let keychain = MockKeychain()
|
||||
let keychainHelper = MockKeychainHelper()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
let transport = MockTransport()
|
||||
|
||||
let viewModel = ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
transport: transport
|
||||
)
|
||||
|
||||
return (viewModel, transport)
|
||||
}
|
||||
|
||||
// MARK: - Tor Notification Handler Tests
|
||||
|
||||
struct ChatViewModelTorTests {
|
||||
|
||||
// MARK: - handleTorWillStart Tests
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorWillStart_whenEnforced_setsAnnouncedFlag() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Precondition: flag should start false
|
||||
#expect(!viewModel.torStatusAnnounced)
|
||||
|
||||
// Action: simulate Tor starting notification
|
||||
viewModel.handleTorWillStart()
|
||||
|
||||
// Wait for Task to complete
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert: flag should be set (torEnforced is true in tests)
|
||||
#expect(viewModel.torStatusAnnounced)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorWillStart_whenAlreadyAnnounced_doesNotDuplicate() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Setup: pre-set the flag
|
||||
viewModel.torStatusAnnounced = true
|
||||
|
||||
// Switch to a geohash channel so messages would be visible
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: "u4pruydq")))
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
let initialMessageCount = viewModel.messages.count
|
||||
|
||||
// Action: call handler again
|
||||
viewModel.handleTorWillStart()
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert: no new message added (flag was already true)
|
||||
#expect(viewModel.messages.count == initialMessageCount)
|
||||
}
|
||||
|
||||
// MARK: - handleTorWillRestart Tests
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorWillRestart_setsPendingFlag() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Precondition
|
||||
#expect(!viewModel.torRestartPending)
|
||||
|
||||
// Action
|
||||
viewModel.handleTorWillRestart()
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert
|
||||
#expect(viewModel.torRestartPending)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorWillRestart_setsFlag_regardlessOfChannel() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Action: call handler (works regardless of channel)
|
||||
viewModel.handleTorWillRestart()
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert: flag should be set
|
||||
#expect(viewModel.torRestartPending)
|
||||
}
|
||||
|
||||
// MARK: - handleTorDidBecomeReady Tests
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorDidBecomeReady_afterRestart_clearsPendingFlag() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Setup: simulate restart pending state
|
||||
viewModel.torRestartPending = true
|
||||
|
||||
// Action
|
||||
viewModel.handleTorDidBecomeReady()
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert: should clear pending flag
|
||||
#expect(!viewModel.torRestartPending)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorDidBecomeReady_initialStart_setsAnnouncedFlag() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Setup: not restarting, but initial ready not announced yet
|
||||
viewModel.torRestartPending = false
|
||||
viewModel.torInitialReadyAnnounced = false
|
||||
|
||||
// Action
|
||||
viewModel.handleTorDidBecomeReady()
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert: should set flag (torEnforced is true in tests)
|
||||
#expect(viewModel.torInitialReadyAnnounced)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorDidBecomeReady_alreadyAnnounced_noDuplicate() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Setup: already announced initial ready
|
||||
viewModel.torRestartPending = false
|
||||
viewModel.torInitialReadyAnnounced = true
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: "u4pruydq")))
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
let initialMessageCount = viewModel.messages.count
|
||||
|
||||
// Action
|
||||
viewModel.handleTorDidBecomeReady()
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert: no new message
|
||||
#expect(viewModel.messages.count == initialMessageCount)
|
||||
}
|
||||
|
||||
// MARK: - handleTorPreferenceChanged Tests
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorPreferenceChanged_resetsAllFlags() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Setup: set all flags
|
||||
viewModel.torStatusAnnounced = true
|
||||
viewModel.torInitialReadyAnnounced = true
|
||||
viewModel.torRestartPending = true
|
||||
|
||||
// Action
|
||||
viewModel.handleTorPreferenceChanged(Notification(name: .init("test")))
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert: all flags reset
|
||||
#expect(!viewModel.torStatusAnnounced)
|
||||
#expect(!viewModel.torInitialReadyAnnounced)
|
||||
#expect(!viewModel.torRestartPending)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import Foundation
|
||||
import Nostr
|
||||
import Testing
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
@Suite(.serialized)
|
||||
struct CommandProcessorTests {
|
||||
private var identityManager = MockIdentityManager(MockKeychain())
|
||||
|
||||
@MainActor
|
||||
@Test func slapNotFoundGrammar() {
|
||||
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
||||
let identityManager = MockIdentityManager(MockKeychain())
|
||||
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
|
||||
let result = processor.process("/slap @system")
|
||||
switch result {
|
||||
case .error(let message):
|
||||
@@ -18,7 +22,8 @@ struct CommandProcessorTests {
|
||||
|
||||
@MainActor
|
||||
@Test func hugNotFoundGrammar() {
|
||||
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
||||
let identityManager = MockIdentityManager(MockKeychain())
|
||||
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
|
||||
let result = processor.process("/hug @system")
|
||||
switch result {
|
||||
case .error(let message):
|
||||
@@ -30,7 +35,8 @@ struct CommandProcessorTests {
|
||||
|
||||
@MainActor
|
||||
@Test func slapUsageMessage() {
|
||||
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
||||
let identityManager = MockIdentityManager(MockKeychain())
|
||||
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
|
||||
let result = processor.process("/slap")
|
||||
switch result {
|
||||
case .error(let message):
|
||||
@@ -39,4 +45,389 @@ struct CommandProcessorTests {
|
||||
Issue.record("Expected error result for usage message")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func msgStartsPrivateChatAndSendsMessage() async {
|
||||
let identityManager = MockIdentityManager(MockKeychain())
|
||||
let context = MockCommandContextProvider()
|
||||
let peerID = PeerID(str: "abcd1234abcd1234")
|
||||
context.nicknameToPeerID["alice"] = peerID
|
||||
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
|
||||
|
||||
let result = await withSelectedChannel(.mesh) {
|
||||
processor.process("/msg @alice hello there")
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let message):
|
||||
#expect(message == "started private chat with alice")
|
||||
default:
|
||||
Issue.record("Expected success result")
|
||||
}
|
||||
#expect(context.startedPrivateChats == [peerID])
|
||||
#expect(context.sentPrivateMessages.count == 1)
|
||||
#expect(context.sentPrivateMessages.first?.content == "hello there")
|
||||
#expect(context.sentPrivateMessages.first?.peerID == peerID)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func whoInMeshListsSortedPeerNicknames() async {
|
||||
let identityManager = MockIdentityManager(MockKeychain())
|
||||
let transport = MockTransport()
|
||||
transport.peerNicknames = [
|
||||
PeerID(str: "b"): "bob",
|
||||
PeerID(str: "a"): "alice"
|
||||
]
|
||||
let processor = CommandProcessor(contextProvider: MockCommandContextProvider(), meshService: transport, identityManager: identityManager)
|
||||
|
||||
let result = await withSelectedChannel(.mesh) {
|
||||
processor.process("/who")
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let message):
|
||||
#expect(message == "online: alice, bob")
|
||||
default:
|
||||
Issue.record("Expected success result")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func whoInGeohashListsVisibleParticipantsExcludingSelf() async throws {
|
||||
let bridge = NostrIdentityBridge(keychain: MockKeychain())
|
||||
let identityManager = MockIdentityManager(MockKeychain())
|
||||
let context = MockCommandContextProvider(idBridge: bridge)
|
||||
let geohash = "u4pruy"
|
||||
let selfPubkey = try bridge.deriveIdentity(forGeohash: geohash).publicKeyHex.lowercased()
|
||||
context.visibleGeoParticipants = [
|
||||
CommandGeoParticipant(id: selfPubkey, displayName: "me"),
|
||||
CommandGeoParticipant(id: String(repeating: "b", count: 64), displayName: "bob")
|
||||
]
|
||||
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
|
||||
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
|
||||
|
||||
let result = await withSelectedChannel(channel) {
|
||||
processor.process("/who")
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let message):
|
||||
#expect(message == "online: bob")
|
||||
default:
|
||||
Issue.record("Expected success result")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func clearInPrivateChatRemovesOnlySelectedConversation() async {
|
||||
let identityManager = MockIdentityManager(MockKeychain())
|
||||
let context = MockCommandContextProvider()
|
||||
let activePeer = PeerID(str: "active")
|
||||
let otherPeer = PeerID(str: "other")
|
||||
context.selectedPrivateChatPeer = activePeer
|
||||
context.privateChats = [
|
||||
activePeer: [makeMessage(sender: "alice", content: "secret")],
|
||||
otherPeer: [makeMessage(sender: "bob", content: "keep")]
|
||||
]
|
||||
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
|
||||
|
||||
let result = await withSelectedChannel(.mesh) {
|
||||
processor.process("/clear")
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .handled:
|
||||
break
|
||||
default:
|
||||
Issue.record("Expected handled result")
|
||||
}
|
||||
#expect(context.privateChats[activePeer] == [])
|
||||
#expect(context.privateChats[otherPeer]?.count == 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func clearInPublicChatClearsTimeline() async {
|
||||
let identityManager = MockIdentityManager(MockKeychain())
|
||||
let context = MockCommandContextProvider()
|
||||
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
|
||||
|
||||
let result = await withSelectedChannel(.mesh) {
|
||||
processor.process("/clear")
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .handled:
|
||||
break
|
||||
default:
|
||||
Issue.record("Expected handled result")
|
||||
}
|
||||
#expect(context.clearCurrentPublicTimelineCallCount == 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func hugInPrivateChatSendsPersonalizedMessageAndLocalEcho() async {
|
||||
let identityManager = MockIdentityManager(MockKeychain())
|
||||
let context = MockCommandContextProvider(nickname: "me")
|
||||
let transport = MockTransport()
|
||||
let peerID = PeerID(str: "abcd1234abcd1234")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.nicknameToPeerID["bob"] = peerID
|
||||
transport.peerNicknames[peerID] = "Bob"
|
||||
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
|
||||
|
||||
let result = await withSelectedChannel(.mesh) {
|
||||
processor.process("/hug @bob")
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .handled:
|
||||
break
|
||||
default:
|
||||
Issue.record("Expected handled result")
|
||||
}
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
#expect(transport.sentPrivateMessages.first?.content == "* 🫂 me hugs you *")
|
||||
#expect(context.localPrivateSystemMessages.first?.content == "🫂 you hugged bob")
|
||||
#expect(context.localPrivateSystemMessages.first?.peerID == peerID)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func slapInPublicChatSendsPublicRawAndEcho() async {
|
||||
let identityManager = MockIdentityManager(MockKeychain())
|
||||
let context = MockCommandContextProvider(nickname: "me")
|
||||
let peerID = PeerID(str: "abcd1234abcd1234")
|
||||
context.nicknameToPeerID["bob"] = peerID
|
||||
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
|
||||
|
||||
let result = await withSelectedChannel(.mesh) {
|
||||
processor.process("/slap @bob")
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .handled:
|
||||
break
|
||||
default:
|
||||
Issue.record("Expected handled result")
|
||||
}
|
||||
#expect(context.sentPublicRawMessages == ["* 🐟 me slaps bob around a bit with a large trout *"])
|
||||
#expect(context.publicSystemMessages == ["🐟 me slaps bob around a bit with a large trout"])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func blockWithoutArgsListsMeshAndGeohashBlocks() async {
|
||||
let identityManager = MockIdentityManager(MockKeychain())
|
||||
let context = MockCommandContextProvider()
|
||||
let transport = MockTransport()
|
||||
let peerID = PeerID(str: "abcd1234abcd1234")
|
||||
transport.peerNicknames[peerID] = "bob"
|
||||
transport.peerFingerprints[peerID] = "fp-bob"
|
||||
context.blockedUsers = ["fp-bob"]
|
||||
context.visibleGeoParticipants = [
|
||||
CommandGeoParticipant(id: String(repeating: "c", count: 64), displayName: "carol")
|
||||
]
|
||||
identityManager.setNostrBlocked(String(repeating: "c", count: 64), isBlocked: true)
|
||||
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
|
||||
|
||||
let result = await withSelectedChannel(.mesh) {
|
||||
processor.process("/block")
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let message):
|
||||
#expect(message == "blocked peers: bob | geohash blocks: carol")
|
||||
default:
|
||||
Issue.record("Expected success result")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func blockAndUnblockMeshPeerUpdateIdentityState() async {
|
||||
let identityManager = MockIdentityManager(MockKeychain())
|
||||
let context = MockCommandContextProvider()
|
||||
let transport = MockTransport()
|
||||
let peerID = PeerID(str: "abcd1234abcd1234")
|
||||
transport.peerFingerprints[peerID] = "fp-bob"
|
||||
context.nicknameToPeerID["bob"] = peerID
|
||||
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
|
||||
|
||||
let blockResult = await withSelectedChannel(.mesh) {
|
||||
processor.process("/block @bob")
|
||||
}
|
||||
switch blockResult {
|
||||
case .success(let message):
|
||||
#expect(message == "blocked bob. you will no longer receive messages from them")
|
||||
default:
|
||||
Issue.record("Expected success result")
|
||||
}
|
||||
#expect(identityManager.isBlocked(fingerprint: "fp-bob"))
|
||||
|
||||
let unblockResult = await withSelectedChannel(.mesh) {
|
||||
processor.process("/unblock bob")
|
||||
}
|
||||
switch unblockResult {
|
||||
case .success(let message):
|
||||
#expect(message == "unblocked bob")
|
||||
default:
|
||||
Issue.record("Expected success result")
|
||||
}
|
||||
#expect(!identityManager.isBlocked(fingerprint: "fp-bob"))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func blockAndUnblockGeohashPeerUseNostrBlockList() async {
|
||||
let identityManager = MockIdentityManager(MockKeychain())
|
||||
let context = MockCommandContextProvider()
|
||||
context.displayNameToNostrPubkey["carol"] = String(repeating: "d", count: 64)
|
||||
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
|
||||
|
||||
let blockResult = await withSelectedChannel(.mesh) {
|
||||
processor.process("/block carol")
|
||||
}
|
||||
switch blockResult {
|
||||
case .success(let message):
|
||||
#expect(message == "blocked carol in geohash chats")
|
||||
default:
|
||||
Issue.record("Expected success result")
|
||||
}
|
||||
#expect(identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64)))
|
||||
|
||||
let unblockResult = await withSelectedChannel(.mesh) {
|
||||
processor.process("/unblock @carol")
|
||||
}
|
||||
switch unblockResult {
|
||||
case .success(let message):
|
||||
#expect(message == "unblocked carol in geohash chats")
|
||||
default:
|
||||
Issue.record("Expected success result")
|
||||
}
|
||||
#expect(!identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64)))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func favoriteCommandIsRejectedOutsideMesh() async {
|
||||
let identityManager = MockIdentityManager(MockKeychain())
|
||||
let processor = CommandProcessor(
|
||||
contextProvider: MockCommandContextProvider(),
|
||||
meshService: MockTransport(),
|
||||
identityManager: identityManager
|
||||
)
|
||||
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: "u4pruy"))
|
||||
|
||||
let result = await withSelectedChannel(channel) {
|
||||
processor.process("/fav alice")
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .error(let message):
|
||||
#expect(message == "favorites are only for mesh peers in #mesh")
|
||||
default:
|
||||
Issue.record("Expected error result")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func withSelectedChannel<T>(_ channel: ChannelID, perform work: @escaping () throws -> T) async rethrows -> T {
|
||||
let originalChannel = LocationChannelManager.shared.selectedChannel
|
||||
await setSelectedChannel(channel)
|
||||
do {
|
||||
let result = try work()
|
||||
await setSelectedChannel(originalChannel)
|
||||
return result
|
||||
} catch {
|
||||
await setSelectedChannel(originalChannel)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func setSelectedChannel(_ channel: ChannelID) async {
|
||||
LocationChannelManager.shared.select(channel)
|
||||
for _ in 0..<40 {
|
||||
if LocationChannelManager.shared.selectedChannel == channel {
|
||||
return
|
||||
}
|
||||
await Task.yield()
|
||||
try? await Task.sleep(nanoseconds: 5_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeMessage(sender: String, content: String) -> BitchatMessage {
|
||||
BitchatMessage(
|
||||
sender: sender,
|
||||
content: content,
|
||||
timestamp: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
isRelay: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MockCommandContextProvider: CommandContextProvider {
|
||||
var nickname: String
|
||||
var selectedPrivateChatPeer: PeerID?
|
||||
var blockedUsers: Set<String> = []
|
||||
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||
let idBridge: NostrIdentityBridge
|
||||
|
||||
var nicknameToPeerID: [String: PeerID] = [:]
|
||||
var visibleGeoParticipants: [CommandGeoParticipant] = []
|
||||
var displayNameToNostrPubkey: [String: String] = [:]
|
||||
|
||||
private(set) var startedPrivateChats: [PeerID] = []
|
||||
private(set) var sentPrivateMessages: [(content: String, peerID: PeerID)] = []
|
||||
private(set) var clearCurrentPublicTimelineCallCount = 0
|
||||
private(set) var sentPublicRawMessages: [String] = []
|
||||
private(set) var localPrivateSystemMessages: [(content: String, peerID: PeerID)] = []
|
||||
private(set) var publicSystemMessages: [String] = []
|
||||
private(set) var toggledFavorites: [PeerID] = []
|
||||
private(set) var favoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
|
||||
|
||||
init(nickname: String = "tester", idBridge: NostrIdentityBridge = NostrIdentityBridge(keychain: MockKeychain())) {
|
||||
self.nickname = nickname
|
||||
self.idBridge = idBridge
|
||||
}
|
||||
|
||||
func getPeerIDForNickname(_ nickname: String) -> PeerID? {
|
||||
nicknameToPeerID[nickname]
|
||||
}
|
||||
|
||||
func getVisibleGeoParticipants() -> [CommandGeoParticipant] {
|
||||
visibleGeoParticipants
|
||||
}
|
||||
|
||||
func nostrPubkeyForDisplayName(_ displayName: String) -> String? {
|
||||
displayNameToNostrPubkey[displayName]
|
||||
}
|
||||
|
||||
func startPrivateChat(with peerID: PeerID) {
|
||||
startedPrivateChats.append(peerID)
|
||||
}
|
||||
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID) {
|
||||
sentPrivateMessages.append((content, peerID))
|
||||
}
|
||||
|
||||
func clearCurrentPublicTimeline() {
|
||||
clearCurrentPublicTimelineCallCount += 1
|
||||
}
|
||||
|
||||
func sendPublicRaw(_ content: String) {
|
||||
sentPublicRawMessages.append(content)
|
||||
}
|
||||
|
||||
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {
|
||||
localPrivateSystemMessages.append((content, peerID))
|
||||
}
|
||||
|
||||
func addPublicSystemMessage(_ content: String) {
|
||||
publicSystemMessages.append(content)
|
||||
}
|
||||
|
||||
func toggleFavorite(peerID: PeerID) {
|
||||
toggledFavorites.append(peerID)
|
||||
}
|
||||
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||
favoriteNotifications.append((peerID, isFavorite))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
import Testing
|
||||
import CryptoKit
|
||||
import struct Foundation.UUID
|
||||
import BitFoundation
|
||||
import Noise
|
||||
@testable import bitchat
|
||||
|
||||
struct PrivateChatE2ETests {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import Testing
|
||||
import struct Foundation.UUID
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
struct PublicChatE2ETests {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
@testable import bitchat
|
||||
|
||||
private func makeTemporaryFileURL(_ name: String) -> URL {
|
||||
FileManager.default.temporaryDirectory.appendingPathComponent(name)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private func makePlatformImage(size: CGSize) -> UIImage {
|
||||
UIGraphicsImageRenderer(size: size).image { context in
|
||||
UIColor.systemTeal.setFill()
|
||||
context.fill(CGRect(origin: .zero, size: size))
|
||||
}
|
||||
}
|
||||
#else
|
||||
private func makePlatformImage(size: CGSize) -> NSImage {
|
||||
let image = NSImage(size: size)
|
||||
image.lockFocus()
|
||||
NSColor.systemTeal.setFill()
|
||||
NSBezierPath(rect: CGRect(origin: .zero, size: size)).fill()
|
||||
image.unlockFocus()
|
||||
return image
|
||||
}
|
||||
#endif
|
||||
|
||||
struct ImageUtilsTests {
|
||||
@Test
|
||||
func processImage_rejectsOversizedSourceFile() throws {
|
||||
let url = makeTemporaryFileURL("image-too-large.bin")
|
||||
try Data(repeating: 0xFF, count: 10 * 1024 * 1024 + 1).write(to: url, options: .atomic)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
#expect(throws: ImageUtilsError.self) {
|
||||
try ImageUtils.processImage(at: url)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func processImage_rejectsInvalidImageData() throws {
|
||||
let url = makeTemporaryFileURL("image-invalid.bin")
|
||||
try Data("not-an-image".utf8).write(to: url, options: .atomic)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
#expect(throws: ImageUtilsError.self) {
|
||||
try ImageUtils.processImage(at: url)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func processImage_writesCompressedJpeg() throws {
|
||||
let image = makePlatformImage(size: CGSize(width: 1024, height: 768))
|
||||
let outputURL = try ImageUtils.processImage(image, maxDimension: 256)
|
||||
defer { try? FileManager.default.removeItem(at: outputURL) }
|
||||
|
||||
let data = try Data(contentsOf: outputURL)
|
||||
|
||||
#expect(outputURL.pathExtension.lowercased() == "jpg")
|
||||
#expect(data.starts(with: Data([0xFF, 0xD8])))
|
||||
#expect(data.count > 0)
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,10 @@
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Nostr
|
||||
import Foundation
|
||||
import CoreBluetooth
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
struct FragmentationTests {
|
||||
@@ -28,7 +30,8 @@ struct FragmentationTests {
|
||||
let ble = BLEService(
|
||||
keychain: mockKeychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: mockIdentityManager
|
||||
identityManager: mockIdentityManager,
|
||||
initializeBluetoothManagers: false
|
||||
)
|
||||
let capture = CaptureDelegate()
|
||||
ble.delegate = capture
|
||||
@@ -63,7 +66,8 @@ struct FragmentationTests {
|
||||
let ble = BLEService(
|
||||
keychain: mockKeychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: mockIdentityManager
|
||||
identityManager: mockIdentityManager,
|
||||
initializeBluetoothManagers: false
|
||||
)
|
||||
let capture = CaptureDelegate()
|
||||
ble.delegate = capture
|
||||
@@ -97,7 +101,8 @@ struct FragmentationTests {
|
||||
let ble = BLEService(
|
||||
keychain: mockKeychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: mockIdentityManager
|
||||
identityManager: mockIdentityManager,
|
||||
initializeBluetoothManagers: false
|
||||
)
|
||||
let capture = CaptureDelegate()
|
||||
ble.delegate = capture
|
||||
@@ -134,7 +139,7 @@ struct FragmentationTests {
|
||||
}
|
||||
}
|
||||
|
||||
try await sleep(1.0)
|
||||
try await capture.waitForReceivedMessages(count: 1, timeout: .seconds(2))
|
||||
|
||||
let message = try #require(capture.receivedMessages.first, "Expected file transfer message")
|
||||
#expect(message.content.hasPrefix("[file]"))
|
||||
@@ -153,7 +158,8 @@ struct FragmentationTests {
|
||||
let ble = BLEService(
|
||||
keychain: mockKeychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: mockIdentityManager
|
||||
identityManager: mockIdentityManager,
|
||||
initializeBluetoothManagers: false
|
||||
)
|
||||
let capture = CaptureDelegate()
|
||||
ble.delegate = capture
|
||||
@@ -205,16 +211,18 @@ extension FragmentationTests {
|
||||
private var expectedPublicMessageCount: Int = 0
|
||||
private var expectedReceivedMessageCount: Int = 0
|
||||
|
||||
var publicMessages: [(peerID: PeerID, nickname: String, content: String)] {
|
||||
private func withLock<T>(_ body: () -> T) -> T {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return _publicMessages
|
||||
return body()
|
||||
}
|
||||
|
||||
var publicMessages: [(peerID: PeerID, nickname: String, content: String)] {
|
||||
withLock { _publicMessages }
|
||||
}
|
||||
|
||||
var receivedMessages: [BitchatMessage] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return _receivedMessages
|
||||
withLock { _receivedMessages }
|
||||
}
|
||||
|
||||
func didReceiveMessage(_ message: BitchatMessage) {
|
||||
@@ -251,27 +259,32 @@ extension FragmentationTests {
|
||||
|
||||
/// Waits for the specified number of public messages to be received
|
||||
func waitForPublicMessages(count: Int, timeout: Duration = .seconds(2)) async throws {
|
||||
lock.lock()
|
||||
if _publicMessages.count >= count {
|
||||
lock.unlock()
|
||||
let isAlreadySatisfied = withLock { () -> Bool in
|
||||
if _publicMessages.count >= count {
|
||||
return true
|
||||
}
|
||||
expectedPublicMessageCount = count
|
||||
return false
|
||||
}
|
||||
if isAlreadySatisfied {
|
||||
return
|
||||
}
|
||||
expectedPublicMessageCount = count
|
||||
lock.unlock()
|
||||
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
group.addTask {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.lock.lock()
|
||||
// Recheck count after acquiring lock to avoid race condition
|
||||
// where message arrives between initial check and continuation install
|
||||
if self._publicMessages.count >= count {
|
||||
self.lock.unlock()
|
||||
let shouldResumeImmediately = self.withLock {
|
||||
// Recheck count after acquiring lock to avoid race condition
|
||||
// where message arrives between initial check and continuation install
|
||||
if self._publicMessages.count >= count {
|
||||
return true
|
||||
}
|
||||
self.publicMessageContinuation = continuation
|
||||
return false
|
||||
}
|
||||
if shouldResumeImmediately {
|
||||
continuation.resume()
|
||||
return
|
||||
}
|
||||
self.publicMessageContinuation = continuation
|
||||
self.lock.unlock()
|
||||
}
|
||||
}
|
||||
group.addTask {
|
||||
@@ -285,27 +298,32 @@ extension FragmentationTests {
|
||||
|
||||
/// Waits for the specified number of received messages
|
||||
func waitForReceivedMessages(count: Int, timeout: Duration = .seconds(2)) async throws {
|
||||
lock.lock()
|
||||
if _receivedMessages.count >= count {
|
||||
lock.unlock()
|
||||
let isAlreadySatisfied = withLock { () -> Bool in
|
||||
if _receivedMessages.count >= count {
|
||||
return true
|
||||
}
|
||||
expectedReceivedMessageCount = count
|
||||
return false
|
||||
}
|
||||
if isAlreadySatisfied {
|
||||
return
|
||||
}
|
||||
expectedReceivedMessageCount = count
|
||||
lock.unlock()
|
||||
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
group.addTask {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.lock.lock()
|
||||
// Recheck count after acquiring lock to avoid race condition
|
||||
// where message arrives between initial check and continuation install
|
||||
if self._receivedMessages.count >= count {
|
||||
self.lock.unlock()
|
||||
let shouldResumeImmediately = self.withLock {
|
||||
// Recheck count after acquiring lock to avoid race condition
|
||||
// where message arrives between initial check and continuation install
|
||||
if self._receivedMessages.count >= count {
|
||||
return true
|
||||
}
|
||||
self.receivedMessageContinuation = continuation
|
||||
return false
|
||||
}
|
||||
if shouldResumeImmediately {
|
||||
continuation.resume()
|
||||
return
|
||||
}
|
||||
self.receivedMessageContinuation = continuation
|
||||
self.lock.unlock()
|
||||
}
|
||||
}
|
||||
group.addTask {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Nostr
|
||||
import Foundation
|
||||
import Combine
|
||||
@testable import bitchat
|
||||
@@ -131,7 +132,7 @@ struct NostrFilterPresenceTests {
|
||||
@MainActor
|
||||
struct ChatViewModelPresenceHandlingTests {
|
||||
|
||||
@Test func handleNostrEvent_presenceUpdatesParticipantTracker() async {
|
||||
@Test func handleNostrEvent_presenceUpdatesParticipantTracker() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
|
||||
@@ -139,18 +140,18 @@ struct ChatViewModelPresenceHandlingTests {
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
|
||||
// Create a presence event (kind 20001)
|
||||
var event = NostrEvent(
|
||||
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .geohashPresence,
|
||||
tags: [["g", geohash]],
|
||||
content: ""
|
||||
)
|
||||
event.id = "presence_evt_1"
|
||||
event.sig = "sig"
|
||||
let signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
|
||||
// Handle the event
|
||||
viewModel.handleNostrEvent(event)
|
||||
viewModel.handleNostrEvent(signed)
|
||||
|
||||
// Allow async processing
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
@@ -160,7 +161,7 @@ struct ChatViewModelPresenceHandlingTests {
|
||||
#expect(count >= 1)
|
||||
}
|
||||
|
||||
@Test func handleNostrEvent_presenceDoesNotAddToTimeline() async {
|
||||
@Test func handleNostrEvent_presenceDoesNotAddToTimeline() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
|
||||
@@ -169,17 +170,17 @@ struct ChatViewModelPresenceHandlingTests {
|
||||
let initialMessageCount = viewModel.messages.count
|
||||
|
||||
// Create a presence event (kind 20001)
|
||||
var event = NostrEvent(
|
||||
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .geohashPresence,
|
||||
tags: [["g", geohash]],
|
||||
content: ""
|
||||
)
|
||||
event.id = "presence_evt_2"
|
||||
event.sig = "sig"
|
||||
let signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
|
||||
viewModel.handleNostrEvent(event)
|
||||
viewModel.handleNostrEvent(signed)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
|
||||
@@ -187,24 +188,24 @@ struct ChatViewModelPresenceHandlingTests {
|
||||
#expect(viewModel.messages.count == initialMessageCount)
|
||||
}
|
||||
|
||||
@Test func handleNostrEvent_chatMessageUpdatesParticipant() async {
|
||||
@Test func handleNostrEvent_chatMessageUpdatesParticipant() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
|
||||
// Create a chat event (kind 20000) - NOT presence
|
||||
var event = NostrEvent(
|
||||
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: [["g", geohash]],
|
||||
content: "Hello world"
|
||||
)
|
||||
event.id = "chat_evt_1"
|
||||
event.sig = "sig"
|
||||
let signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
|
||||
viewModel.handleNostrEvent(event)
|
||||
viewModel.handleNostrEvent(signed)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
|
||||
@@ -223,25 +224,25 @@ struct ChatViewModelPresenceHandlingTests {
|
||||
#expect(chatKind == 20000)
|
||||
}
|
||||
|
||||
@Test func subscribeNostrEvent_acceptsPresenceKind() async {
|
||||
@Test func subscribeNostrEvent_acceptsPresenceKind() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
|
||||
// Create presence event
|
||||
var event = NostrEvent(
|
||||
pubkey: "test1234test1234test1234test1234test1234test1234test1234test1234",
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .geohashPresence,
|
||||
tags: [["g", geohash]],
|
||||
content: ""
|
||||
)
|
||||
event.id = "subscribe_presence_evt"
|
||||
event.sig = "sig"
|
||||
let signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
|
||||
// subscribeNostrEvent should accept kind 20001
|
||||
viewModel.subscribeNostrEvent(event)
|
||||
viewModel.subscribeNostrEvent(signed)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
|
||||
@@ -250,7 +251,7 @@ struct ChatViewModelPresenceHandlingTests {
|
||||
#expect(count >= 1)
|
||||
}
|
||||
|
||||
@Test func subscribeNostrEvent_presenceForNonActiveGeohash() async {
|
||||
@Test func subscribeNostrEvent_presenceForNonActiveGeohash() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let activeGeohash = "u4pruydq"
|
||||
let otherGeohash = "87yw7"
|
||||
@@ -258,18 +259,18 @@ struct ChatViewModelPresenceHandlingTests {
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: activeGeohash)))
|
||||
|
||||
// Create presence event for a DIFFERENT geohash
|
||||
var event = NostrEvent(
|
||||
pubkey: "other1234other1234other1234other1234other1234other1234other1234",
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .geohashPresence,
|
||||
tags: [["g", otherGeohash]],
|
||||
content: ""
|
||||
)
|
||||
event.id = "other_geohash_presence"
|
||||
event.sig = "sig"
|
||||
let signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
|
||||
// Use subscribeNostrEvent with geohash parameter
|
||||
viewModel.subscribeNostrEvent(event, gh: otherGeohash)
|
||||
viewModel.subscribeNostrEvent(signed, gh: otherGeohash)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
struct GossipSyncManagerTests {
|
||||
@@ -7,12 +8,14 @@ struct GossipSyncManagerTests {
|
||||
private let myPeerID = PeerID(str: "0102030405060708")
|
||||
|
||||
@Test func concurrentPacketIntakeAndSyncRequest() async throws {
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID)
|
||||
let requestSyncManager = RequestSyncManager()
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, requestSyncManager: requestSyncManager)
|
||||
let delegate = RecordingDelegate()
|
||||
manager.delegate = delegate
|
||||
|
||||
try await confirmation("sync request sent") { sent in
|
||||
delegate.onSend = {
|
||||
delegate.onSend = nil
|
||||
sent()
|
||||
}
|
||||
|
||||
@@ -34,7 +37,7 @@ struct GossipSyncManagerTests {
|
||||
}
|
||||
|
||||
manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0)
|
||||
try await sleep(0.002)
|
||||
try await TestHelpers.waitFor({ delegate.lastPacket != nil }, timeout: TestConstants.shortTimeout)
|
||||
}
|
||||
|
||||
let lastPacket = try #require(delegate.lastPacket, "Expected sync packet to be sent")
|
||||
@@ -47,7 +50,8 @@ struct GossipSyncManagerTests {
|
||||
config.stalePeerCleanupIntervalSeconds = 0
|
||||
config.stalePeerTimeoutSeconds = 5
|
||||
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
||||
let requestSyncManager = RequestSyncManager()
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||
let peerHex = "0011223344556677"
|
||||
let senderData = try #require(Data(hexString: peerHex))
|
||||
let initialTimestampMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
@@ -92,7 +96,8 @@ struct GossipSyncManagerTests {
|
||||
config.stalePeerTimeoutSeconds = 5
|
||||
config.maxMessageAgeSeconds = 100
|
||||
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
||||
let requestSyncManager = RequestSyncManager()
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||
let peerHex = "8899aabbccddeeff"
|
||||
let senderData = try #require(Data(hexString: peerHex))
|
||||
let staleTimestampMs = UInt64(Date().addingTimeInterval(-(config.stalePeerTimeoutSeconds + 1)).timeIntervalSince1970 * 1000)
|
||||
@@ -136,7 +141,8 @@ struct GossipSyncManagerTests {
|
||||
config.fileTransferSyncIntervalSeconds = 1
|
||||
config.maintenanceIntervalSeconds = 0
|
||||
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
||||
let requestSyncManager = RequestSyncManager()
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||
let delegate = RecordingDelegate()
|
||||
manager.delegate = delegate
|
||||
|
||||
@@ -206,7 +212,8 @@ struct GossipSyncManagerTests {
|
||||
config.fragmentSyncIntervalSeconds = 0
|
||||
config.fileTransferSyncIntervalSeconds = 0
|
||||
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
||||
let requestSyncManager = RequestSyncManager()
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||
let delegate = RecordingDelegate()
|
||||
manager.delegate = delegate
|
||||
|
||||
@@ -240,7 +247,7 @@ struct GossipSyncManagerTests {
|
||||
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .fragment)
|
||||
manager.handleRequestSync(from: peer, request: request)
|
||||
|
||||
try await sleep(0.01)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
let sentPackets = delegate.packets
|
||||
#expect(sentPackets.count == 1)
|
||||
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
|
||||
@@ -268,4 +275,8 @@ private final class RecordingDelegate: GossipSyncManager.Delegate {
|
||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
|
||||
packet
|
||||
}
|
||||
|
||||
func getConnectedPeers() -> [PeerID] {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import Testing
|
||||
import BitFoundation
|
||||
import Noise
|
||||
@testable import bitchat
|
||||
|
||||
struct IntegrationTests {
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import BitFoundation
|
||||
import Noise
|
||||
@testable import bitchat
|
||||
|
||||
final class TestNetworkHelper {
|
||||
|
||||
@@ -1,530 +0,0 @@
|
||||
//import Foundation
|
||||
//import XCTest
|
||||
//@testable import bitchat
|
||||
//
|
||||
//private let localizationTestsDirectoryURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
|
||||
//private let testsRootURL = localizationTestsDirectoryURL.deletingLastPathComponent()
|
||||
//private let repoRootURL = testsRootURL.deletingLastPathComponent()
|
||||
//
|
||||
//final class LocalizationCatalogTests: XCTestCase {
|
||||
// // Ensures every app locale includes exactly the same keys as Base.
|
||||
// func testAppCatalogLocaleParity() throws {
|
||||
// let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||
// assertLocaleParity(context: context, catalogName: "App")
|
||||
// }
|
||||
//
|
||||
// // Verifies format placeholders stay consistent across app locales.
|
||||
// func testAppCatalogPlaceholderConsistency() throws {
|
||||
// let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||
// assertPlaceholderConsistency(context: context, catalogName: "App")
|
||||
// }
|
||||
//
|
||||
// // Guards a core set of app strings from going empty per locale.
|
||||
// func testAppPrimaryKeysNonEmpty() throws {
|
||||
// let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||
// let primaryKeys = try loadPrimaryKeys().app
|
||||
// assertPrimaryKeysPresent(context: context, keys: primaryKeys, catalogName: "App")
|
||||
// }
|
||||
//
|
||||
// // Ensures every share extension locale matches Base key coverage.
|
||||
// func testShareExtensionCatalogLocaleParity() throws {
|
||||
// let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||
// assertLocaleParity(context: context, catalogName: "ShareExtension")
|
||||
// }
|
||||
//
|
||||
// // Verifies share extension placeholders align across locales.
|
||||
// func testShareExtensionCatalogPlaceholderConsistency() throws {
|
||||
// let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||
// assertPlaceholderConsistency(context: context, catalogName: "ShareExtension")
|
||||
// }
|
||||
//
|
||||
// // Confirms critical share extension strings remain non-empty per locale.
|
||||
// func testShareExtensionPrimaryKeysNonEmpty() throws {
|
||||
// let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||
// let primaryKeys = try loadPrimaryKeys().shareExtension
|
||||
// assertPrimaryKeysPresent(context: context, keys: primaryKeys, catalogName: "ShareExtension")
|
||||
// }
|
||||
//
|
||||
// // Validates that configured locales contain expected string values.
|
||||
// func testLocalizationExpectedValues() throws {
|
||||
// let appContext = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||
// let shareContext = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||
// let config = try loadPrimaryKeys()
|
||||
//
|
||||
// guard let testLocales = config.testLocales else {
|
||||
// // If no testLocales specified, skip this test
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// guard let expectedValues = config.expectedValues else {
|
||||
// XCTFail("No expectedValues configured in PrimaryLocalizationKeys.json")
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// // Loop through each locale to test
|
||||
// for locale in testLocales {
|
||||
// guard let localeExpectedValues = expectedValues[locale] else {
|
||||
// XCTFail("No expected values configured for locale '\(locale)' in PrimaryLocalizationKeys.json")
|
||||
// continue
|
||||
// }
|
||||
//
|
||||
// // Test each expected key/value pair for this locale
|
||||
// for (key, expectedValue) in localeExpectedValues {
|
||||
// if config.app.contains(key) {
|
||||
// assertLocaleStringValue(context: appContext, locale: locale, key: key, expectedValue: expectedValue, catalogName: "App")
|
||||
// } else if config.shareExtension.contains(key) {
|
||||
// assertLocaleStringValue(context: shareContext, locale: locale, key: key, expectedValue: expectedValue, catalogName: "ShareExtension")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Ensures configured test locales are present and complete.
|
||||
// func testConfiguredLocalesCompleteness() throws {
|
||||
// let appContext = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||
// let shareContext = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||
// let config = try loadPrimaryKeys()
|
||||
//
|
||||
// guard let testLocales = config.testLocales else {
|
||||
// // If no testLocales specified, skip this test
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// let baseLocale = appContext.baseLocale
|
||||
// let baseAppKeys = appContext.keysByLocale[baseLocale] ?? Set()
|
||||
// let baseShareKeys = shareContext.keysByLocale[baseLocale] ?? Set()
|
||||
//
|
||||
// for locale in testLocales {
|
||||
// // Skip base locale comparison with itself
|
||||
// if locale == baseLocale { continue }
|
||||
//
|
||||
// // Verify locale is present in both catalogs
|
||||
// XCTAssertTrue(appContext.locales.contains(locale), "Locale '\(locale)' missing from app catalog")
|
||||
// XCTAssertTrue(shareContext.locales.contains(locale), "Locale '\(locale)' missing from share extension catalog")
|
||||
//
|
||||
// // Verify locale has same number of keys as base locale
|
||||
// let appLocaleKeys = appContext.keysByLocale[locale] ?? Set()
|
||||
// XCTAssertEqual(appLocaleKeys.count, baseAppKeys.count, "Locale '\(locale)' app catalog missing keys compared to \(baseLocale)")
|
||||
//
|
||||
// let shareLocaleKeys = shareContext.keysByLocale[locale] ?? Set()
|
||||
// XCTAssertEqual(shareLocaleKeys.count, baseShareKeys.count, "Locale '\(locale)' share extension catalog missing keys compared to \(baseLocale)")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // MARK: - Assertions
|
||||
//
|
||||
// private func assertLocaleParity(context: CatalogContext, catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
|
||||
// let baseLocale = context.baseLocale
|
||||
// guard let baseKeys = context.keysByLocale[baseLocale] else {
|
||||
// return XCTFail("Missing base locale \(baseLocale) in \(catalogName) catalog", file: file, line: line)
|
||||
// }
|
||||
//
|
||||
// for (locale, keys) in context.keysByLocale.sorted(by: { $0.key < $1.key }) {
|
||||
// XCTAssertEqual(keys, baseKeys, "Locale \(locale) has key mismatch in \(catalogName) catalog", file: file, line: line)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private func assertPlaceholderConsistency(context: CatalogContext, catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
|
||||
// let baseLocale = context.baseLocale
|
||||
// guard let baseSignatures = context.placeholderSignature[baseLocale] else {
|
||||
// return XCTFail("Missing base placeholder signature for \(catalogName)", file: file, line: line)
|
||||
// }
|
||||
//
|
||||
// for (locale, localeSignatures) in context.placeholderSignature.sorted(by: { $0.key < $1.key }) {
|
||||
// guard locale != baseLocale else { continue }
|
||||
// for key in baseSignatures.keys.sorted() {
|
||||
// guard let baseMap = baseSignatures[key] else {
|
||||
// continue
|
||||
// }
|
||||
// guard let localeMap = localeSignatures[key] else {
|
||||
// return XCTFail("Key \(key) missing for locale \(locale) in \(catalogName) catalog", file: file, line: line)
|
||||
// }
|
||||
// for path in baseMap.keys.sorted() {
|
||||
// let expected = normalizedPlaceholders(baseMap[path, default: []])
|
||||
// let actual = normalizedPlaceholders(localeMap[path, default: []])
|
||||
// XCTAssertEqual(actual, expected, "Placeholder mismatch for key \(key) at \(path) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||
// }
|
||||
// for (localePath, localeTokens) in localeMap {
|
||||
// guard baseMap[localePath] == nil else { continue }
|
||||
// guard let fallback = fallbackPath(for: localePath, baseMap: baseMap) else {
|
||||
// XCTFail("Unexpected variation \(localePath) for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||
// continue
|
||||
// }
|
||||
// let expected = normalizedPlaceholders(baseMap[fallback, default: []])
|
||||
// let actual = normalizedPlaceholders(localeTokens)
|
||||
// XCTAssertEqual(actual, expected, "Placeholder mismatch for key \(key) at \(localePath) (fallback \(fallback)) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private func assertPrimaryKeysPresent(context: CatalogContext, keys: [String], catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
|
||||
// for key in keys {
|
||||
// guard let entry = context.catalog.strings[key] else {
|
||||
// XCTFail("Missing primary key \(key) in \(catalogName) catalog", file: file, line: line)
|
||||
// continue
|
||||
// }
|
||||
// for locale in context.locales.sorted() {
|
||||
// guard let localization = entry.localizations[locale], let unit = localization.stringUnit else {
|
||||
// XCTFail("Missing localization for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||
// continue
|
||||
// }
|
||||
// let segments = gatherSegments(from: unit)
|
||||
// XCTAssertFalse(segments.isEmpty, "No content for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||
// for segment in segments {
|
||||
// let trimmed = segment.value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
// XCTAssertFalse(trimmed.isEmpty, "Empty translation for key \(key) at \(segment.path) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private func assertLocaleStringValue(context: CatalogContext, locale: String, key: String, expectedValue: String, catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
|
||||
// guard let entry = context.catalog.strings[key] else {
|
||||
// XCTFail("Missing key \(key) in \(catalogName) catalog", file: file, line: line)
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// guard let localization = entry.localizations[locale], let unit = localization.stringUnit else {
|
||||
// XCTFail("Missing \(locale) localization for key \(key) in \(catalogName) catalog", file: file, line: line)
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// // For simple strings (non-pluralized)
|
||||
// if let actualValue = unit.value {
|
||||
// XCTAssertEqual(actualValue, expectedValue, "\(locale) translation mismatch for key \(key) in \(catalogName) catalog. Expected: '\(expectedValue)', Actual: '\(actualValue)'", file: file, line: line)
|
||||
// } else {
|
||||
// XCTFail("Key \(key) has no value in \(locale) localization for \(catalogName) catalog", file: file, line: line)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // MARK: - Loading
|
||||
//
|
||||
// private func loadContext(relativePath: String) throws -> CatalogContext {
|
||||
// let catalog = try loadCatalog(relativePath: relativePath)
|
||||
// let locales = catalog.locales
|
||||
// let baseLocale = catalog.sourceLanguage
|
||||
// var keysByLocale: [String: Set<String>] = [:]
|
||||
// var placeholderSignature: [String: [String: [String: [String]]]] = [:]
|
||||
//
|
||||
// for locale in locales {
|
||||
// var localeKeys: Set<String> = []
|
||||
// var localePlaceholders: [String: [String: [String]]] = [:]
|
||||
// for (key, entry) in catalog.strings {
|
||||
// guard let localization = entry.localizations[locale], let unit = localization.stringUnit else {
|
||||
// continue
|
||||
// }
|
||||
// localeKeys.insert(key)
|
||||
// let segments = gatherSegments(from: unit)
|
||||
// var pathMap: [String: [String]] = [:]
|
||||
// for segment in segments {
|
||||
// pathMap[segment.path] = placeholders(in: segment.value)
|
||||
// }
|
||||
// localePlaceholders[key] = pathMap
|
||||
// }
|
||||
// keysByLocale[locale] = localeKeys
|
||||
// placeholderSignature[locale] = localePlaceholders
|
||||
// }
|
||||
//
|
||||
// return CatalogContext(catalog: catalog, locales: locales, baseLocale: baseLocale, keysByLocale: keysByLocale, placeholderSignature: placeholderSignature)
|
||||
// }
|
||||
//
|
||||
// private func loadCatalog(relativePath: String) throws -> StringCatalog {
|
||||
// let url = repoRootURL.appendingPathComponent(relativePath)
|
||||
// let data = try Data(contentsOf: url)
|
||||
// return try JSONDecoder().decode(StringCatalog.self, from: data)
|
||||
// }
|
||||
//
|
||||
// private func loadPrimaryKeys() throws -> PrimaryKeyConfig {
|
||||
// let url = localizationTestsDirectoryURL.appendingPathComponent("PrimaryLocalizationKeys.json")
|
||||
// let data = try Data(contentsOf: url)
|
||||
// return try JSONDecoder().decode(PrimaryKeyConfig.self, from: data)
|
||||
// }
|
||||
//
|
||||
// // MARK: - Regression Tests
|
||||
//
|
||||
// /// Helper method to access the app bundle for localization testing.
|
||||
// /// This ensures tests use the actual app's Localizable.xcstrings instead of
|
||||
// /// the test bundle, preventing false positives where localization keys are
|
||||
// /// returned instead of translated strings.
|
||||
// private func appBundle() -> Bundle {
|
||||
// Bundle(for: ChatViewModel.self)
|
||||
// }
|
||||
//
|
||||
// /// Tests to prevent regression of the pluralization format string issue
|
||||
// /// These tests ensure that String(localized:) properly handles
|
||||
// /// pluralized format strings with %#@variable@ syntax
|
||||
// func testPluralizedFormatStringsDoNotCrash() {
|
||||
// // These are the exact calls that were causing runtime errors
|
||||
//
|
||||
// // Test 1: content.accessibility.people_count with Int argument
|
||||
// XCTAssertNoThrow({
|
||||
// let result = String(
|
||||
// localized: "content.accessibility.people_count",
|
||||
// bundle: appBundle(),
|
||||
// comment: "Accessibility label announcing number of people in header"
|
||||
// )
|
||||
// XCTAssertFalse(result.isEmpty, "Should return formatted string")
|
||||
// // Verify we're getting actual localized content, not just the key
|
||||
// XCTAssertNotEqual(result, "content.accessibility.people_count", "Should return localized string, not key")
|
||||
// }(), "People count localization should not throw")
|
||||
//
|
||||
// // Test 2: location_notes.header with String and Int arguments
|
||||
// XCTAssertNoThrow({
|
||||
// let result = String(
|
||||
// localized: "location_notes.header",
|
||||
// bundle: appBundle(),
|
||||
// comment: "Header displaying the geohash and localized note count"
|
||||
// )
|
||||
// XCTAssertFalse(result.isEmpty, "Should return formatted string")
|
||||
// // Verify we're getting actual localized content, not just the key
|
||||
// XCTAssertNotEqual(result, "location_notes.header", "Should return localized string, not key")
|
||||
// }(), "Location notes header localization should not throw")
|
||||
// }
|
||||
//
|
||||
// func testFormatStringArgumentMatching() {
|
||||
// // Verify that the format strings properly handle their expected arguments
|
||||
//
|
||||
// // People count expects: %#@people@ format with Int
|
||||
// let peopleResult = String(
|
||||
// localized: "content.accessibility.people_count",
|
||||
// bundle: appBundle(),
|
||||
// comment: "Test"
|
||||
// )
|
||||
//
|
||||
// // Should not show format specifiers in the base string
|
||||
// XCTAssertFalse(peopleResult.isEmpty, "Should return non-empty string")
|
||||
//
|
||||
// // Location notes expects: #%@ • %#@note_count@ format with String, Int
|
||||
// let notesResult = String(
|
||||
// localized: "location_notes.header",
|
||||
// bundle: appBundle(),
|
||||
// comment: "Test"
|
||||
// )
|
||||
//
|
||||
// XCTAssertFalse(notesResult.isEmpty, "Should return non-empty string")
|
||||
// }
|
||||
//
|
||||
// func testPluralizedStringsWithArguments() {
|
||||
// // Test the pluralized strings with actual format arguments
|
||||
//
|
||||
// // Test people count with different values
|
||||
// let testCases = [0, 1, 2, 5]
|
||||
//
|
||||
// for count in testCases {
|
||||
// let result = String(
|
||||
// format: String(localized: "content.accessibility.people_count", bundle: appBundle(), comment: "Test"),
|
||||
// locale: .current,
|
||||
// count
|
||||
// )
|
||||
//
|
||||
// // Just verify it doesn't crash and returns a reasonable string
|
||||
// XCTAssertFalse(result.isEmpty,
|
||||
// "People count should return non-empty string for count \(count)")
|
||||
// XCTAssertTrue(result.contains("\(count)"),
|
||||
// "Result should contain the count number for count \(count)")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func testLocationNotesHeaderWithArguments() {
|
||||
// let geohash = "abc123"
|
||||
// let testCases = [0, 1, 2, 10]
|
||||
//
|
||||
// for count in testCases {
|
||||
// let result = String(
|
||||
// format: String(localized: "location_notes.header", bundle: appBundle(), comment: "Test"),
|
||||
// locale: .current,
|
||||
// geohash, count
|
||||
// )
|
||||
//
|
||||
// // Verify basic structure is maintained
|
||||
// XCTAssertTrue(result.contains(geohash),
|
||||
// "Result should contain geohash for count \(count)")
|
||||
// XCTAssertTrue(result.contains("\(count)"),
|
||||
// "Result should contain count for count \(count)")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func testLocationChannelsRowTitleWithArguments() {
|
||||
// let label = "test-channel"
|
||||
// let testCases = [0, 1, 2, 5]
|
||||
//
|
||||
// for count in testCases {
|
||||
// let result = String(
|
||||
// format: String(localized: "location_channels.row_title", bundle: appBundle(), comment: "Test"),
|
||||
// locale: .current,
|
||||
// label, count
|
||||
// )
|
||||
//
|
||||
// // Verify basic structure is maintained
|
||||
// XCTAssertTrue(result.contains(label),
|
||||
// "Result should contain label for count \(count)")
|
||||
// XCTAssertTrue(result.contains("\(count)"),
|
||||
// "Result should contain count for count \(count)")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func testNoArgumentsLocalization() {
|
||||
// // Test that strings without arguments still work
|
||||
// let result = String(
|
||||
// localized: "common.ok",
|
||||
// bundle: appBundle(),
|
||||
// comment: "OK button text"
|
||||
// )
|
||||
//
|
||||
// XCTAssertFalse(result.isEmpty, "Simple localization should work")
|
||||
// }
|
||||
//
|
||||
// func testLocalizationEdgeCases() {
|
||||
// // Test edge cases that might cause issues
|
||||
//
|
||||
// // Large numbers
|
||||
// let largeNumberResult = String(
|
||||
// format: String(localized: "content.accessibility.people_count", bundle: appBundle(), comment: "Test"),
|
||||
// locale: .current,
|
||||
// 1000000
|
||||
// )
|
||||
// XCTAssertTrue(largeNumberResult.contains("1000000"), "Should handle large numbers")
|
||||
//
|
||||
// // Zero
|
||||
// let zeroResult = String(
|
||||
// format: String(localized: "content.accessibility.people_count", bundle: appBundle(), comment: "Test"),
|
||||
// locale: .current,
|
||||
// 0
|
||||
// )
|
||||
// XCTAssertTrue(zeroResult.contains("0"), "Should handle zero")
|
||||
//
|
||||
// // Empty geohash (edge case)
|
||||
// let emptyGeohashResult = String(
|
||||
// format: String(localized: "location_notes.header", bundle: appBundle(), comment: "Test"),
|
||||
// locale: .current,
|
||||
// "", 1
|
||||
// )
|
||||
// XCTAssertFalse(emptyGeohashResult.isEmpty, "Should handle empty geohash")
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//// MARK: - Helpers
|
||||
//
|
||||
//private struct CatalogContext {
|
||||
// let catalog: StringCatalog
|
||||
// let locales: [String]
|
||||
// let baseLocale: String
|
||||
// let keysByLocale: [String: Set<String>]
|
||||
// let placeholderSignature: [String: [String: [String: [String]]]]
|
||||
//}
|
||||
//
|
||||
//private struct StringCatalog: Decodable {
|
||||
// let sourceLanguage: String
|
||||
// let strings: [String: CatalogEntry]
|
||||
//
|
||||
// var locales: [String] {
|
||||
// var localeSet: Set<String> = []
|
||||
// for entry in strings.values {
|
||||
// localeSet.formUnion(entry.localizations.keys)
|
||||
// }
|
||||
// return localeSet.sorted()
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//private struct CatalogEntry: Decodable {
|
||||
// let localizations: [String: CatalogLocalization]
|
||||
//}
|
||||
//
|
||||
//private struct CatalogLocalization: Decodable {
|
||||
// let stringUnit: CatalogStringUnit?
|
||||
//}
|
||||
//
|
||||
//private struct CatalogStringUnit: Decodable {
|
||||
// let state: String
|
||||
// let value: String?
|
||||
// let variations: CatalogVariations?
|
||||
// let comment: String?
|
||||
//}
|
||||
//
|
||||
//private struct CatalogVariations: Decodable {
|
||||
// let plural: [String: [String: CatalogVariationValue]]?
|
||||
//}
|
||||
//
|
||||
//private struct CatalogVariationValue: Decodable {
|
||||
// let stringUnit: CatalogStringUnit?
|
||||
//}
|
||||
//
|
||||
//private struct Segment {
|
||||
// let components: [String]
|
||||
// let value: String
|
||||
//
|
||||
// var path: String {
|
||||
// components.isEmpty ? "base" : components.joined(separator: ".")
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//private func gatherSegments(from unit: CatalogStringUnit, prefix: [String] = []) -> [Segment] {
|
||||
// var segments: [Segment] = []
|
||||
// if let value = unit.value {
|
||||
// segments.append(Segment(components: prefix, value: value))
|
||||
// } else if prefix.isEmpty {
|
||||
// segments.append(Segment(components: [], value: ""))
|
||||
// }
|
||||
// if let plural = unit.variations?.plural {
|
||||
// for (variable, categories) in plural.sorted(by: { $0.key < $1.key }) {
|
||||
// for (category, variation) in categories.sorted(by: { $0.key < $1.key }) {
|
||||
// if let nested = variation.stringUnit {
|
||||
// var nextPrefix = prefix
|
||||
// nextPrefix.append("plural")
|
||||
// nextPrefix.append(variable)
|
||||
// nextPrefix.append(category)
|
||||
// segments.append(contentsOf: gatherSegments(from: nested, prefix: nextPrefix))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// return segments
|
||||
//}
|
||||
//
|
||||
//private func normalizedPlaceholders(_ tokens: [String]) -> [String] {
|
||||
// tokens.sorted()
|
||||
//}
|
||||
//
|
||||
//private func fallbackPath(for localePath: String, baseMap: [String: [String]]) -> String? {
|
||||
// let parts = localePath.split(separator: ".")
|
||||
// guard parts.count == 3, parts.first == "plural" else {
|
||||
// return nil
|
||||
// }
|
||||
// let variable = parts[1]
|
||||
// let otherKey = "plural.\(variable).other"
|
||||
// if baseMap[otherKey] != nil {
|
||||
// return otherKey
|
||||
// }
|
||||
// let oneKey = "plural.\(variable).one"
|
||||
// if baseMap[oneKey] != nil {
|
||||
// return oneKey
|
||||
// }
|
||||
// return nil
|
||||
//}
|
||||
//
|
||||
//private let placeholderRegex: NSRegularExpression = {
|
||||
// let pattern = "%(?:\\d+\\$)?#@[A-Za-z0-9_]+@|%(?:\\d+\\$)?[#0\\- +'\"]*(?:\\d+|\\*)?(?:\\.\\d+)?(?:hh|h|ll|l|z|t|L)?[a-zA-Z@]"
|
||||
// return try! NSRegularExpression(pattern: pattern, options: [])
|
||||
//}()
|
||||
//
|
||||
//private func placeholders(in string: String) -> [String] {
|
||||
// let range = NSRange(location: 0, length: (string as NSString).length)
|
||||
// let matches = placeholderRegex.matches(in: string, options: [], range: range)
|
||||
// var tokens: [String] = []
|
||||
// for match in matches {
|
||||
// if let range = Range(match.range, in: string) {
|
||||
// let token = String(string[range])
|
||||
// if token == "%%" { continue }
|
||||
// tokens.append(token)
|
||||
// }
|
||||
// }
|
||||
// return tokens
|
||||
//}
|
||||
//
|
||||
//private struct PrimaryKeyConfig: Decodable {
|
||||
// let app: [String]
|
||||
// let shareExtension: [String]
|
||||
// let expectedValues: [String: [String: String]]?
|
||||
// let testLocales: [String]?
|
||||
//}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Testing
|
||||
import Nostr
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
@@ -44,4 +45,12 @@ struct LocationChannelsTests {
|
||||
let id2 = try idBridge.deriveIdentity(forGeohash: gh)
|
||||
#expect(id1.publicKeyHex == id2.publicKeyHex)
|
||||
}
|
||||
|
||||
@Test func geohashNeighborsNearPoleSkipOutOfBoundsCells() {
|
||||
let nearPole = Geohash.encode(latitude: 89.9999, longitude: 0.0, precision: 8)
|
||||
let neighbors = Geohash.neighbors(of: nearPole)
|
||||
|
||||
#expect(neighbors.isEmpty == false)
|
||||
#expect(neighbors.count < 8)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +1,53 @@
|
||||
import Testing
|
||||
import Nostr
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
@MainActor
|
||||
struct LocationNotesManagerTests {
|
||||
// func testSubscribeWithoutRelaysSetsNoRelaysState() {
|
||||
// var subscribeCalled = false
|
||||
// let deps = LocationNotesDependencies(
|
||||
// relayLookup: { _, _ in [] },
|
||||
// subscribe: { _, _, _, _, _ in
|
||||
// subscribeCalled = true
|
||||
// },
|
||||
// unsubscribe: { _ in },
|
||||
// sendEvent: { _, _ in },
|
||||
// deriveIdentity: { _ in fatalError("should not derive identity") },
|
||||
// now: { Date() }
|
||||
// )
|
||||
//
|
||||
// let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
||||
//
|
||||
// XCTAssertFalse(subscribeCalled)
|
||||
// XCTAssertEqual(manager.state, .noRelays)
|
||||
// XCTAssertTrue(manager.initialLoadComplete)
|
||||
// XCTAssertEqual(manager.errorMessage, String(localized: "location_notes.error.no_relays"))
|
||||
// // Make sure we're getting an actual translated value and not the localization key
|
||||
// XCTAssertNotEqual(manager.errorMessage, "location_notes.error.no_relays")
|
||||
// }
|
||||
//
|
||||
// func testSendWhenNoRelaysSurfacesError() {
|
||||
// var sendCalled = false
|
||||
// let deps = LocationNotesDependencies(
|
||||
// relayLookup: { _, _ in [] },
|
||||
// subscribe: { _, _, _, _, _ in },
|
||||
// unsubscribe: { _ in },
|
||||
// sendEvent: { _, _ in sendCalled = true },
|
||||
// deriveIdentity: { _ in throw TestError.shouldNotDerive },
|
||||
// now: { Date() }
|
||||
// )
|
||||
//
|
||||
// let manager = LocationNotesManager(geohash: "zzzzzzzz", dependencies: deps)
|
||||
// manager.send(content: "hello", nickname: "tester")
|
||||
//
|
||||
// XCTAssertFalse(sendCalled)
|
||||
// XCTAssertEqual(manager.state, .noRelays)
|
||||
// XCTAssertEqual(manager.errorMessage, String(localized: "location_notes.error.no_relays"))
|
||||
// // Make sure we're getting an actual translated value and not the localization key
|
||||
// XCTAssertNotEqual(manager.errorMessage, "location_notes.error.no_relays")
|
||||
// }
|
||||
@Test
|
||||
func subscribeWithoutRelays_setsNoRelaysState() {
|
||||
var subscribeCalled = false
|
||||
let deps = LocationNotesDependencies(
|
||||
relayLookup: { _, _ in [] },
|
||||
subscribe: { _, _, _, _, _ in
|
||||
subscribeCalled = true
|
||||
},
|
||||
unsubscribe: { _ in },
|
||||
sendEvent: { _, _ in },
|
||||
deriveIdentity: { _ in try NostrIdentity.generate() },
|
||||
now: { Date() }
|
||||
)
|
||||
|
||||
@Test func subscribeUsesGeoRelaysAndAppendsNotes() {
|
||||
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
||||
|
||||
#expect(subscribeCalled == false)
|
||||
#expect(manager.state == .noRelays)
|
||||
#expect(manager.initialLoadComplete)
|
||||
#expect(manager.errorMessage == String(localized: "location_notes.error.no_relays"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func sendWithoutRelays_surfacesNoRelaysError() {
|
||||
var sendCalled = false
|
||||
let deps = LocationNotesDependencies(
|
||||
relayLookup: { _, _ in [] },
|
||||
subscribe: { _, _, _, _, _ in },
|
||||
unsubscribe: { _ in },
|
||||
sendEvent: { _, _ in sendCalled = true },
|
||||
deriveIdentity: { _ in throw TestError.shouldNotDerive },
|
||||
now: { Date() }
|
||||
)
|
||||
|
||||
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
||||
manager.send(content: "hello", nickname: "tester")
|
||||
|
||||
#expect(sendCalled == false)
|
||||
#expect(manager.state == .noRelays)
|
||||
#expect(manager.errorMessage == String(localized: "location_notes.error.no_relays"))
|
||||
}
|
||||
|
||||
@Test func subscribeUsesGeoRelaysAndAppendsNotes() throws {
|
||||
var relaysCaptured: [String] = []
|
||||
var storedHandler: ((NostrEvent) -> Void)?
|
||||
var storedEOSE: (() -> Void)?
|
||||
@@ -71,15 +70,16 @@ struct LocationNotesManagerTests {
|
||||
#expect(relaysCaptured == ["wss://relay.one"])
|
||||
#expect(manager.state == .loading)
|
||||
|
||||
var event = NostrEvent(
|
||||
pubkey: "pub",
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .textNote,
|
||||
tags: [["g", "u4pruydq"]],
|
||||
content: "hi"
|
||||
)
|
||||
event.id = "event1"
|
||||
storedHandler?(event)
|
||||
let signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
storedHandler?(signed)
|
||||
storedEOSE?()
|
||||
|
||||
#expect(manager.state == .ready)
|
||||
@@ -87,6 +87,100 @@ struct LocationNotesManagerTests {
|
||||
#expect(manager.notes.first?.content == "hi")
|
||||
}
|
||||
|
||||
@Test
|
||||
func setGeohash_invalidValueIsIgnored() {
|
||||
var subscribeCount = 0
|
||||
let deps = LocationNotesDependencies(
|
||||
relayLookup: { _, _ in ["wss://relay.one"] },
|
||||
subscribe: { _, _, _, _, _ in
|
||||
subscribeCount += 1
|
||||
},
|
||||
unsubscribe: { _ in },
|
||||
sendEvent: { _, _ in },
|
||||
deriveIdentity: { _ in try NostrIdentity.generate() },
|
||||
now: { Date() }
|
||||
)
|
||||
|
||||
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
||||
manager.setGeohash("not-valid")
|
||||
|
||||
#expect(manager.geohash == "u4pruydq")
|
||||
#expect(subscribeCount == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func refreshAndCancel_manageSubscriptions() {
|
||||
var subscribeIDs: [String] = []
|
||||
var unsubscribedIDs: [String] = []
|
||||
let deps = LocationNotesDependencies(
|
||||
relayLookup: { _, _ in ["wss://relay.one"] },
|
||||
subscribe: { _, id, _, _, _ in
|
||||
subscribeIDs.append(id)
|
||||
},
|
||||
unsubscribe: { id in
|
||||
unsubscribedIDs.append(id)
|
||||
},
|
||||
sendEvent: { _, _ in },
|
||||
deriveIdentity: { _ in try NostrIdentity.generate() },
|
||||
now: { Date() }
|
||||
)
|
||||
|
||||
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
||||
manager.refresh()
|
||||
manager.cancel()
|
||||
|
||||
#expect(subscribeIDs.count == 2)
|
||||
#expect(unsubscribedIDs.count == 2)
|
||||
#expect(manager.state == .idle)
|
||||
#expect(manager.errorMessage == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func send_successCreatesLocalEchoAndClearsError() throws {
|
||||
var sentEvents: [NostrEvent] = []
|
||||
let identity = try NostrIdentity.generate()
|
||||
let deps = LocationNotesDependencies(
|
||||
relayLookup: { _, _ in ["wss://relay.one"] },
|
||||
subscribe: { _, _, _, _, _ in },
|
||||
unsubscribe: { _ in },
|
||||
sendEvent: { event, _ in
|
||||
sentEvents.append(event)
|
||||
},
|
||||
deriveIdentity: { _ in identity },
|
||||
now: { Date(timeIntervalSince1970: 123_456) }
|
||||
)
|
||||
|
||||
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
||||
manager.send(content: " hello note ", nickname: "Builder")
|
||||
|
||||
#expect(sentEvents.count == 1)
|
||||
#expect(manager.state == .ready)
|
||||
#expect(manager.errorMessage == nil)
|
||||
#expect(manager.notes.first?.content == "hello note")
|
||||
#expect(manager.notes.first?.displayName.hasPrefix("Builder#") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func send_failureFormatsErrorMessageAndClearErrorRemovesIt() {
|
||||
let deps = LocationNotesDependencies(
|
||||
relayLookup: { _, _ in ["wss://relay.one"] },
|
||||
subscribe: { _, _, _, _, _ in },
|
||||
unsubscribe: { _ in },
|
||||
sendEvent: { _, _ in },
|
||||
deriveIdentity: { _ in throw TestError.shouldNotDerive },
|
||||
now: { Date() }
|
||||
)
|
||||
|
||||
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
||||
manager.send(content: "hello", nickname: "Builder")
|
||||
|
||||
#expect(manager.errorMessage?.isEmpty == false)
|
||||
|
||||
manager.clearError()
|
||||
|
||||
#expect(manager.errorMessage == nil)
|
||||
}
|
||||
|
||||
private enum TestError: Error {
|
||||
case shouldNotDerive
|
||||
}
|
||||
|
||||
@@ -9,9 +9,112 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
struct MessageFormattingEngineTests {
|
||||
// MARK: - Formatting Behavior Tests
|
||||
|
||||
@MainActor
|
||||
@Test func formatMessage_regularMessageFormatsHeaderContentAndTimestamp() {
|
||||
let senderPeerID = PeerID(str: "abcdef1234567890")
|
||||
let context = MockMessageFormattingContext(
|
||||
nickname: "carol",
|
||||
peerURLs: [senderPeerID: URL(string: "https://example.com/peers/alice")!]
|
||||
)
|
||||
let message = BitchatMessage(
|
||||
id: "message-1",
|
||||
sender: "alice#a1b2",
|
||||
content: "hello #mesh https://example.com",
|
||||
timestamp: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
isRelay: false,
|
||||
senderPeerID: senderPeerID
|
||||
)
|
||||
|
||||
let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light)
|
||||
|
||||
#expect(String(formatted.characters) == "<@alice#a1b2> hello #mesh https://example.com [\(message.formattedTimestamp)]")
|
||||
#expect(message.getCachedFormattedText(isDark: false, isSelf: false) != nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func formatMessage_systemMessageUsesSystemLayout() {
|
||||
let context = MockMessageFormattingContext(nickname: "carol")
|
||||
let message = BitchatMessage(
|
||||
id: "system-1",
|
||||
sender: "system",
|
||||
content: "connected",
|
||||
timestamp: Date(timeIntervalSince1970: 1_700_000_123),
|
||||
isRelay: false
|
||||
)
|
||||
|
||||
let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .dark)
|
||||
|
||||
#expect(String(formatted.characters) == "* connected * [\(message.formattedTimestamp)]")
|
||||
#expect(message.getCachedFormattedText(isDark: true, isSelf: false) != nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func formatMessage_longSelfMessageFallsBackToPlainContentPath() {
|
||||
let context = MockMessageFormattingContext(
|
||||
nickname: "me",
|
||||
selfMessageIDs: ["self-1"]
|
||||
)
|
||||
let longContent = String(repeating: "a", count: 4_500)
|
||||
let message = BitchatMessage(
|
||||
id: "self-1",
|
||||
sender: "me#cafe",
|
||||
content: longContent,
|
||||
timestamp: Date(timeIntervalSince1970: 1_700_000_456),
|
||||
isRelay: false
|
||||
)
|
||||
|
||||
let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light)
|
||||
|
||||
#expect(String(formatted.characters) == "<@me#cafe> \(longContent) [\(message.formattedTimestamp)]")
|
||||
#expect(message.getCachedFormattedText(isDark: false, isSelf: true) != nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func formatMessage_mentionsAreRenderedThroughMentionFormatter() {
|
||||
let context = MockMessageFormattingContext(nickname: "carol")
|
||||
let message = BitchatMessage(
|
||||
id: "message-mention",
|
||||
sender: "alice",
|
||||
content: "hi @bob#a1b2",
|
||||
timestamp: Date(timeIntervalSince1970: 1_700_000_789),
|
||||
isRelay: false
|
||||
)
|
||||
|
||||
let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light)
|
||||
|
||||
#expect(String(formatted.characters) == "<@alice> hi bob#a1b2 [\(message.formattedTimestamp)]")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func formatHeader_formatsNormalAndSystemSenders() {
|
||||
let context = MockMessageFormattingContext(nickname: "carol")
|
||||
let normalMessage = BitchatMessage(
|
||||
id: "header-1",
|
||||
sender: "alice#a1b2",
|
||||
content: "hello",
|
||||
timestamp: Date(timeIntervalSince1970: 1_700_001_000),
|
||||
isRelay: false
|
||||
)
|
||||
let systemMessage = BitchatMessage(
|
||||
id: "header-2",
|
||||
sender: "system",
|
||||
content: "notice",
|
||||
timestamp: Date(timeIntervalSince1970: 1_700_001_111),
|
||||
isRelay: false
|
||||
)
|
||||
|
||||
let normalHeader = MessageFormattingEngine.formatHeader(normalMessage, context: context, colorScheme: .light)
|
||||
let systemHeader = MessageFormattingEngine.formatHeader(systemMessage, context: context, colorScheme: .dark)
|
||||
|
||||
#expect(String(normalHeader.characters) == "<@alice#a1b2> ")
|
||||
#expect(String(systemHeader.characters) == "system")
|
||||
}
|
||||
|
||||
// MARK: - Mention Extraction Tests
|
||||
|
||||
@@ -221,3 +324,32 @@ struct MessageFormattingEngineTests {
|
||||
#expect(content.hasVeryLongToken(threshold: 50))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MockMessageFormattingContext: MessageFormattingContext {
|
||||
let nickname: String
|
||||
private let selfMessageIDs: Set<String>
|
||||
private let peerURLs: [PeerID: URL]
|
||||
|
||||
init(
|
||||
nickname: String,
|
||||
selfMessageIDs: Set<String> = [],
|
||||
peerURLs: [PeerID: URL] = [:]
|
||||
) {
|
||||
self.nickname = nickname
|
||||
self.selfMessageIDs = selfMessageIDs
|
||||
self.peerURLs = peerURLs
|
||||
}
|
||||
|
||||
func isSelfMessage(_ message: BitchatMessage) -> Bool {
|
||||
selfMessageIDs.contains(message.id)
|
||||
}
|
||||
|
||||
func senderColor(for message: BitchatMessage, isDark: Bool) -> Color {
|
||||
.red
|
||||
}
|
||||
|
||||
func peerURL(for peerID: PeerID) -> URL? {
|
||||
peerURLs[peerID]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
final class MockBLEBus {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user