Compare commits

..
14 Commits
Author SHA1 Message Date
callebtc 991def1deb realtime waveform 2025-09-18 19:03:19 +02:00
callebtc ac7feb380e live recording 2025-09-18 18:59:49 +02:00
callebtc 57c5d81981 fix waveform alignment 2025-09-18 18:34:54 +02:00
callebtc fa04e801b6 image size 2025-09-18 18:24:23 +02:00
callebtc 3c21783eae left corners rounded, right not yet 2025-09-18 18:20:12 +02:00
callebtc b196a55c37 adjust TLV sizes 2025-09-18 17:19:51 +02:00
callebtc ade700e7f0 file transfer wip 2025-09-18 17:12:58 +02:00
callebtc ea7b90c074 images wip 2025-09-17 01:31:34 +02:00
callebtc 5486fc6a69 increase amplitude 2025-09-16 22:49:18 +02:00
callebtc 7415039183 use v2 packet for FILE_TRANSFER 2025-09-16 22:34:24 +02:00
callebtc 7b73728f1d logging file type 2025-09-15 21:01:20 +02:00
callebtc 1ea90295ce revert 2025-09-15 21:01:04 +02:00
callebtc c9fac83433 wip 2025-09-15 20:04:09 +02:00
callebtc 5b799e1a50 wip voice 2025-09-15 19:57:02 +02:00
65 changed files with 2992 additions and 2879 deletions
-20
View File
@@ -1,20 +0,0 @@
# Prevent Github Languages stats skewing:
# Binaries and assets
**/*.xcframework/** linguist-vendored
**/*.xcassets/** linguist-vendored
# Generated files
**/*.pbxproj linguist-generated
**/*.storyboard linguist-generated
Package.resolved linguist-generated
# Downloaded CSVs
relays/online_relays_gps.csv linguist-vendored
# Docs
**/*.md linguist-documentation
# Configs
Configs/*.xcconfig linguist-documentation
**/*.plist linguist-documentation
+1 -1
View File
@@ -1,4 +1,4 @@
MARKETING_VERSION = 1.4.2 MARKETING_VERSION = 1.4.0
CURRENT_PROJECT_VERSION = 1 CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 16.0 IPHONEOS_DEPLOYMENT_TARGET = 16.0
+16 -4
View File
@@ -14,6 +14,7 @@ default:
# Check prerequisites # Check prerequisites
check: check:
@echo "Checking prerequisites..." @echo "Checking prerequisites..."
@command -v xcodegen >/dev/null 2>&1 || (echo "❌ XcodeGen not found. Install with: brew install xcodegen" && exit 1)
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ Xcode not found. Install Xcode from App Store" && exit 1) @command -v xcodebuild >/dev/null 2>&1 || (echo "❌ Xcode not found. Install Xcode from App Store" && exit 1)
@security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0) @security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
@echo "✅ All prerequisites met" @echo "✅ All prerequisites met"
@@ -21,6 +22,8 @@ check:
# Backup original files # Backup original files
backup: backup:
@echo "Backing up original project configuration..." @echo "Backing up original project configuration..."
@cp project.yml project.yml.backup 2>/dev/null || true
@# Backup other files that get modified by xcodegen
@if [ -f bitchat.xcodeproj/project.pbxproj ]; then cp bitchat.xcodeproj/project.pbxproj bitchat.xcodeproj/project.pbxproj.backup; fi @if [ -f bitchat.xcodeproj/project.pbxproj ]; then cp bitchat.xcodeproj/project.pbxproj bitchat.xcodeproj/project.pbxproj.backup; fi
@if [ -f bitchat/Info.plist ]; then cp bitchat/Info.plist bitchat/Info.plist.backup; fi @if [ -f bitchat/Info.plist ]; then cp bitchat/Info.plist bitchat/Info.plist.backup; fi
@@ -41,10 +44,15 @@ patch-for-macos: backup
@# Move iOS-specific files out of the way temporarily @# Move iOS-specific files out of the way temporarily
@if [ -f bitchat/LaunchScreen.storyboard ]; then mv bitchat/LaunchScreen.storyboard bitchat/LaunchScreen.storyboard.ios; fi @if [ -f bitchat/LaunchScreen.storyboard ]; then mv bitchat/LaunchScreen.storyboard bitchat/LaunchScreen.storyboard.ios; fi
# Generate Xcode project with patches
generate: patch-for-macos
@echo "Generating Xcode project..."
@xcodegen generate
# Build the macOS app # Build the macOS app
build: #check generate build: check generate
@echo "Building BitChat for macOS..." @echo "Building BitChat for macOS..."
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build @xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
# Run the macOS app # Run the macOS app
run: build run: build
@@ -67,7 +75,9 @@ clean: restore
# Quick run without cleaning (for development) # Quick run without cleaning (for development)
dev-run: check dev-run: check
@echo "Quick development build..." @echo "Quick development build..."
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build @if [ ! -f project.yml.backup ]; then just patch-for-macos; fi
@xcodegen generate
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}" @find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
# Show app info # Show app info
@@ -96,9 +106,11 @@ nuke:
@echo "🧨 Nuclear clean - removing all build artifacts and backups..." @echo "🧨 Nuclear clean - removing all build artifacts and backups..."
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true @rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true
@rm -rf bitchat.xcodeproj 2>/dev/null || true @rm -rf bitchat.xcodeproj 2>/dev/null || true
@rm -f project.yml.backup 2>/dev/null || true
@rm -f project-macos.yml 2>/dev/null || true
@rm -f bitchat.xcodeproj/project.pbxproj.backup 2>/dev/null || true @rm -f bitchat.xcodeproj/project.pbxproj.backup 2>/dev/null || true
@rm -f bitchat/Info.plist.backup 2>/dev/null || true @rm -f bitchat/Info.plist.backup 2>/dev/null || true
@# Restore iOS-specific files if they were moved @# Restore iOS-specific files if they were moved
@if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi @if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi
@git checkout bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore" @git checkout -- project.yml bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore"
@echo "✅ Nuclear clean complete" @echo "✅ Nuclear clean complete"
+2 -2
View File
@@ -9,7 +9,7 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622) 📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
> [!WARNING] > [!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. > 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](http://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
## License ## License
@@ -22,7 +22,7 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback) - **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE - **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers - **Privacy First**: No accounts, no phone numbers, no persistent identifiers
- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, NIP-17 for Nostr - **Private Message End-to-End Encryption**: [Noise Protocol](http://noiseprotocol.org) for mesh, NIP-17 for Nostr
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface - **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
- **Universal App**: Native support for iOS and macOS - **Universal App**: Native support for iOS and macOS
- **Emergency Wipe**: Triple-tap to instantly clear all data - **Emergency Wipe**: Triple-tap to instantly clear all data
+10 -134
View File
@@ -11,7 +11,6 @@
0481A3A02E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */; }; 0481A3A02E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */; };
0481A3A12E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */; }; 0481A3A12E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */; };
048A88812E76FD18000FBCDD /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; }; 048A88812E76FD18000FBCDD /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; };
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; }; 3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; }; 885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; }; A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
@@ -28,13 +27,6 @@
remoteGlobalIDString = AF077EA0474EDEDE2C72716C; remoteGlobalIDString = AF077EA0474EDEDE2C72716C;
remoteInfo = bitchat_iOS; remoteInfo = bitchat_iOS;
}; };
E35E7AF9854A2E72452DD34F /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 475D96681D0EA0AE57A4E06E /* Project object */;
proxyType = 1;
remoteGlobalIDString = 57CA17A36A2532A6CFF367BB;
remoteInfo = bitchatShareExtension;
};
FF470234EF8C6BB8865B80B5 /* PBXContainerItemProxy */ = { FF470234EF8C6BB8865B80B5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy; isa = PBXContainerItemProxy;
containerPortal = 475D96681D0EA0AE57A4E06E /* Project object */; containerPortal = 475D96681D0EA0AE57A4E06E /* Project object */;
@@ -50,7 +42,6 @@
dstPath = ""; dstPath = "";
dstSubfolder = PlugIns; dstSubfolder = PlugIns;
files = ( files = (
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */,
); );
name = "Embed Foundation Extensions"; name = "Embed Foundation Extensions";
}; };
@@ -60,7 +51,6 @@
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
0481A35C2E6DA18600FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; }; 0481A35C2E6DA18600FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; };
0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = "tor-nolzma.xcframework"; sourceTree = "<group>"; }; 0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = "tor-nolzma.xcframework"; sourceTree = "<group>"; };
61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = bitchatShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; }; 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
96D0D41CA19EE5A772AA8434 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; }; 96D0D41CA19EE5A772AA8434 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -68,13 +58,6 @@
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Services/TransportConfig.swift,
);
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
};
A6E32D1C2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_iOS" target */ = { A6E32D1C2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_iOS" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
@@ -91,20 +74,12 @@
); );
target = 0576A29205865664C0937536 /* bitchat_macOS */; target = 0576A29205865664C0937536 /* bitchat_macOS */;
}; };
A6E32D232E762EAB0032EA8A /* Exceptions for "bitchatShareExtension" folder in "bitchatShareExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
ShareViewController.swift,
);
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
A6E32C972E762EA70032EA8A /* bitchat */ = { A6E32C972E762EA70032EA8A /* bitchat */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = ( exceptions = (
A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */,
A6E32D1C2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_iOS" target */, A6E32D1C2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_iOS" target */,
A6E32D1D2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_macOS" target */, A6E32D1D2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_macOS" target */,
); );
@@ -113,9 +88,6 @@
}; };
A6E32D212E762EAB0032EA8A /* bitchatShareExtension */ = { A6E32D212E762EAB0032EA8A /* bitchatShareExtension */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
A6E32D232E762EAB0032EA8A /* Exceptions for "bitchatShareExtension" folder in "bitchatShareExtension" target */,
);
path = bitchatShareExtension; path = bitchatShareExtension;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -180,7 +152,6 @@
children = ( children = (
96D0D41CA19EE5A772AA8434 /* bitchat.app */, 96D0D41CA19EE5A772AA8434 /* bitchat.app */,
8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */, 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */,
61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */,
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */, C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */,
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */, 03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */,
); );
@@ -231,19 +202,6 @@
productReference = 03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */; productReference = 03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */;
productType = "com.apple.product-type.bundle.unit-test"; productType = "com.apple.product-type.bundle.unit-test";
}; };
57CA17A36A2532A6CFF367BB /* bitchatShareExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = E4EA6DC648DF55FF84032EB5 /* Build configuration list for PBXNativeTarget "bitchatShareExtension" */;
buildPhases = (
0A08E70F08F55FD5BA8C7EF3 /* Sources */,
);
buildRules = (
);
name = bitchatShareExtension;
productName = bitchatShareExtension;
productReference = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */ = { 6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget "bitchatTests_iOS" */; buildConfigurationList = 38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget "bitchatTests_iOS" */;
@@ -274,9 +232,6 @@
); );
buildRules = ( buildRules = (
); );
dependencies = (
6EB655BA5DB11909C1DEC460 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
A6E32C972E762EA70032EA8A /* bitchat */, A6E32C972E762EA70032EA8A /* bitchat */,
); );
@@ -315,7 +270,6 @@
projectDirPath = ""; projectDirPath = "";
projectRoot = ""; projectRoot = "";
targets = ( targets = (
57CA17A36A2532A6CFF367BB /* bitchatShareExtension */,
6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */, 6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */,
47FF23248747DD7CB666CB91 /* bitchatTests_macOS */, 47FF23248747DD7CB666CB91 /* bitchatTests_macOS */,
AF077EA0474EDEDE2C72716C /* bitchat_iOS */, AF077EA0474EDEDE2C72716C /* bitchat_iOS */,
@@ -340,11 +294,6 @@
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
0A08E70F08F55FD5BA8C7EF3 /* Sources */ = {
isa = PBXSourcesBuildPhase;
files = (
);
};
137ABE739BF20ACDDF8CC605 /* Sources */ = { 137ABE739BF20ACDDF8CC605 /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
files = ( files = (
@@ -373,11 +322,6 @@
target = 0576A29205865664C0937536 /* bitchat_macOS */; target = 0576A29205865664C0937536 /* bitchat_macOS */;
targetProxy = FF470234EF8C6BB8865B80B5 /* PBXContainerItemProxy */; targetProxy = FF470234EF8C6BB8865B80B5 /* PBXContainerItemProxy */;
}; };
6EB655BA5DB11909C1DEC460 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
targetProxy = E35E7AF9854A2E72452DD34F /* PBXContainerItemProxy */;
};
D8C09F21DB7DC06E8E672C21 /* PBXTargetDependency */ = { D8C09F21DB7DC06E8E672C21 /* PBXTargetDependency */ = {
isa = PBXTargetDependency; isa = PBXTargetDependency;
target = AF077EA0474EDEDE2C72716C /* bitchat_iOS */; target = AF077EA0474EDEDE2C72716C /* bitchat_iOS */;
@@ -462,49 +406,18 @@
}; };
name = Release; name = Release;
}; };
3DCF45111852FB2AEBE05E31 /* Release configuration for PBXNativeTarget "bitchatShareExtension" */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
baseConfigurationReferenceRelativePath = Release.xcconfig;
buildSettings = {
CODE_SIGNING_ALLOWED = YES;
CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
INFOPLIST_FILE = bitchatShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_VERSION = "$(SWIFT_VERSION)";
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};
702E7395723CADA4B830F4A9 /* Debug configuration for PBXNativeTarget "bitchat_iOS" */ = { 702E7395723CADA4B830F4A9 /* Debug configuration for PBXNativeTarget "bitchat_iOS" */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */; baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
baseConfigurationReferenceRelativePath = Debug.xcconfig; baseConfigurationReferenceRelativePath = Debug.xcconfig;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDebug; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_ALLOWED = YES;
CODE_SIGNING_REQUIRED = YES; CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements; CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; DEVELOPMENT_TEAM = 66W2RU3J5T;
ENABLE_PREVIEWS = NO; ENABLE_PREVIEWS = NO;
INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_FILE = bitchat/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat; INFOPLIST_KEY_CFBundleDisplayName = bitchat;
@@ -516,6 +429,7 @@
); );
MARKETING_VERSION = "$(MARKETING_VERSION)"; MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
"PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = "chat.bitchat-local";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
@@ -564,7 +478,7 @@
CODE_SIGNING_REQUIRED = YES; CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements; CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; DEVELOPMENT_TEAM = 66W2RU3J5T;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_FILE = bitchat/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat; INFOPLIST_KEY_CFBundleDisplayName = bitchat;
@@ -576,6 +490,7 @@
); );
MARKETING_VERSION = "$(MARKETING_VERSION)"; MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
"PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = "chat.bitchat-local";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
@@ -600,11 +515,10 @@
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; DEVELOPMENT_TEAM = 66W2RU3J5T;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_FILE = bitchat/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat; INFOPLIST_KEY_CFBundleDisplayName = bitchat;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
@@ -612,6 +526,7 @@
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = "$(MARKETING_VERSION)"; MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
"PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "chat.bitchat-local";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
SDKROOT = macosx; SDKROOT = macosx;
@@ -684,7 +599,7 @@
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */; baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
baseConfigurationReferenceRelativePath = Debug.xcconfig; baseConfigurationReferenceRelativePath = Debug.xcconfig;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDebug; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_ALLOWED = YES;
CODE_SIGNING_REQUIRED = YES; CODE_SIGNING_REQUIRED = YES;
@@ -692,11 +607,10 @@
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; DEVELOPMENT_TEAM = 66W2RU3J5T;
ENABLE_PREVIEWS = NO; ENABLE_PREVIEWS = NO;
INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_FILE = bitchat/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat; INFOPLIST_KEY_CFBundleDisplayName = bitchat;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
@@ -704,6 +618,7 @@
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = "$(MARKETING_VERSION)"; MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
"PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "chat.bitchat-local";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
SDKROOT = macosx; SDKROOT = macosx;
@@ -778,37 +693,6 @@
}; };
name = Debug; name = Debug;
}; };
DAC5E82049F8A97360BE63D6 /* Debug configuration for PBXNativeTarget "bitchatShareExtension" */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
baseConfigurationReferenceRelativePath = Debug.xcconfig;
buildSettings = {
CODE_SIGNING_ALLOWED = YES;
CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
INFOPLIST_FILE = bitchatShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_VERSION = "$(SWIFT_VERSION)";
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@@ -852,14 +736,6 @@
); );
defaultConfigurationName = Debug; defaultConfigurationName = Debug;
}; };
E4EA6DC648DF55FF84032EB5 /* Build configuration list for PBXNativeTarget "bitchatShareExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
DAC5E82049F8A97360BE63D6 /* Debug configuration for PBXNativeTarget "bitchatShareExtension" */,
3DCF45111852FB2AEBE05E31 /* Release configuration for PBXNativeTarget "bitchatShareExtension" */,
);
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */ /* Begin XCLocalSwiftPackageReference section */
@@ -1,38 +0,0 @@
{
"images" : [
{
"filename" : "image-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "image-1024 1.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"filename" : "image-1024 2.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

@@ -0,0 +1,91 @@
import Foundation
#if os(iOS)
import AVFoundation
import CoreGraphics
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
private var recorder: AVAudioRecorder?
private(set) var isRecording = false
func startRecording(to url: URL) throws {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker])
try session.setActive(true)
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 44100,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 32000
]
recorder = try AVAudioRecorder(url: url, settings: settings)
recorder?.delegate = self
recorder?.isMeteringEnabled = true
guard recorder?.record() == true else { throw NSError(domain: "VoiceRecorder", code: -1) }
isRecording = true
}
func stopRecording(completion: @escaping () -> Void) {
guard isRecording else { completion(); return }
// Add 500ms padding to avoid clipping
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.recorder?.stop()
self?.isRecording = false
completion()
}
}
// MARK: - Live metrics for visualizer
/// Returns normalized amplitude [0,1] based on average power in dB.
/// Call periodically while recording to drive a live waveform.
func pollNormalizedAmplitude() -> CGFloat {
guard let r = recorder, isRecording else { return 0 }
r.updateMeters()
// Use peak power for responsiveness, map dB [-160,0] -> linear [0,1]
let db = r.peakPower(forChannel: 0)
let linear = pow(10.0, db / 20.0) // 0..1
if linear.isNaN || linear.isInfinite { return 0 }
// Keep linear to avoid globally scaling up low amplitudes
return CGFloat(max(0, min(1, linear)))
}
/// Current elapsed recording time in milliseconds.
func currentTimeMs() -> Int {
guard let r = recorder, isRecording else { return 0 }
return Int(r.currentTime * 1000)
}
}
enum VoiceRecorderPaths {
static func outgoingURL() throws -> URL {
let fm = FileManager.default
let base = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let folder = base.appendingPathComponent("bitchat_voicenotes/outgoing", isDirectory: true)
if !fm.fileExists(atPath: folder.path) {
try fm.createDirectory(at: folder, withIntermediateDirectories: true)
}
let ts = ISO8601DateFormatter()
ts.formatOptions = [.withInternetDateTime, .withDashSeparatorInDate, .withColonSeparatorInTime]
let name = "voice_\(Int(Date().timeIntervalSince1970)).m4a"
return folder.appendingPathComponent(name)
}
}
#else
/// Minimal macOS-compatible stubs so the macOS target builds without AVFAudio.
final class VoiceRecorder: NSObject {
private(set) var isRecording = false
func startRecording(to url: URL) throws {
// Voice recording is iOS-only in this project
throw NSError(domain: "VoiceRecorder", code: -1, userInfo: [NSLocalizedDescriptionKey: "Voice recording is not supported on macOS in this build."])
}
func stopRecording(completion: @escaping () -> Void) { completion() }
}
enum VoiceRecorderPaths {
static func outgoingURL() throws -> URL {
throw NSError(domain: "VoiceRecorder", code: -2, userInfo: [NSLocalizedDescriptionKey: "Voice recording is not supported on macOS in this build."])
}
}
#endif
+76
View File
@@ -0,0 +1,76 @@
import Foundation
import AVFoundation
import Accelerate
struct WaveformCache {
static var shared = WaveformCache()
private var cache: [String: [Float]] = [:]
mutating func set(_ bins: [Float], for path: String) { cache[path] = bins }
func get(_ path: String) -> [Float]? { cache[path] }
}
enum WaveformExtractor {
static func extractBins(url: URL, binCount: Int = 120) -> [Float] {
if let cached = WaveformCache.shared.get(url.path) { return cached }
let asset = AVURLAsset(url: url)
guard let track = asset.tracks(withMediaType: .audio).first else { return [] }
let reader: AVAssetReader
do { reader = try AVAssetReader(asset: asset) } catch { return [] }
let outputSettings: [String: Any] = [
AVFormatIDKey: kAudioFormatLinearPCM,
AVLinearPCMBitDepthKey: 16,
AVLinearPCMIsBigEndianKey: false,
AVLinearPCMIsFloatKey: false,
AVLinearPCMIsNonInterleaved: false
]
let output = AVAssetReaderTrackOutput(track: track, outputSettings: outputSettings)
output.alwaysCopiesSampleData = false
if reader.canAdd(output) { reader.add(output) } else { return [] }
guard reader.startReading() else { return [] }
var samples: [Float] = []
while reader.status == .reading {
guard let buffer = output.copyNextSampleBuffer() else { break }
if let block = CMSampleBufferGetDataBuffer(buffer) {
var length = 0
var dataPointer: UnsafeMutablePointer<Int8>?
if CMBlockBufferGetDataPointer(block, atOffset: 0, lengthAtOffsetOut: nil, totalLengthOut: &length, dataPointerOut: &dataPointer) == kCMBlockBufferNoErr,
let base = dataPointer {
// 16-bit signed little endian
let count = length / 2
var floats = [Float](repeating: 0, count: count)
base.withMemoryRebound(to: Int16.self, capacity: count) { ptr in
vDSP.convertElements(of: UnsafeBufferPointer(start: ptr, count: count), to: &floats)
}
// Normalize to [-1,1]
var maxVal: Float = 32768.0
vDSP.divide(floats, maxVal, result: &floats)
samples += floats
}
}
CMSampleBufferInvalidate(buffer)
}
guard !samples.isEmpty, reader.status == .completed || reader.status == .reading else { return [] }
// Reduce to bins by RMS per window
let window = max(1, samples.count / binCount)
var bins: [Float] = []
bins.reserveCapacity(binCount)
var i = 0
while i < samples.count && bins.count < binCount {
let end = min(i + window, samples.count)
let slice = samples[i..<end]
var sum: Float = 0
vDSP_svesq(slice.map { $0 }, 1, &sum, vDSP_Length(slice.count))
let rms = sqrtf(sum / Float(slice.count))
bins.append(min(1.0, rms))
i = end
}
if bins.count < binCount {
bins += Array(repeating: 0, count: binCount - bins.count)
}
WaveformCache.shared.set(bins, for: url.path)
return bins
}
}
+17
View File
@@ -158,6 +158,23 @@ struct IdentityCache: Codable {
var version: Int = 1 var version: Int = 1
} }
// MARK: - Identity Resolution
enum IdentityHint {
case unknown
case likelyKnown(fingerprint: String)
case ambiguous(candidates: Set<String>)
case verified(fingerprint: String)
}
// MARK: - Pending Actions
struct PendingActions {
var toggleFavorite: Bool?
var setTrustLevel: TrustLevel?
var setPetname: String?
}
// //
// MARK: - Migration Support // MARK: - Migration Support
+6
View File
@@ -33,12 +33,18 @@
<string>$(MACOSX_DEPLOYMENT_TARGET)</string> <string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSBluetoothAlwaysUsageDescription</key> <key>NSBluetoothAlwaysUsageDescription</key>
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string> <string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>bitchat saves received images to your photo library when you choose to save them.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>bitchat needs access to your photos to let you pick images to send.</string>
<key>NSBluetoothPeripheralUsageDescription</key> <key>NSBluetoothPeripheralUsageDescription</key>
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string> <string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>bitchat uses the camera to scan QR codes to verify peers.</string> <string>bitchat uses the camera to scan QR codes to verify peers.</string>
<key>NSLocationWhenInUseUsageDescription</key> <key>NSLocationWhenInUseUsageDescription</key>
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string> <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 and send short voice notes to nearby peers.</string>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>bluetooth-central</string> <string>bluetooth-central</string>
+133
View File
@@ -0,0 +1,133 @@
//
// BitchatFilePacket.swift
// bitchat
//
// TLV encoder/decoder for file transfer payloads (images, audio, and generic files)
// v2 Spec: 0x01 FILE_NAME (utf8), 0x02 FILE_SIZE (4 bytes, BE), 0x03 MIME_TYPE (utf8), 0x04 CONTENT (4-byte len)
//
import Foundation
import CryptoKit
struct BitchatFilePacket {
enum TLVType: UInt8 {
case fileName = 0x01
case fileSize = 0x02
case mimeType = 0x03
case content = 0x04
}
let fileName: String
let fileSize: UInt32
let mimeType: String
let content: Data
func encode() -> Data? {
var out = Data()
// Standard TLV helper for FILE_NAME and MIME_TYPE (2-byte length)
func appendStandardTLV(_ type: TLVType, _ value: Data) {
out.append(type.rawValue)
let len = UInt16(min(value.count, 0xFFFF))
out.append(UInt8((len >> 8) & 0xFF))
out.append(UInt8(len & 0xFF))
out.append(value.prefix(Int(len)))
}
// FILE_NAME
if let nameData = fileName.data(using: .utf8) {
appendStandardTLV(.fileName, nameData)
}
// FILE_SIZE (4 bytes, UInt32 BE) - v2 spec
out.append(TLVType.fileSize.rawValue)
out.append(UInt8(0)) // Length high byte = 0
out.append(UInt8(4)) // Length low byte = 4
let size32 = UInt32(min(fileSize, UInt32.max))
out.append(UInt8((size32 >> 24) & 0xFF))
out.append(UInt8((size32 >> 16) & 0xFF))
out.append(UInt8((size32 >> 8) & 0xFF))
out.append(UInt8(size32 & 0xFF))
// MIME_TYPE
if let mimeData = mimeType.data(using: .utf8) {
appendStandardTLV(.mimeType, mimeData)
}
// CONTENT (4-byte length) - v2 spec
out.append(TLVType.content.rawValue)
let contentLen = UInt32(content.count)
out.append(UInt8((contentLen >> 24) & 0xFF))
out.append(UInt8((contentLen >> 16) & 0xFF))
out.append(UInt8((contentLen >> 8) & 0xFF))
out.append(UInt8(contentLen & 0xFF))
out.append(content)
return out
}
static func decode(from data: Data) -> BitchatFilePacket? {
var idx = 0
func read(_ n: Int) -> Data? {
guard idx + n <= data.count else { return nil }
defer { idx += n }
return data.subdata(in: idx..<(idx+n))
}
func read8() -> UInt8? { read(1)?.first }
func read16() -> UInt16? {
guard let d = read(2) else { return nil }
return (UInt16(d[0]) << 8) | UInt16(d[1])
}
func read32() -> UInt32? {
guard let d = read(4) else { return nil }
return (UInt32(d[0]) << 24) | (UInt32(d[1]) << 16) | (UInt32(d[2]) << 8) | UInt32(d[3])
}
var name: String?
var size: UInt32?
var mime: String?
var contentData = Data()
while idx < data.count {
guard let t = read8(), let tlvType = TLVType(rawValue: t) else { return nil }
// CONTENT uses 4-byte length; others use 2-byte length
let len: Int
if tlvType == .content {
guard let len32 = read32() else { return nil }
len = Int(len32)
} else {
guard let len16 = read16() else { return nil }
len = Int(len16)
}
guard len >= 0, idx + len <= data.count else { return nil }
let val = data.subdata(in: idx..<(idx+len))
idx += len
switch tlvType {
case .fileName:
name = String(data: val, encoding: .utf8)
case .fileSize:
guard len == 4 else { return nil } // v2 spec: 4 bytes
var s: UInt32 = 0
for b in val { s = (s << 8) | UInt32(b) }
size = s
case .mimeType:
mime = String(data: val, encoding: .utf8)
case .content:
// Expect single CONTENT TLV; if multiple, concatenate defensively
contentData.append(val)
}
}
// Validate
guard !contentData.isEmpty else { return nil }
let finalSize = size ?? UInt32(contentData.count)
let finalName = name ?? "file"
let finalMime = mime ?? "application/octet-stream"
return BitchatFilePacket(fileName: finalName, fileSize: finalSize, mimeType: finalMime, content: contentData)
}
}
extension Data { func sha256Hex() -> String { SHA256.hash(data: self).map { String(format: "%02x", $0) }.joined() } }
+14
View File
@@ -22,6 +22,19 @@ struct BitchatPacket: Codable {
var signature: Data? var signature: Data?
var ttl: UInt8 var ttl: UInt8
// Full initialization with explicit version
init(version: UInt8, type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
self.version = version
self.type = type
self.senderID = senderID
self.recipientID = recipientID
self.timestamp = timestamp
self.payload = payload
self.signature = signature
self.ttl = ttl
}
// Backward compatible constructor (defaults to v1)
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) { init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
self.version = 1 self.version = 1
self.type = type self.type = type
@@ -74,6 +87,7 @@ struct BitchatPacket: Codable {
// Create a copy without signature and with fixed TTL for signing // Create a copy without signature and with fixed TTL for signing
// TTL must be excluded because it changes during relay // TTL must be excluded because it changes during relay
let unsignedPacket = BitchatPacket( let unsignedPacket = BitchatPacket(
version: version, // Preserve packet version for signing
type: type, type: type,
senderID: senderID, senderID: senderID,
recipientID: recipientID, recipientID: recipientID,
@@ -0,0 +1,358 @@
//
// NoiseHandshakeCoordinator.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
/// Coordinates Noise handshakes to prevent race conditions and ensure reliable encryption establishment
final class NoiseHandshakeCoordinator {
// MARK: - Handshake State
enum HandshakeState: Equatable {
case idle
case waitingToInitiate(since: Date)
case initiating(attempt: Int, lastAttempt: Date)
case responding(since: Date)
case waitingForResponse(messagesSent: [Data], timeout: Date)
case established(since: Date)
case failed(reason: String, canRetry: Bool, lastAttempt: Date)
var isActive: Bool {
switch self {
case .idle, .established, .failed:
return false
default:
return true
}
}
}
// MARK: - Properties
private var handshakeStates: [String: HandshakeState] = [:]
private var handshakeQueue = DispatchQueue(label: "chat.bitchat.noise.handshake", attributes: .concurrent)
// Configuration
private let maxHandshakeAttempts = 3
private let handshakeTimeout: TimeInterval = 10.0
private let retryDelay: TimeInterval = 2.0
private let minTimeBetweenHandshakes: TimeInterval = 1.0 // Reduced from 5.0 for faster recovery
private let establishedSessionTTL: TimeInterval = 300.0 // 5 minutes - sessions older than this can be cleaned up
private let maxEstablishedSessions = 50 // Limit total established sessions
// Track handshake messages to detect duplicates
private var processedHandshakeMessages: Set<Data> = []
private let messageHistoryLimit = 100
// MARK: - Role Determination
/// Deterministically determine who should initiate the handshake
/// Lower peer ID becomes the initiator to prevent simultaneous attempts
func determineHandshakeRole(myPeerID: String, remotePeerID: String) -> NoiseRole {
// Use simple string comparison for deterministic ordering
return myPeerID < remotePeerID ? .initiator : .responder
}
/// Check if we should initiate handshake with a peer
func shouldInitiateHandshake(myPeerID: String, remotePeerID: String, forceIfStale: Bool = false) -> Bool {
return handshakeQueue.sync {
// Check if we're already in an active handshake
if let state = handshakeStates[remotePeerID], state.isActive {
// Check if the handshake is stale and we should force a new one
if forceIfStale {
switch state {
case .initiating(_, let lastAttempt):
if Date().timeIntervalSince(lastAttempt) > handshakeTimeout {
SecureLogger.warning("Forcing new handshake with \(remotePeerID) - previous stuck in initiating", category: .handshake)
return true
}
default:
break
}
}
SecureLogger.debug("Already in active handshake with \(remotePeerID), state: \(state)", category: .handshake)
return false
}
// Check role
let role = determineHandshakeRole(myPeerID: myPeerID, remotePeerID: remotePeerID)
if role != .initiator {
return false
}
// Check if we've failed recently and can't retry yet
if case .failed(_, let canRetry, let lastAttempt) = handshakeStates[remotePeerID] {
if !canRetry {
return false
}
if Date().timeIntervalSince(lastAttempt) < retryDelay {
return false
}
}
return true
}
}
/// Record that we're initiating a handshake
func recordHandshakeInitiation(peerID: String) {
handshakeQueue.async(flags: .barrier) {
let attempt = self.getCurrentAttempt(for: peerID) + 1
self.handshakeStates[peerID] = .initiating(attempt: attempt, lastAttempt: Date())
SecureLogger.info("Recording handshake initiation with \(peerID), attempt \(attempt)", category: .handshake)
}
}
/// Record that we're responding to a handshake
func recordHandshakeResponse(peerID: String) {
handshakeQueue.async(flags: .barrier) {
self.handshakeStates[peerID] = .responding(since: Date())
SecureLogger.info("Recording handshake response to \(peerID)", category: .handshake)
}
}
/// Record successful handshake completion
func recordHandshakeSuccess(peerID: String) {
handshakeQueue.async(flags: .barrier) {
self.handshakeStates[peerID] = .established(since: Date())
SecureLogger.info("Handshake successfully established with \(peerID)", category: .handshake)
}
}
/// Record handshake failure
func recordHandshakeFailure(peerID: String, reason: String) {
handshakeQueue.async(flags: .barrier) {
let attempts = self.getCurrentAttempt(for: peerID)
let canRetry = attempts < self.maxHandshakeAttempts
self.handshakeStates[peerID] = .failed(reason: reason, canRetry: canRetry, lastAttempt: Date())
SecureLogger.warning("Handshake failed with \(peerID): \(reason), canRetry: \(canRetry)", category: .handshake)
}
}
/// Check if we should accept an incoming handshake initiation
func shouldAcceptHandshakeInitiation(myPeerID: String, remotePeerID: String) -> Bool {
return handshakeQueue.sync {
// If we're already established, reject new handshakes
if case .established = handshakeStates[remotePeerID] {
SecureLogger.debug("Rejecting handshake from \(remotePeerID) - already established", category: .handshake)
return false
}
let role = determineHandshakeRole(myPeerID: myPeerID, remotePeerID: remotePeerID)
// If we're the initiator and already initiating, this is a race condition
if role == .initiator {
if case .initiating = handshakeStates[remotePeerID] {
// They shouldn't be initiating, but accept it to recover from race condition
SecureLogger.warning("Accepting handshake from \(remotePeerID) despite being initiator (race condition recovery)", category: .handshake)
return true
}
}
// If we're the responder, we should accept
return true
}
}
/// Check if this is a duplicate handshake message
func isDuplicateHandshakeMessage(_ data: Data) -> Bool {
return handshakeQueue.sync {
if processedHandshakeMessages.contains(data) {
return true
}
// Add to processed messages with size limit
if processedHandshakeMessages.count >= messageHistoryLimit {
processedHandshakeMessages.removeAll()
}
processedHandshakeMessages.insert(data)
return false
}
}
/// Get time to wait before next handshake attempt
func getRetryDelay(for peerID: String) -> TimeInterval? {
return handshakeQueue.sync {
guard let state = handshakeStates[peerID] else { return nil }
switch state {
case .failed(_, let canRetry, let lastAttempt):
if !canRetry { return nil }
let timeSinceFailure = Date().timeIntervalSince(lastAttempt)
if timeSinceFailure >= retryDelay {
return 0
}
return retryDelay - timeSinceFailure
case .initiating(_, let lastAttempt):
let timeSinceAttempt = Date().timeIntervalSince(lastAttempt)
if timeSinceAttempt >= minTimeBetweenHandshakes {
return 0
}
return minTimeBetweenHandshakes - timeSinceAttempt
default:
return nil
}
}
}
/// Reset handshake state for a peer
func resetHandshakeState(for peerID: String) {
handshakeQueue.async(flags: .barrier) {
self.handshakeStates.removeValue(forKey: peerID)
SecureLogger.debug("Reset handshake state for \(peerID)", category: .handshake)
}
}
/// Clean up stale handshake states and old established sessions
func cleanupStaleHandshakes(staleTimeout: TimeInterval = 30.0) -> [String] {
return handshakeQueue.sync {
let now = Date()
var stalePeerIDs: [String] = []
var establishedSessions: [(peerID: String, since: Date)] = []
for (peerID, state) in handshakeStates {
var isStale = false
switch state {
case .initiating(_, let lastAttempt):
if now.timeIntervalSince(lastAttempt) > staleTimeout {
isStale = true
}
case .responding(let since):
if now.timeIntervalSince(since) > staleTimeout {
isStale = true
}
case .waitingForResponse(_, let timeout):
if now > timeout {
isStale = true
}
case .established(let since):
// Track established sessions for potential cleanup
establishedSessions.append((peerID, since))
// Clean up very old established sessions
if now.timeIntervalSince(since) > establishedSessionTTL {
isStale = true
}
default:
break
}
if isStale {
stalePeerIDs.append(peerID)
SecureLogger.warning("Found stale handshake state for \(peerID): \(state)", category: .handshake)
}
}
// If we have too many established sessions, clean up the oldest ones
if establishedSessions.count > maxEstablishedSessions {
// Sort by age (oldest first)
let sortedSessions = establishedSessions.sorted { $0.since < $1.since }
let sessionsToRemove = sortedSessions.count - maxEstablishedSessions
for i in 0..<sessionsToRemove {
let peerID = sortedSessions[i].peerID
stalePeerIDs.append(peerID)
SecureLogger.info("Removing old established session for \(peerID) to maintain session limit", category: .handshake)
}
}
// Clean up stale states
for peerID in stalePeerIDs {
handshakeStates.removeValue(forKey: peerID)
}
if !stalePeerIDs.isEmpty {
SecureLogger.info("Cleaned up \(stalePeerIDs.count) stale handshake states", category: .handshake)
}
return stalePeerIDs
}
}
/// Get current handshake state
func getHandshakeState(for peerID: String) -> HandshakeState {
return handshakeQueue.sync {
return handshakeStates[peerID] ?? .idle
}
}
/// Get current retry count for a peer
func getRetryCount(for peerID: String) -> Int {
return handshakeQueue.sync {
switch handshakeStates[peerID] {
case .initiating(let attempt, _):
return attempt - 1 // Attempts start at 1, retries start at 0
default:
return 0
}
}
}
/// Increment retry count for a peer
func incrementRetryCount(for peerID: String) {
handshakeQueue.async(flags: .barrier) {
let currentAttempt = self.getCurrentAttempt(for: peerID)
self.handshakeStates[peerID] = .initiating(attempt: currentAttempt + 1, lastAttempt: Date())
}
}
// MARK: - Private Helpers
private func getCurrentAttempt(for peerID: String) -> Int {
switch handshakeStates[peerID] {
case .initiating(let attempt, _):
return attempt
case .failed(_, _, _):
// Count previous attempts
return 1 // Simplified for now
default:
return 0
}
}
/// Log current handshake states for debugging
func logHandshakeStates() {
handshakeQueue.sync {
SecureLogger.debug("=== Handshake States ===", category: .handshake)
for (peerID, state) in handshakeStates {
let stateDesc: String
switch state {
case .idle:
stateDesc = "idle"
case .waitingToInitiate(let since):
stateDesc = "waiting to initiate (since \(since))"
case .initiating(let attempt, let lastAttempt):
stateDesc = "initiating (attempt \(attempt), last: \(lastAttempt))"
case .responding(let since):
stateDesc = "responding (since: \(since))"
case .waitingForResponse(let messages, let timeout):
stateDesc = "waiting for response (\(messages.count) messages, timeout: \(timeout))"
case .established(let since):
stateDesc = "established (since \(since))"
case .failed(let reason, let canRetry, let lastAttempt):
stateDesc = "failed: \(reason) (canRetry: \(canRetry), last: \(lastAttempt))"
}
SecureLogger.debug(" \(peerID): \(stateDesc)", category: .handshake)
}
SecureLogger.debug("========================", category: .handshake)
}
}
/// Clear all handshake states - used during panic mode
func clearAllHandshakeStates() {
handshakeQueue.async(flags: .barrier) {
SecureLogger.warning("Clearing all handshake states for panic mode", category: .handshake)
self.handshakeStates.removeAll()
self.processedHandshakeMessages.removeAll()
}
}
}
@@ -210,15 +210,6 @@ final class NoiseRateLimiter {
self.messageTimestamps.removeValue(forKey: peerID) self.messageTimestamps.removeValue(forKey: peerID)
} }
} }
func resetAll() {
queue.async(flags: .barrier) {
self.handshakeTimestamps.removeAll()
self.messageTimestamps.removeAll()
self.globalHandshakeTimestamps.removeAll()
self.globalMessageTimestamps.removeAll()
}
}
} }
// MARK: - Security Errors // MARK: - Security Errors
-9
View File
@@ -309,15 +309,6 @@ final class NoiseSessionManager {
} }
} }
func removeAllSessions() {
managerQueue.sync(flags: .barrier) {
for (_, session) in sessions {
session.reset()
}
sessions.removeAll()
}
}
func getEstablishedSessions() -> [String: NoiseSession] { func getEstablishedSessions() -> [String: NoiseSession] {
return managerQueue.sync { return managerQueue.sync {
return sessions.filter { $0.value.isEstablished() } return sessions.filter { $0.value.isEstablished() }
+6 -24
View File
@@ -150,31 +150,13 @@ struct NostrIdentityBridge {
/// Clear all Nostr identity associations and current identity /// Clear all Nostr identity associations and current identity
static func clearAllAssociations() { static func clearAllAssociations() {
let query: [String: Any] = [ // Delete current Nostr identity
kSecClass as String: kSecClassGenericPassword, KeychainHelper.delete(key: currentIdentityKey, service: keychainService)
kSecAttrService as String: keychainService, KeychainHelper.delete(key: deviceSeedKey, service: keychainService)
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnAttributes as String: true
]
var result: AnyObject? // Note: We can't efficiently delete all noise-nostr associations
let status = SecItemCopyMatching(query as CFDictionary, &result) // without tracking them, but they'll be orphaned and eventually cleaned up
if status == errSecSuccess, let items = result as? [[String: Any]] { // The important part is deleting the current identity so a new one is generated
for item in items {
var deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService
]
if let account = item[kSecAttrAccount as String] as? String {
deleteQuery[kSecAttrAccount as String] = account
}
SecItemDelete(deleteQuery as CFDictionary)
}
} else if status == errSecItemNotFound {
// nothing persisted; no action needed
}
deviceSeedCache = nil
} }
// MARK: - Per-Geohash Identities (Location Channels) // MARK: - Per-Geohash Identities (Location Channels)
+21 -110
View File
@@ -35,14 +35,10 @@ final class NostrRelayManager: ObservableObject {
"wss://nostr21.com" "wss://nostr21.com"
// For local testing, you can add: "ws://localhost:8080" // For local testing, you can add: "ws://localhost:8080"
] ]
private static let defaultRelaySet = Set(defaultRelays)
@Published private(set) var relays: [Relay] = [] @Published private(set) var relays: [Relay] = []
@Published private(set) var isConnected = false @Published private(set) var isConnected = false
private var allowDefaultRelays: Bool = false
private var hasMutualFavorites: Bool = false
private var hasLocationPermission: Bool = false
private var connections: [String: URLSessionWebSocketTask] = [:] private var connections: [String: URLSessionWebSocketTask] = [:]
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON) private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
@@ -69,8 +65,6 @@ final class NostrRelayManager: ObservableObject {
private let messageQueueLock = NSLock() private let messageQueueLock = NSLock()
private let encoder = JSONEncoder() private let encoder = JSONEncoder()
private let decoder = JSONDecoder() private let decoder = JSONDecoder()
private var networkService: NetworkActivationService { NetworkActivationService.shared }
private var shouldUseTor: Bool { networkService.userTorEnabled }
// Exponential backoff configuration // Exponential backoff configuration
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
@@ -84,36 +78,16 @@ final class NostrRelayManager: ObservableObject {
private var connectionGeneration: Int = 0 private var connectionGeneration: Int = 0
init() { init() {
hasMutualFavorites = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty // Initialize with default relays
hasLocationPermission = LocationChannelManager.shared.permissionState == .authorized self.relays = Self.defaultRelays.map { Relay(url: $0) }
applyDefaultRelayPolicy(force: true)
// Deterministic JSON shape for outbound requests // Deterministic JSON shape for outbound requests
self.encoder.outputFormatting = .sortedKeys self.encoder.outputFormatting = .sortedKeys
FavoritesPersistenceService.shared.$mutualFavorites
.receive(on: DispatchQueue.main)
.sink { [weak self] favorites in
guard let self = self else { return }
self.hasMutualFavorites = !favorites.isEmpty
self.applyDefaultRelayPolicy()
}
.store(in: &cancellables)
LocationChannelManager.shared.$permissionState
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self = self else { return }
let authorized = (state == .authorized)
if authorized == self.hasLocationPermission { return }
self.hasLocationPermission = authorized
self.applyDefaultRelayPolicy()
}
.store(in: &cancellables)
} }
/// Connect to all configured relays /// Connect to all configured relays
func connect() { func connect() {
// Global network policy gate // Global network policy gate
guard networkService.activationAllowed else { return } if !TorManager.shared.isAutoStartAllowed() { return }
if shouldUseTor {
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect. // Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
Task.detached { Task.detached {
let ready = await TorManager.shared.awaitReady() let ready = await TorManager.shared.awaitReady()
@@ -128,12 +102,6 @@ final class NostrRelayManager: ObservableObject {
} }
} }
} }
} else {
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (direct)", category: .session)
for relay in self.relays {
connectToRelay(relay.url)
}
}
} }
/// Disconnect from all relays /// Disconnect from all relays
@@ -152,10 +120,8 @@ final class NostrRelayManager: ObservableObject {
/// Ensure connections exist to the given relay URLs (idempotent). /// Ensure connections exist to the given relay URLs (idempotent).
func ensureConnections(to relayUrls: [String]) { func ensureConnections(to relayUrls: [String]) {
// Global network policy gate // Global network policy gate
guard networkService.activationAllowed else { return } if !TorManager.shared.isAutoStartAllowed() { return }
let targets = allowedRelayList(from: relayUrls) if TorManager.shared.torEnforced && !TorManager.shared.isReady {
guard !targets.isEmpty else { return }
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
// Defer until Tor is fully ready; avoid queuing connection attempts early // Defer until Tor is fully ready; avoid queuing connection attempts early
Task.detached { [weak self] in Task.detached { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -164,21 +130,22 @@ final class NostrRelayManager: ObservableObject {
} }
return return
} }
var existing = Set(relays.map { $0.url }) let existing = Set(relays.map { $0.url })
for url in targets where !existing.contains(url) { for url in Set(relayUrls) {
if !existing.contains(url) {
relays.append(Relay(url: url)) relays.append(Relay(url: url))
existing.insert(url)
} }
for url in targets where connections[url] == nil { if connections[url] == nil {
connectToRelay(url) connectToRelay(url)
} }
} }
}
/// Send an event to specified relays (or all if none specified) /// Send an event to specified relays (or all if none specified)
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) { func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
// Global network policy gate // Global network policy gate
guard networkService.activationAllowed else { return } if !TorManager.shared.isAutoStartAllowed() { return }
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady { if TorManager.shared.torEnforced && !TorManager.shared.isReady {
// Defer sends until Tor is ready to avoid premature queueing // Defer sends until Tor is ready to avoid premature queueing
Task.detached { [weak self] in Task.detached { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -187,9 +154,7 @@ final class NostrRelayManager: ObservableObject {
} }
return return
} }
let requestedRelays = relayUrls ?? Self.defaultRelays let targetRelays = relayUrls ?? Self.defaultRelays
let targetRelays = allowedRelayList(from: requestedRelays)
guard !targetRelays.isEmpty else { return }
ensureConnections(to: targetRelays) ensureConnections(to: targetRelays)
// Attempt immediate send to relays with active connections; queue the rest // Attempt immediate send to relays with active connections; queue the rest
@@ -255,7 +220,7 @@ final class NostrRelayManager: ObservableObject {
onEOSE: (() -> Void)? = nil onEOSE: (() -> Void)? = nil
) { ) {
// Global network policy gate // Global network policy gate
guard networkService.activationAllowed else { return } if !TorManager.shared.isAutoStartAllowed() { return }
// Coalesce rapid duplicate subscribe requests only if a handler already exists // Coalesce rapid duplicate subscribe requests only if a handler already exists
let now = Date() let now = Date()
if messageHandlers[id] != nil { if messageHandlers[id] != nil {
@@ -264,7 +229,7 @@ final class NostrRelayManager: ObservableObject {
} }
} }
subscribeCoalesce[id] = now subscribeCoalesce[id] = now
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady { if TorManager.shared.torEnforced && !TorManager.shared.isReady {
// Defer subscription setup until Tor is ready; avoid queuing subs early // Defer subscription setup until Tor is ready; avoid queuing subs early
Task.detached { [weak self] in Task.detached { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -292,23 +257,19 @@ final class NostrRelayManager: ObservableObject {
// Target specific relays if provided; else default. Filter permanently failed relays. // Target specific relays if provided; else default. Filter permanently failed relays.
let baseUrls = relayUrls ?? Self.defaultRelays let baseUrls = relayUrls ?? Self.defaultRelays
let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) } let urls = baseUrls.filter { !isPermanentlyFailed($0) }
let urls = allowedRelayList(from: candidateUrls)
// Always queue subscriptions; sending happens when a relay reports connected // Always queue subscriptions; sending happens when a relay reports connected
let existingSet = Set(relays.map { $0.url }) let existingSet = Set(relays.map { $0.url })
for url in urls where !existingSet.contains(url) { for url in urls where !existingSet.contains(url) {
relays.append(Relay(url: url)) relays.append(Relay(url: url))
} }
for url in candidateUrls { for url in urls {
var map = self.pendingSubscriptions[url] ?? [:] var map = self.pendingSubscriptions[url] ?? [:]
map[id] = messageString map[id] = messageString
self.pendingSubscriptions[url] = map self.pendingSubscriptions[url] = map
} }
// Initialize EOSE tracking if requested // Initialize EOSE tracking if requested
if let onEOSE = onEOSE { if let onEOSE = onEOSE {
if urls.isEmpty {
onEOSE()
} else {
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil) var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
// Fallback timeout to avoid hanging if a relay never sends EOSE // Fallback timeout to avoid hanging if a relay never sends EOSE
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
@@ -323,7 +284,6 @@ final class NostrRelayManager: ObservableObject {
} }
eoseTrackers[id] = tracker eoseTrackers[id] = tracker
} }
}
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session) SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
// Ensure we actually have sockets opening to these relays so queued REQs can flush // Ensure we actually have sockets opening to these relays so queued REQs can flush
ensureConnections(to: urls) ensureConnections(to: urls)
@@ -338,55 +298,6 @@ final class NostrRelayManager: ObservableObject {
} }
} }
private func applyDefaultRelayPolicy(force: Bool = false) {
let shouldAllow = hasMutualFavorites || hasLocationPermission
if !force && shouldAllow == allowDefaultRelays { return }
allowDefaultRelays = shouldAllow
if shouldAllow {
var existing = Set(relays.map { $0.url })
for url in Self.defaultRelays where !existing.contains(url) {
relays.append(Relay(url: url))
existing.insert(url)
}
if networkService.activationAllowed {
ensureConnections(to: Self.defaultRelays)
}
} else {
for url in Self.defaultRelays {
if let connection = connections[url] {
connection.cancel(with: .goingAway, reason: nil)
}
connections.removeValue(forKey: url)
subscriptions.removeValue(forKey: url)
}
messageQueueLock.lock()
for index in (0..<messageQueue.count).reversed() {
var item = messageQueue[index]
item.pendingRelays.subtract(Self.defaultRelaySet)
if item.pendingRelays.isEmpty {
messageQueue.remove(at: index)
} else {
messageQueue[index] = item
}
}
messageQueueLock.unlock()
relays.removeAll { Self.defaultRelaySet.contains($0.url) }
updateConnectionStatus()
}
}
private func allowedRelayList(from urls: [String]) -> [String] {
var seen = Set<String>()
var result: [String] = []
for url in urls {
if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue }
if seen.insert(url).inserted {
result.append(url)
}
}
return result
}
/// Unsubscribe from a subscription /// Unsubscribe from a subscription
func unsubscribe(id: String) { func unsubscribe(id: String) {
messageHandlers.removeValue(forKey: id) messageHandlers.removeValue(forKey: id)
@@ -415,14 +326,14 @@ final class NostrRelayManager: ObservableObject {
private func connectToRelay(_ urlString: String) { private func connectToRelay(_ urlString: String) {
// Global network policy gate // Global network policy gate
guard networkService.activationAllowed else { return } if !TorManager.shared.isAutoStartAllowed() { return }
guard let url = URL(string: urlString) else { guard let url = URL(string: urlString) else {
SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session) SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session)
return return
} }
// Avoid initiating connections while app is backgrounded; we'll reconnect on foreground // Avoid initiating connections while app is backgrounded; we'll reconnect on foreground
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isForeground() { if TorManager.shared.torEnforced && !TorManager.shared.isForeground() {
return return
} }
@@ -437,7 +348,7 @@ final class NostrRelayManager: ObservableObject {
// Attempting to connect to Nostr relay via the proxied session // Attempting to connect to Nostr relay via the proxied session
// If Tor is enforced but not ready, delay connection until it is. // If Tor is enforced but not ready, delay connection until it is.
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady { if TorManager.shared.torEnforced && !TorManager.shared.isReady {
Task.detached { [weak self] in Task.detached { [weak self] in
guard let self = self else { return } guard let self = self else { return }
let ready = await TorManager.shared.awaitReady() let ready = await TorManager.shared.awaitReady()
@@ -623,7 +534,7 @@ final class NostrRelayManager: ObservableObject {
private func handleDisconnection(relayUrl: String, error: Error) { private func handleDisconnection(relayUrl: String, error: Error) {
// If networking is disallowed, do not schedule reconnection // If networking is disallowed, do not schedule reconnection
if !networkService.activationAllowed { if !TorManager.shared.isAutoStartAllowed() {
connections.removeValue(forKey: relayUrl) connections.removeValue(forKey: relayUrl)
subscriptions.removeValue(forKey: relayUrl) subscriptions.removeValue(forKey: relayUrl)
updateRelayStatus(relayUrl, isConnected: false, error: error) updateRelayStatus(relayUrl, isConnected: false, error: error)
+52 -20
View File
@@ -22,11 +22,12 @@
/// ///
/// ## Wire Format /// ## Wire Format
/// ``` /// ```
/// Header (Fixed 13 bytes): /// Header (13 bytes for v1, 15 bytes for v2):
/// +--------+------+-----+-----------+-------+----------------+ /// +--------+------+-----+-----------+-------+---------------+
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength | /// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes | /// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes (v1) |
/// +--------+------+-----+-----------+-------+----------------+ /// | | | | | | 4 bytes (v2) |
/// +--------+------+-----+-----------+-------+---------------+
/// ///
/// Variable sections: /// Variable sections:
/// +----------+-------------+---------+------------+ /// +----------+-------------+---------+------------+
@@ -56,9 +57,10 @@
/// - Bits 3-7: Reserved for future use /// - Bits 3-7: Reserved for future use
/// ///
/// ## Size Constraints /// ## Size Constraints
/// - Maximum packet size: 65,535 bytes (16-bit length field) /// - Maximum packet size: 4.2GB (32-bit length field for v2+)
/// - Typical packet size: < 512 bytes (BLE MTU) /// - Typical packet size: < 512 bytes (BLE MTU)
/// - Minimum packet size: 21 bytes (header + sender ID) /// - Minimum packet size: 21 bytes (header + sender ID)
/// - Legacy v1 packets: max 65,535 bytes (16-bit length field)
/// ///
/// ## Encoding Process /// ## Encoding Process
/// 1. Construct header with fixed fields /// 1. Construct header with fixed fields
@@ -105,7 +107,8 @@ extension Data {
/// their binary wire format representation. /// their binary wire format representation.
/// - Note: All multi-byte values use network byte order (big-endian) /// - Note: All multi-byte values use network byte order (big-endian)
struct BinaryProtocol { struct BinaryProtocol {
static let headerSize = 13 static let headerSizeV1 = 13
static let headerSizeV2 = 15
static let senderIDSize = 8 static let senderIDSize = 8
static let recipientIDSize = 8 static let recipientIDSize = 8
static let signatureSize = 64 static let signatureSize = 64
@@ -116,6 +119,11 @@ struct BinaryProtocol {
static let isCompressed: UInt8 = 0x04 static let isCompressed: UInt8 = 0x04
} }
/// Get header size based on protocol version
static func getHeaderSize(_ version: UInt8) -> Int {
return version >= 2 ? headerSizeV2 : headerSizeV1
}
// Encode BitchatPacket to binary format // Encode BitchatPacket to binary format
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? { static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
var data = Data() var data = Data()
@@ -138,9 +146,10 @@ struct BinaryProtocol {
} else { } else {
} }
// Header // Header with version-dependent size
let headerSize = getHeaderSize(packet.version)
// Reserve capacity to reduce reallocations. Estimate base size conservatively. // Reserve capacity to reduce reallocations. Estimate base size conservatively.
// header(13) + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad // header + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad
let estimatedPayload = payload.count + (isCompressed ? 2 : 0) let estimatedPayload = payload.count + (isCompressed ? 2 : 0)
let estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255 let estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255
data.reserveCapacity(estimated) data.reserveCapacity(estimated)
@@ -166,13 +175,20 @@ struct BinaryProtocol {
} }
data.append(flags) data.append(flags)
// Payload length (2 bytes, big-endian) - includes original size if compressed // Payload length - version-dependent (2 bytes for v1, 4 bytes for v2)
let payloadDataSize = payload.count + (isCompressed ? 2 : 0) let payloadDataSize = payload.count + (isCompressed ? 2 : 0)
let payloadLength = UInt16(payloadDataSize) if packet.version >= 2 {
// v2+: 4 bytes big-endian
data.append(UInt8((payloadDataSize >> 24) & 0xFF))
data.append(UInt8((payloadLength >> 8) & 0xFF)) data.append(UInt8((payloadDataSize >> 16) & 0xFF))
data.append(UInt8(payloadLength & 0xFF)) data.append(UInt8((payloadDataSize >> 8) & 0xFF))
data.append(UInt8(payloadDataSize & 0xFF))
} else {
// v1: 2 bytes big-endian
let payloadLength16 = UInt16(payloadDataSize)
data.append(UInt8((payloadLength16 >> 8) & 0xFF))
data.append(UInt8(payloadLength16 & 0xFF))
}
// SenderID (exactly 8 bytes) // SenderID (exactly 8 bytes)
let senderBytes = packet.senderID.prefix(senderIDSize) let senderBytes = packet.senderID.prefix(senderIDSize)
@@ -227,8 +243,8 @@ struct BinaryProtocol {
// Core decoding implementation used by decode(_:) with and without padding removal // Core decoding implementation used by decode(_:) with and without padding removal
private static func decodeCore(_ raw: Data) -> BitchatPacket? { private static func decodeCore(_ raw: Data) -> BitchatPacket? {
// Minimum size: header + senderID // Minimum size: smallest header (v1) + senderID
guard raw.count >= headerSize + senderIDSize else { return nil } guard raw.count >= headerSizeV1 + senderIDSize else { return nil }
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
guard let base = buf.baseAddress else { return nil } guard let base = buf.baseAddress else { return nil }
@@ -249,6 +265,14 @@ struct BinaryProtocol {
offset += 2 offset += 2
return v return v
} }
// Read big-endian 32-bit
func read32() -> UInt32? {
guard require(4) else { return nil }
let p = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
let v = (UInt32(p[0]) << 24) | (UInt32(p[1]) << 16) | (UInt32(p[2]) << 8) | UInt32(p[3])
offset += 4
return v
}
// Copy N bytes into Data // Copy N bytes into Data
func readData(_ n: Int) -> Data? { func readData(_ n: Int) -> Data? {
guard require(n) else { return nil } guard require(n) else { return nil }
@@ -258,8 +282,8 @@ struct BinaryProtocol {
return d return d
} }
// Version // Version - accept both v1 and v2+
guard let version = read8(), version == 1 else { return nil } guard let version = read8(), version >= 1 else { return nil }
guard let type = read8() else { return nil } guard let type = read8() else { return nil }
guard let ttl = read8() else { return nil } guard let ttl = read8() else { return nil }
@@ -277,8 +301,15 @@ struct BinaryProtocol {
let hasSignature = (flags & Flags.hasSignature) != 0 let hasSignature = (flags & Flags.hasSignature) != 0
let isCompressed = (flags & Flags.isCompressed) != 0 let isCompressed = (flags & Flags.isCompressed) != 0
// Payload length // Payload length - version-dependent (2 bytes for v1, 4 bytes for v2+)
guard let payloadLen = read16(), payloadLen <= 65535 else { return nil } let payloadLen: UInt32
if version >= 2 {
guard let len32 = read32(), len32 <= 4_294_967_295 else { return nil }
payloadLen = len32
} else {
guard let len16 = read16(), len16 <= 65535 else { return nil }
payloadLen = UInt32(len16)
}
// SenderID // SenderID
guard let senderID = readData(senderIDSize) else { return nil } guard let senderID = readData(senderIDSize) else { return nil }
@@ -317,6 +348,7 @@ struct BinaryProtocol {
guard offset <= buf.count else { return nil } guard offset <= buf.count else { return nil }
return BitchatPacket( return BitchatPacket(
version: version,
type: type, type: type,
senderID: senderID, senderID: senderID,
recipientID: recipientID, recipientID: recipientID,
+2
View File
@@ -78,6 +78,7 @@ enum MessageType: UInt8 {
// Fragmentation (simplified) // Fragmentation (simplified)
case fragment = 0x20 // Single fragment type for large messages case fragment = 0x20 // Single fragment type for large messages
case fileTransfer = 0x22 // File transfer (audio/images) TLV payload
var description: String { var description: String {
switch self { switch self {
@@ -88,6 +89,7 @@ enum MessageType: UInt8 {
case .noiseHandshake: return "noiseHandshake" case .noiseHandshake: return "noiseHandshake"
case .noiseEncrypted: return "noiseEncrypted" case .noiseEncrypted: return "noiseEncrypted"
case .fragment: return "fragment" case .fragment: return "fragment"
case .fileTransfer: return "fileTransfer"
} }
} }
} }
+243 -207
View File
@@ -7,78 +7,6 @@ import CryptoKit
import UIKit import UIKit
#endif #endif
struct NotificationStreamAssembler {
private var buffer = Data()
mutating func append(_ chunk: Data) -> (frames: [Data], droppedPrefixes: [UInt8], reset: Bool) {
guard !chunk.isEmpty else { return ([], [], false) }
buffer.append(chunk)
var frames: [Data] = []
var dropped: [UInt8] = []
var reset = false
let maxFrameLength = TransportConfig.blePendingWriteBufferCapBytes
let minHeaderBytes = 14 // version + type + ttl + timestamp(8) + flags + length(2)
let minFramePrefix = minHeaderBytes + BinaryProtocol.senderIDSize
while buffer.count >= minFramePrefix {
guard let first = buffer.first else { break }
if first != 1 {
dropped.append(buffer.removeFirst())
continue
}
guard buffer.count >= minHeaderBytes else { break }
let headerBytes = Array(buffer.prefix(minFramePrefix))
guard headerBytes.count == minFramePrefix else { break }
let flags = headerBytes[11]
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
let payloadLen = (Int(headerBytes[12]) << 8) | Int(headerBytes[13])
var frameLength = minFramePrefix + payloadLen
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
if hasSignature { frameLength += BinaryProtocol.signatureSize }
guard frameLength > 0, frameLength <= maxFrameLength else {
buffer.removeAll()
reset = true
break
}
if buffer.count < frameLength {
// Check if a new frame start exists within the incomplete buffer; if so, drop leading partial bytes.
if let nextStart = buffer.dropFirst().firstIndex(of: 1) {
let dropCount = buffer.distance(from: buffer.startIndex, to: nextStart)
if dropCount > 0 {
buffer.removeFirst(dropCount)
dropped.append(1) // treat as dropped partial start
}
}
break
}
let frame = Data(buffer.prefix(frameLength))
frames.append(frame)
buffer.removeFirst(frameLength)
}
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
buffer.removeAll(keepingCapacity: false)
}
return (frames, dropped, reset)
}
mutating func reset() {
buffer.removeAll(keepingCapacity: false)
}
}
/// BLEService Bluetooth Mesh Transport /// BLEService Bluetooth Mesh Transport
/// - Emits events exclusively via `BitchatDelegate` for UI. /// - Emits events exclusively via `BitchatDelegate` for UI.
/// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`). /// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`).
@@ -88,7 +16,7 @@ final class BLEService: NSObject {
// MARK: - Constants // MARK: - Constants
#if DEBUG #if DEBUG
static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5A") // testnet static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") // testnet
#else #else
static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") // mainnet static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") // mainnet
#endif #endif
@@ -112,7 +40,6 @@ final class BLEService: NSObject {
var isConnecting: Bool = false var isConnecting: Bool = false
var isConnected: Bool = false var isConnected: Bool = false
var lastConnectionAttempt: Date? = nil var lastConnectionAttempt: Date? = nil
var assembler = NotificationStreamAssembler()
} }
private var peripherals: [String: PeripheralState] = [:] // UUID -> PeripheralState private var peripherals: [String: PeripheralState] = [:] // UUID -> PeripheralState
private var peerToPeripheralUUID: [String: String] = [:] // PeerID -> Peripheral UUID private var peerToPeripheralUUID: [String: String] = [:] // PeerID -> Peripheral UUID
@@ -162,9 +89,8 @@ final class BLEService: NSObject {
var myPeerID: String = "" var myPeerID: String = ""
var myNickname: String = "anon" var myNickname: String = "anon"
private var noiseService: NoiseEncryptionService private let noiseService: NoiseEncryptionService
private let identityManager: SecureIdentityStateManagerProtocol private let identityManager: SecureIdentityStateManagerProtocol
private let keychain: KeychainManagerProtocol
private var myPeerIDData: Data = Data() private var myPeerIDData: Data = Data()
// MARK: - Advertising Privacy // MARK: - Advertising Privacy
@@ -264,9 +190,7 @@ final class BLEService: NSObject {
// MARK: - Helpers: IDs, selection, and write backpressure // MARK: - Helpers: IDs, selection, and write backpressure
private func makeMessageID(for packet: BitchatPacket) -> String { private func makeMessageID(for packet: BitchatPacket) -> String {
let senderID = packet.senderID.hexEncodedString() let senderID = packet.senderID.hexEncodedString()
let digest = SHA256.hash(data: packet.payload) return "\(senderID)-\(packet.timestamp)-\(packet.type)"
let digestPrefix = digest.prefix(4).map { String(format: "%02x", $0) }.joined()
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
} }
private func subsetSizeForFanout(_ n: Int) -> Int { private func subsetSizeForFanout(_ n: Int) -> Int {
@@ -406,45 +330,34 @@ final class BLEService: NSObject {
} }
} }
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
service.onPeerAuthenticated = { [weak self] peerID, fingerprint in
SecureLogger.debug("🔐 Noise session authenticated with \(peerID), fingerprint: \(fingerprint.prefix(16))...")
self?.messageQueue.async { [weak self] in
self?.sendPendingMessagesAfterHandshake(for: peerID)
self?.sendPendingNoisePayloadsAfterHandshake(for: peerID)
}
self?.messageQueue.async { [weak self] in
self?.sendAnnounce(forceSend: true)
}
}
}
private func refreshPeerIdentity() {
let fingerprint = noiseService.getIdentityFingerprint()
myPeerID = String(fingerprint.prefix(16))
myPeerIDData = Data(hexString: myPeerID) ?? Data()
}
private func restartGossipManager() {
gossipSyncManager?.stop()
let sync = GossipSyncManager(myPeerID: myPeerID)
sync.delegate = self
sync.start()
gossipSyncManager = sync
}
init(keychain: KeychainManagerProtocol, identityManager: SecureIdentityStateManagerProtocol) { init(keychain: KeychainManagerProtocol, identityManager: SecureIdentityStateManagerProtocol) {
self.keychain = keychain
noiseService = NoiseEncryptionService(keychain: keychain) noiseService = NoiseEncryptionService(keychain: keychain)
self.identityManager = identityManager self.identityManager = identityManager
super.init() super.init()
configureNoiseServiceCallbacks(for: noiseService) // Derive stable peer ID from Noise static public key fingerprint (first 8 bytes 16 hex chars)
refreshPeerIdentity() let fingerprint = noiseService.getIdentityFingerprint() // 64 hex chars
self.myPeerID = String(fingerprint.prefix(16))
self.myPeerIDData = Data(hexString: myPeerID) ?? Data()
// Set queue key for identification // Set queue key for identification
messageQueue.setSpecific(key: messageQueueKey, value: ()) messageQueue.setSpecific(key: messageQueueKey, value: ())
// Set up Noise session establishment callback
// This ensures we send pending messages only when session is truly established
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
SecureLogger.debug("🔐 Noise session authenticated with \(peerID), fingerprint: \(fingerprint.prefix(16))...")
// Send any messages that were queued during handshake
self?.messageQueue.async { [weak self] in
self?.sendPendingMessagesAfterHandshake(for: peerID)
self?.sendPendingNoisePayloadsAfterHandshake(for: peerID)
}
// Proactive presence nudge: announce immediately after handshake
self?.messageQueue.async { [weak self] in
self?.sendAnnounce(forceSend: true)
}
}
// Set up application state tracking (iOS only) // Set up application state tracking (iOS only)
#if os(iOS) #if os(iOS)
// Check initial state on main thread // Check initial state on main thread
@@ -494,7 +407,10 @@ final class BLEService: NSObject {
requestPeerDataPublish() requestPeerDataPublish()
// Initialize gossip sync manager // Initialize gossip sync manager
restartGossipManager() let sync = GossipSyncManager(myPeerID: myPeerID)
sync.delegate = self
sync.start()
self.gossipSyncManager = sync
} }
func setNickname(_ nickname: String) { func setNickname(_ nickname: String) {
@@ -823,46 +739,6 @@ final class BLEService: NSObject {
centralToPeerID.removeAll() centralToPeerID.removeAll()
} }
func resetIdentityForPanic(currentNickname: String) {
messageQueue.sync(flags: .barrier) {
pendingMessagesAfterHandshake.removeAll()
pendingNoisePayloadsAfterHandshake.removeAll()
}
collectionsQueue.sync(flags: .barrier) {
recentAnnounceBySender.removeAll()
recentAnnounceOrder.removeAll()
pendingPeripheralWrites.removeAll()
pendingNotifications.removeAll()
pendingDirectedRelays.removeAll()
ingressByMessageID.removeAll()
recentPacketTimestamps.removeAll()
scheduledRelays.values.forEach { $0.cancel() }
scheduledRelays.removeAll()
}
bleQueue.sync {
pendingWriteBuffers.removeAll()
recentConnectTimeouts.removeAll()
}
recentDisconnectNotifies.removeAll()
noiseService.clearEphemeralStateForPanic()
noiseService.clearPersistentIdentity()
let newNoise = NoiseEncryptionService(keychain: keychain)
noiseService = newNoise
configureNoiseServiceCallbacks(for: newNoise)
refreshPeerIdentity()
restartGossipManager()
setNickname(currentNickname)
messageDeduplicator.reset()
requestPeerDataPublish()
startServices()
}
func getNoiseService() -> NoiseEncryptionService { func getNoiseService() -> NoiseEncryptionService {
return noiseService return noiseService
} }
@@ -1185,13 +1061,6 @@ final class BLEService: NSObject {
// Determine last-hop link for this message to avoid echoing back // Determine last-hop link for this message to avoid echoing back
let messageID = makeMessageID(for: packet) let messageID = makeMessageID(for: packet)
let ingressLink: LinkID? = collectionsQueue.sync { ingressByMessageID[messageID]?.link } let ingressLink: LinkID? = collectionsQueue.sync { ingressByMessageID[messageID]?.link }
let directedPeerHint: String? = {
if let explicit = directedOnlyPeer { return explicit }
if let recipient = packet.recipientID?.hexEncodedString(), !recipient.isEmpty {
return recipient
}
return nil
}()
let states = snapshotPeripheralStates() let states = snapshotPeripheralStates()
var minCentralWriteLen: Int? var minCentralWriteLen: Int?
@@ -1245,7 +1114,7 @@ final class BLEService: NSObject {
// Special-case control/presence messages: do NOT subset to maximize immediate coverage // Special-case control/presence messages: do NOT subset to maximize immediate coverage
var selectedPeripheralIDs = Set(allowedPeripheralIDs) var selectedPeripheralIDs = Set(allowedPeripheralIDs)
var selectedCentralIDs = Set(allowedCentralIDs) var selectedCentralIDs = Set(allowedCentralIDs)
if directedPeerHint == nil if directedOnlyPeer == nil
&& packet.type != MessageType.fragment.rawValue && packet.type != MessageType.fragment.rawValue
&& packet.type != MessageType.announce.rawValue && packet.type != MessageType.announce.rawValue
&& packet.type != MessageType.requestSync.rawValue { && packet.type != MessageType.requestSync.rawValue {
@@ -1256,7 +1125,7 @@ final class BLEService: NSObject {
} }
// If directed and we currently have no links to forward on, spool for a short window // If directed and we currently have no links to forward on, spool for a short window
if let only = directedPeerHint, if let only = directedOnlyPeer,
selectedPeripheralIDs.isEmpty && selectedCentralIDs.isEmpty, selectedPeripheralIDs.isEmpty && selectedCentralIDs.isEmpty,
(packet.type == MessageType.noiseEncrypted.rawValue || packet.type == MessageType.noiseHandshake.rawValue) { (packet.type == MessageType.noiseEncrypted.rawValue || packet.type == MessageType.noiseHandshake.rawValue) {
spoolDirectedPacket(packet, recipientPeerID: only) spoolDirectedPacket(packet, recipientPeerID: only)
@@ -1408,6 +1277,127 @@ final class BLEService: NSObject {
} }
} }
// File transfer with progress: send TLV payload inside a FILE_TRANSFER packet.
// Reports .partiallyDelivered via delegate for the provided messageID during fragmentation.
func sendFileTransferTLV(_ payload: Data, recipientPeerID: String?, transferId: String, messageID: String) {
// Build packet
let recipientData: Data? = recipientPeerID.flatMap { Data(hexString: $0) }
var packet = BitchatPacket(
version: 2, // FILE_TRANSFER uses v2 for 4-byte payload length to support large files
type: MessageType.fileTransfer.rawValue,
senderID: myPeerIDData,
recipientID: recipientData,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: messageTTL
)
// Log debug info about the file TLV
if let fp = BitchatFilePacket.decode(from: payload) {
let sha = fp.content.sha256Hex()
let scope = (recipientData == nil || recipientData == Data(repeating: 0xFF, count: 8)) ? "broadcast" : "private"
SecureLogger.debug("📤 FILE_TRANSFER send (\(scope)) name=\(fp.fileName) mime=\(fp.mimeType) size=\(fp.fileSize) sha256=\(sha)", category: .session)
} else {
SecureLogger.warning("⚠️ FILE_TRANSFER send: failed to decode TLV for logging (payload=\(payload.count) bytes)", category: .session)
}
// For private sends, sign (integrity)
if recipientData != nil {
if let signed = noiseService.signPacket(packet) { packet = signed }
}
// Encode once to decide if fragmentation is needed on current links
let pad = padPolicy(for: packet.type)
guard let data = packet.toBinaryData(padding: pad) else { return }
// Determine max link MTU across active links similar to sendOnAllLinks
let states = snapshotPeripheralStates()
var minCentralWriteLen: Int?
for s in states where s.isConnected {
let m = s.peripheral.maximumWriteValueLength(for: .withoutResponse)
minCentralWriteLen = minCentralWriteLen.map { min($0, m) } ?? m
}
var minNotifyLen: Int?
do {
let (centrals, _) = snapshotSubscribedCentrals()
if !centrals.isEmpty {
minNotifyLen = centrals.map { $0.maximumUpdateValueLength }.min()
}
}
let needsFragment = {
if let minLen = [minCentralWriteLen, minNotifyLen].compactMap({ $0 }).min() {
return data.count > minLen
}
return false
}()
if needsFragment {
let overhead = 13 + 8 + 8 + 13
let minLen = [minCentralWriteLen, minNotifyLen].compactMap({ $0 }).min() ?? defaultFragmentSize
let chunk = max(64, minLen - overhead)
// Create fragments and emit progress per fragment
guard let fullData = packet.toBinaryData(padding: pad) else { return }
let fragmentID = Data((0..<8).map { _ in UInt8.random(in: 0...255) })
let fragments = stride(from: 0, to: fullData.count, by: chunk).map { offset in
Data(fullData[offset..<min(offset + chunk, fullData.count)])
}
let total = fragments.count
// Pause scanning for long trains as in sendFragmentedPacket
if total > 4 {
bleQueue.async { [weak self] in
guard let self = self, let c = self.centralManager, c.state == .poweredOn else { return }
if c.isScanning { c.stopScan() }
let expectedMs = min(TransportConfig.bleExpectedWriteMaxMs, total * TransportConfig.bleExpectedWritePerFragmentMs)
self.bleQueue.asyncAfter(deadline: .now() + .milliseconds(expectedMs)) { [weak self] in self?.startScanning() }
}
}
for (index, fragment) in fragments.enumerated() {
var fragPayload = Data()
fragPayload.append(fragmentID)
fragPayload.append(contentsOf: withUnsafeBytes(of: UInt16(index).bigEndian) { Data($0) })
fragPayload.append(contentsOf: withUnsafeBytes(of: UInt16(total).bigEndian) { Data($0) })
fragPayload.append(packet.type)
fragPayload.append(fragment)
let fragmentRecipient: Data? = recipientData
let fragmentPacket = BitchatPacket(
type: MessageType.fragment.rawValue,
senderID: packet.senderID,
recipientID: fragmentRecipient,
timestamp: packet.timestamp,
payload: fragPayload,
signature: nil,
ttl: packet.ttl
)
let perFragMs = (recipientData != nil) ? TransportConfig.bleFragmentSpacingDirectedMs : TransportConfig.bleFragmentSpacingMs
let delayMs = index * perFragMs
// Emit progress to UI just before scheduling send
notifyUI { [weak self] in
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: index, total: total))
}
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in
self?.broadcastPacket(fragmentPacket)
if index == total - 1 {
self?.notifyUI { [weak self] in
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
}
}
}
}
} else {
// Single packet path
broadcastPacket(packet)
notifyUI { [weak self] in
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
}
}
}
private func handleFragment(_ packet: BitchatPacket, from peerID: String) { private func handleFragment(_ packet: BitchatPacket, from peerID: String) {
// Don't process our own fragments // Don't process our own fragments
if peerID == myPeerID { if peerID == myPeerID {
@@ -1550,6 +1540,9 @@ final class BLEService: NSObject {
case .fragment: case .fragment:
handleFragment(packet, from: senderID) handleFragment(packet, from: senderID)
case .fileTransfer:
handleFileTransfer(packet, from: senderID)
case .leave: case .leave:
handleLeave(packet, from: senderID) handleLeave(packet, from: senderID)
@@ -1592,6 +1585,72 @@ final class BLEService: NSObject {
} }
} }
private func handleFileTransfer(_ packet: BitchatPacket, from peerID: String) {
// Decode TLV
guard let file = BitchatFilePacket.decode(from: packet.payload) else {
SecureLogger.error("❌ Failed to decode file TLV from \(peerID)", category: .session)
return
}
let sha = file.content.sha256Hex()
let scope = (packet.recipientID == nil || packet.recipientID == Data(repeating: 0xFF, count: 8)) ? "broadcast" : "private"
SecureLogger.debug("📥 FILE_TRANSFER recv (\(scope)) from=\(peerID.prefix(8))… name=\(file.fileName) mime=\(file.mimeType) size=\(file.fileSize) sha256=\(sha)", category: .session)
// Determine if this is a true direct message; Android may use 0xFF..FF to denote broadcast
let isBroadcastRecipient: Bool = {
guard let rid = packet.recipientID else { return true }
return rid == Data(repeating: 0xFF, count: 8)
}()
// Persist to app files in type-specific folder
let isAudio = file.mimeType.lowercased().hasPrefix("audio/")
let subfolder = isAudio ? "voicenotes/incoming" : "images/incoming"
let ext: String = {
if let inferred = file.fileName.split(separator: ".").last { return String(inferred) }
if isAudio { return "m4a" }
return "bin"
}()
let unique = "\(Int(Date().timeIntervalSince1970))_\(UUID().uuidString.prefix(8)).\(ext)"
guard let savedURL = saveToAppFiles(subpath: subfolder, fileName: unique, data: file.content) else { return }
// Build synthetic message
let nickname = peerNickname(peerID: peerID) ?? "user"
let ts = Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000.0)
let marker = isAudio ? "[voice]" : "[image]"
let content = "\(marker) \(savedURL.path)"
let message = BitchatMessage(
id: UUID().uuidString,
sender: nickname,
content: content,
timestamp: ts,
isRelay: false,
originalSender: nil,
isPrivate: (packet.recipientID != nil) && !isBroadcastRecipient,
recipientNickname: nil,
senderPeerID: peerID,
mentions: nil,
deliveryStatus: nil
)
notifyUI { [weak self] in
self?.delegate?.didReceiveMessage(message)
}
}
private func saveToAppFiles(subpath: String, fileName: String, data: Data) -> URL? {
do {
let fm = FileManager.default
let base = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let folder = base.appendingPathComponent("bitchat_\(subpath)", isDirectory: true)
if !fm.fileExists(atPath: folder.path) {
try fm.createDirectory(at: folder, withIntermediateDirectories: true)
}
let url = folder.appendingPathComponent(fileName)
try data.write(to: url, options: .atomic)
return url
} catch {
SecureLogger.error("❌ Failed to save incoming file: \(error)", category: .session)
return nil
}
}
private func handleAnnounce(_ packet: BitchatPacket, from peerID: String) { private func handleAnnounce(_ packet: BitchatPacket, from peerID: String) {
guard let announcement = AnnouncementPacket.decode(from: packet.payload) else { guard let announcement = AnnouncementPacket.decode(from: packet.payload) else {
SecureLogger.error("❌ Failed to decode announce packet from \(peerID)", category: .session) SecureLogger.error("❌ Failed to decode announce packet from \(peerID)", category: .session)
@@ -2597,8 +2656,7 @@ extension BLEService: CBCentralManagerDelegate {
peerID: nil, peerID: nil,
isConnecting: true, isConnecting: true,
isConnected: false, isConnected: false,
lastConnectionAttempt: Date(), lastConnectionAttempt: Date()
assembler: NotificationStreamAssembler()
) )
peripheral.delegate = self peripheral.delegate = self
@@ -2647,9 +2705,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
characteristic: nil, characteristic: nil,
peerID: nil, peerID: nil,
isConnecting: false, isConnecting: false,
isConnected: true, isConnected: true
lastConnectionAttempt: nil,
assembler: NotificationStreamAssembler()
) )
} }
@@ -2787,8 +2843,7 @@ extension BLEService {
peerID: nil, peerID: nil,
isConnecting: true, isConnecting: true,
isConnected: false, isConnected: false,
lastConnectionAttempt: Date(), lastConnectionAttempt: Date()
assembler: NotificationStreamAssembler()
) )
peripheral.delegate = self peripheral.delegate = self
let options: [String: Any] = [ let options: [String: Any] = [
@@ -2925,72 +2980,53 @@ extension BLEService: CBPeripheralDelegate {
return return
} }
guard let data = characteristic.value, !data.isEmpty else { guard let data = characteristic.value else {
SecureLogger.warning("⚠️ No data in notification", category: .session) SecureLogger.warning("⚠️ No data in notification", category: .session)
return return
} }
bufferNotificationChunk(data, from: peripheral) // Received BLE notification
// Process directly on main thread to avoid deadlocks (matches original implementation)
guard let packet = BinaryProtocol.decode(data) else {
// Avoid dumping entire payload; log size and short prefix for diagnostics
let prefix = data.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
SecureLogger.error("❌ Failed to decode notification packet (len=\(data.count), prefix=\(prefix))", category: .session)
return
} }
private func bufferNotificationChunk(_ chunk: Data, from peripheral: CBPeripheral) { // Use the packet's senderID as the peer identifier
let peripheralUUID = peripheral.identifier.uuidString
var state = peripherals[peripheralUUID] ?? PeripheralState(
peripheral: peripheral,
characteristic: nil,
peerID: nil,
isConnecting: false,
isConnected: peripheral.state == .connected,
lastConnectionAttempt: nil,
assembler: NotificationStreamAssembler()
)
var assembler = state.assembler
let result = assembler.append(chunk)
state.assembler = assembler
peripherals[peripheralUUID] = state
for byte in result.droppedPrefixes {
SecureLogger.warning("⚠️ Dropping byte from BLE stream (unexpected prefix \(String(format: "%02x", byte)))", category: .session)
}
if result.reset {
SecureLogger.error("❌ Invalid BLE frame length; reset notification stream", category: .session)
}
for frame in result.frames {
guard let packet = BinaryProtocol.decode(frame) else {
let prefix = frame.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
SecureLogger.error("❌ Failed to decode assembled notification frame (len=\(frame.count), prefix=\(prefix))", category: .session)
continue
}
processNotificationPacket(packet, from: peripheral, peripheralUUID: peripheralUUID)
}
}
private func processNotificationPacket(_ packet: BitchatPacket, from peripheral: CBPeripheral, peripheralUUID: String) {
let senderID = packet.senderID.hexEncodedString() let senderID = packet.senderID.hexEncodedString()
// Only log non-announce packets
if packet.type != MessageType.announce.rawValue { if packet.type != MessageType.announce.rawValue {
SecureLogger.debug("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID)", category: .session) SecureLogger.debug("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID)", category: .session)
} }
let peripheralUUID = peripheral.identifier.uuidString
// Update mapping ONLY for announce packets that come directly from the peer (not relayed)
if packet.type == MessageType.announce.rawValue { if packet.type == MessageType.announce.rawValue {
// Only update mapping if this is a direct announce (TTL == messageTTL means not relayed)
if packet.ttl == messageTTL { if packet.ttl == messageTTL {
if var state = peripherals[peripheralUUID] { if var state = peripherals[peripheralUUID] {
state.peerID = senderID state.peerID = senderID
peripherals[peripheralUUID] = state peripherals[peripheralUUID] = state
} }
peerToPeripheralUUID[senderID] = peripheralUUID peerToPeripheralUUID[senderID] = peripheralUUID
// Mapping update - direct announce from peer
} }
// Record ingress link for last-hop suppression and process
let msgID = makeMessageID(for: packet) let msgID = makeMessageID(for: packet)
collectionsQueue.async(flags: .barrier) { [weak self] in collectionsQueue.async(flags: .barrier) { [weak self] in
self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date()) self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())
} }
// Process the announce packet regardless of whether we updated the mapping
handleReceivedPacket(packet, from: senderID) handleReceivedPacket(packet, from: senderID)
} else { } else {
// For non-announce packets, DO NOT update mappings
// These could be relayed packets from other peers
// Always use the packet's original senderID
// Record ingress link for last-hop suppression and process
let msgID = makeMessageID(for: packet) let msgID = makeMessageID(for: packet)
collectionsQueue.async(flags: .barrier) { [weak self] in collectionsQueue.async(flags: .barrier) { [weak self] in
self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date()) self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())
-1
View File
@@ -282,7 +282,6 @@ final class KeychainManager: KeychainManagerProtocol {
"com.bitchat.deviceidentity", "com.bitchat.deviceidentity",
"com.bitchat.noise.identity", "com.bitchat.noise.identity",
"chat.bitchat.passwords", "chat.bitchat.passwords",
"chat.bitchat.nostr",
"bitchat.keychain", "bitchat.keychain",
"bitchat", "bitchat",
"com.bitchat" "com.bitchat"
+7 -53
View File
@@ -1,34 +1,5 @@
import BitLogger
import Foundation import Foundation
struct LocationNotesCounterDependencies {
typealias RelayLookup = @MainActor (_ geohash: String, _ count: Int) -> [String]
typealias Subscribe = @MainActor (_ filter: NostrFilter, _ id: String, _ relays: [String], _ handler: @escaping (NostrEvent) -> Void, _ onEOSE: (() -> Void)?) -> Void
typealias Unsubscribe = @MainActor (_ id: String) -> Void
var relayLookup: RelayLookup
var subscribe: Subscribe
var unsubscribe: Unsubscribe
static let live = LocationNotesCounterDependencies(
relayLookup: { geohash, count in
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
},
subscribe: { filter, id, relays, handler, onEOSE in
NostrRelayManager.shared.subscribe(
filter: filter,
id: id,
relayUrls: relays,
handler: handler,
onEOSE: onEOSE
)
},
unsubscribe: { id in
NostrRelayManager.shared.unsubscribe(id: id)
}
)
}
/// Lightweight background counter for location notes (kind 1) at building-level geohash (8 chars). /// Lightweight background counter for location notes (kind 1) at building-level geohash (8 chars).
@MainActor @MainActor
final class LocationNotesCounter: ObservableObject { final class LocationNotesCounter: ObservableObject {
@@ -37,45 +8,29 @@ final class LocationNotesCounter: ObservableObject {
@Published private(set) var geohash: String? = nil @Published private(set) var geohash: String? = nil
@Published private(set) var count: Int? = 0 @Published private(set) var count: Int? = 0
@Published private(set) var initialLoadComplete: Bool = false @Published private(set) var initialLoadComplete: Bool = false
@Published private(set) var relayAvailable: Bool = true
private var subscriptionID: String? = nil private var subscriptionID: String? = nil
private var noteIDs = Set<String>() private var noteIDs = Set<String>()
private let dependencies: LocationNotesCounterDependencies
private init(dependencies: LocationNotesCounterDependencies = .live) { private init() {}
self.dependencies = dependencies
}
init(testDependencies: LocationNotesCounterDependencies) {
self.dependencies = testDependencies
}
func subscribe(geohash gh: String) { func subscribe(geohash gh: String) {
let norm = gh.lowercased() let norm = gh.lowercased()
if geohash == norm, subscriptionID != nil { return } if geohash == norm, subscriptionID != nil { return }
// Unsubscribe previous without clearing count to avoid flicker // Unsubscribe previous without clearing count to avoid flicker
if let sub = subscriptionID { dependencies.unsubscribe(sub) } if let sub = subscriptionID { NostrRelayManager.shared.unsubscribe(id: sub) }
subscriptionID = nil subscriptionID = nil
geohash = norm geohash = norm
noteIDs.removeAll() noteIDs.removeAll()
initialLoadComplete = false initialLoadComplete = false
relayAvailable = true
// Subscribe only to the building geohash (precision 8) // Subscribe only to the building geohash (precision 8)
let subID = "locnotes-count-\(norm)-\(UUID().uuidString.prefix(6))" let subID = "locnotes-count-\(norm)-\(UUID().uuidString.prefix(6))"
let relays = dependencies.relayLookup(norm, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
relayAvailable = false
initialLoadComplete = true
count = 0
SecureLogger.warning("LocationNotesCounter: no geo relays for geohash=\(norm)", category: .session)
return
}
subscriptionID = subID subscriptionID = subID
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 500) let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 500)
dependencies.subscribe(filter, subID, relays, { [weak self] event in let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: norm, count: TransportConfig.nostrGeoRelayCount)
let relayUrls: [String]? = relays.isEmpty ? nil : relays
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: relayUrls, handler: { [weak self] event in
guard let self = self else { return } guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return } guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == norm }) else { return } guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == norm }) else { return }
@@ -83,17 +38,16 @@ final class LocationNotesCounter: ObservableObject {
self.noteIDs.insert(event.id) self.noteIDs.insert(event.id)
self.count = self.noteIDs.count self.count = self.noteIDs.count
} }
}, { [weak self] in }, onEOSE: { [weak self] in
self?.initialLoadComplete = true self?.initialLoadComplete = true
}) })
} }
func cancel() { func cancel() {
if let sub = subscriptionID { dependencies.unsubscribe(sub) } if let sub = subscriptionID { NostrRelayManager.shared.unsubscribe(id: sub) }
subscriptionID = nil subscriptionID = nil
geohash = nil geohash = nil
count = 0 count = 0
noteIDs.removeAll() noteIDs.removeAll()
relayAvailable = true
} }
} }
+14 -115
View File
@@ -1,57 +1,10 @@
import BitLogger import BitLogger
import Foundation import Foundation
/// Dependencies for location notes, allowing tests to stub relay/identity behavior. /// Persistent location notes (Nostr kind 1) scoped to a street-level geohash (precision 7).
struct LocationNotesDependencies {
typealias RelayLookup = @MainActor (_ geohash: String, _ count: Int) -> [String]
typealias Subscribe = @MainActor (_ filter: NostrFilter, _ id: String, _ relays: [String], _ handler: @escaping (NostrEvent) -> Void, _ onEOSE: (() -> Void)?) -> Void
typealias Unsubscribe = @MainActor (_ id: String) -> Void
typealias SendEvent = @MainActor (_ event: NostrEvent, _ relayUrls: [String]) -> Void
var relayLookup: RelayLookup
var subscribe: Subscribe
var unsubscribe: Unsubscribe
var sendEvent: SendEvent
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
var now: () -> Date
static let live = LocationNotesDependencies(
relayLookup: { geohash, count in
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
},
subscribe: { filter, id, relays, handler, onEOSE in
NostrRelayManager.shared.subscribe(
filter: filter,
id: id,
relayUrls: relays,
handler: handler,
onEOSE: onEOSE
)
},
unsubscribe: { id in
NostrRelayManager.shared.unsubscribe(id: id)
},
sendEvent: { event, relays in
NostrRelayManager.shared.sendEvent(event, to: relays)
},
deriveIdentity: { geohash in
try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
},
now: { Date() }
)
}
/// Persistent location notes (Nostr kind 1) scoped to a building-level geohash (precision 8).
/// Subscribes to and publishes notes for a given geohash and provides a send API. /// Subscribes to and publishes notes for a given geohash and provides a send API.
@MainActor @MainActor
final class LocationNotesManager: ObservableObject { final class LocationNotesManager: ObservableObject {
enum State: Equatable {
case idle
case loading
case ready
case noRelays
}
struct Note: Identifiable, Equatable { struct Note: Identifiable, Equatable {
let id: String let id: String
let pubkey: String let pubkey: String
@@ -71,14 +24,10 @@ final class LocationNotesManager: ObservableObject {
@Published private(set) var notes: [Note] = [] // reverse-chron sorted @Published private(set) var notes: [Note] = [] // reverse-chron sorted
@Published private(set) var geohash: String @Published private(set) var geohash: String
@Published private(set) var initialLoadComplete: Bool = false @Published private(set) var initialLoadComplete: Bool = false
@Published private(set) var state: State = .loading
@Published private(set) var errorMessage: String?
private var subscriptionID: String? private var subscriptionID: String?
private let dependencies: LocationNotesDependencies
init(geohash: String, dependencies: LocationNotesDependencies = .live) { init(geohash: String) {
self.geohash = geohash.lowercased() self.geohash = geohash.lowercased()
self.dependencies = dependencies
subscribe() subscribe()
} }
@@ -86,7 +35,7 @@ final class LocationNotesManager: ObservableObject {
let norm = newGeohash.lowercased() let norm = newGeohash.lowercased()
guard norm != geohash else { return } guard norm != geohash else { return }
if let sub = subscriptionID { if let sub = subscriptionID {
dependencies.unsubscribe(sub) NostrRelayManager.shared.unsubscribe(id: sub)
subscriptionID = nil subscriptionID = nil
} }
geohash = norm geohash = norm
@@ -94,43 +43,15 @@ final class LocationNotesManager: ObservableObject {
subscribe() subscribe()
} }
func refresh() {
if let sub = subscriptionID {
dependencies.unsubscribe(sub)
subscriptionID = nil
}
notes.removeAll()
subscribe()
}
func clearError() {
errorMessage = nil
}
private func subscribe() { private func subscribe() {
state = .loading
errorMessage = nil
if let sub = subscriptionID {
dependencies.unsubscribe(sub)
subscriptionID = nil
}
let subID = "locnotes-\(geohash)-\(UUID().uuidString.prefix(8))" let subID = "locnotes-\(geohash)-\(UUID().uuidString.prefix(8))"
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
subscriptionID = nil
initialLoadComplete = true
state = .noRelays
errorMessage = "No geo relays available near this location. Try again soon."
SecureLogger.warning("LocationNotesManager: no geo relays for geohash=\(geohash)", category: .session)
return
}
subscriptionID = subID subscriptionID = subID
initialLoadComplete = false
// For persistent notes, allow relays to return recent history without an aggressive time cutoff // For persistent notes, allow relays to return recent history without an aggressive time cutoff
let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200) let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200)
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
dependencies.subscribe(filter, subID, relays, { [weak self] event in let relayUrls: [String]? = relays.isEmpty ? nil : relays
initialLoadComplete = false
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: relayUrls, handler: { [weak self] event in
guard let self = self else { return } guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return } guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
// Ensure matching tag // Ensure matching tag
@@ -141,13 +62,8 @@ final class LocationNotesManager: ObservableObject {
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick) let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
self.notes.append(note) self.notes.append(note)
self.notes.sort { $0.createdAt > $1.createdAt } self.notes.sort { $0.createdAt > $1.createdAt }
self.state = .ready }, onEOSE: { [weak self] in
}, { [weak self] in self?.initialLoadComplete = true
guard let self = self else { return }
self.initialLoadComplete = true
if self.state != .noRelays {
self.state = .ready
}
}) })
} }
@@ -155,46 +71,29 @@ final class LocationNotesManager: ObservableObject {
func send(content: String, nickname: String) { func send(content: String, nickname: String) {
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return } guard !trimmed.isEmpty else { return }
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
state = .noRelays
errorMessage = "No geo relays available near this location. Try again soon."
SecureLogger.warning("LocationNotesManager: send blocked, no geo relays for geohash=\(geohash)", category: .session)
return
}
do { do {
let id = try dependencies.deriveIdentity(geohash) let id = try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
let event = try NostrProtocol.createGeohashTextNote( let event = try NostrProtocol.createGeohashTextNote(
content: trimmed, content: trimmed,
geohash: geohash, geohash: geohash,
senderIdentity: id, senderIdentity: id,
nickname: nickname nickname: nickname
) )
dependencies.sendEvent(event, relays) let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
NostrRelayManager.shared.sendEvent(event, to: relays)
// Optimistic local-echo // Optimistic local-echo
let echo = Note( let echo = Note(id: event.id, pubkey: id.publicKeyHex, content: trimmed, createdAt: Date(), nickname: nickname)
id: event.id,
pubkey: id.publicKeyHex,
content: trimmed,
createdAt: dependencies.now(),
nickname: nickname
)
self.notes.insert(echo, at: 0) self.notes.insert(echo, at: 0)
self.state = .ready
self.errorMessage = nil
} catch { } catch {
SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session) SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session)
errorMessage = "Failed to send note. \(error.localizedDescription)"
} }
} }
/// Explicitly cancel subscription and release resources. /// Explicitly cancel subscription and release resources.
func cancel() { func cancel() {
if let sub = subscriptionID { if let sub = subscriptionID {
dependencies.unsubscribe(sub) NostrRelayManager.shared.unsubscribe(id: sub)
subscriptionID = nil subscriptionID = nil
} }
state = .idle
errorMessage = nil
} }
} }
+15 -58
View File
@@ -10,12 +10,9 @@ final class NetworkActivationService: ObservableObject {
static let shared = NetworkActivationService() static let shared = NetworkActivationService()
@Published private(set) var activationAllowed: Bool = false @Published private(set) var activationAllowed: Bool = false
@Published private(set) var userTorEnabled: Bool = true
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var started = false private var started = false
private let torPreferenceKey = "networkActivationService.userTorEnabled"
private var torAutoStartDesired: Bool = false
private init() {} private init() {}
@@ -23,23 +20,9 @@ final class NetworkActivationService: ObservableObject {
guard !started else { return } guard !started else { return }
started = true started = true
if let stored = UserDefaults.standard.object(forKey: torPreferenceKey) as? Bool {
userTorEnabled = stored
} else {
userTorEnabled = true
}
// Initial compute // Initial compute
let allowed = basePolicyAllowed() activationAllowed = Self.computeAllowed()
activationAllowed = allowed TorManager.shared.setAutoStartAllowed(activationAllowed)
torAutoStartDesired = allowed && userTorEnabled
TorManager.shared.setAutoStartAllowed(torAutoStartDesired)
applyTorState(torDesired: torAutoStartDesired)
if allowed {
NostrRelayManager.shared.connect()
} else {
NostrRelayManager.shared.disconnect()
}
// React to location permission changes // React to location permission changes
LocationChannelManager.shared.$permissionState LocationChannelManager.shared.$permissionState
@@ -58,56 +41,30 @@ final class NetworkActivationService: ObservableObject {
.store(in: &cancellables) .store(in: &cancellables)
} }
func setUserTorEnabled(_ enabled: Bool) {
guard enabled != userTorEnabled else { return }
userTorEnabled = enabled
UserDefaults.standard.set(enabled, forKey: torPreferenceKey)
NotificationCenter.default.post(
name: .TorUserPreferenceChanged,
object: nil,
userInfo: ["enabled": enabled]
)
reevaluate()
}
private func reevaluate() { private func reevaluate() {
let allowed = basePolicyAllowed() let allowed = Self.computeAllowed()
let torDesired = allowed && userTorEnabled if allowed != activationAllowed {
let statusChanged = allowed != activationAllowed
let torChanged = torDesired != torAutoStartDesired
if statusChanged {
SecureLogger.info("NetworkActivationService: activationAllowed -> \(allowed)", category: .session) SecureLogger.info("NetworkActivationService: activationAllowed -> \(allowed)", category: .session)
activationAllowed = allowed activationAllowed = allowed
} TorManager.shared.setAutoStartAllowed(allowed)
if statusChanged || torChanged {
torAutoStartDesired = torDesired
TorManager.shared.setAutoStartAllowed(torDesired)
applyTorState(torDesired: torDesired)
}
if allowed { if allowed {
if torChanged { // Kick Tor + relays if we're now permitted
// Reset relay sockets when switching transport path (Tor direct) TorManager.shared.startIfNeeded()
NostrRelayManager.shared.disconnect() // If app is in foreground, begin relay connections
} if TorManager.shared.isForeground() {
NostrRelayManager.shared.connect() NostrRelayManager.shared.connect()
} else if statusChanged { }
} else {
// Transitioned to disallowed: disconnect relays and shut down Tor
NostrRelayManager.shared.disconnect() NostrRelayManager.shared.disconnect()
TorManager.shared.goDormantOnBackground()
}
} }
} }
private func basePolicyAllowed() -> Bool { private static func computeAllowed() -> Bool {
let permOK = LocationChannelManager.shared.permissionState == .authorized let permOK = LocationChannelManager.shared.permissionState == .authorized
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
return permOK || hasMutual return permOK || hasMutual
} }
private func applyTorState(torDesired: Bool) {
TorURLSession.shared.setProxyMode(useTor: torDesired)
if torDesired {
TorManager.shared.startIfNeeded()
} else {
TorManager.shared.shutdownCompletely()
}
}
} }
@@ -62,6 +62,7 @@
/// ## Integration Points /// ## Integration Points
/// - **BLEService**: Calls this service for all private messages /// - **BLEService**: Calls this service for all private messages
/// - **ChatViewModel**: Monitors encryption status for UI indicators /// - **ChatViewModel**: Monitors encryption status for UI indicators
/// - **NoiseHandshakeCoordinator**: Prevents handshake race conditions
/// - **KeychainManager**: Secure storage for identity keys /// - **KeychainManager**: Secure storage for identity keys
/// ///
/// ## Thread Safety /// ## Thread Safety
@@ -526,15 +527,6 @@ final class NoiseEncryptionService {
SecureLogger.info(.sessionExpired(peerID: peerID)) SecureLogger.info(.sessionExpired(peerID: peerID))
} }
func clearEphemeralStateForPanic() {
sessionManager.removeAllSessions()
serviceQueue.sync(flags: .barrier) {
peerFingerprints.removeAll()
fingerprintToPeerID.removeAll()
}
rateLimiter.resetAll()
}
// MARK: - Private Helpers // MARK: - Private Helpers
private func handleSessionEstablished(peerID: String, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) { private func handleSessionEstablished(peerID: String, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
+4
View File
@@ -4,6 +4,10 @@ import Combine
// Minimal Nostr transport conforming to Transport for offline sending // Minimal Nostr transport conforming to Transport for offline sending
final class NostrTransport: Transport { final class NostrTransport: Transport {
func sendFileTransferTLV(_ payload: Data, recipientPeerID: String?, transferId: String, messageID: String) {
return
}
weak var delegate: BitchatDelegate? weak var delegate: BitchatDelegate?
weak var peerEventsDelegate: TransportPeerEventsDelegate? weak var peerEventsDelegate: TransportPeerEventsDelegate?
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
+23 -16
View File
@@ -18,17 +18,13 @@ struct RelayController {
isAnnounce: Bool, isAnnounce: Bool,
degree: Int, degree: Int,
highDegreeThreshold: Int) -> RelayDecision { highDegreeThreshold: Int) -> RelayDecision {
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
// Suppress obvious non-relays // Suppress obvious non-relays
if ttlCap <= 1 || senderIsSelf { if ttl <= 1 || senderIsSelf { return RelayDecision(shouldRelay: false, newTTL: ttl, delayMs: 0) }
return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)
}
// For session-critical or directed traffic, be deterministic and reliable // For session-critical or directed traffic, be deterministic and reliable
if isHandshake || isDirectedFragment || isDirectedEncrypted { if isHandshake || isDirectedFragment || isDirectedEncrypted {
// Always relay with no TTL cap for these types // Always relay with no TTL cap for these types
let newTTL = ttlCap &- 1 let newTTL = (ttl &- 1)
// Slight jitter to desynchronize without adding too much latency // Slight jitter to desynchronize without adding too much latency
// Tighter for faster multi-hop handshakes and directed DMs // Tighter for faster multi-hop handshakes and directed DMs
let delayRange: ClosedRange<Int> = isHandshake ? 10...35 : 20...60 let delayRange: ClosedRange<Int> = isHandshake ? 10...35 : 20...60
@@ -36,17 +32,28 @@ struct RelayController {
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs) return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
} }
// TTL clamping for broadcast // Degree-aware probability to reduce floods in dense graphs (broadcast/public)
// - Dense graphs: keep lower but still allow multi-hop bridging let baseProb: Double
// - Announces get a bit more headroom switch degree {
let ttlLimit: UInt8 = { case 0...2: baseProb = 1.0
if degree >= highDegreeThreshold { case 3...4: baseProb = 0.9
return max(UInt8(2), min(ttlCap, UInt8(5))) case 5...6: baseProb = 0.7
case 7...9: baseProb = 0.55
default: baseProb = 0.45
} }
let preferred = UInt8(isAnnounce ? 7 : 6) let prob = baseProb
return max(UInt8(2), min(ttlCap, preferred)) let shouldRelay = Double.random(in: 0...1) <= prob
// TTL clamping for broadcast
// - Dense graphs: keep very low to avoid floods
// - Sparse graphs: allow slightly longer reach for multi-hop discovery
// - Announces in sparse graphs get a bit more headroom
let ttlCap: UInt8 = {
if degree >= highDegreeThreshold { return 3 }
return isAnnounce ? 7 : 6
}() }()
let newTTL = ttlLimit &- 1 let clamped = max(1, min(ttl, ttlCap))
let newTTL = clamped &- 1
// Wider jitter window to allow duplicate suppression to win more often // Wider jitter window to allow duplicate suppression to win more often
// For sparse graphs (<=2), relay quickly to avoid cancellation races // For sparse graphs (<=2), relay quickly to avoid cancellation races
@@ -57,6 +64,6 @@ struct RelayController {
case 6...9: delayMs = Int.random(in: 80...180) case 6...9: delayMs = Int.random(in: 80...180)
default: delayMs = Int.random(in: 100...220) default: delayMs = Int.random(in: 100...220)
} }
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs) return RelayDecision(shouldRelay: shouldRelay, newTTL: newTTL, delayMs: delayMs)
} }
} }
+3 -114
View File
@@ -68,7 +68,6 @@ final class TorManager: ObservableObject {
private var controlMonitorStarted = false private var controlMonitorStarted = false
private var pathMonitor: NWPathMonitor? private var pathMonitor: NWPathMonitor?
private var isAppForeground: Bool = true private var isAppForeground: Bool = true
private var isDormant: Bool = false
private var lastRestartAt: Date? = nil private var lastRestartAt: Date? = nil
// Global policy gate: only allow Tor to start when true // Global policy gate: only allow Tor to start when true
private(set) var allowAutoStart: Bool = false private(set) var allowAutoStart: Bool = false
@@ -84,7 +83,6 @@ final class TorManager: ObservableObject {
guard isAppForeground else { return } guard isAppForeground else { return }
guard !didStart else { return } guard !didStart else { return }
didStart = true didStart = true
isDormant = false
isStarting = true isStarting = true
lastError = nil lastError = nil
// Announce initial start so UI can show a status message // Announce initial start so UI can show a status message
@@ -494,39 +492,18 @@ final class TorManager: ObservableObject {
return true return true
} }
if !claimed { return } if !claimed { return }
if await self.resumeTorIfPossible() {
await MainActor.run {
self.restarting = false
self.isStarting = false
}
return
}
await self.restartTor() await self.restartTor()
await MainActor.run { self.restarting = false } await MainActor.run { self.restarting = false }
} }
} }
func goDormantOnBackground() { func goDormantOnBackground() {
// Prefer Tor's DORMANT mode so we can resume on foreground without a full restart. // Stricter model: fully stop Tor when app backgrounds to save power
// If the control port is unreachable, fall back to a hard shutdown. // and avoid half-suspended states. We'll restart cleanly on .active.
Task.detached { [weak self] in Task.detached { [weak self] in
guard let self = self else { return } guard let self = self else { return }
let signaled = await self.controlSendSignal("DORMANT")
if signaled {
SecureLogger.info("TorManager: signalled DORMANT", category: .session)
await MainActor.run {
self.isDormant = true
self.isReady = false
self.socksReady = false
self.isStarting = false
}
return
}
SecureLogger.warning("TorManager: DORMANT signal failed; shutting down", category: .session)
_ = tor_host_shutdown() _ = tor_host_shutdown()
await MainActor.run { await MainActor.run {
self.isDormant = false
self.isReady = false self.isReady = false
self.socksReady = false self.socksReady = false
self.bootstrapProgress = 0 self.bootstrapProgress = 0
@@ -539,24 +516,6 @@ final class TorManager: ObservableObject {
} }
} }
func shutdownCompletely() {
Task.detached { [weak self] in
guard let self = self else { return }
_ = tor_host_shutdown()
await MainActor.run {
self.isDormant = false
self.isReady = false
self.socksReady = false
self.bootstrapProgress = 0
self.bootstrapSummary = ""
self.isStarting = false
self.didStart = false
self.restarting = false
self.controlMonitorStarted = false
}
}
}
private func restartTor() async { private func restartTor() async {
await MainActor.run { await MainActor.run {
// Announce restart so UI can notify the user // Announce restart so UI can notify the user
@@ -566,28 +525,14 @@ final class TorManager: ObservableObject {
self.bootstrapProgress = 0 self.bootstrapProgress = 0
self.bootstrapSummary = "" self.bootstrapSummary = ""
self.isStarting = true self.isStarting = true
self.isDormant = false
self.lastRestartAt = Date() self.lastRestartAt = Date()
} }
// Prefer clean shutdown via owning controller FD; join the tor thread // Prefer clean shutdown via owning controller FD; join the tor thread
_ = tor_host_shutdown() _ = tor_host_shutdown()
// As a fallback, try control signal if needed (harmless if tor already down) // As a fallback, try control signal if needed (harmless if tor already down)
_ = await controlSendSignal("SHUTDOWN") _ = await controlSendSignal("SHUTDOWN")
// Allow Tor thread to fully terminate before re-starting.
var waited = 0
while tor_host_is_running() != 0 && waited < 40 {
try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
waited += 1
}
if waited >= 40 {
SecureLogger.warning("TorManager: tor_host_is_running still true before restart", category: .session)
}
// Allow control monitor and start logic to reinitialize cleanly
await MainActor.run {
self.controlMonitorStarted = false
self.didStart = false
}
// Now start fresh // Now start fresh
await MainActor.run { self.didStart = false }
await MainActor.run { self.startIfNeeded() } await MainActor.run { self.startIfNeeded() }
} }
@@ -653,62 +598,6 @@ final class TorManager: ObservableObject {
return (text?.contains("250")) == true return (text?.contains("250")) == true
} }
private func resumeTorIfPossible() async -> Bool {
let wasDormant = await MainActor.run { self.isDormant }
let pendingReady = await MainActor.run { self.socksReady && !self.isReady }
let needsWake = wasDormant || pendingReady
if !needsWake {
return false
}
let activated = await controlSendSignal("ACTIVE")
let pinged = await controlPingBootstrap(timeout: 3.0)
if !activated && !pinged {
SecureLogger.warning("TorManager: ACTIVE signal failed", category: .session)
return false
}
if let info = await controlGetBootstrapInfo() {
await MainActor.run {
self.bootstrapProgress = info.progress
self.bootstrapSummary = info.summary
}
}
await MainActor.run {
self.isDormant = false
self.isStarting = true
self.socksReady = false
}
let firstReady = await waitForSocksReady(timeout: 12.0)
if firstReady {
await MainActor.run {
self.socksReady = true
self.isStarting = false
}
SecureLogger.info("TorManager: resumed Tor via ACTIVE signal", category: .session)
return true
}
if pinged {
let secondReady = await waitForSocksReady(timeout: 20.0)
await MainActor.run {
self.socksReady = secondReady
self.isStarting = !secondReady
}
if secondReady {
SecureLogger.info("TorManager: resumed Tor after extended wait", category: .session)
return true
}
} else {
await MainActor.run { self.isStarting = false }
}
SecureLogger.warning("TorManager: ACTIVE resume failed; will restart", category: .session)
return false
}
private func controlExchange(lines: [String], timeout: TimeInterval) async -> String? { private func controlExchange(lines: [String], timeout: TimeInterval) async -> String? {
guard let cookiePath = dataDirectoryURL()?.appendingPathComponent("control_auth_cookie"), guard let cookiePath = dataDirectoryURL()?.appendingPathComponent("control_auth_cookie"),
let cookie = try? Data(contentsOf: cookiePath) else { return nil } let cookie = try? Data(contentsOf: cookiePath) else { return nil }
@@ -4,5 +4,4 @@ extension Notification.Name {
static let TorDidBecomeReady = Notification.Name("TorDidBecomeReady") static let TorDidBecomeReady = Notification.Name("TorDidBecomeReady")
static let TorWillRestart = Notification.Name("TorWillRestart") static let TorWillRestart = Notification.Name("TorWillRestart")
static let TorWillStart = Notification.Name("TorWillStart") static let TorWillStart = Notification.Name("TorWillStart")
static let TorUserPreferenceChanged = Notification.Name("TorUserPreferenceChanged")
} }
+22 -19
View File
@@ -4,34 +4,43 @@ import CFNetwork
#endif #endif
/// Provides a shared URLSession that routes traffic via Tor's SOCKS5 proxy /// Provides a shared URLSession that routes traffic via Tor's SOCKS5 proxy
/// when Tor is enforced/ready. Allows swapping between proxied and direct /// when Tor is enforced/ready. Falls back to a default session only when
/// sessions so UI can toggle Tor usage at runtime. /// compiled with the `BITCHAT_DEV_ALLOW_CLEARNET` flag.
final class TorURLSession { final class TorURLSession {
static let shared = TorURLSession() static let shared = TorURLSession()
// Default (no proxy) session for direct Nostr access when Tor is disabled. // Default (no proxy) session for local development when dev bypass is enabled.
private var defaultSession: URLSession = TorURLSession.makeDefaultSession() private var defaultSession: URLSession = {
let cfg = URLSessionConfiguration.default
cfg.waitsForConnectivity = true
return URLSession(configuration: cfg)
}()
// Proxied (SOCKS5) session that routes through Tor. // Proxied (SOCKS5) session that routes through Tor.
private var torSession: URLSession = TorURLSession.makeTorSession() private var torSession: URLSession = TorURLSession.makeTorSession()
private var useTorProxy: Bool = true
var session: URLSession { var session: URLSession {
useTorProxy ? torSession : defaultSession #if BITCHAT_DEV_ALLOW_CLEARNET
// Dev bypass: use direct session. Call sites may still await Tor if desired.
return defaultSession
#else
// Production: always use the Tor-proxied session. Call sites ensure readiness.
return torSession
#endif
} }
// Recreate sessions so new clients bind to the fresh SOCKS/control ports after a Tor restart. // Recreate sessions so new clients bind to the fresh SOCKS/control ports after a Tor restart.
func rebuild() { func rebuild() {
defaultSession = TorURLSession.makeDefaultSession() #if BITCHAT_DEV_ALLOW_CLEARNET
defaultSession = {
let cfg = URLSessionConfiguration.default
cfg.waitsForConnectivity = true
return URLSession(configuration: cfg)
}()
#endif
torSession = TorURLSession.makeTorSession() torSession = TorURLSession.makeTorSession()
} }
func setProxyMode(useTor: Bool) {
guard useTorProxy != useTor else { return }
useTorProxy = useTor
rebuild()
}
private static func makeTorSession() -> URLSession { private static func makeTorSession() -> URLSession {
let cfg = URLSessionConfiguration.ephemeral let cfg = URLSessionConfiguration.ephemeral
cfg.waitsForConnectivity = true cfg.waitsForConnectivity = true
@@ -54,10 +63,4 @@ final class TorURLSession {
#endif #endif
return URLSession(configuration: cfg) return URLSession(configuration: cfg)
} }
private static func makeDefaultSession() -> URLSession {
let cfg = URLSessionConfiguration.default
cfg.waitsForConnectivity = true
return URLSession(configuration: cfg)
}
} }
+3
View File
@@ -47,6 +47,9 @@ protocol Transport: AnyObject {
func sendBroadcastAnnounce() func sendBroadcastAnnounce()
func sendDeliveryAck(for messageID: String, to peerID: String) func sendDeliveryAck(for messageID: String, to peerID: String)
// Media transfer (file transfer TLV)
func sendFileTransferTLV(_ payload: Data, recipientPeerID: String?, transferId: String, messageID: String)
// QR verification (optional for transports) // QR verification (optional for transports)
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data) func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data) func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data)
+1 -1
View File
@@ -188,7 +188,7 @@ enum TransportConfig {
static let uiWindowStepCount: Int = 200 static let uiWindowStepCount: Int = 200
// Share extension // Share extension
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0 static let uiShareExtensionDismissDelaySeconds: TimeInterval = 0.3
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0 static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60 static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
} }
+16 -70
View File
@@ -4,7 +4,7 @@ import CryptoKit
// Golomb-Coded Set (GCS) filter utilities for sync. // Golomb-Coded Set (GCS) filter utilities for sync.
// Hashing: // Hashing:
// - Packet ID is 16 bytes (see PacketIdUtil). For GCS mapping, use h64 = first 8 bytes of SHA-256 over the 16-byte ID. // - Packet ID is 16 bytes (see PacketIdUtil). For GCS mapping, use h64 = first 8 bytes of SHA-256 over the 16-byte ID.
// - Map to [1, M) by computing (h64 % M) and remapping 0 -> 1 to avoid zero-length deltas. // - Map to [0, M) via (h64 % M).
// Encoding (v1): // Encoding (v1):
// - Sort mapped values ascending; encode deltas (first is v0, then vi - v{i-1}) as positive integers x >= 1. // - Sort mapped values ascending; encode deltas (first is v0, then vi - v{i-1}) as positive integers x >= 1.
// - Golomb-Rice with parameter P: q = (x - 1) >> P encoded as unary (q ones then a zero), then write P-bit remainder r = (x - 1) & ((1<<P)-1). // - Golomb-Rice with parameter P: q = (x - 1) >> P encoded as unary (q ones then a zero), then write P-bit remainder r = (x - 1) & ((1<<P)-1).
@@ -29,40 +29,25 @@ enum GCSFilter {
static func buildFilter(ids: [Data], maxBytes: Int, targetFpr: Double) -> Params { static func buildFilter(ids: [Data], maxBytes: Int, targetFpr: Double) -> Params {
let p = deriveP(targetFpr: targetFpr) let p = deriveP(targetFpr: targetFpr)
guard !ids.isEmpty else {
return Params(p: p, m: 1, data: Data())
}
let cap = estimateMaxElements(sizeBytes: maxBytes, p: p) let cap = estimateMaxElements(sizeBytes: maxBytes, p: p)
let selected = Array(ids.prefix(cap)) let n = min(ids.count, cap)
let range = max(1, hashRange(count: selected.count, p: p)) let selected = Array(ids.prefix(n))
let modulo = UInt64(range) // Map to [0, M)
let mInit = UInt32(n << p)
var mapped = selected var mapped = selected.map { id16 -> UInt64 in
.map { h64($0) } let h = h64(id16)
.map { mapHash($0, modulo: modulo) } return UInt64(h % UInt64(max(1, mInit)))
.sorted() }.sorted()
mapped = normalizeMappedValues(mapped, modulo: modulo)
if mapped.isEmpty {
return Params(p: p, m: range, data: Data())
}
var encoded = encode(sorted: mapped, p: p) var encoded = encode(sorted: mapped, p: p)
var trimmedCount = mapped.count var trimmedN = n
// Trim if over budget
while encoded.count > maxBytes && trimmedCount > 0 { while encoded.count > maxBytes && trimmedN > 0 {
if trimmedCount == 1 { trimmedN = (trimmedN * 9) / 10 // drop ~10%
mapped.removeAll() mapped = Array(mapped.prefix(trimmedN))
encoded = Data()
break
}
trimmedCount = max(1, (trimmedCount * 9) / 10)
mapped = Array(mapped.prefix(trimmedCount))
encoded = encode(sorted: mapped, p: p) encoded = encode(sorted: mapped, p: p)
} }
let finalM = UInt32(max(1, trimmedN << p))
return Params(p: p, m: range, data: encoded) return Params(p: p, m: finalM, data: encoded)
} }
static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] { static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] {
@@ -92,12 +77,6 @@ enum GCSFilter {
return false return false
} }
static func bucket(for id: Data, modulus m: UInt32) -> UInt64 {
let modulo = UInt64(max(1, m))
guard modulo > 1 else { return 0 }
return mapHash(h64(id), modulo: modulo)
}
private static func h64(_ id16: Data) -> UInt64 { private static func h64(_ id16: Data) -> UInt64 {
var hasher = SHA256() var hasher = SHA256()
hasher.update(data: id16) hasher.update(data: id16)
@@ -109,39 +88,6 @@ enum GCSFilter {
return x & 0x7fff_ffff_ffff_ffff return x & 0x7fff_ffff_ffff_ffff
} }
private static func hashRange(count: Int, p: Int) -> UInt32 {
guard count > 0 else { return 1 }
if p >= 64 { return UInt32.max }
let multiplier = UInt64(1) << UInt64(p)
let (product, overflow) = UInt64(count).multipliedReportingOverflow(by: multiplier)
if overflow { return UInt32.max }
if product == 0 { return 1 }
return product > UInt64(UInt32.max) ? UInt32.max : UInt32(product)
}
private static func mapHash(_ hash: UInt64, modulo: UInt64) -> UInt64 {
guard modulo > 1 else { return 0 }
let value = hash % modulo
if value == 0 { return 1 }
return value
}
private static func normalizeMappedValues(_ values: [UInt64], modulo: UInt64) -> [UInt64] {
guard modulo > 1 else { return [] }
guard !values.isEmpty else { return [] }
var result: [UInt64] = []
result.reserveCapacity(values.count)
var last: UInt64 = 0
for value in values {
let normalized = min(value, modulo - 1)
if normalized > last {
result.append(normalized)
last = normalized
}
}
return result
}
private static func encode(sorted: [UInt64], p: Int) -> Data { private static func encode(sorted: [UInt64], p: Int) -> Data {
let writer = BitWriter() let writer = BitWriter()
var prev: UInt64 = 0 var prev: UInt64 = 0
+10 -20
View File
@@ -1,4 +1,5 @@
import Foundation import Foundation
import CryptoKit
// Gossip-based sync manager using on-demand GCS filters // Gossip-based sync manager using on-demand GCS filters
final class GossipSyncManager { final class GossipSyncManager {
@@ -52,12 +53,6 @@ final class GossipSyncManager {
} }
func onPublicPacketSeen(_ packet: BitchatPacket) { func onPublicPacketSeen(_ packet: BitchatPacket) {
queue.async { [weak self] in
self?._onPublicPacketSeen(packet)
}
}
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
let mt = MessageType(rawValue: packet.type) let mt = MessageType(rawValue: packet.type)
let isBroadcastRecipient: Bool = { let isBroadcastRecipient: Bool = {
guard let r = packet.recipientID else { return true } guard let r = packet.recipientID else { return true }
@@ -124,17 +119,18 @@ final class GossipSyncManager {
} }
func handleRequestSync(fromPeerID: String, request: RequestSyncPacket) { func handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
queue.async { [weak self] in
self?._handleRequestSync(fromPeerID: fromPeerID, request: request)
}
}
private func _handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
// Decode GCS into sorted set and prepare membership checker // Decode GCS into sorted set and prepare membership checker
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data) let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
func mightContain(_ id: Data) -> Bool { func mightContain(_ id: Data) -> Bool {
let bucket = GCSFilter.bucket(for: id, modulus: request.m) var hasher = SHA256()
return GCSFilter.contains(sortedValues: sorted, candidate: bucket) hasher.update(data: id) // 16-byte PacketId
let digest = hasher.finalize()
let db = Data(digest)
var x: UInt64 = 0
let take = min(8, db.count)
for i in 0..<take { x = (x << 8) | UInt64(db[i]) }
let v = (x & 0x7fff_ffff_ffff_ffff) % UInt64(request.m)
return GCSFilter.contains(sortedValues: sorted, candidate: v)
} }
// 1) Announcements: send latest per peer if requester lacks them // 1) Announcements: send latest per peer if requester lacks them
@@ -186,12 +182,6 @@ final class GossipSyncManager {
// Explicit removal hook for LEAVE/stale peer // Explicit removal hook for LEAVE/stale peer
func removeAnnouncementForPeer(_ peerID: String) { func removeAnnouncementForPeer(_ peerID: String) {
queue.async { [weak self] in
self?._removeAnnouncementForPeer(peerID)
}
}
private func _removeAnnouncementForPeer(_ peerID: String) {
let normalizedPeerID = peerID.lowercased() let normalizedPeerID = peerID.lowercased()
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID) _ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
-37
View File
@@ -1,37 +0,0 @@
import SwiftUI
/// Provides Dynamic Type aware font helpers that map existing fixed sizes onto
/// preferred text styles so the UI scales with user accessibility settings.
extension Font {
static func bitchatSystem(size: CGFloat, weight: Font.Weight = .regular, design: Font.Design = .default) -> Font {
let style = Font.TextStyle.bitchatPreferredStyle(for: size)
var font = Font.system(style, design: design)
if weight != .regular {
font = font.weight(weight)
}
return font
}
}
private extension Font.TextStyle {
static func bitchatPreferredStyle(for size: CGFloat) -> Font.TextStyle {
switch size {
case ..<11.5:
return .caption2
case ..<13.0:
return .caption
case ..<14.0:
return .footnote
case ..<16.5:
return .body
case ..<19.0:
return .title3
case ..<23.0:
return .title2
case ..<30.0:
return .title
default:
return .largeTitle
}
}
}
+1 -3
View File
@@ -8,9 +8,7 @@ struct InputValidator {
struct Limits { struct Limits {
static let maxNicknameLength = 50 static let maxNicknameLength = 50
// BinaryProtocol caps payload length at UInt16.max (65_535). Leave headroom static let maxMessageLength = 10_000
// for headers/padding by limiting user content to 60_000 bytes.
static let maxMessageLength = 60_000
static let maxReasonLength = 200 static let maxReasonLength = 200
static let maxPeerIDLength = 64 static let maxPeerIDLength = 64
static let hexPeerIDLength = 16 // 8 bytes = 16 hex chars static let hexPeerIDLength = 16 // 8 bytes = 16 hex chars
@@ -0,0 +1,7 @@
// ChatViewModel+Extensions.swift
// bitchat
//
// Deprecated helper. No longer needed after MessageHeaderLine simplification.
// Intentionally left empty to avoid accessing private properties across files.
// This file remains as a placeholder to preserve project references if any.
@@ -0,0 +1,113 @@
// ChatViewModel+Images.swift
// bitchat
//
// Extracted image sending helpers to keep ChatViewModel compact.
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
#if os(iOS)
import UIKit
#endif
extension ChatViewModel {
// MARK: - Images (Send)
#if os(iOS)
@MainActor
func sendImage(_ image: UIImage) {
// Downscale on long edge to 512px with 85% JPEG quality (Android parity)
guard let data = downscaleJPEG(image, maxDimension: 512, quality: 0.85) else { return }
// Persist to app files (outgoing)
guard let fileURL = saveOutgoingImage(data: data) else { return }
let name = fileURL.lastPathComponent
let mime = "image/jpeg"
let tlv = BitchatFilePacket(fileName: name, fileSize: UInt32(data.count), mimeType: mime, content: data)
guard let payload = tlv.encode() else { return }
let transferId = payload.sha256Hex()
let contentMarker = "[image] \(fileURL.path)"
let now = Date()
if let peer = selectedPrivateChatPeer {
let messageID = UUID().uuidString
let msg = BitchatMessage(
id: messageID,
sender: nickname,
content: contentMarker,
timestamp: now,
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: meshService.peerNickname(peerID: peer),
senderPeerID: meshService.myPeerID,
mentions: nil,
deliveryStatus: .sending
)
// appendToPrivateChat is private in ChatViewModel; inline minimal append
var arr = privateChats[peer] ?? []
arr.append(msg)
privateChats[peer] = arr
objectWillChange.send()
meshService.sendFileTransferTLV(payload, recipientPeerID: peer, transferId: transferId, messageID: messageID)
} else {
let messageID = UUID().uuidString
let msg = BitchatMessage(
id: messageID,
sender: nickname,
content: contentMarker,
timestamp: now,
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: meshService.myPeerID,
mentions: nil,
deliveryStatus: .sending
)
// public buffer helpers are private; append directly to visible messages
messages.append(msg)
meshService.sendFileTransferTLV(payload, recipientPeerID: nil, transferId: transferId, messageID: messageID)
}
}
// Downscale without cropping; preserves aspect ratio. Long edge == maxDimension.
internal func downscaleJPEG(_ image: UIImage, maxDimension: CGFloat, quality: CGFloat) -> Data? {
let size = image.size
guard size.width > 0 && size.height > 0 else { return image.jpegData(compressionQuality: quality) }
let maxSide = max(size.width, size.height)
let scale = min(1.0, maxDimension / maxSide)
let target = CGSize(width: floor(size.width * scale), height: floor(size.height * scale))
let rendererFormat = UIGraphicsImageRendererFormat.default()
rendererFormat.scale = 1
let renderer = UIGraphicsImageRenderer(size: target, format: rendererFormat)
let scaled = renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: target))
}
return scaled.jpegData(compressionQuality: quality)
}
internal func saveOutgoingImage(data: Data) -> URL? {
do {
let fm = FileManager.default
let base = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let folder = base.appendingPathComponent("bitchat_images/outgoing", isDirectory: true)
if !fm.fileExists(atPath: folder.path) {
try fm.createDirectory(at: folder, withIntermediateDirectories: true)
}
let ts = Int(Date().timeIntervalSince1970)
let fileURL = folder.appendingPathComponent("img_\(ts)_\(UUID().uuidString.prefix(8)).jpg")
try data.write(to: fileURL, options: .atomic)
return fileURL
} catch {
// Avoid BitLogger dependency in this small extension
print("❌ Failed to save outgoing image: \(error)")
return nil
}
}
#endif
}
File diff suppressed because it is too large Load Diff
+22 -32
View File
@@ -88,7 +88,7 @@ struct AppInfoView: View {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { dismiss() }) { Button(action: { dismiss() }) {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced)) .font(.system(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
.frame(width: 32, height: 32) .frame(width: 32, height: 32)
} }
@@ -106,29 +106,16 @@ struct AppInfoView: View {
// Header // Header
VStack(alignment: .center, spacing: 8) { VStack(alignment: .center, spacing: 8) {
Text(Strings.appName) Text(Strings.appName)
.font(.bitchatSystem(size: 32, weight: .bold, design: .monospaced)) .font(.system(size: 32, weight: .bold, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
Text(Strings.tagline) Text(Strings.tagline)
.font(.bitchatSystem(size: 16, design: .monospaced)) .font(.system(size: 16, design: .monospaced))
.foregroundColor(secondaryTextColor) .foregroundColor(secondaryTextColor)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical) .padding(.vertical)
// How to Use
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.HowToUse.title)
VStack(alignment: .leading, spacing: 8) {
ForEach(Strings.HowToUse.instructions, id: \.self) { instruction in
Text(instruction)
}
}
.font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(textColor)
}
// Features // Features
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.Features.title) SectionHeader(Strings.Features.title)
@@ -175,13 +162,26 @@ struct AppInfoView: View {
description: Strings.Privacy.panic.2) description: Strings.Privacy.panic.2)
} }
// How to Use
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.HowToUse.title)
VStack(alignment: .leading, spacing: 8) {
ForEach(Strings.HowToUse.instructions, id: \.self) { instruction in
Text(instruction)
}
}
.font(.system(size: 14, design: .monospaced))
.foregroundColor(textColor)
}
// Warning // Warning
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
SectionHeader(Strings.Warning.title) SectionHeader(Strings.Warning.title)
.foregroundColor(Color.red) .foregroundColor(Color.red)
Text(Strings.Warning.message) Text(Strings.Warning.message)
.font(.bitchatSystem(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(Color.red) .foregroundColor(Color.red)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
@@ -211,7 +211,7 @@ struct SectionHeader: View {
var body: some View { var body: some View {
Text(title) Text(title)
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) .font(.system(size: 16, weight: .bold, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
.padding(.top, 8) .padding(.top, 8)
} }
@@ -234,17 +234,17 @@ struct FeatureRow: View {
var body: some View { var body: some View {
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
Image(systemName: icon) Image(systemName: icon)
.font(.bitchatSystem(size: 20)) .font(.system(size: 20))
.foregroundColor(textColor) .foregroundColor(textColor)
.frame(width: 30) .frame(width: 30)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(title) Text(title)
.font(.bitchatSystem(size: 14, weight: .semibold, design: .monospaced)) .font(.system(size: 14, weight: .semibold, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
Text(description) Text(description)
.font(.bitchatSystem(size: 12, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
.foregroundColor(secondaryTextColor) .foregroundColor(secondaryTextColor)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
@@ -254,16 +254,6 @@ struct FeatureRow: View {
} }
} }
#Preview("Default") { #Preview {
AppInfoView() AppInfoView()
} }
#Preview("Dynamic Type XXL") {
AppInfoView()
.environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge)
}
#Preview("Dynamic Type XS") {
AppInfoView()
.environment(\.sizeCategory, .extraSmall)
}
+257 -65
View File
@@ -9,6 +9,7 @@
import SwiftUI import SwiftUI
#if os(iOS) #if os(iOS)
import UIKit import UIKit
import PhotosUI
#endif #endif
// MARK: - Supporting Types // MARK: - Supporting Types
@@ -30,7 +31,6 @@ struct ContentView: View {
@State private var textFieldSelection: NSRange? = nil @State private var textFieldSelection: NSRange? = nil
@FocusState private var isTextFieldFocused: Bool @FocusState private var isTextFieldFocused: Bool
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@State private var showPeerList = false @State private var showPeerList = false
@State private var showSidebar = false @State private var showSidebar = false
@State private var sidebarDragOffset: CGFloat = 0 @State private var sidebarDragOffset: CGFloat = 0
@@ -54,11 +54,25 @@ struct ContentView: View {
@State private var showLocationNotes = false @State private var showLocationNotes = false
@State private var notesGeohash: String? = nil @State private var notesGeohash: String? = nil
@State private var sheetNotesCount: Int = 0 @State private var sheetNotesCount: Int = 0
@ScaledMetric(relativeTo: .body) private var headerHeight: CGFloat = 44
// Timer-based refresh removed; use LocationChannelManager live updates instead // Timer-based refresh removed; use LocationChannelManager live updates instead
// Window sizes for rendering (infinite scroll up) // Window sizes for rendering (infinite scroll up)
@State private var windowCountPublic: Int = 300 @State private var windowCountPublic: Int = 300
@State private var windowCountPrivate: [String: Int] = [:] @State private var windowCountPrivate: [String: Int] = [:]
// Measure input field height so recorder overlay matches it
@State private var inputFieldMeasuredHeight: CGFloat = 0
private struct InputHeightPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() }
}
#if os(iOS)
@State private var voiceRecorder = VoiceRecorder()
@State private var isRecordingVoice = false
@State private var recordingTimer: Timer? = nil
@State private var recordingElapsedMs: Int = 0
@State private var recordingAmplitudeNorm: CGFloat = 0
@State private var currentRecordingURL: URL? = nil
@State private var isShowingImagePicker = false
#endif
// MARK: - Computed Properties // MARK: - Computed Properties
@@ -74,10 +88,6 @@ struct ContentView: View {
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8) colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
} }
private var headerLineLimit: Int? {
dynamicTypeSize.isAccessibilitySize ? 2 : 1
}
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
@@ -258,6 +268,16 @@ struct ContentView: View {
scrollThrottleTimer?.invalidate() scrollThrottleTimer?.invalidate()
autocompleteDebounceTimer?.invalidate() autocompleteDebounceTimer?.invalidate()
} }
#if os(iOS)
.sheet(isPresented: $isShowingImagePicker) {
ImagePickerView { image in
if let img = image {
viewModel.sendImage(img)
}
isShowingImagePicker = false
}
}
#endif
} }
// MARK: - Message List View // MARK: - Message List View
@@ -311,6 +331,42 @@ struct ContentView: View {
// Precompute heavy token scans once per row // Precompute heavy token scans once per row
let cashuTokens = message.content.extractCashuTokens() let cashuTokens = message.content.extractCashuTokens()
let lightningLinks = message.content.extractLightningLinks() let lightningLinks = message.content.extractLightningLinks()
let isVoice = message.content.hasPrefix("[voice] ")
let isImage = message.content.hasPrefix("[image] ")
if isVoice {
// Header line above media: sender + timestamp
MessageHeaderLine(message: message)
.environmentObject(viewModel)
// Hide the raw marker text; render custom voice view
HStack(alignment: .center, spacing: 8) {
let path = String(message.content.dropFirst("[voice] ".count))
let prog: Double? = {
if let st = message.deliveryStatus {
if case .partiallyDelivered(let r, let t) = st, t > 0 {
return min(1.0, max(0.0, Double(r) / Double(t)))
}
}
return nil
}()
VoiceMessageRow(fileURL: URL(fileURLWithPath: path), sendProgress: prog)
}
.frame(maxWidth: .infinity, alignment: .leading)
// Delivery status for our own private voice messages
if message.isPrivate && message.sender == viewModel.nickname,
let status = message.deliveryStatus {
DeliveryStatusView(status: status, colorScheme: colorScheme)
.padding(.leading, 4)
}
} else if isImage {
// Header line above image: sender + timestamp
MessageHeaderLine(message: message)
.environmentObject(viewModel)
// Hide the raw marker text; render image view
let path = String(message.content.dropFirst("[image] ".count))
ImageMessageRow(path: path, message: message)
.environmentObject(viewModel)
} else {
HStack(alignment: .top, spacing: 0) { HStack(alignment: .top, spacing: 0) {
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty
let isExpanded = expandedMessageIDs.contains(message.id) let isExpanded = expandedMessageIDs.contains(message.id)
@@ -326,6 +382,7 @@ struct ContentView: View {
.padding(.leading, 4) .padding(.leading, 4)
} }
} }
}
// Expand/Collapse for very long messages // Expand/Collapse for very long messages
if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty { if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty {
@@ -334,7 +391,7 @@ struct ContentView: View {
if isExpanded { expandedMessageIDs.remove(message.id) } if isExpanded { expandedMessageIDs.remove(message.id) }
else { expandedMessageIDs.insert(message.id) } else { expandedMessageIDs.insert(message.id) }
} }
.font(.bitchatSystem(size: 11, weight: .medium, design: .monospaced)) .font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(Color.blue) .foregroundColor(Color.blue)
.padding(.top, 4) .padding(.top, 4)
} }
@@ -468,13 +525,8 @@ struct ContentView: View {
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
} }
} }
if viewModel.isSelfSender(peerID: selectedMessageSenderID, displayName: selectedMessageSender) {
selectedMessageSender = nil
selectedMessageSenderID = nil
} else {
showMessageActions = true showMessageActions = true
} }
}
.onOpenURL { url in .onOpenURL { url in
guard url.scheme == "bitchat", url.host == "geohash" else { return } guard url.scheme == "bitchat", url.host == "geohash" else { return }
let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased() let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
@@ -712,7 +764,7 @@ struct ContentView: View {
}) { }) {
HStack { HStack {
Text(suggestion) Text(suggestion)
.font(.bitchatSystem(size: 11, design: .monospaced)) .font(.system(size: 11, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
.fontWeight(.medium) .fontWeight(.medium)
Spacer() Spacer()
@@ -770,14 +822,14 @@ struct ContentView: View {
HStack { HStack {
// Show all aliases together // Show all aliases together
Text(info.commands.joined(separator: ", ")) Text(info.commands.joined(separator: ", "))
.font(.bitchatSystem(size: 11, design: .monospaced)) .font(.system(size: 11, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
.fontWeight(.medium) .fontWeight(.medium)
// Show syntax if any // Show syntax if any
if let syntax = info.syntax { if let syntax = info.syntax {
Text(syntax) Text(syntax)
.font(.bitchatSystem(size: 10, design: .monospaced)) .font(.system(size: 10, design: .monospaced))
.foregroundColor(secondaryTextColor.opacity(0.8)) .foregroundColor(secondaryTextColor.opacity(0.8))
} }
@@ -785,7 +837,7 @@ struct ContentView: View {
// Show description // Show description
Text(info.description) Text(info.description)
.font(.bitchatSystem(size: 10, design: .monospaced)) .font(.system(size: 10, design: .monospaced))
.foregroundColor(secondaryTextColor) .foregroundColor(secondaryTextColor)
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
@@ -806,13 +858,42 @@ struct ContentView: View {
} }
HStack(alignment: .center, spacing: 4) { HStack(alignment: .center, spacing: 4) {
ZStack(alignment: .leading) {
// Always keep the TextField mounted so the keyboard state doesn't change
TextField("type a message...", text: $messageText) TextField("type a message...", text: $messageText)
.textFieldStyle(.plain) .textFieldStyle(.plain)
.font(.bitchatSystem(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
.focused($isTextFieldFocused) .focused($isTextFieldFocused)
.padding(.leading, 12) .padding(.leading, 12)
// iOS keyboard autocomplete and capitalization enabled by default .opacity(isRecordingVoice ? 0.0 : 1.0)
// Measure the text field height so we can match recorder height
.background(GeometryReader { geo in
Color.clear.preference(key: InputHeightPreferenceKey.self, value: geo.size.height)
})
// Recording overlay: live waveform + elapsed/max time, same height as input field
if isRecordingVoice {
HStack(alignment: .center, spacing: 12) {
RealtimeScrollingWaveform(
amplitudeNorm: recordingAmplitudeNorm,
bars: 240,
barColor: (colorScheme == .dark ? Color.green : Color(red: 0, green: 0.6, blue: 0.3))
)
.frame(height: max(28, inputFieldMeasuredHeight))
let secs = max(0, recordingElapsedMs / 1000)
let mm = secs / 60
let ss = secs % 60
Text(String(format: "%02d:%02d / 00:10", mm, ss))
.font(.system(size: 12, design: .monospaced))
.foregroundColor(textColor)
.padding(.trailing, 4)
}
.padding(.leading, 12)
.frame(height: max(28, inputFieldMeasuredHeight))
}
}
.onChange(of: messageText) { newValue in .onChange(of: messageText) { newValue in
// Cancel previous debounce timer // Cancel previous debounce timer
autocompleteDebounceTimer?.invalidate() autocompleteDebounceTimer?.invalidate()
@@ -877,21 +958,71 @@ struct ContentView: View {
commandSuggestions = [] commandSuggestions = []
} }
} }
.onSubmit { .onSubmit { sendMessage() }
sendMessage() .onPreferenceChange(InputHeightPreferenceKey.self) { inputFieldMeasuredHeight = $0 }
Group {
if messageText.isEmpty {
#if os(iOS)
HStack(spacing: 8) {
// Plus button for image picker (hidden while recording)
if !isRecordingVoice {
Button(action: { showImagePicker() }) {
Image(systemName: "plus.circle")
.font(.system(size: 22))
.foregroundColor(viewModel.selectedPrivateChatPeer != nil ? Color.orange : textColor)
}
.buttonStyle(.plain)
.accessibilityLabel("Add media")
} }
Image(systemName: isRecordingVoice ? "mic.circle.fill" : "mic.circle")
.font(.system(size: 22))
.foregroundColor(isRecordingVoice ? .red : (viewModel.selectedPrivateChatPeer != nil ? Color.orange : textColor))
.highPriorityGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
if !isRecordingVoice {
do {
let url = try VoiceRecorderPaths.outgoingURL()
try voiceRecorder.startRecording(to: url)
currentRecordingURL = url
isRecordingVoice = true
startRecordingMeters()
hapticStart()
} catch {
isRecordingVoice = false
}
}
}
.onEnded { _ in
finishRecordingAndSend()
}
)
}
.padding(.trailing, 12)
#else
// Non-iOS: keep send button placeholder
Button(action: sendMessage) { Button(action: sendMessage) {
Image(systemName: "arrow.up.circle.fill") Image(systemName: "arrow.up.circle.fill")
.font(.bitchatSystem(size: 20)) .font(.system(size: 20))
.foregroundColor(messageText.isEmpty ? Color.gray : .foregroundColor(Color.gray)
viewModel.selectedPrivateChatPeer != nil }
? Color.orange : textColor) .buttonStyle(.plain)
.padding(.trailing, 12)
#endif
} else {
Button(action: sendMessage) {
Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 20))
.foregroundColor(viewModel.selectedPrivateChatPeer != nil ? Color.orange : textColor)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.trailing, 12) .padding(.trailing, 12)
.accessibilityLabel("Send message") .accessibilityLabel("Send message")
.accessibilityHint(messageText.isEmpty ? "Enter a message to send" : "Double tap to send") .accessibilityHint("Double tap to send")
}
}
} }
.padding(.vertical, 8) .padding(.vertical, 8)
.background(backgroundColor.opacity(0.95)) .background(backgroundColor.opacity(0.95))
@@ -911,6 +1042,67 @@ struct ContentView: View {
messageText = "" messageText = ""
} }
#if os(iOS)
private func startRecordingMeters() {
recordingTimer?.invalidate()
recordingElapsedMs = 0
recordingAmplitudeNorm = 0
// Poll meters ~12.5Hz for elapsed and ~50Hz for waveform via the view's ticker.
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { _ in
recordingAmplitudeNorm = voiceRecorder.pollNormalizedAmplitude()
recordingElapsedMs = voiceRecorder.currentTimeMs()
if recordingElapsedMs >= 10_000 {
// Auto-stop at 10s
finishRecordingAndSend()
}
}
}
private func finishRecordingAndSend() {
guard isRecordingVoice else { return }
voiceRecorder.stopRecording {
isRecordingVoice = false
recordingTimer?.invalidate(); recordingTimer = nil
hapticStop()
let url = currentRecordingURL
currentRecordingURL = nil
if let u = url {
viewModel.sendVoiceNote(fileURL: u)
} else {
// Fallback to latest file in case we lost the URL
do {
let fm = FileManager.default
let base = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let folder = base.appendingPathComponent("bitchat_voicenotes/outgoing", isDirectory: true)
let files = try fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.contentModificationDateKey], options: [.skipsHiddenFiles])
let latest = files.sorted { (a, b) -> Bool in
let ad = (try? a.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
let bd = (try? b.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
return ad > bd
}.first
if let u = latest { viewModel.sendVoiceNote(fileURL: u) }
} catch {
// ignore
}
}
}
}
private func showImagePicker() {
isShowingImagePicker = true
}
private func hapticStart() {
let gen = UIImpactFeedbackGenerator(style: .medium)
gen.prepare(); gen.impactOccurred()
}
private func hapticStop() {
let gen = UINotificationFeedbackGenerator()
gen.prepare(); gen.notificationOccurred(.success)
}
#endif
// MARK: - Sidebar View // MARK: - Sidebar View
private var sidebarView: some View { private var sidebarView: some View {
@@ -924,20 +1116,20 @@ struct ContentView: View {
// Header - match main toolbar height // Header - match main toolbar height
HStack { HStack {
Text("PEOPLE") Text("PEOPLE")
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) .font(.system(size: 16, weight: .bold, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
Spacer() Spacer()
// Show QR in mesh on all platforms // Show QR in mesh on all platforms
if case .mesh = locationManager.selectedChannel { if case .mesh = locationManager.selectedChannel {
Button(action: { showVerifySheet = true }) { Button(action: { showVerifySheet = true }) {
Image(systemName: "qrcode") Image(systemName: "qrcode")
.font(.bitchatSystem(size: 14)) .font(.system(size: 14))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help("Verification: show my QR or scan a friend") .help("Verification: show my QR or scan a friend")
} }
} }
.frame(height: headerHeight) // Match header height .frame(height: 44) // Match header height
.padding(.horizontal, 12) .padding(.horizontal, 12)
.background(backgroundColor.opacity(0.95)) .background(backgroundColor.opacity(0.95))
@@ -1088,7 +1280,7 @@ struct ContentView: View {
private var mainHeaderView: some View { private var mainHeaderView: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
Text("bitchat/") Text("bitchat/")
.font(.bitchatSystem(size: 18, weight: .medium, design: .monospaced)) .font(.system(size: 18, weight: .medium, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
.onTapGesture(count: 3) { .onTapGesture(count: 3) {
// PANIC: Triple-tap to clear all data // PANIC: Triple-tap to clear all data
@@ -1101,12 +1293,12 @@ struct ContentView: View {
HStack(spacing: 0) { HStack(spacing: 0) {
Text("@") Text("@")
.font(.bitchatSystem(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor) .foregroundColor(secondaryTextColor)
TextField("nickname", text: $viewModel.nickname) TextField("nickname", text: $viewModel.nickname)
.textFieldStyle(.plain) .textFieldStyle(.plain)
.font(.bitchatSystem(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.frame(maxWidth: 80) .frame(maxWidth: 80)
.foregroundColor(textColor) .foregroundColor(textColor)
.focused($isNicknameFieldFocused) .focused($isNicknameFieldFocused)
@@ -1145,7 +1337,7 @@ struct ContentView: View {
if viewModel.hasAnyUnreadMessages { if viewModel.hasAnyUnreadMessages {
Button(action: { viewModel.openMostRelevantPrivateChat() }) { Button(action: { viewModel.openMostRelevantPrivateChat() }) {
Image(systemName: "envelope.fill") Image(systemName: "envelope.fill")
.font(.bitchatSystem(size: 12)) .font(.system(size: 12))
.foregroundColor(Color.orange) .foregroundColor(Color.orange)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -1165,7 +1357,7 @@ struct ContentView: View {
let currentCount = (notesCounter.count ?? 0) let currentCount = (notesCounter.count ?? 0)
let hasNotes = (!notesCounter.initialLoadComplete ? max(currentCount, sheetNotesCount) : currentCount) > 0 let hasNotes = (!notesCounter.initialLoadComplete ? max(currentCount, sheetNotesCount) : currentCount) > 0
Image(systemName: "long.text.page.and.pencil") Image(systemName: "long.text.page.and.pencil")
.font(.bitchatSystem(size: 12)) .font(.system(size: 12))
.foregroundColor(hasNotes ? textColor : Color.gray) .foregroundColor(hasNotes ? textColor : Color.gray)
.padding(.top, 1) .padding(.top, 1)
} }
@@ -1179,7 +1371,7 @@ struct ContentView: View {
if case .location(let ch) = locationManager.selectedChannel { if case .location(let ch) = locationManager.selectedChannel {
Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) { Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) {
Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark") Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark")
.font(.bitchatSystem(size: 12)) .font(.system(size: 12))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("Toggle bookmark for #\(ch.geohash)") .accessibilityLabel("Toggle bookmark for #\(ch.geohash)")
@@ -1202,9 +1394,9 @@ struct ContentView: View {
} }
}() }()
Text(badgeText) Text(badgeText)
.font(.bitchatSystem(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(badgeColor) .foregroundColor(badgeColor)
.lineLimit(headerLineLimit) .lineLimit(1)
.fixedSize(horizontal: true, vertical: false) .fixedSize(horizontal: true, vertical: false)
.layoutPriority(2) .layoutPriority(2)
.accessibilityLabel("location channels") .accessibilityLabel("location channels")
@@ -1216,15 +1408,15 @@ struct ContentView: View {
HStack(spacing: 4) { HStack(spacing: 4) {
// People icon with count // People icon with count
Image(systemName: "person.2.fill") Image(systemName: "person.2.fill")
.font(.bitchatSystem(size: 11)) .font(.system(size: 11))
.accessibilityLabel("\(headerOtherPeersCount) people") .accessibilityLabel("\(headerOtherPeersCount) people")
Text("\(headerOtherPeersCount)") Text("\(headerOtherPeersCount)")
.font(.bitchatSystem(size: 12, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
.accessibilityHidden(true) .accessibilityHidden(true)
} }
.foregroundColor(headerCountColor) .foregroundColor(headerCountColor)
.padding(.leading, 2) .padding(.leading, 2)
.lineLimit(headerLineLimit) .lineLimit(1)
.fixedSize(horizontal: true, vertical: false) .fixedSize(horizontal: true, vertical: false)
// QR moved to the PEOPLE header in the sidebar when on mesh channel // QR moved to the PEOPLE header in the sidebar when on mesh channel
@@ -1241,7 +1433,7 @@ struct ContentView: View {
.environmentObject(viewModel) .environmentObject(viewModel)
} }
} }
.frame(height: headerHeight) .frame(height: 44)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.sheet(isPresented: $showLocationChannelsSheet) { .sheet(isPresented: $showLocationChannelsSheet) {
LocationChannelsSheet(isPresented: $showLocationChannelsSheet) LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
@@ -1257,22 +1449,22 @@ struct ContentView: View {
VStack(spacing: 12) { VStack(spacing: 12) {
HStack { HStack {
Text("notes") Text("notes")
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) .font(.system(size: 16, weight: .bold, design: .monospaced))
Spacer() Spacer()
Button(action: { showLocationNotes = false }) { Button(action: { showLocationNotes = false }) {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced)) .font(.system(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
.frame(width: 32, height: 32) .frame(width: 32, height: 32)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("Close") .accessibilityLabel("Close")
} }
.frame(height: headerHeight) .frame(height: 44)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.background(backgroundColor.opacity(0.95)) .background(backgroundColor.opacity(0.95))
Text("location unavailable") Text("location unavailable")
.font(.bitchatSystem(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor) .foregroundColor(secondaryTextColor)
Button("enable location") { Button("enable location") {
LocationChannelManager.shared.enableLocationChannels() LocationChannelManager.shared.enableLocationChannels()
@@ -1423,19 +1615,19 @@ struct ContentView: View {
case .bluetoothConnected: case .bluetoothConnected:
// Radio icon for mesh connection // Radio icon for mesh connection
Image(systemName: "dot.radiowaves.left.and.right") Image(systemName: "dot.radiowaves.left.and.right")
.font(.bitchatSystem(size: 14)) .font(.system(size: 14))
.foregroundColor(textColor) .foregroundColor(textColor)
.accessibilityLabel("Connected via mesh") .accessibilityLabel("Connected via mesh")
case .meshReachable: case .meshReachable:
// point.3 filled icon for reachable via mesh (not directly connected) // point.3 filled icon for reachable via mesh (not directly connected)
Image(systemName: "point.3.filled.connected.trianglepath.dotted") Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.bitchatSystem(size: 14)) .font(.system(size: 14))
.foregroundColor(textColor) .foregroundColor(textColor)
.accessibilityLabel("Reachable via mesh") .accessibilityLabel("Reachable via mesh")
case .nostrAvailable: case .nostrAvailable:
// Purple globe for Nostr // Purple globe for Nostr
Image(systemName: "globe") Image(systemName: "globe")
.font(.bitchatSystem(size: 14)) .font(.system(size: 14))
.foregroundColor(.purple) .foregroundColor(.purple)
.accessibilityLabel("Available via Nostr") .accessibilityLabel("Available via Nostr")
case .offline: case .offline:
@@ -1445,25 +1637,25 @@ struct ContentView: View {
} else if viewModel.meshService.isPeerReachable(headerPeerID) { } else if viewModel.meshService.isPeerReachable(headerPeerID) {
// Fallback: reachable via mesh but not in current peer list // Fallback: reachable via mesh but not in current peer list
Image(systemName: "point.3.filled.connected.trianglepath.dotted") Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.bitchatSystem(size: 14)) .font(.system(size: 14))
.foregroundColor(textColor) .foregroundColor(textColor)
.accessibilityLabel("Reachable via mesh") .accessibilityLabel("Reachable via mesh")
} else if isNostrAvailable { } else if isNostrAvailable {
// Fallback to Nostr if peer not in list but is mutual favorite // Fallback to Nostr if peer not in list but is mutual favorite
Image(systemName: "globe") Image(systemName: "globe")
.font(.bitchatSystem(size: 14)) .font(.system(size: 14))
.foregroundColor(.purple) .foregroundColor(.purple)
.accessibilityLabel("Available via Nostr") .accessibilityLabel("Available via Nostr")
} else if viewModel.meshService.isPeerConnected(headerPeerID) || viewModel.connectedPeers.contains(headerPeerID) { } else if viewModel.meshService.isPeerConnected(headerPeerID) || viewModel.connectedPeers.contains(headerPeerID) {
// Fallback: if peer lookup is missing but mesh reports connected, show radio // Fallback: if peer lookup is missing but mesh reports connected, show radio
Image(systemName: "dot.radiowaves.left.and.right") Image(systemName: "dot.radiowaves.left.and.right")
.font(.bitchatSystem(size: 14)) .font(.system(size: 14))
.foregroundColor(textColor) .foregroundColor(textColor)
.accessibilityLabel("Connected via mesh") .accessibilityLabel("Connected via mesh")
} }
Text("\(privatePeerNick)") Text("\(privatePeerNick)")
.font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced)) .font(.system(size: 16, weight: .medium, design: .monospaced))
.foregroundColor(textColor) // Dynamic encryption status icon (hide for geohash DMs) .foregroundColor(textColor) // Dynamic encryption status icon (hide for geohash DMs)
if !privatePeerID.hasPrefix("nostr_") { if !privatePeerID.hasPrefix("nostr_") {
// Use short peer ID if available for encryption status (sessions keyed by short ID) // Use short peer ID if available for encryption status (sessions keyed by short ID)
@@ -1476,7 +1668,7 @@ struct ContentView: View {
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID) let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
if let icon = encryptionStatus.icon { if let icon = encryptionStatus.icon {
Image(systemName: icon) Image(systemName: icon)
.font(.bitchatSystem(size: 14)) .font(.system(size: 14))
.foregroundColor(encryptionStatus == .noiseVerified ? textColor : .foregroundColor(encryptionStatus == .noiseVerified ? textColor :
encryptionStatus == .noiseSecured ? textColor : encryptionStatus == .noiseSecured ? textColor :
Color.red) Color.red)
@@ -1498,7 +1690,7 @@ struct ContentView: View {
} }
}) { }) {
Image(systemName: "chevron.left") Image(systemName: "chevron.left")
.font(.bitchatSystem(size: 12)) .font(.system(size: 12))
.foregroundColor(textColor) .foregroundColor(textColor)
.frame(width: 44, height: 44, alignment: .leading) .frame(width: 44, height: 44, alignment: .leading)
.contentShape(Rectangle()) .contentShape(Rectangle())
@@ -1514,7 +1706,7 @@ struct ContentView: View {
viewModel.toggleFavorite(peerID: headerPeerID) viewModel.toggleFavorite(peerID: headerPeerID)
}) { }) {
Image(systemName: viewModel.isFavorite(peerID: headerPeerID) ? "star.fill" : "star") Image(systemName: viewModel.isFavorite(peerID: headerPeerID) ? "star.fill" : "star")
.font(.bitchatSystem(size: 16)) .font(.system(size: 16))
.foregroundColor(viewModel.isFavorite(peerID: headerPeerID) ? Color.yellow : textColor) .foregroundColor(viewModel.isFavorite(peerID: headerPeerID) ? Color.yellow : textColor)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -1523,7 +1715,7 @@ struct ContentView: View {
} }
} }
} }
.frame(height: headerHeight) .frame(height: 44)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.background(backgroundColor.opacity(0.95)) .background(backgroundColor.opacity(0.95))
} }
@@ -1580,7 +1772,7 @@ private struct PaymentChipView: View {
HStack(spacing: 6) { HStack(spacing: 6) {
Text(emoji) Text(emoji)
Text(label) Text(label)
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) .font(.system(size: 12, weight: .semibold, design: .monospaced))
} }
.padding(.vertical, 6) .padding(.vertical, 6)
.padding(.horizontal, 12) .padding(.horizontal, 12)
@@ -1621,20 +1813,20 @@ struct DeliveryStatusView: View {
switch status { switch status {
case .sending: case .sending:
Image(systemName: "circle") Image(systemName: "circle")
.font(.bitchatSystem(size: 10)) .font(.system(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.6)) .foregroundColor(secondaryTextColor.opacity(0.6))
case .sent: case .sent:
Image(systemName: "checkmark") Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10)) .font(.system(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.6)) .foregroundColor(secondaryTextColor.opacity(0.6))
case .delivered(let nickname, _): case .delivered(let nickname, _):
HStack(spacing: -2) { HStack(spacing: -2) {
Image(systemName: "checkmark") Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10)) .font(.system(size: 10))
Image(systemName: "checkmark") Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10)) .font(.system(size: 10))
} }
.foregroundColor(textColor.opacity(0.8)) .foregroundColor(textColor.opacity(0.8))
.help("Delivered to \(nickname)") .help("Delivered to \(nickname)")
@@ -1642,25 +1834,25 @@ struct DeliveryStatusView: View {
case .read(let nickname, _): case .read(let nickname, _):
HStack(spacing: -2) { HStack(spacing: -2) {
Image(systemName: "checkmark") Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10, weight: .bold)) .font(.system(size: 10, weight: .bold))
Image(systemName: "checkmark") Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10, weight: .bold)) .font(.system(size: 10, weight: .bold))
} }
.foregroundColor(Color(red: 0.0, green: 0.478, blue: 1.0)) // Bright blue .foregroundColor(Color(red: 0.0, green: 0.478, blue: 1.0)) // Bright blue
.help("Read by \(nickname)") .help("Read by \(nickname)")
case .failed(let reason): case .failed(let reason):
Image(systemName: "exclamationmark.triangle") Image(systemName: "exclamationmark.triangle")
.font(.bitchatSystem(size: 10)) .font(.system(size: 10))
.foregroundColor(Color.red.opacity(0.8)) .foregroundColor(Color.red.opacity(0.8))
.help("Failed: \(reason)") .help("Failed: \(reason)")
case .partiallyDelivered(let reached, let total): case .partiallyDelivered(let reached, let total):
HStack(spacing: 1) { HStack(spacing: 1) {
Image(systemName: "checkmark") Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10)) .font(.system(size: 10))
Text("\(reached)/\(total)") Text("\(reached)/\(total)")
.font(.bitchatSystem(size: 10, design: .monospaced)) .font(.system(size: 10, design: .monospaced))
} }
.foregroundColor(secondaryTextColor.opacity(0.6)) .foregroundColor(secondaryTextColor.opacity(0.6))
.help("Delivered to \(reached) of \(total) members") .help("Delivered to \(reached) of \(total) members")
+14 -14
View File
@@ -27,14 +27,14 @@ struct FingerprintView: View {
// Header // Header
HStack { HStack {
Text("SECURITY VERIFICATION") Text("SECURITY VERIFICATION")
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) .font(.system(size: 16, weight: .bold, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
Spacer() Spacer()
Button(action: { dismiss() }) { Button(action: { dismiss() }) {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.bitchatSystem(size: 14, weight: .semibold)) .font(.system(size: 14, weight: .semibold))
} }
.foregroundColor(textColor) .foregroundColor(textColor)
} }
@@ -66,17 +66,17 @@ struct FingerprintView: View {
HStack { HStack {
if let icon = encryptionStatus.icon { if let icon = encryptionStatus.icon {
Image(systemName: icon) Image(systemName: icon)
.font(.bitchatSystem(size: 20)) .font(.system(size: 20))
.foregroundColor(encryptionStatus == .noiseVerified ? Color.green : textColor) .foregroundColor(encryptionStatus == .noiseVerified ? Color.green : textColor)
} }
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(peerNickname) Text(peerNickname)
.font(.bitchatSystem(size: 18, weight: .semibold, design: .monospaced)) .font(.system(size: 18, weight: .semibold, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
Text(encryptionStatus.description) Text(encryptionStatus.description)
.font(.bitchatSystem(size: 12, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
.foregroundColor(textColor.opacity(0.7)) .foregroundColor(textColor.opacity(0.7))
} }
@@ -89,12 +89,12 @@ struct FingerprintView: View {
// Their fingerprint // Their fingerprint
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("THEIR FINGERPRINT:") Text("THEIR FINGERPRINT:")
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced)) .font(.system(size: 12, weight: .bold, design: .monospaced))
.foregroundColor(textColor.opacity(0.7)) .foregroundColor(textColor.opacity(0.7))
if let fingerprint = viewModel.getFingerprint(for: statusPeerID) { if let fingerprint = viewModel.getFingerprint(for: statusPeerID) {
Text(formatFingerprint(fingerprint)) Text(formatFingerprint(fingerprint))
.font(.bitchatSystem(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.lineLimit(nil) .lineLimit(nil)
@@ -115,7 +115,7 @@ struct FingerprintView: View {
} }
} else { } else {
Text("not available - handshake in progress") Text("not available - handshake in progress")
.font(.bitchatSystem(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(Color.orange) .foregroundColor(Color.orange)
.padding() .padding()
} }
@@ -124,12 +124,12 @@ struct FingerprintView: View {
// My fingerprint // My fingerprint
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("YOUR FINGERPRINT:") Text("YOUR FINGERPRINT:")
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced)) .font(.system(size: 12, weight: .bold, design: .monospaced))
.foregroundColor(textColor.opacity(0.7)) .foregroundColor(textColor.opacity(0.7))
let myFingerprint = viewModel.getMyFingerprint() let myFingerprint = viewModel.getMyFingerprint()
Text(formatFingerprint(myFingerprint)) Text(formatFingerprint(myFingerprint))
.font(.bitchatSystem(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.lineLimit(nil) .lineLimit(nil)
@@ -156,14 +156,14 @@ struct FingerprintView: View {
VStack(spacing: 12) { VStack(spacing: 12) {
Text(isVerified ? "✓ VERIFIED" : "⚠️ NOT VERIFIED") Text(isVerified ? "✓ VERIFIED" : "⚠️ NOT VERIFIED")
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced)) .font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(isVerified ? Color.green : Color.orange) .foregroundColor(isVerified ? Color.green : Color.orange)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
Text(isVerified ? Text(isVerified ?
"you have verified this person's identity." : "you have verified this person's identity." :
"compare these fingerprints with \(peerNickname) using a secure channel.") "compare these fingerprints with \(peerNickname) using a secure channel.")
.font(.bitchatSystem(size: 12, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
.foregroundColor(textColor.opacity(0.7)) .foregroundColor(textColor.opacity(0.7))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.lineLimit(nil) .lineLimit(nil)
@@ -176,7 +176,7 @@ struct FingerprintView: View {
dismiss() dismiss()
}) { }) {
Text("MARK AS VERIFIED") Text("MARK AS VERIFIED")
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced)) .font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(.white) .foregroundColor(.white)
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.vertical, 10) .padding(.vertical, 10)
@@ -190,7 +190,7 @@ struct FingerprintView: View {
dismiss() dismiss()
}) { }) {
Text("REMOVE VERIFICATION") Text("REMOVE VERIFICATION")
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced)) .font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(.white) .foregroundColor(.white)
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.vertical, 10) .padding(.vertical, 10)
+6 -6
View File
@@ -12,7 +12,7 @@ struct GeohashPeopleList: View {
if viewModel.visibleGeohashPeople().isEmpty { if viewModel.visibleGeohashPeople().isEmpty {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Text("nobody around...") Text("nobody around...")
.font(.bitchatSystem(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor) .foregroundColor(secondaryTextColor)
.padding(.horizontal) .padding(.horizontal)
.padding(.top, 12) .padding(.top, 12)
@@ -51,30 +51,30 @@ struct GeohashPeopleList: View {
let icon = teleported ? "face.dashed" : "mappin.and.ellipse" let icon = teleported ? "face.dashed" : "mappin.and.ellipse"
let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark) let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark)
let rowColor: Color = isMe ? .orange : assignedColor let rowColor: Color = isMe ? .orange : assignedColor
Image(systemName: icon).font(.bitchatSystem(size: 12)).foregroundColor(rowColor) Image(systemName: icon).font(.system(size: 12)).foregroundColor(rowColor)
let (base, suffix) = splitSuffix(from: person.displayName) let (base, suffix) = splitSuffix(from: person.displayName)
HStack(spacing: 0) { HStack(spacing: 0) {
Text(base) Text(base)
.font(.bitchatSystem(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.fontWeight(isMe ? .bold : .regular) .fontWeight(isMe ? .bold : .regular)
.foregroundColor(rowColor) .foregroundColor(rowColor)
if !suffix.isEmpty { if !suffix.isEmpty {
let suffixColor = isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6) let suffixColor = isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6)
Text(suffix) Text(suffix)
.font(.bitchatSystem(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(suffixColor) .foregroundColor(suffixColor)
} }
if isMe { if isMe {
Text(" (you)") Text(" (you)")
.font(.bitchatSystem(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(rowColor) .foregroundColor(rowColor)
} }
} }
if let me = myHex, person.id != me { if let me = myHex, person.id != me {
if viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) { if viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) {
Image(systemName: "nosign") Image(systemName: "nosign")
.font(.bitchatSystem(size: 10)) .font(.system(size: 10))
.foregroundColor(.red) .foregroundColor(.red)
.help("Blocked in geochash") .help("Blocked in geochash")
} }
+54
View File
@@ -0,0 +1,54 @@
// ImageMessageRow.swift
// bitchat
//
// Renders an image message bubble with rounded corners and optional delivery status.
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
#if os(iOS)
import UIKit
#endif
struct ImageMessageRow: View {
let path: String
let message: BitchatMessage
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var viewModel: ChatViewModel
var body: some View {
#if os(iOS)
if let uiImage = UIImage(contentsOfFile: path) {
VStack(alignment: .leading, spacing: 4) {
// Ensure image aligns with the same left edge as text messages
HStack(spacing: 0) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 300, maxHeight: 400, alignment: .topLeading)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
Spacer(minLength: 0)
}
if message.isPrivate && message.sender == viewModel.nickname,
let status = message.deliveryStatus {
DeliveryStatusView(status: status, colorScheme: colorScheme)
.padding(.leading, 4)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
} else {
Text("⚠️ Image unavailable")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
#else
Text("📷 Image: \(path)")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(colorScheme == .dark ? .green : Color(red: 0, green: 0.5, blue: 0))
.frame(maxWidth: .infinity, alignment: .leading)
#endif
}
}
+68
View File
@@ -0,0 +1,68 @@
// ImagePickerView.swift
// Simple UIKit-based image picker bridge
import SwiftUI
#if os(iOS)
import UIKit
import PhotosUI
struct ImagePickerView: UIViewControllerRepresentable {
typealias UIViewControllerType = UIViewController
var onPicked: (UIImage?) -> Void
func makeUIViewController(context: Context) -> UIViewController {
if #available(iOS 14.0, *) {
var config = PHPickerConfiguration(photoLibrary: .shared())
config.filter = .images
config.selectionLimit = 1
let picker = PHPickerViewController(configuration: config)
picker.delegate = context.coordinator
return picker
} else {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.sourceType = .photoLibrary
picker.allowsEditing = false
return picker
}
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(onPicked: onPicked)
}
final class Coordinator: NSObject, PHPickerViewControllerDelegate, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let onPicked: (UIImage?) -> Void
init(onPicked: @escaping (UIImage?) -> Void) { self.onPicked = onPicked }
// PHPicker
@available(iOS 14.0, *)
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
guard let item = results.first else { onPicked(nil); return }
if item.itemProvider.canLoadObject(ofClass: UIImage.self) {
item.itemProvider.loadObject(ofClass: UIImage.self) { object, _ in
DispatchQueue.main.async {
self.onPicked(object as? UIImage)
}
}
} else {
onPicked(nil)
}
}
// UIImagePicker
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
onPicked(nil)
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
let image = info[.originalImage] as? UIImage
picker.dismiss(animated: true)
onPicked(image)
}
}
}
#endif
+43 -101
View File
@@ -9,7 +9,6 @@ struct LocationChannelsSheet: View {
@Binding var isPresented: Bool @Binding var isPresented: Bool
@ObservedObject private var manager = LocationChannelManager.shared @ObservedObject private var manager = LocationChannelManager.shared
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared @ObservedObject private var bookmarks = GeohashBookmarksStore.shared
@ObservedObject private var network = NetworkActivationService.shared
@EnvironmentObject var viewModel: ChatViewModel @EnvironmentObject var viewModel: ChatViewModel
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@State private var customGeohash: String = "" @State private var customGeohash: String = ""
@@ -20,14 +19,10 @@ struct LocationChannelsSheet: View {
var body: some View { var body: some View {
NavigationView { NavigationView {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
Text("#location channels") Text("#location channels")
.font(.bitchatSystem(size: 18, design: .monospaced)) .font(.system(size: 18, design: .monospaced))
Spacer()
closeButton
}
Text("chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps. your IP address is hidden by routing all traffic over tor.") Text("chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps. your IP address is hidden by routing all traffic over tor.")
.font(.bitchatSystem(size: 12, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
Group { Group {
@@ -35,7 +30,7 @@ struct LocationChannelsSheet: View {
case LocationChannelManager.PermissionState.notDetermined: case LocationChannelManager.PermissionState.notDetermined:
Button(action: { manager.enableLocationChannels() }) { Button(action: { manager.enableLocationChannels() }) {
Text("get location and my geohashes") Text("get location and my geohashes")
.font(.bitchatSystem(size: 12, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
.foregroundColor(standardGreen) .foregroundColor(standardGreen)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 6) .padding(.vertical, 6)
@@ -46,7 +41,7 @@ struct LocationChannelsSheet: View {
case LocationChannelManager.PermissionState.denied, LocationChannelManager.PermissionState.restricted: case LocationChannelManager.PermissionState.denied, LocationChannelManager.PermissionState.restricted:
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("location permission denied. enable in settings to use location channels.") Text("location permission denied. enable in settings to use location channels.")
.font(.bitchatSystem(size: 12, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
Button("open settings") { openSystemLocationSettings() } Button("open settings") { openSystemLocationSettings() }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -64,9 +59,29 @@ struct LocationChannelsSheet: View {
.background(backgroundColor) .background(backgroundColor)
#if os(iOS) #if os(iOS)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(true) .toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { isPresented = false }) {
Image(systemName: "xmark")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
}
#else #else
.navigationTitle("") .toolbar {
ToolbarItem(placement: .automatic) {
Button(action: { isPresented = false }) {
Image(systemName: "xmark")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.frame(width: 20, height: 20)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
}
#endif #endif
} }
#if os(iOS) #if os(iOS)
@@ -96,16 +111,6 @@ struct LocationChannelsSheet: View {
.onChange(of: manager.availableChannels) { _ in } .onChange(of: manager.availableChannels) { _ in }
} }
private var closeButton: some View {
Button(action: { isPresented = false }) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
private var channelList: some View { private var channelList: some View {
List { List {
// Mesh option first (no bookmark) // Mesh option first (no bookmark)
@@ -113,7 +118,6 @@ struct LocationChannelsSheet: View {
manager.select(ChannelID.mesh) manager.select(ChannelID.mesh)
isPresented = false isPresented = false
} }
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
// Nearby options // Nearby options
if !manager.availableChannels.isEmpty { if !manager.availableChannels.isEmpty {
@@ -132,7 +136,7 @@ struct LocationChannelsSheet: View {
trailingAccessory: { trailingAccessory: {
Button(action: { bookmarks.toggle(channel.geohash) }) { Button(action: { bookmarks.toggle(channel.geohash) }) {
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark") Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
.font(.bitchatSystem(size: 14)) .font(.system(size: 14))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.leading, 8) .padding(.leading, 8)
@@ -143,22 +147,20 @@ struct LocationChannelsSheet: View {
manager.select(ChannelID.location(channel)) manager.select(ChannelID.location(channel))
isPresented = false isPresented = false
} }
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
} }
} else { } else {
HStack { HStack {
ProgressView() ProgressView()
Text("finding nearby channels…") Text("finding nearby channels…")
.font(.bitchatSystem(size: 12, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
} }
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
} }
// Custom geohash teleport // Custom geohash teleport
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 2) { HStack(spacing: 2) {
Text("#") Text("#")
.font(.bitchatSystem(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
TextField("geohash", text: $customGeohash) TextField("geohash", text: $customGeohash)
#if os(iOS) #if os(iOS)
@@ -166,7 +168,7 @@ struct LocationChannelsSheet: View {
.autocorrectionDisabled(true) .autocorrectionDisabled(true)
.keyboardType(.asciiCapable) .keyboardType(.asciiCapable)
#endif #endif
.font(.bitchatSystem(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.onChange(of: customGeohash) { newValue in .onChange(of: customGeohash) { newValue in
// Allow only geohash base32 characters, strip '#', limit length // Allow only geohash base32 characters, strip '#', limit length
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
@@ -194,13 +196,14 @@ struct LocationChannelsSheet: View {
}) { }) {
HStack(spacing: 6) { HStack(spacing: 6) {
Text("teleport") Text("teleport")
.font(.bitchatSystem(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
Image(systemName: "face.dashed") Image(systemName: "face.dashed")
.font(.bitchatSystem(size: 14)) .font(.system(size: 14))
} }
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.font(.bitchatSystem(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.padding(.horizontal, 10)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(Color.secondary.opacity(0.12)) .background(Color.secondary.opacity(0.12))
.cornerRadius(6) .cornerRadius(6)
@@ -209,17 +212,16 @@ struct LocationChannelsSheet: View {
} }
if let err = customError { if let err = customError {
Text(err) Text(err)
.font(.bitchatSystem(size: 12, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
.foregroundColor(.red) .foregroundColor(.red)
} }
} }
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
// Bookmarked geohashes // Bookmarked geohashes
if !bookmarks.bookmarks.isEmpty { if !bookmarks.bookmarks.isEmpty {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("bookmarked") Text("bookmarked")
.font(.bitchatSystem(size: 12, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
VStack(spacing: 6) { VStack(spacing: 6) {
ForEach(bookmarks.bookmarks, id: \.self) { gh in ForEach(bookmarks.bookmarks, id: \.self) { gh in
@@ -236,7 +238,7 @@ struct LocationChannelsSheet: View {
trailingAccessory: { trailingAccessory: {
Button(action: { bookmarks.toggle(gh) }) { Button(action: { bookmarks.toggle(gh) }) {
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark") Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
.font(.bitchatSystem(size: 14)) .font(.system(size: 14))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.leading, 8) .padding(.leading, 8)
@@ -260,18 +262,15 @@ struct LocationChannelsSheet: View {
.cornerRadius(8) .cornerRadius(8)
} }
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
} }
// Footer action inside the list // Footer action inside the list
if manager.permissionState == LocationChannelManager.PermissionState.authorized { if manager.permissionState == LocationChannelManager.PermissionState.authorized {
torToggleSection
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 4, trailing: 0))
Button(action: { Button(action: {
openSystemLocationSettings() openSystemLocationSettings()
}) { }) {
Text("remove location access") Text("remove location access")
.font(.bitchatSystem(size: 12, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1)) .foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 6) .padding(.vertical, 6)
@@ -281,7 +280,6 @@ struct LocationChannelsSheet: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 10, trailing: 0))
} }
} }
.listStyle(.plain) .listStyle(.plain)
@@ -319,12 +317,12 @@ struct LocationChannelsSheet: View {
let parts = splitTitleAndCount(title) let parts = splitTitleAndCount(title)
HStack(spacing: 4) { HStack(spacing: 4) {
Text(parts.base) Text(parts.base)
.font(.bitchatSystem(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.fontWeight(titleBold ? .bold : .regular) .fontWeight(titleBold ? .bold : .regular)
.foregroundColor(titleColor ?? Color.primary) .foregroundColor(titleColor ?? Color.primary)
if let count = parts.countSuffix, !count.isEmpty { if let count = parts.countSuffix, !count.isEmpty {
Text(count) Text(count)
.font(.bitchatSystem(size: 11, design: .monospaced)) .font(.system(size: 11, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} }
@@ -335,7 +333,7 @@ struct LocationChannelsSheet: View {
return subtitlePrefix return subtitlePrefix
}() }()
Text(subtitleFull) Text(subtitleFull)
.font(.bitchatSystem(size: 12, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail) .truncationMode(.tail)
@@ -343,7 +341,7 @@ struct LocationChannelsSheet: View {
Spacer() Spacer()
if isSelected { if isSelected {
Text("✔︎") Text("✔︎")
.font(.bitchatSystem(size: 16, design: .monospaced)) .font(.system(size: 16, design: .monospaced))
.foregroundColor(standardGreen) .foregroundColor(standardGreen)
} }
trailingAccessory() trailingAccessory()
@@ -411,36 +409,8 @@ struct LocationChannelsSheet: View {
} }
} }
// MARK: - TOR Toggle & Standardized Colors // MARK: - Standardized Colors
extension LocationChannelsSheet { extension LocationChannelsSheet {
private var torToggleBinding: Binding<Bool> {
Binding(
get: { network.userTorEnabled },
set: { network.setUserTorEnabled($0) }
)
}
private var torToggleSection: some View {
VStack(alignment: .leading, spacing: 8) {
Toggle(isOn: torToggleBinding) {
VStack(alignment: .leading, spacing: 2) {
Text("tor routing")
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.foregroundColor(.primary)
Text("hides your ip for location channels. recommended: on.")
.font(.bitchatSystem(size: 11, design: .monospaced))
.foregroundColor(.secondary)
}
}
.toggleStyle(IRCToggleStyle(accent: standardGreen))
}
.padding(12)
.background(Color.secondary.opacity(0.12))
.cornerRadius(8)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
private var standardGreen: Color { private var standardGreen: Color {
(colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0) (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
} }
@@ -449,34 +419,6 @@ extension LocationChannelsSheet {
} }
} }
private struct IRCToggleStyle: ToggleStyle {
let accent: Color
func makeBody(configuration: Configuration) -> some View {
Button(action: { configuration.isOn.toggle() }) {
HStack(spacing: 12) {
configuration.label
Spacer()
Text(configuration.isOn ? "on" : "off")
.textCase(.uppercase)
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.foregroundColor(configuration.isOn ? accent : .secondary)
.padding(.vertical, 4)
.padding(.horizontal, 10)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(accent.opacity(configuration.isOn ? 0.18 : 0.08))
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(accent.opacity(configuration.isOn ? 0.35 : 0.15), lineWidth: 1)
)
}
}
.buttonStyle(.plain)
}
}
// MARK: - Coverage helpers // MARK: - Coverage helpers
extension LocationChannelsSheet { extension LocationChannelsSheet {
private func coverageString(forPrecision len: Int) -> String { private func coverageString(forPrecision len: Int) -> String {
+97 -183
View File
@@ -7,7 +7,6 @@ struct LocationNotesView: View {
let onNotesCountChanged: ((Int) -> Void)? let onNotesCountChanged: ((Int) -> Void)?
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@ObservedObject private var locationManager = LocationChannelManager.shared @ObservedObject private var locationManager = LocationChannelManager.shared
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var draft: String = "" @State private var draft: String = ""
@@ -19,24 +18,30 @@ struct LocationNotesView: View {
_manager = StateObject(wrappedValue: LocationNotesManager(geohash: gh)) _manager = StateObject(wrappedValue: LocationNotesManager(geohash: gh))
} }
private var backgroundColor: Color { colorScheme == .dark ? .black : .white } private var backgroundColor: Color {
private var accentGreen: Color { colorScheme == .dark ? .green : Color(red: 0, green: 0.5, blue: 0) } colorScheme == .dark ? Color.black : Color.white
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 } }
private var textColor: Color {
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var secondaryTextColor: Color {
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
}
// Slightly darker green for hash suffix emphasis
private var darkerTextColor: Color {
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.4, blue: 0)
}
var body: some View { var body: some View {
#if os(macOS)
VStack(spacing: 0) { VStack(spacing: 0) {
ScrollView { header
VStack(spacing: 0) { Divider()
headerSection list
notesContent Divider()
} input
} }
.background(backgroundColor) .background(backgroundColor)
inputSection .foregroundColor(textColor)
}
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
.background(backgroundColor)
.onDisappear { manager.cancel() } .onDisappear { manager.cancel() }
.onChange(of: geohash) { newValue in .onChange(of: geohash) { newValue in
manager.setGeohash(newValue) manager.setGeohash(newValue)
@@ -45,202 +50,100 @@ struct LocationNotesView: View {
.onChange(of: manager.notes.count) { newValue in .onChange(of: manager.notes.count) { newValue in
onNotesCountChanged?(newValue) onNotesCountChanged?(newValue)
} }
#else
NavigationView {
VStack(spacing: 0) {
headerSection
ScrollView {
notesContent
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
inputSection
}
.background(backgroundColor)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(true)
#else
.navigationTitle("")
#endif
}
#if os(iOS)
.presentationDetents([.large])
#endif
.background(backgroundColor)
.onDisappear { manager.cancel() }
.onChange(of: geohash) { newValue in
manager.setGeohash(newValue)
}
.onAppear { onNotesCountChanged?(manager.notes.count) }
.onChange(of: manager.notes.count) { newValue in
onNotesCountChanged?(newValue)
}
#endif
} }
private var closeButton: some View { private var header: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
let c = manager.notes.count
Text("\(c) \(c == 1 ? "note" : "notes") ")
.font(.system(size: 16, weight: .bold, design: .monospaced))
Text("@ ")
.font(.system(size: 16, weight: .bold, design: .monospaced))
Text("#\(geohash)")
.font(.system(size: 16, weight: .bold, design: .monospaced))
.foregroundColor(textColor)
}
if let buildingName = locationManager.locationNames[.building], !buildingName.isEmpty {
Text(buildingName)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(secondaryTextColor)
} else if let blockName = locationManager.locationNames[.block], !blockName.isEmpty {
Text(blockName)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(secondaryTextColor)
}
}
Spacer()
Button(action: { dismiss() }) { Button(action: { dismiss() }) {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced)) .font(.system(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
.frame(width: 32, height: 32) .frame(width: 32, height: 32)
.contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("Close") .accessibilityLabel("Close")
} }
.frame(height: 44)
private var headerSection: some View { .padding(.horizontal, 12)
let count = manager.notes.count .background(backgroundColor.opacity(0.95))
return VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
Text("#\(geohash)\(count) \(count == 1 ? "note" : "notes")")
.font(.bitchatSystem(size: 18, design: .monospaced))
Spacer()
closeButton
}
if let building = locationManager.locationNames[.building], !building.isEmpty {
Text(building)
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(accentGreen)
} else if let block = locationManager.locationNames[.block], !block.isEmpty {
Text(block)
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(accentGreen)
}
Text("add short permanent notes to this location for other visitors to find.")
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
if manager.state == .loading && !manager.initialLoadComplete {
Text("loading recent notes…")
.font(.bitchatSystem(size: 11, design: .monospaced))
.foregroundColor(.secondary)
} else if manager.state == .noRelays {
Text("geo relays unavailable; notes paused")
.font(.bitchatSystem(size: 11, design: .monospaced))
.foregroundColor(.secondary)
}
}
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 12)
.background(backgroundColor)
} }
private var notesContent: some View { private var list: some View {
LazyVStack(alignment: .leading, spacing: 12) { ScrollView {
if manager.state == .noRelays { LazyVStack(alignment: .leading, spacing: 8) {
noRelaysRow
} else if manager.state == .loading && !manager.initialLoadComplete {
loadingRow
} else if manager.notes.isEmpty {
emptyRow
} else {
ForEach(manager.notes) { note in ForEach(manager.notes) { note in
noteRow(note) VStack(alignment: .leading, spacing: 2) {
}
}
if let error = manager.errorMessage, manager.state != .noRelays {
errorRow(message: error)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
private func noteRow(_ note: LocationNotesManager.Note) -> some View {
let baseName = note.displayName.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? note.displayName
let ts = timestampText(for: note.createdAt)
return VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) { HStack(spacing: 6) {
Text("@\(baseName)") // Show @name without the #abcd suffix; timestamp in brackets
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) HStack(spacing: 0) {
if !ts.isEmpty { Text("@")
Text(ts) .font(.system(size: 12, weight: .semibold, design: .monospaced))
.font(.bitchatSystem(size: 11, design: .monospaced)) .foregroundColor(textColor)
.foregroundColor(.secondary) let parts = splitSuffix(from: note.displayName)
Text(parts.0)
.font(.system(size: 12, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
} }
Spacer() let ts = timestampText(for: note.createdAt)
Text(ts.isEmpty ? "" : "[\(ts)]")
.font(.system(size: 11, design: .monospaced))
.foregroundColor(secondaryTextColor.opacity(0.8))
} }
Text(note.content) Text(note.content)
.font(.bitchatSystem(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
.padding(.vertical, 4) .padding(.horizontal, 12)
} }
private var noRelaysRow: some View {
VStack(alignment: .leading, spacing: 4) {
Text("no geo relays nearby")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
Text("notes rely on geo relays. check connection and try again.")
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary)
Button("retry") { manager.refresh() }
.font(.bitchatSystem(size: 12, design: .monospaced))
.buttonStyle(.plain)
}
.padding(.vertical, 6)
}
private var loadingRow: some View {
HStack(spacing: 10) {
ProgressView()
Text("loading notes…")
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary)
Spacer()
} }
.padding(.vertical, 8) .padding(.vertical, 8)
} }
.background(backgroundColor)
private var emptyRow: some View {
VStack(alignment: .leading, spacing: 4) {
Text("no notes yet")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
Text("be the first to add one for this spot.")
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary)
}
.padding(.vertical, 6)
} }
private func errorRow(message: String) -> some View { private var input: some View {
VStack(alignment: .leading, spacing: 4) { HStack(alignment: .center, spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.bitchatSystem(size: 12, design: .monospaced))
Text(message)
.font(.bitchatSystem(size: 12, design: .monospaced))
Spacer()
}
Button("dismiss") { manager.clearError() }
.font(.bitchatSystem(size: 12, design: .monospaced))
.buttonStyle(.plain)
}
.padding(.vertical, 6)
}
private var inputSection: some View {
HStack(alignment: .top, spacing: 10) {
TextField("add a note for this place", text: $draft, axis: .vertical) TextField("add a note for this place", text: $draft, axis: .vertical)
.textFieldStyle(.plain) .textFieldStyle(.plain)
.font(.bitchatSystem(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.lineLimit(maxDraftLines, reservesSpace: true) .lineLimit(3, reservesSpace: true)
.padding(.vertical, 6) .padding(.horizontal, 12)
Button(action: send) { Button(action: send) {
Image(systemName: "arrow.up.circle.fill") Image(systemName: "arrow.up.circle.fill")
.font(.bitchatSystem(size: 20)) .font(.system(size: 20))
.foregroundColor(sendButtonEnabled ? accentGreen : .secondary) .foregroundColor(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? Color.gray : textColor)
} }
.padding(.top, 2)
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(!sendButtonEnabled) .disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.padding(.trailing, 12)
} }
.padding(.horizontal, 16) .frame(minHeight: 44)
.padding(.vertical, 14) .padding(.vertical, 8)
.background(backgroundColor) .background(backgroundColor.opacity(0.95))
.overlay(Divider(), alignment: .top)
} }
private func send() { private func send() {
@@ -250,17 +153,15 @@ struct LocationNotesView: View {
draft = "" draft = ""
} }
private var sendButtonEnabled: Bool {
!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && manager.state != .noRelays
}
// MARK: - Timestamp Formatting // MARK: - Timestamp Formatting
private func timestampText(for date: Date) -> String { private func timestampText(for date: Date) -> String {
let now = Date() let now = Date()
if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 { if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
// Relative (minute/hour/day), no seconds
let rel = Self.relativeFormatter.string(from: date, to: now) ?? "" let rel = Self.relativeFormatter.string(from: date, to: now) ?? ""
return rel.isEmpty ? "" : "\(rel) ago" return rel.isEmpty ? "" : "\(rel) ago"
} else { } else {
// Absolute date (MMM d or MMM d, yyyy if different year)
let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year) let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year)
let fmt = sameYear ? Self.absDateFormatter : Self.absDateYearFormatter let fmt = sameYear ? Self.absDateFormatter : Self.absDateYearFormatter
return fmt.string(from: date) return fmt.string(from: date)
@@ -288,3 +189,16 @@ struct LocationNotesView: View {
return f return f
}() }()
} }
// Helper to split a trailing #abcd suffix
private func splitSuffix(from name: String) -> (String, String) {
guard name.count >= 5 else { return (name, "") }
let suffix = String(name.suffix(5))
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
}) {
let base = String(name.dropLast(5))
return (base, suffix)
}
return (name, "")
}
+14 -14
View File
@@ -15,7 +15,7 @@ struct MeshPeerList: View {
if viewModel.allPeers.isEmpty { if viewModel.allPeers.isEmpty {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Text("nobody around...") Text("nobody around...")
.font(.bitchatSystem(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor) .foregroundColor(secondaryTextColor)
.padding(.horizontal) .padding(.horizontal)
.padding(.top, 12) .padding(.top, 12)
@@ -45,27 +45,27 @@ struct MeshPeerList: View {
let baseColor = isMe ? Color.orange : assigned let baseColor = isMe ? Color.orange : assigned
if isMe { if isMe {
Image(systemName: "person.fill") Image(systemName: "person.fill")
.font(.bitchatSystem(size: 10)) .font(.system(size: 10))
.foregroundColor(baseColor) .foregroundColor(baseColor)
} else if peer.isConnected { } else if peer.isConnected {
// Mesh-connected peer: radio icon // Mesh-connected peer: radio icon
Image(systemName: "antenna.radiowaves.left.and.right") Image(systemName: "antenna.radiowaves.left.and.right")
.font(.bitchatSystem(size: 10)) .font(.system(size: 10))
.foregroundColor(baseColor) .foregroundColor(baseColor)
} else if peer.isReachable { } else if peer.isReachable {
// Mesh-reachable (relayed): point.3 icon // Mesh-reachable (relayed): point.3 icon
Image(systemName: "point.3.filled.connected.trianglepath.dotted") Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.bitchatSystem(size: 10)) .font(.system(size: 10))
.foregroundColor(baseColor) .foregroundColor(baseColor)
} else if peer.isMutualFavorite { } else if peer.isMutualFavorite {
// Mutual favorite reachable via Nostr: globe icon (purple) // Mutual favorite reachable via Nostr: globe icon (purple)
Image(systemName: "globe") Image(systemName: "globe")
.font(.bitchatSystem(size: 10)) .font(.system(size: 10))
.foregroundColor(.purple) .foregroundColor(.purple)
} else { } else {
// Fallback icon for others (dimmed) // Fallback icon for others (dimmed)
Image(systemName: "person") Image(systemName: "person")
.font(.bitchatSystem(size: 10)) .font(.system(size: 10))
.foregroundColor(secondaryTextColor) .foregroundColor(secondaryTextColor)
} }
@@ -73,19 +73,19 @@ struct MeshPeerList: View {
let (base, suffix) = splitSuffix(from: displayName) let (base, suffix) = splitSuffix(from: displayName)
HStack(spacing: 0) { HStack(spacing: 0) {
Text(base) Text(base)
.font(.bitchatSystem(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(baseColor) .foregroundColor(baseColor)
if !suffix.isEmpty { if !suffix.isEmpty {
let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6) let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6)
Text(suffix) Text(suffix)
.font(.bitchatSystem(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(suffixColor) .foregroundColor(suffixColor)
} }
} }
if !isMe, viewModel.isPeerBlocked(peer.id) { if !isMe, viewModel.isPeerBlocked(peer.id) {
Image(systemName: "nosign") Image(systemName: "nosign")
.font(.bitchatSystem(size: 10)) .font(.system(size: 10))
.foregroundColor(.red) .foregroundColor(.red)
.help("Blocked") .help("Blocked")
} }
@@ -94,7 +94,7 @@ struct MeshPeerList: View {
if peer.isConnected { if peer.isConnected {
if let icon = item.enc.icon { if let icon = item.enc.icon {
Image(systemName: icon) Image(systemName: icon)
.font(.bitchatSystem(size: 10)) .font(.system(size: 10))
.foregroundColor(baseColor) .foregroundColor(baseColor)
} }
} else { } else {
@@ -102,12 +102,12 @@ struct MeshPeerList: View {
if let fp = viewModel.getFingerprint(for: peer.id), if let fp = viewModel.getFingerprint(for: peer.id),
viewModel.verifiedFingerprints.contains(fp) { viewModel.verifiedFingerprints.contains(fp) {
Image(systemName: "checkmark.seal.fill") Image(systemName: "checkmark.seal.fill")
.font(.bitchatSystem(size: 10)) .font(.system(size: 10))
.foregroundColor(baseColor) .foregroundColor(baseColor)
} else if let icon = item.enc.icon { } else if let icon = item.enc.icon {
// Fallback to whatever status says (likely lock if we had a past session) // Fallback to whatever status says (likely lock if we had a past session)
Image(systemName: icon) Image(systemName: icon)
.font(.bitchatSystem(size: 10)) .font(.system(size: 10))
.foregroundColor(baseColor) .foregroundColor(baseColor)
} }
} }
@@ -118,7 +118,7 @@ struct MeshPeerList: View {
// Unread message indicator for this peer // Unread message indicator for this peer
if !isMe, item.hasUnread { if !isMe, item.hasUnread {
Image(systemName: "envelope.fill") Image(systemName: "envelope.fill")
.font(.bitchatSystem(size: 10)) .font(.system(size: 10))
.foregroundColor(.orange) .foregroundColor(.orange)
.help("New messages") .help("New messages")
} }
@@ -126,7 +126,7 @@ struct MeshPeerList: View {
if !isMe { if !isMe {
Button(action: { onToggleFavorite(peer.id) }) { Button(action: { onToggleFavorite(peer.id) }) {
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star") Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
.font(.bitchatSystem(size: 12)) .font(.system(size: 12))
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor) .foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
+71
View File
@@ -0,0 +1,71 @@
// MessageHeaderLine.swift
// bitchat
//
// Renders a header like normal messages: "<@nickname> [HH:mm:ss]"
// Used above media rows (images, audio) to match text message style.
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
struct MessageHeaderLine: View {
@EnvironmentObject var viewModel: ChatViewModel
@Environment(\.colorScheme) var colorScheme
let message: BitchatMessage
var body: some View {
Text(formattedHeader())
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, 2)
}
private func formattedHeader() -> AttributedString {
var result = AttributedString()
let isDark = colorScheme == .dark
let baseColor = viewModel.peerColor(for: message, isDark: isDark)
// Determine self to bold our own name
let isSelf: Bool = {
if let spid = message.senderPeerID {
return spid == viewModel.meshService.myPeerID
}
return message.sender == viewModel.nickname || message.sender.hasPrefix(viewModel.nickname + "#")
}()
// Split suffix like name#abcd
let (base, suffix) = message.sender.splitSuffix()
var senderStyle = AttributeContainer()
senderStyle.foregroundColor = baseColor
senderStyle.font = .system(size: 14, weight: isSelf ? .bold : .medium, design: .monospaced)
if let spid = message.senderPeerID,
let url = URL(string: "bitchat://user/\(spid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid)") {
senderStyle.link = url
}
// "<@"
result.append(AttributedString("<@").mergingAttributes(senderStyle))
// base name
result.append(AttributedString(base).mergingAttributes(senderStyle))
// optional suffix with lighter color
if !suffix.isEmpty {
var suf = senderStyle
suf.foregroundColor = baseColor.opacity(0.6)
result.append(AttributedString(suffix).mergingAttributes(suf))
}
// Close angle without adding content or ">"
result.append(AttributedString("> ").mergingAttributes(senderStyle))
// Timestamp like normal messages
let ts = AttributedString("[\(message.formattedTimestamp)]")
var tsStyle = AttributeContainer()
tsStyle.foregroundColor = Color.gray.opacity(0.7)
tsStyle.font = .system(size: 10, design: .monospaced)
result.append(ts.mergingAttributes(tsStyle))
return result
}
}
@@ -0,0 +1,60 @@
import SwiftUI
import Combine
/// Real-time scrolling waveform for live recording.
/// Provide a normalized amplitude [0,1]. The view maintains a sliding window of bars.
struct RealtimeScrollingWaveform: View {
var amplitudeNorm: CGFloat
var bars: Int = 240
var barColor: Color = Color(red: 0, green: 1, blue: 0.5) // neon-ish green
@State private var samples: [CGFloat] = []
@State private var ticker = Timer.publish(every: 0.02, on: .main, in: .common).autoconnect()
var body: some View {
GeometryReader { geo in
Canvas { ctx, size in
let w = size.width
let h = size.height
guard w > 0 && h > 0 else { return }
let n = samples.count
guard n > 0 else { return }
let stepX = w / CGFloat(n)
let midY = h / 2
let stroke: CGFloat = 1.2
for i in 0..<n {
let amp = max(0, min(1, samples[i]))
// Amplify only higher amplitudes so quiet parts stay subtle
let t: CGFloat = 0.6
let k: CGFloat = 0.7
let boosted = amp <= t ? amp : min(1, amp + k * (amp - t))
let lineH = max(1, boosted * (h * 0.95))
let x = CGFloat(i) * stepX + stepX / 2
let yTop = midY - lineH / 2
let yBot = midY + lineH / 2
var path = Path()
path.move(to: CGPoint(x: x, y: yTop))
path.addLine(to: CGPoint(x: x, y: yBot))
ctx.stroke(path, with: .color(barColor), lineWidth: stroke)
}
}
.onAppear {
if samples.isEmpty { samples = Array(repeating: 0, count: bars) }
}
.onChange(of: bars) { newVal in
let clamped = max(8, newVal)
samples = Array(samples.suffix(clamped))
if samples.count < clamped {
samples.insert(contentsOf: Array(repeating: 0, count: clamped - samples.count), at: 0)
}
}
.onReceive(ticker) { _ in
let v = max(0, min(1, amplitudeNorm))
samples.append(v)
let overflow = samples.count - bars
if overflow > 0 { samples.removeFirst(overflow) }
}
}
}
}
+10 -10
View File
@@ -16,7 +16,7 @@ struct MyQRView: View {
var body: some View { var body: some View {
VStack(spacing: 12) { VStack(spacing: 12) {
Text("scan to verify me") Text("scan to verify me")
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) .font(.system(size: 16, weight: .bold, design: .monospaced))
VStack(spacing: 10) { VStack(spacing: 10) {
QRCodeImage(data: qrString, size: 240) QRCodeImage(data: qrString, size: 240)
@@ -24,7 +24,7 @@ struct MyQRView: View {
// Non-scrolling, fully visible URL (wraps across lines) // Non-scrolling, fully visible URL (wraps across lines)
Text(qrString) Text(qrString)
.font(.bitchatSystem(size: 11, design: .monospaced)) .font(.system(size: 11, design: .monospaced))
.textSelection(.enabled) .textSelection(.enabled)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
@@ -60,7 +60,7 @@ struct QRCodeImage: View {
.frame(width: size, height: size) .frame(width: size, height: size)
.overlay( .overlay(
Text("qr unavailable") Text("qr unavailable")
.font(.bitchatSystem(size: 12, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
.foregroundColor(.gray) .foregroundColor(.gray)
) )
} }
@@ -119,7 +119,7 @@ struct QRScanView: View {
.clipShape(RoundedRectangle(cornerRadius: 8)) .clipShape(RoundedRectangle(cornerRadius: 8))
#else #else
Text("paste qr content to validate:") Text("paste qr content to validate:")
.font(.bitchatSystem(size: 14, weight: .medium, design: .monospaced)) .font(.system(size: 14, weight: .medium, design: .monospaced))
TextEditor(text: $input) TextEditor(text: $input)
.frame(height: 100) .frame(height: 100)
.border(Color.gray.opacity(0.4)) .border(Color.gray.opacity(0.4))
@@ -255,7 +255,7 @@ struct VerificationSheetView: View {
// Top header (always at top) // Top header (always at top)
HStack { HStack {
Text("VERIFY") Text("VERIFY")
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced)) .font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(accentColor) .foregroundColor(accentColor)
Spacer() Spacer()
Button(action: { Button(action: {
@@ -263,7 +263,7 @@ struct VerificationSheetView: View {
isPresented = false isPresented = false
}) { }) {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.bitchatSystem(size: 14, weight: .semibold)) .font(.system(size: 14, weight: .semibold))
.foregroundColor(accentColor) .foregroundColor(accentColor)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -279,7 +279,7 @@ struct VerificationSheetView: View {
if showingScanner { if showingScanner {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("scan a friend's qr") Text("scan a friend's qr")
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) .font(.system(size: 16, weight: .bold, design: .monospaced))
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.foregroundColor(accentColor) .foregroundColor(accentColor)
@@ -310,13 +310,13 @@ struct VerificationSheetView: View {
if showingScanner { if showingScanner {
Button(action: { showingScanner = false }) { Button(action: { showingScanner = false }) {
Label("show my qr", systemImage: "qrcode") Label("show my qr", systemImage: "qrcode")
.font(.bitchatSystem(size: 13, design: .monospaced)) .font(.system(size: 13, design: .monospaced))
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
} else { } else {
Button(action: { showingScanner = true }) { Button(action: { showingScanner = true }) {
Label("scan someone else's qr", systemImage: "camera.viewfinder") Label("scan someone else's qr", systemImage: "camera.viewfinder")
.font(.bitchatSystem(size: 13, weight: .medium, design: .monospaced)) .font(.system(size: 13, weight: .medium, design: .monospaced))
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.tint(.gray) .tint(.gray)
@@ -328,7 +328,7 @@ struct VerificationSheetView: View {
viewModel.verifiedFingerprints.contains(fp) { viewModel.verifiedFingerprints.contains(fp) {
Button(action: { viewModel.unverifyFingerprint(for: pid) }) { Button(action: { viewModel.unverifyFingerprint(for: pid) }) {
Label("remove verification", systemImage: "minus.circle") Label("remove verification", systemImage: "minus.circle")
.font(.bitchatSystem(size: 12, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.tint(.gray) .tint(.gray)
+100
View File
@@ -0,0 +1,100 @@
import SwiftUI
import AVFoundation
struct VoiceMessageRow: View {
let fileURL: URL
var sendProgress: Double? = nil // 0..1 filling during send
@State private var bins: [Float] = []
@State private var isPlaying = false
@State private var progress: Double = 0
@State private var duration: TimeInterval = 0
@State private var player: AVAudioPlayer?
var body: some View {
HStack(alignment: .center, spacing: 8) {
// Play / Pause control (fixed size similar to Android's 28dp)
Button(action: togglePlay) {
Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 24))
}
.buttonStyle(.plain)
// Waveform takes remaining width, fixed height, vertically centered
GeometryReader { geo in
let barWidth: CGFloat = 2
let barSpacing: CGFloat = 2
let step = barWidth + barSpacing
let maxBars = max(0, Int(floor(geo.size.width / step)))
let displayCount = min(maxBars, bins.count)
ZStack(alignment: .leading) {
// Base waveform (gray)
HStack(spacing: barSpacing) {
ForEach(0..<displayCount, id: \.self) { i in
let v = bins[i]
let normalizedV = v <= 0 ? 0 : min(1.0, log(1.0 + v * 9.0) / log(10.0))
let h = max(2, CGFloat(normalizedV) * geo.size.height)
VStack { Spacer(minLength: 0); Capsule().fill(Color.gray.opacity(0.4)).frame(width: barWidth, height: h); Spacer(minLength: 0) }
}
}
// Filled progress (blue) or send progress if provided
let activeProgress = sendProgress ?? progress
let filledBars = Int(Double(displayCount) * activeProgress)
HStack(spacing: barSpacing) {
ForEach(0..<max(0, min(displayCount, filledBars)), id: \.self) { i in
let v = bins[i]
let normalizedV = v <= 0 ? 0 : min(1.0, log(1.0 + v * 9.0) / log(10.0))
let h = max(2, CGFloat(normalizedV) * geo.size.height)
VStack { Spacer(minLength: 0); Capsule().fill(Color.blue).frame(width: barWidth, height: h); Spacer(minLength: 0) }
}
}
}
.clipped() // prevent overflow beyond available width/height
}
.frame(height: 36)
// Reserve width for duration to avoid being pushed out by waveform
Text(formattedDuration)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
.frame(width: 44, alignment: .trailing)
}
.onAppear(perform: load)
.onDisappear { player?.stop(); isPlaying = false }
}
private var formattedDuration: String {
let total = duration
let mins = Int(total) / 60
let secs = Int(total) % 60
return String(format: "%d:%02d", mins, secs)
}
private func load() {
bins = WaveformExtractor.extractBins(url: fileURL, binCount: 120)
do {
player = try AVAudioPlayer(contentsOf: fileURL)
duration = player?.duration ?? 0
player?.prepareToPlay()
startProgressTimer()
} catch {
// Ignore
}
}
private func togglePlay() {
guard let p = player else { return }
if isPlaying { p.pause(); isPlaying = false }
else { p.play(); isPlaying = true; startProgressTimer() }
}
private func startProgressTimer() {
guard let p = player else { return }
Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { t in
if !isPlaying || p.duration == 0 { t.invalidate(); return }
progress = min(1.0, p.currentTime / p.duration)
if progress >= 1.0 { isPlaying = false; t.invalidate() }
}
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
<true/> <true/>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.chat.bitchat</string> <string>group.chat.bitchat-local</string>
</array> </array>
<key>com.apple.security.device.bluetooth</key> <key>com.apple.security.device.bluetooth</key>
<true/> <true/>
+1 -1
View File
@@ -6,7 +6,7 @@
<true/> <true/>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.chat.bitchat</string> <string>group.chat.bitchat-local</string>
</array> </array>
<key>com.apple.security.device.bluetooth</key> <key>com.apple.security.device.bluetooth</key>
<true/> <true/>
@@ -35,9 +35,8 @@ final class ShareViewController: UIViewController {
statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor), statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor) statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)
]) ])
DispatchQueue.global().async {
self.processShare() processShare()
}
} }
// MARK: - Processing // MARK: - Processing
@@ -164,7 +163,7 @@ final class ShareViewController: UIViewController {
statusLabel.text = msg statusLabel.text = msg
// Complete shortly after showing status // Complete shortly after showing status
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) { DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) {
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil) self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
} }
} }
} }
-16
View File
@@ -1,16 +0,0 @@
import SwiftUI
import XCTest
@testable import bitchat
final class FontBitchatTests: XCTestCase {
func testMonospacedMapping() {
XCTAssertEqual(Font.bitchatSystem(size: 10, design: .monospaced), Font.system(.caption2, design: .monospaced))
XCTAssertEqual(Font.bitchatSystem(size: 14, design: .monospaced), Font.system(.body, design: .monospaced))
XCTAssertEqual(Font.bitchatSystem(size: 20, design: .monospaced), Font.system(.title2, design: .monospaced))
}
func testWeightIsPreserved() {
let bold = Font.bitchatSystem(size: 14, weight: .bold, design: .monospaced)
XCTAssertEqual(bold, Font.system(.body, design: .monospaced).weight(.bold))
}
}
-22
View File
@@ -1,22 +0,0 @@
import XCTest
@testable import bitchat
final class GCSFilterTests: XCTestCase {
func testBuildFilterWithDuplicateIdsProducesStableEncoding() {
let id = Data(repeating: 0xAB, count: 16)
let ids = Array(repeating: id, count: 64)
let params = GCSFilter.buildFilter(ids: ids, maxBytes: 128, targetFpr: 0.01)
XCTAssertGreaterThanOrEqual(params.m, 1)
let decoded = GCSFilter.decodeToSortedSet(p: params.p, m: params.m, data: params.data)
XCTAssertLessThanOrEqual(decoded.count, 1)
}
func testBucketAvoidsZeroCandidate() {
let id = Data(repeating: 0x01, count: 16)
let bucket = GCSFilter.bucket(for: id, modulus: 2)
XCTAssertNotEqual(bucket, 0)
XCTAssertLessThan(bucket, 2)
}
}
-70
View File
@@ -1,70 +0,0 @@
import Foundation
import XCTest
@testable import bitchat
final class GossipSyncManagerTests: XCTestCase {
func testConcurrentPacketIntakeAndSyncRequest() {
let manager = GossipSyncManager(myPeerID: "0102030405060708")
let delegate = RecordingDelegate()
let sendExpectation = expectation(description: "sync request sent")
delegate.onSend = { sendExpectation.fulfill() }
manager.delegate = delegate
let iterations = 200
let group = DispatchGroup()
for i in 0..<iterations {
group.enter()
DispatchQueue.global(qos: .userInitiated).async {
let packet = BitchatPacket(
type: MessageType.message.rawValue,
senderID: Data(hexString: "1122334455667788") ?? Data(),
recipientID: nil,
timestamp: 1_000_000 + UInt64(i),
payload: Data([UInt8(truncatingIfNeeded: i)]),
signature: nil,
ttl: 1
)
manager.onPublicPacketSeen(packet)
Thread.sleep(forTimeInterval: 0.001)
group.leave()
}
}
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.002) {
manager.scheduleInitialSyncToPeer("FFFFFFFFFFFFFFFF", delaySeconds: 0.0)
}
group.wait()
wait(for: [sendExpectation], timeout: 2.0)
guard let lastPacket = delegate.lastPacket else {
XCTFail("Expected sync packet to be sent")
return
}
XCTAssertEqual(lastPacket.type, MessageType.requestSync.rawValue)
XCTAssertNotNil(RequestSyncPacket.decode(from: lastPacket.payload))
}
}
private final class RecordingDelegate: GossipSyncManager.Delegate {
var onSend: (() -> Void)?
private(set) var lastPacket: BitchatPacket?
private let lock = NSLock()
func sendPacket(_ packet: BitchatPacket) {
lock.lock()
lastPacket = packet
lock.unlock()
onSend?()
}
func sendPacket(to peerID: String, packet: BitchatPacket) {
sendPacket(packet)
}
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
packet
}
}
@@ -1,146 +0,0 @@
import XCTest
@testable import bitchat
@MainActor
final class LocationNotesManagerTests: XCTestCase {
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: "abcd1234", dependencies: deps)
XCTAssertFalse(subscribeCalled)
XCTAssertEqual(manager.state, .noRelays)
XCTAssertTrue(manager.initialLoadComplete)
XCTAssertEqual(manager.errorMessage, "No geo relays available near this location. Try again soon.")
}
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, "No geo relays available near this location. Try again soon.")
}
func testSubscribeUsesGeoRelaysAndAppendsNotes() {
var relaysCaptured: [String] = []
var storedHandler: ((NostrEvent) -> Void)?
var storedEOSE: (() -> Void)?
let deps = LocationNotesDependencies(
relayLookup: { _, _ in ["wss://relay.one"] },
subscribe: { filter, id, relays, handler, eose in
XCTAssertEqual(filter.kinds, [1])
XCTAssertFalse(id.isEmpty)
relaysCaptured = relays
storedHandler = handler
storedEOSE = eose
},
unsubscribe: { _ in },
sendEvent: { _, _ in },
deriveIdentity: { _ in throw TestError.shouldNotDerive },
now: { Date() }
)
let manager = LocationNotesManager(geohash: "abcd1234", dependencies: deps)
XCTAssertEqual(relaysCaptured, ["wss://relay.one"])
XCTAssertEqual(manager.state, .loading)
var event = NostrEvent(
pubkey: "pub",
createdAt: Date(),
kind: .textNote,
tags: [["g", "abcd1234"]],
content: "hi"
)
event.id = "event1"
storedHandler?(event)
storedEOSE?()
XCTAssertEqual(manager.state, .ready)
XCTAssertEqual(manager.notes.count, 1)
XCTAssertEqual(manager.notes.first?.content, "hi")
}
private enum TestError: Error {
case shouldNotDerive
}
}
@MainActor
final class LocationNotesCounterTests: XCTestCase {
func testSubscribeWithoutRelaysMarksUnavailable() {
var subscribeCalled = false
let deps = LocationNotesCounterDependencies(
relayLookup: { _, _ in [] },
subscribe: { _, _, _, _, _ in subscribeCalled = true },
unsubscribe: { _ in }
)
let counter = LocationNotesCounter(testDependencies: deps)
counter.subscribe(geohash: "abcdefgh")
XCTAssertFalse(subscribeCalled)
XCTAssertFalse(counter.relayAvailable)
XCTAssertTrue(counter.initialLoadComplete)
XCTAssertEqual(counter.count, 0)
}
func testSubscribeCountsUniqueNotes() {
var storedHandler: ((NostrEvent) -> Void)?
var storedEOSE: (() -> Void)?
let deps = LocationNotesCounterDependencies(
relayLookup: { _, _ in ["wss://relay.geo"] },
subscribe: { filter, id, relays, handler, eose in
XCTAssertEqual(relays, ["wss://relay.geo"])
XCTAssertEqual(filter.kinds, [1])
XCTAssertFalse(id.isEmpty)
storedHandler = handler
storedEOSE = eose
},
unsubscribe: { _ in }
)
let counter = LocationNotesCounter(testDependencies: deps)
counter.subscribe(geohash: "abcdefgh")
var first = NostrEvent(
pubkey: "pub",
createdAt: Date(),
kind: .textNote,
tags: [["g", "abcdefgh"]],
content: "a"
)
first.id = "eventA"
storedHandler?(first)
let duplicate = first
storedHandler?(duplicate)
storedEOSE?()
XCTAssertTrue(counter.relayAvailable)
XCTAssertEqual(counter.count, 1)
XCTAssertTrue(counter.initialLoadComplete)
}
}
@@ -1,111 +0,0 @@
import XCTest
@testable import bitchat
final class NotificationStreamAssemblerTests: XCTestCase {
private func makePacket(timestamp: UInt64 = 0x0102030405) -> BitchatPacket {
let sender = Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77])
return BitchatPacket(
type: MessageType.message.rawValue,
senderID: sender,
recipientID: nil,
timestamp: timestamp,
payload: Data([0xDE, 0xAD, 0xBE, 0xEF]),
signature: nil,
ttl: 3
)
}
func testAssemblesSingleFrameAcrossChunks() {
var assembler = NotificationStreamAssembler()
let packet = makePacket()
guard let frame = packet.toBinaryData(padding: false) else {
return XCTFail("Failed to encode packet")
}
XCTAssertNotNil(BinaryProtocol.decode(frame))
let payloadLen = (Int(frame[12]) << 8) | Int(frame[13])
XCTAssertEqual(payloadLen, packet.payload.count)
let splitIndex = min(20, max(1, frame.count / 2))
let first = frame.prefix(splitIndex)
let second = frame.suffix(from: splitIndex)
XCTAssertEqual(first.count + second.count, frame.count)
var result = assembler.append(first)
XCTAssertTrue(result.frames.isEmpty)
XCTAssertTrue(result.droppedPrefixes.isEmpty)
XCTAssertFalse(result.reset)
result = assembler.append(second)
XCTAssertEqual(result.frames.count, 1)
XCTAssertTrue(result.droppedPrefixes.isEmpty)
XCTAssertFalse(result.reset)
guard let frameData = result.frames.first else {
return XCTFail("Missing frame data")
}
if frameData.count != frame.count {
XCTFail("Frame size mismatch: expected \(frame.count) got \(frameData.count)\nframe=\(Array(frame))\nassembled=\(Array(frameData))")
return
}
guard let decoded = BinaryProtocol.decode(frameData) else {
return XCTFail("Failed to decode frame")
}
XCTAssertEqual(decoded.type, packet.type)
XCTAssertEqual(decoded.payload, packet.payload)
XCTAssertEqual(decoded.senderID, packet.senderID)
XCTAssertEqual(decoded.timestamp, packet.timestamp)
var directAssembler = NotificationStreamAssembler()
let directResult = directAssembler.append(frame)
XCTAssertEqual(directResult.frames.first?.count, frame.count)
}
func testAssemblesMultipleFramesSequentially() {
var assembler = NotificationStreamAssembler()
let packet1 = makePacket(timestamp: 0xABC)
let packet2 = makePacket(timestamp: 0xDEF)
guard let frame1 = packet1.toBinaryData(padding: false),
let frame2 = packet2.toBinaryData(padding: false) else {
return XCTFail("Failed to encode packets")
}
var combined = Data()
combined.append(frame1)
combined.append(frame2)
let firstChunk = combined.prefix(20)
let secondChunk = combined.suffix(from: 20)
var result = assembler.append(firstChunk)
XCTAssertTrue(result.frames.isEmpty)
result = assembler.append(secondChunk)
XCTAssertEqual(result.frames.count, 2)
guard let decoded1 = BinaryProtocol.decode(result.frames[0]),
let decoded2 = BinaryProtocol.decode(result.frames[1]) else {
return XCTFail("Failed to decode frames")
}
XCTAssertEqual(decoded1.timestamp, packet1.timestamp)
XCTAssertEqual(decoded2.timestamp, packet2.timestamp)
}
func testDropsInvalidPrefixByte() {
var assembler = NotificationStreamAssembler()
let packet = makePacket(timestamp: 0xF00)
guard let frame = packet.toBinaryData(padding: false) else {
return XCTFail("Failed to encode packet")
}
var noisyFrame = Data([0x00])
noisyFrame.append(frame)
let result = assembler.append(noisyFrame)
XCTAssertEqual(result.droppedPrefixes, [0x00])
XCTAssertEqual(result.frames.count, 1)
XCTAssertFalse(result.reset)
guard let decoded = BinaryProtocol.decode(result.frames[0]) else {
return XCTFail("Failed to decode frame after drop")
}
XCTAssertEqual(decoded.timestamp, packet.timestamp)
}
}
+247 -258
View File
@@ -1,275 +1,264 @@
Relay URL,Latitude,Longitude Relay URL,Latitude,Longitude
nostr.tac.lol,47.4748,-122.273 relay.laantungir.net,-19.4692,-42.5315
relay.javi.space,43.4633,11.8796 relay.endfiat.money,43.6532,-79.3832
nostr.einundzwanzig.space,50.1109,8.68213
nostr.kalf.org,52.3676,4.90414
wot.dtonon.com,43.6532,-79.3832
nostr-01.yakihonne.com,1.32123,103.695
wot.basspistol.org,49.4521,11.0767
relay.puresignal.news,43.6532,-79.3832
nostrelites.org,41.8781,-87.6298
nostr-relay.nextblockvending.com,47.674,-122.122
relay03.lnfi.network,39.0997,-94.5786
nproxy.kristapsk.lv,60.1699,24.9384
relay.toastr.net,40.8054,-74.0241
relay.davidebtc.me,51.5072,-0.127586
wot.nostr.net,43.6532,-79.3832
relay.mattybs.lol,43.6532,-79.3832
relay.zone667.com,60.1699,24.9384 relay.zone667.com,60.1699,24.9384
nostr.kungfu-g.rip,33.7946,-84.4488
relay.mccormick.cx,52.3563,4.95714
relay.dwadziesciajeden.pl,52.2297,21.0122
nostr.data.haus,50.4754,12.3683
vitor.nostr1.com,40.7128,-74.006
purpura.cloud,43.6532,-79.3832
relay2.angor.io,48.1046,11.6002
nos.lol,50.4754,12.3683
nostr.rohoss.com,50.1109,8.68213
strfry.bonsai.com,37.8715,-122.273
relay.fountain.fm,39.0997,-94.5786
relay.npubhaus.com,43.6532,-79.3832
relay.nostr.wirednet.jp,34.706,135.493
soloco.nl,43.6532,-79.3832
shu01.shugur.net,21.4902,39.2246
nostr.davidebtc.me,51.5072,-0.127586
pyramid.fiatjaf.com,50.1109,8.68213
relay.ditto.pub,43.6532,-79.3832
relay.nostr.vet,52.6467,4.7395
relay.wavlake.com,41.2619,-95.8608
ribo.eu.nostria.app,52.3676,4.90414
relay.ngengine.org,43.6532,-79.3832
relay.bitcoinveneto.org,64.1466,-21.9426
no.str.cr,9.92857,-84.0528
relay.primal.net,43.6532,-79.3832
ynostr.yael.at,60.1699,24.9384
nostr.camalolo.com,24.1469,120.684
purplerelay.com,50.1109,8.68213
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
relay.internationalright-wing.org,-22.5022,-48.7114
wheat.happytavern.co,43.6532,-79.3832
nostr.lostr.space,43.6532,-79.3832
relay.tagayasu.xyz,43.6715,-79.38
relay.varke.eu,52.6921,6.19372
free.relayted.de,50.1109,8.68213
nostr.thebiglake.org,32.71,-96.6745
nostr.lojong.info,43.6532,-79.3832
nostr.now,36.55,139.733
relay.jmoose.rocks,60.1699,24.9384
relay.holzeis.me,43.6532,-79.3832
nostr.roundrockbitcoiners.com,40.8054,-74.0241
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
relay2.ngengine.org,43.6532,-79.3832
nostr.snowbla.de,60.1699,24.9384
4u2ni0zjbjvni.clorecloud.net,43.6532,-79.3832
shu04.shugur.net,25.2604,55.2989 shu04.shugur.net,25.2604,55.2989
relay.fr13nd5.com,52.5233,13.3426 relay.bitcoinartclock.com,50.4754,12.3683
nostr.vulpem.com,49.4543,11.0746 relay.nostromo.social,49.4543,11.0746
temp.iris.to,43.6532,-79.3832 nostr.liberty.fans,36.9104,-89.5875
x.kojira.io,43.6532,-79.3832
adre.su,59.9311,30.3609
nostr-dev.wellorder.net,45.5201,-122.99
nostr.mom,50.4754,12.3683
relay.nostr.place,32.7767,-96.797
wot.nostr.place,30.2672,-97.7431
nostr.carroarmato0.be,50.9928,3.26317
nostrelay.circum.space,51.2217,6.77616
relay.chorus.community,50.1109,8.68213
relay.nostr.net,50.4754,12.3683
relay.nostr-check.me,43.6532,-79.3832
relay.nostrhub.fr,48.1046,11.6002
relay.nostraddress.com,43.6532,-79.3832
nostr.rblb.it,43.4633,11.8796
nostr.red5d.dev,43.6532,-79.3832
santo.iguanatech.net,40.8302,-74.1299
relay02.lnfi.network,39.0997,-94.5786
relay.21e6.cz,50.1682,14.0546
a.nos.lol,50.4754,12.3683
shu02.shugur.net,21.4902,39.2246
schnorr.me,43.6532,-79.3832
nostr.n7ekb.net,47.4941,-122.294
wot.shaving.kiwi,43.6532,-79.3832
dev-nostr.bityacht.io,25.0797,121.234
relay.credenso.cafe,43.1149,-80.7228
relay-testnet.k8s.layer3.news,37.3387,-121.885
relay.mess.ch,47.3591,8.55292
inbox.azzamo.net,52.2633,21.0283
prl.plus,55.7623,37.6381
yabu.me,35.6092,139.73
relayrs.notoshi.win,43.6532,-79.3832
premium.primal.net,43.6532,-79.3832
nostr.coincrowd.fund,39.0438,-77.4874
nostr.2b9t.xyz,34.0549,-118.243
nostr.thaliyal.com,40.8218,-74.45
relay.exit.pub,50.4754,12.3683
nostr.jfischer.org,49.0291,8.35696
relay.origin.land,35.6673,139.751
nostr.myshosholoza.co.za,52.3676,4.90414
relay.nostriot.com,41.5695,-83.9786
relay.btcforplebs.com,43.6532,-79.3832
relay.chakany.systems,43.6532,-79.3832
nostr.openhoofd.nl,51.9229,4.40833
nostrcheck.me,43.6532,-79.3832
nostr.plantroon.com,50.1013,8.62643
satsage.xyz,37.3986,-121.964
nostr.faultables.net,43.6532,-79.3832
nostr.calitabby.net,39.9268,-75.0246
relay.freeplace.nl,52.3676,4.90414
relay.nostrhub.tech,49.4543,11.0746
roles-az-achieving-somebody.trycloudflare.com,43.6532,-79.3832 roles-az-achieving-somebody.trycloudflare.com,43.6532,-79.3832
relay.arx-ccn.com,50.4754,12.3683 nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
cyberspace.nostr1.com,40.7128,-74.006 relay.nostr.wirednet.jp,34.706,135.493
nostr.smut.cloud,43.6532,-79.3832 nostr.einundzwanzig.space,50.1109,8.68213
nostr-02.czas.top,53.471,9.88208 relay.21e6.cz,50.1682,14.0546
relay.tapestry.ninja,40.8054,-74.0241 relay04.lnfi.network,39.0997,-94.5786
relay.mostro.network,40.8302,-74.1299 relay.chorus.community,50.1109,8.68213
wot.brightbolt.net,47.6735,-116.781 relay.nostr.place,32.7767,-96.797
nostr.spaceshell.xyz,43.6532,-79.3832 relay.vrtmrz.net,43.6532,-79.3832
nostr.rikmeijer.nl,50.4754,12.3683 noxir.kpherox.dev,34.8587,135.509
relay.artx.market,43.652,-79.3633 wot.nostr.net,43.6532,-79.3832
strfry.felixzieger.de,50.1013,8.62643 relay.cypherflow.ai,48.8566,2.35222
relay.seq1.net,43.6532,-79.3832 wot.sudocarlos.com,51.5072,-0.127586
relay.cosmicbolt.net,37.3986,-121.964 nostr.jerrynya.fun,31.2304,121.474
relay.electriclifestyle.com,26.2897,-80.1293 nostr2.girino.org,43.6532,-79.3832
r.bitcoinhold.net,43.6532,-79.3832 nostrings-relay-dev.fly.dev,41.8781,-87.6298
nostr-relay.amethyst.name,39.0067,-77.4291 fanfares.nostr1.com,40.7128,-74.006
relay.stream.labs.h3.se,59.4016,17.9455 nostr.red5d.dev,43.6532,-79.3832
relay.unknown.cloud,43.6532,-79.3832 nostr.hifish.org,47.4043,8.57398
nostr-02.yakihonne.com,1.32123,103.695 nostr.now,36.55,139.733
relay.coinos.io,43.6532,-79.3832 relay.nostr.band,60.1699,24.9384
relay5.bitransfer.org,43.6532,-79.3832 relay.wavlake.com,41.2619,-95.8608
nostr.bilthon.dev,25.8128,-80.2377
khatru.nostrver.se,51.8933,4.42083
relay.bitcoindistrict.org,43.6532,-79.3832
nostr.makibisskey.work,43.6532,-79.3832
relay.nostraddress.com,43.6532,-79.3832
relay.jmoose.rocks,60.1699,24.9384
relay.davidebtc.me,51.5072,-0.127586
a.nos.lol,50.4754,12.3683
nostr.tadryanom.me,43.6532,-79.3832
relay.nostrdice.com,-33.8688,151.209
relay.lumina.rocks,49.0291,8.35695
relay.goodmorningbitcoin.com,43.6532,-79.3832
nostr.rtvslawenia.com,49.4543,11.0746
relay.mattybs.lol,43.6532,-79.3832
relay-dev.satlantis.io,40.8302,-74.1299 relay-dev.satlantis.io,40.8302,-74.1299
nostream.breadslice.com,43.6532,-79.3832 nostream.breadslice.com,43.6532,-79.3832
relay.fundstr.me,42.3601,-71.0589 nostr.vulpem.com,49.4543,11.0746
nostr.oxtr.dev,50.4754,12.3683 nostr.rohoss.com,50.1109,8.68213
nos.xmark.cc,50.6924,3.20113
nostr.mikoshi.de,50.1109,8.68213
relay.magiccity.live,25.8128,-80.2377
nostr-verified.wellorder.net,45.5201,-122.99
nostr.makibisskey.work,43.6532,-79.3832
wot.nostr.party,36.1627,-86.7816
relay.copylaradio.com,51.223,6.78245
nostr.sathoarder.com,48.5734,7.75211
relay.jeffg.fyi,43.6532,-79.3832
relay.wellorder.net,45.5201,-122.99
nostr.ovia.to,43.6532,-79.3832
black.nostrcity.club,41.8781,-87.6298
relay.nostrcheck.me,43.6532,-79.3832
nostr-relay.cbrx.io,43.6532,-79.3832
nostr-03.dorafactory.org,1.35208,103.82
nostr-relay.zimage.com,34.282,-118.439
relay.sigit.io,50.4754,12.3683
relay.laantungir.net,-19.4692,-42.5315
relay-admin.thaliyal.com,40.8218,-74.45
relay.mwaters.net,50.9871,2.12554
relay.lumina.rocks,49.0291,8.35695
nostr.satstralia.com,64.1476,-21.9392
nostr.sagaciousd.com,49.2827,-123.121
relay.bullishbounty.com,43.6532,-79.3832
wot.dergigi.com,64.1476,-21.9392
relay.vrtmrz.net,43.6532,-79.3832
nostr.0x7e.xyz,47.4988,8.72369
articles.layer3.news,37.3387,-121.885 articles.layer3.news,37.3387,-121.885
relay.digitalezukunft.cyou,45.5019,-73.5674 nos.lol,50.4754,12.3683
nostr.notribe.net,40.8302,-74.1299 relay.artx.market,43.652,-79.3633
nostr.21crypto.ch,47.4988,8.72369 wot.sebastix.social,51.8933,4.42083
relay.lifpay.me,1.35208,103.82
relay.g1sms.fr,43.9432,2.07537
relay.snort.social,43.6532,-79.3832
relay.getsafebox.app,43.6532,-79.3832
relay.moinsen.com,50.4754,12.3683
nostr.blankfors.se,60.1699,24.9384
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
orangesync.tech,50.1109,8.68213
relay.ru.ac.th,13.7584,100.622
orangepiller.org,60.1699,24.9384
relay.nostr.band,60.1699,24.9384
wot.soundhsa.com,34.0479,-118.256
khatru.nostrver.se,51.8933,4.42083
nostr.hifish.org,47.4043,8.57398
freelay.sovbit.host,64.1476,-21.9392
nostr.bilthon.dev,25.8128,-80.2377
nostr.night7.space,50.4754,12.3683
relay.illuminodes.com,47.6061,-122.333
relayone.soundhsa.com,34.0479,-118.256
nostr.azzamo.net,52.2633,21.0283
fenrir-s.notoshi.win,43.6532,-79.3832
nostr.spicyz.io,43.6532,-79.3832
nostrelay.memory-art.xyz,43.6532,-79.3832
nostr.coincards.com,53.5501,-113.469
alien.macneilmediagroup.com,43.6532,-79.3832 alien.macneilmediagroup.com,43.6532,-79.3832
nostrings-relay-dev.fly.dev,41.8781,-87.6298 relay.unknown.cloud,43.6532,-79.3832
relay.satlantis.io,32.8769,-80.0114 nostr.lojong.info,43.6532,-79.3832
srtrelay.c-stellar.net,43.6532,-79.3832
relay.agora.social,50.7383,15.0648
relay.bitcoindistrict.org,43.6532,-79.3832
relay.0xchat.com,1.35208,103.82
wot.utxo.one,43.6532,-79.3832
relay.bitcoinartclock.com,50.4754,12.3683
nostr.zenon.network,43.5009,-70.4428 nostr.zenon.network,43.5009,-70.4428
wot.codingarena.top,50.4754,12.3683 orangesync.tech,50.1109,8.68213
nostr-02.dorafactory.org,1.35208,103.82 nostr.davidebtc.me,51.5072,-0.127586
relay.nostromo.social,49.4543,11.0746 internationalright-wing.org,-22.5022,-48.7114
theoutpost.life,64.1476,-21.9392 nostr.rikmeijer.nl,50.4754,12.3683
strfry.shock.network,41.8959,-88.2169 ynostr.yael.at,60.1699,24.9384
strfry.openhoofd.nl,51.9229,4.40833
itanostr.space,52.2931,4.79099
relay.barine.co,43.6532,-79.3832
nostr.middling.mydns.jp,35.8099,140.12
relay.olas.app,50.4754,12.3683
nostr.girino.org,43.6532,-79.3832
nostr-relay.online,43.6532,-79.3832
relay.evanverma.com,40.8302,-74.1299
relay.angor.io,48.1046,11.6002
relay.nostrdice.com,-33.8688,151.209
ribo.us.nostria.app,41.5868,-93.625
relay01.lnfi.network,39.0997,-94.5786
nostr.namek.link,43.6532,-79.3832
relay.aloftus.io,34.0881,-118.379
ithurtswhenip.ee,51.223,6.78245 ithurtswhenip.ee,51.223,6.78245
ribo.af.nostria.app,-26.2041,28.0473 relay.wellorder.net,45.5201,-122.99
shu05.shugur.net,48.8566,2.35222 nostr.sathoarder.com,48.5734,7.75211
noxir.kpherox.dev,34.8587,135.509 purplerelay.com,50.1109,8.68213
relay.degmods.com,50.4754,12.3683 yabu.me,35.6092,139.73
relay.goodmorningbitcoin.com,43.6532,-79.3832
nostr.4rs.nl,49.0291,8.35696
nostr-relay.psfoundation.info,39.0438,-77.4874
relay.hasenpfeffr.com,39.0438,-77.4874
fanfares.nostr1.com,40.7128,-74.006
relay.nosto.re,51.8933,4.42083
nostr.liberty.fans,36.9104,-89.5875
nostr-2.21crypto.ch,47.4988,8.72369
relay-rpi.edufeed.org,49.4543,11.0746
offchain.pub,36.1809,-115.241
nostr.88mph.life,43.6532,-79.3832 nostr.88mph.life,43.6532,-79.3832
nostr.luisschwab.net,43.6532,-79.3832 nostr.overmind.lol,43.6532,-79.3832
nostr.casa21.space,43.6532,-79.3832 rnostr.breadslice.com,43.6532,-79.3832
nostr.hekster.org,37.3986,-121.964
relay.utxo.farm,35.6916,139.768
zap.watch,45.5029,-73.5723 zap.watch,45.5029,-73.5723
nostr-pub.wellorder.net,45.5201,-122.99 wot.basspistol.org,49.4521,11.0767
wot.sovbit.host,64.1466,-21.9426 shu01.shugur.net,21.4902,39.2246
relay.endfiat.money,43.6532,-79.3832 relay.electriclifestyle.com,26.2897,-80.1293
nostr2.girino.org,43.6532,-79.3832 relay.mccormick.cx,52.3563,4.95714
nostr.jerrynya.fun,31.2304,121.474 nostr.middling.mydns.jp,35.8099,140.12
nostr.spacecitynode.com,29.7057,-95.2706 nostr.smut.cloud,43.6532,-79.3832
satsage.xyz,37.3986,-121.964
srtrelay.c-stellar.net,43.6532,-79.3832
nostr.0x7e.xyz,47.4988,8.72369
shu02.shugur.net,21.4902,39.2246
nostrelites.org,41.8781,-87.6298
relay-admin.thaliyal.com,40.8218,-74.45
wot.soundhsa.com,34.0479,-118.256
nostrcheck.me,43.6532,-79.3832
relay.nostrhub.tech,49.4543,11.0746
relay.stream.labs.h3.se,59.4016,17.9455
nostrelay.memory-art.xyz,43.6532,-79.3832
nostr.n7ekb.net,47.4941,-122.294
relay.nosto.re,51.8933,4.42083
nostr.girino.org,43.6532,-79.3832
relay.siamdev.cc,13.9178,100.424 relay.siamdev.cc,13.9178,100.424
relay.notoshi.win,13.7829,100.546
relay.nostx.io,43.6532,-79.3832
relay.cypherflow.ai,48.8566,2.35222
relay.letsfo.com,51.098,17.0321
librerelay.aaroniumii.com,43.6532,-79.3832
gnostr.com,40.9017,29.1616
nostr.pleb.one,38.6327,-90.1961
nostr.mehdibekhtaoui.com,49.4939,-1.54813 nostr.mehdibekhtaoui.com,49.4939,-1.54813
nostr.tadryanom.me,43.6532,-79.3832 orangepiller.org,60.1699,24.9384
relay.orangepill.ovh,49.1689,-0.358841 nostr.plantroon.com,50.1013,8.62643
nostr.stakey.net,52.3676,4.90414 nostr-verified.wellorder.net,45.5201,-122.99
nostr.rtvslawenia.com,49.4543,11.0746 relay.primal.net,43.6532,-79.3832
relay.bitcoinveneto.org,64.1466,-21.9426
relay.hasenpfeffr.com,39.0438,-77.4874
strfry.openhoofd.nl,51.9229,4.40833
relay.aloftus.io,34.0881,-118.379
nostr.spaceshell.xyz,43.6532,-79.3832
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
ribo.af.nostria.app,-26.2041,28.0473
nostr.tac.lol,47.4748,-122.273
relay.satlantis.io,32.8769,-80.0114
nostr.azzamo.net,52.2633,21.0283
strfry.bonsai.com,37.8715,-122.273
relay.agora.social,50.7383,15.0648
nostr-relay.amethyst.name,39.0067,-77.4291
relay.toastr.net,40.8054,-74.0241
nostr.thebiglake.org,32.71,-96.6745
nostr-relay.nextblockvending.com,47.674,-122.122
vitor.nostr1.com,40.7057,-74.0136
relay.btcforplebs.com,43.6532,-79.3832
relay.g1sms.fr,43.9432,2.07537
nostr.jfischer.org,49.0291,8.35696
nostr.mikoshi.de,52.52,13.405
relay.notoshi.win,13.7829,100.546
pyramid.fiatjaf.com,50.1109,8.68213
relay.coinos.io,43.6532,-79.3832
relay.freeplace.nl,52.3676,4.90414
nostr-relay.psfoundation.info,39.0438,-77.4874
relay.copylaradio.com,51.223,6.78245
relay.exit.pub,50.4754,12.3683
freelay.sovbit.host,64.1476,-21.9392
nostr.satstralia.com,64.1476,-21.9392
nostr.l484.com,30.2944,-97.6223
nostr.rblb.it,43.4633,11.8796
nostr.2b9t.xyz,34.0549,-118.243
nostr.dlsouza.lol,50.1109,8.68213
strfry.shock.network,41.8959,-88.2169
offchain.pub,36.1809,-115.241
nostr-01.yakihonne.com,1.32123,103.695
nostr.kungfu-g.rip,33.7946,-84.4488
relay.letsfo.com,51.098,17.0321
relay.lifpay.me,1.35208,103.82
relay.damus.io,43.6532,-79.3832 relay.damus.io,43.6532,-79.3832
dev-relay.lnfi.network,39.0997,-94.5786 relay2.angor.io,48.1046,11.6002
slick.mjex.me,39.048,-77.4817 relayrs.notoshi.win,43.6532,-79.3832
wot.sudocarlos.com,51.5072,-0.127586 relay2.ngengine.org,43.6532,-79.3832
relay04.lnfi.network,39.0997,-94.5786 portal-relay.pareto.space,49.4543,11.0746
inbox.azzamo.net,52.2633,21.0283
nostr-dev.wellorder.net,45.5201,-122.99
nostr.stakey.net,52.3676,4.90414
relay.13room.space,43.6532,-79.3832 relay.13room.space,43.6532,-79.3832
relay.conduit.market,43.6532,-79.3832 relay.fountain.fm,39.0997,-94.5786
black.nostrcity.club,41.8781,-87.6298
nostr-2.21crypto.ch,47.4988,8.72369
dev-nostr.bityacht.io,25.0797,121.234
santo.iguanatech.net,40.8302,-74.1299
relay.angor.io,48.1046,11.6002
relay.tagayasu.xyz,43.6715,-79.38
relay.npubhaus.com,43.6532,-79.3832
relay01.lnfi.network,39.0997,-94.5786
nostr.myshosholoza.co.za,52.3676,4.90414
relay02.lnfi.network,39.0997,-94.5786
gnostr.com,40.9017,29.1616
nostr.sagaciousd.com,49.2827,-123.121
nostr.night7.space,50.4754,12.3683
schnorr.me,43.6532,-79.3832
nostr.blankfors.se,60.1699,24.9384
relay.mostro.network,40.8302,-74.1299
purpura.cloud,43.6532,-79.3832
ribo.eu.nostria.app,52.3676,4.90414
vidono.apps.slidestr.net,48.8566,2.35222
wheat.happytavern.co,43.6532,-79.3832
nostr.faultables.net,43.6532,-79.3832
relay5.bitransfer.org,43.6532,-79.3832
relay.nostrhub.fr,48.1046,11.6002
nostr.thaliyal.com,40.8218,-74.45
relay.holzeis.me,43.6532,-79.3832
relay.nostriot.com,41.5695,-83.9786
nostr.openhoofd.nl,51.9229,4.40833
relay.nostr.vet,52.6467,4.7395
nostr.camalolo.com,24.1469,120.684
relay.origin.land,35.6673,139.751
relay.chakany.systems,43.6532,-79.3832
relay.0xchat.com,1.35208,103.82
nostr.mom,50.4754,12.3683
4u2ni0zjbjvni.clorecloud.net,43.6532,-79.3832
prl.plus,55.7623,37.6381
relay.moinsen.com,50.4754,12.3683
nostr-02.czas.top,53.471,9.88208
relay.sigit.io,50.4754,12.3683
relay.nostrcheck.me,43.6532,-79.3832
relay03.lnfi.network,39.0997,-94.5786
relay.sincensura.org,43.6532,-79.3832
nostr.coincards.com,53.5501,-113.469
nostr-03.dorafactory.org,1.35208,103.82
relay.credenso.cafe,43.1149,-80.7228
nostr.fbxl.net,48.3809,-89.2477
relay.bullishbounty.com,43.6532,-79.3832
nos.xmark.cc,50.6924,3.20113
x.kojira.io,43.6532,-79.3832
wot.sovbit.host,64.1466,-21.9426
shu05.shugur.net,48.8566,2.35222
nostr.carroarmato0.be,50.9928,3.26317
relay.cosmicbolt.net,37.3986,-121.964
r.bitcoinhold.net,43.6532,-79.3832
nostr.diakod.com,43.6532,-79.3832
nostr-relay.cbrx.io,43.6532,-79.3832
nostr.coincrowd.fund,39.0438,-77.4874
cyberspace.nostr1.com,40.7128,-74.006
relay.barine.co,43.6532,-79.3832
relay.orangepill.ovh,49.1689,-0.358841
no.str.cr,9.92857,-84.0528
nostr.casa21.space,43.6532,-79.3832
relay.mwaters.net,50.9871,2.12554
relay.magiccity.live,25.8128,-80.2377
relayone.soundhsa.com,34.0479,-118.256
slick.mjex.me,39.048,-77.4817
relay.utxo.farm,35.6916,139.768
theoutpost.life,64.1476,-21.9392
nostr.hekster.org,37.3986,-121.964
strfry.felixzieger.de,50.1013,8.62643
relay.mess.ch,47.3591,8.55292
wot.codingarena.top,50.4754,12.3683
nostrelay.circum.space,51.2217,6.77616
nostr-relay.online,43.6532,-79.3832
temp.iris.to,43.6532,-79.3832
wot.dergigi.com,64.1476,-21.9392
wot.brightbolt.net,47.6735,-116.781
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
wot.nostr.place,30.2672,-97.7431
ribo.us.nostria.app,41.5868,-93.625
relay.nostr.net,50.4754,12.3683
nostr-02.dorafactory.org,1.35208,103.82
relay.tapestry.ninja,40.8054,-74.0241
adre.su,59.9311,30.3609
librerelay.aaroniumii.com,43.6532,-79.3832
nostr-pub.wellorder.net,45.5201,-122.99
kitchen.zap.cooking,43.6532,-79.3832
nostr.21crypto.ch,47.4988,8.72369
nostr-02.yakihonne.com,1.32123,103.695
relay.javi.space,43.4633,11.8796
nostr.ser1.net,12.9716,77.5946
relay-rpi.edufeed.org,49.4543,11.0746
premium.primal.net,43.6532,-79.3832
relay.degmods.com,50.4754,12.3683
relay.arx-ccn.com,50.4754,12.3683
nostr.chaima.info,51.223,6.78245 nostr.chaima.info,51.223,6.78245
relay.illuminodes.com,47.6061,-122.333
relay.nostx.io,43.6532,-79.3832
relay.puresignal.news,43.6532,-79.3832
fenrir-s.notoshi.win,43.6532,-79.3832
relay.getsafebox.app,43.6532,-79.3832
relay.conduit.market,43.6532,-79.3832
relay.jeffg.fyi,43.6532,-79.3832
nproxy.kristapsk.lv,60.1699,24.9384
relay.olas.app,50.4754,12.3683
relay.dwadziesciajeden.pl,52.2297,21.0122
relay-testnet.k8s.layer3.news,37.3387,-121.885
nostr.pleb.one,38.6327,-90.1961
relay.digitalezukunft.cyou,45.5019,-73.5674
relay.evanverma.com,40.8302,-74.1299
wot.dtonon.com,43.6532,-79.3832
relay.seq1.net,43.6532,-79.3832
nostr.kalf.org,52.3676,4.90414
nostr.snowbla.de,60.1699,24.9384
nostr.spicyz.io,43.6532,-79.3832
nostr-relay.zimage.com,34.282,-118.439
nostr.spacecitynode.com,29.7057,-95.2706
dev-relay.lnfi.network,39.0997,-94.5786
itanostr.space,52.2931,4.79099
1 Relay URL Latitude Longitude
2 nostr.tac.lol relay.laantungir.net 47.4748 -19.4692 -122.273 -42.5315
3 relay.javi.space relay.endfiat.money 43.4633 43.6532 11.8796 -79.3832
nostr.einundzwanzig.space 50.1109 8.68213
nostr.kalf.org 52.3676 4.90414
wot.dtonon.com 43.6532 -79.3832
nostr-01.yakihonne.com 1.32123 103.695
wot.basspistol.org 49.4521 11.0767
relay.puresignal.news 43.6532 -79.3832
nostrelites.org 41.8781 -87.6298
nostr-relay.nextblockvending.com 47.674 -122.122
relay03.lnfi.network 39.0997 -94.5786
nproxy.kristapsk.lv 60.1699 24.9384
relay.toastr.net 40.8054 -74.0241
relay.davidebtc.me 51.5072 -0.127586
wot.nostr.net 43.6532 -79.3832
relay.mattybs.lol 43.6532 -79.3832
4 relay.zone667.com 60.1699 24.9384
nostr.kungfu-g.rip 33.7946 -84.4488
relay.mccormick.cx 52.3563 4.95714
relay.dwadziesciajeden.pl 52.2297 21.0122
nostr.data.haus 50.4754 12.3683
vitor.nostr1.com 40.7128 -74.006
purpura.cloud 43.6532 -79.3832
relay2.angor.io 48.1046 11.6002
nos.lol 50.4754 12.3683
nostr.rohoss.com 50.1109 8.68213
strfry.bonsai.com 37.8715 -122.273
relay.fountain.fm 39.0997 -94.5786
relay.npubhaus.com 43.6532 -79.3832
relay.nostr.wirednet.jp 34.706 135.493
soloco.nl 43.6532 -79.3832
shu01.shugur.net 21.4902 39.2246
nostr.davidebtc.me 51.5072 -0.127586
pyramid.fiatjaf.com 50.1109 8.68213
relay.ditto.pub 43.6532 -79.3832
relay.nostr.vet 52.6467 4.7395
relay.wavlake.com 41.2619 -95.8608
ribo.eu.nostria.app 52.3676 4.90414
relay.ngengine.org 43.6532 -79.3832
relay.bitcoinveneto.org 64.1466 -21.9426
no.str.cr 9.92857 -84.0528
relay.primal.net 43.6532 -79.3832
ynostr.yael.at 60.1699 24.9384
nostr.camalolo.com 24.1469 120.684
purplerelay.com 50.1109 8.68213
nostr-rs-relay-ishosta.phamthanh.me 43.6532 -79.3832
relay.internationalright-wing.org -22.5022 -48.7114
wheat.happytavern.co 43.6532 -79.3832
nostr.lostr.space 43.6532 -79.3832
relay.tagayasu.xyz 43.6715 -79.38
relay.varke.eu 52.6921 6.19372
free.relayted.de 50.1109 8.68213
nostr.thebiglake.org 32.71 -96.6745
nostr.lojong.info 43.6532 -79.3832
nostr.now 36.55 139.733
relay.jmoose.rocks 60.1699 24.9384
relay.holzeis.me 43.6532 -79.3832
nostr.roundrockbitcoiners.com 40.8054 -74.0241
nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
relay2.ngengine.org 43.6532 -79.3832
nostr.snowbla.de 60.1699 24.9384
4u2ni0zjbjvni.clorecloud.net 43.6532 -79.3832
5 shu04.shugur.net 25.2604 55.2989
6 relay.fr13nd5.com relay.bitcoinartclock.com 52.5233 50.4754 13.3426 12.3683
7 nostr.vulpem.com relay.nostromo.social 49.4543 11.0746
8 temp.iris.to nostr.liberty.fans 43.6532 36.9104 -79.3832 -89.5875
x.kojira.io 43.6532 -79.3832
adre.su 59.9311 30.3609
nostr-dev.wellorder.net 45.5201 -122.99
nostr.mom 50.4754 12.3683
relay.nostr.place 32.7767 -96.797
wot.nostr.place 30.2672 -97.7431
nostr.carroarmato0.be 50.9928 3.26317
nostrelay.circum.space 51.2217 6.77616
relay.chorus.community 50.1109 8.68213
relay.nostr.net 50.4754 12.3683
relay.nostr-check.me 43.6532 -79.3832
relay.nostrhub.fr 48.1046 11.6002
relay.nostraddress.com 43.6532 -79.3832
nostr.rblb.it 43.4633 11.8796
nostr.red5d.dev 43.6532 -79.3832
santo.iguanatech.net 40.8302 -74.1299
relay02.lnfi.network 39.0997 -94.5786
relay.21e6.cz 50.1682 14.0546
a.nos.lol 50.4754 12.3683
shu02.shugur.net 21.4902 39.2246
schnorr.me 43.6532 -79.3832
nostr.n7ekb.net 47.4941 -122.294
wot.shaving.kiwi 43.6532 -79.3832
dev-nostr.bityacht.io 25.0797 121.234
relay.credenso.cafe 43.1149 -80.7228
relay-testnet.k8s.layer3.news 37.3387 -121.885
relay.mess.ch 47.3591 8.55292
inbox.azzamo.net 52.2633 21.0283
prl.plus 55.7623 37.6381
yabu.me 35.6092 139.73
relayrs.notoshi.win 43.6532 -79.3832
premium.primal.net 43.6532 -79.3832
nostr.coincrowd.fund 39.0438 -77.4874
nostr.2b9t.xyz 34.0549 -118.243
nostr.thaliyal.com 40.8218 -74.45
relay.exit.pub 50.4754 12.3683
nostr.jfischer.org 49.0291 8.35696
relay.origin.land 35.6673 139.751
nostr.myshosholoza.co.za 52.3676 4.90414
relay.nostriot.com 41.5695 -83.9786
relay.btcforplebs.com 43.6532 -79.3832
relay.chakany.systems 43.6532 -79.3832
nostr.openhoofd.nl 51.9229 4.40833
nostrcheck.me 43.6532 -79.3832
nostr.plantroon.com 50.1013 8.62643
satsage.xyz 37.3986 -121.964
nostr.faultables.net 43.6532 -79.3832
nostr.calitabby.net 39.9268 -75.0246
relay.freeplace.nl 52.3676 4.90414
relay.nostrhub.tech 49.4543 11.0746
9 roles-az-achieving-somebody.trycloudflare.com 43.6532 -79.3832
10 relay.arx-ccn.com nostr-rs-relay.dev.fedibtc.com 50.4754 39.0438 12.3683 -77.4874
11 cyberspace.nostr1.com relay.nostr.wirednet.jp 40.7128 34.706 -74.006 135.493
12 nostr.smut.cloud nostr.einundzwanzig.space 43.6532 50.1109 -79.3832 8.68213
13 nostr-02.czas.top relay.21e6.cz 53.471 50.1682 9.88208 14.0546
14 relay.tapestry.ninja relay04.lnfi.network 40.8054 39.0997 -74.0241 -94.5786
15 relay.mostro.network relay.chorus.community 40.8302 50.1109 -74.1299 8.68213
16 wot.brightbolt.net relay.nostr.place 47.6735 32.7767 -116.781 -96.797
17 nostr.spaceshell.xyz relay.vrtmrz.net 43.6532 -79.3832
18 nostr.rikmeijer.nl noxir.kpherox.dev 50.4754 34.8587 12.3683 135.509
19 relay.artx.market wot.nostr.net 43.652 43.6532 -79.3633 -79.3832
20 strfry.felixzieger.de relay.cypherflow.ai 50.1013 48.8566 8.62643 2.35222
21 relay.seq1.net wot.sudocarlos.com 43.6532 51.5072 -79.3832 -0.127586
22 relay.cosmicbolt.net nostr.jerrynya.fun 37.3986 31.2304 -121.964 121.474
23 relay.electriclifestyle.com nostr2.girino.org 26.2897 43.6532 -80.1293 -79.3832
24 r.bitcoinhold.net nostrings-relay-dev.fly.dev 43.6532 41.8781 -79.3832 -87.6298
25 nostr-relay.amethyst.name fanfares.nostr1.com 39.0067 40.7128 -77.4291 -74.006
26 relay.stream.labs.h3.se nostr.red5d.dev 59.4016 43.6532 17.9455 -79.3832
27 relay.unknown.cloud nostr.hifish.org 43.6532 47.4043 -79.3832 8.57398
28 nostr-02.yakihonne.com nostr.now 1.32123 36.55 103.695 139.733
29 relay.coinos.io relay.nostr.band 43.6532 60.1699 -79.3832 24.9384
30 relay5.bitransfer.org relay.wavlake.com 43.6532 41.2619 -79.3832 -95.8608
31 nostr.bilthon.dev 25.8128 -80.2377
32 khatru.nostrver.se 51.8933 4.42083
33 relay.bitcoindistrict.org 43.6532 -79.3832
34 nostr.makibisskey.work 43.6532 -79.3832
35 relay.nostraddress.com 43.6532 -79.3832
36 relay.jmoose.rocks 60.1699 24.9384
37 relay.davidebtc.me 51.5072 -0.127586
38 a.nos.lol 50.4754 12.3683
39 nostr.tadryanom.me 43.6532 -79.3832
40 relay.nostrdice.com -33.8688 151.209
41 relay.lumina.rocks 49.0291 8.35695
42 relay.goodmorningbitcoin.com 43.6532 -79.3832
43 nostr.rtvslawenia.com 49.4543 11.0746
44 relay.mattybs.lol 43.6532 -79.3832
45 relay-dev.satlantis.io 40.8302 -74.1299
46 nostream.breadslice.com 43.6532 -79.3832
47 relay.fundstr.me nostr.vulpem.com 42.3601 49.4543 -71.0589 11.0746
48 nostr.oxtr.dev nostr.rohoss.com 50.4754 50.1109 12.3683 8.68213
nos.xmark.cc 50.6924 3.20113
nostr.mikoshi.de 50.1109 8.68213
relay.magiccity.live 25.8128 -80.2377
nostr-verified.wellorder.net 45.5201 -122.99
nostr.makibisskey.work 43.6532 -79.3832
wot.nostr.party 36.1627 -86.7816
relay.copylaradio.com 51.223 6.78245
nostr.sathoarder.com 48.5734 7.75211
relay.jeffg.fyi 43.6532 -79.3832
relay.wellorder.net 45.5201 -122.99
nostr.ovia.to 43.6532 -79.3832
black.nostrcity.club 41.8781 -87.6298
relay.nostrcheck.me 43.6532 -79.3832
nostr-relay.cbrx.io 43.6532 -79.3832
nostr-03.dorafactory.org 1.35208 103.82
nostr-relay.zimage.com 34.282 -118.439
relay.sigit.io 50.4754 12.3683
relay.laantungir.net -19.4692 -42.5315
relay-admin.thaliyal.com 40.8218 -74.45
relay.mwaters.net 50.9871 2.12554
relay.lumina.rocks 49.0291 8.35695
nostr.satstralia.com 64.1476 -21.9392
nostr.sagaciousd.com 49.2827 -123.121
relay.bullishbounty.com 43.6532 -79.3832
wot.dergigi.com 64.1476 -21.9392
relay.vrtmrz.net 43.6532 -79.3832
nostr.0x7e.xyz 47.4988 8.72369
49 articles.layer3.news 37.3387 -121.885
50 relay.digitalezukunft.cyou nos.lol 45.5019 50.4754 -73.5674 12.3683
51 nostr.notribe.net relay.artx.market 40.8302 43.652 -74.1299 -79.3633
52 nostr.21crypto.ch wot.sebastix.social 47.4988 51.8933 8.72369 4.42083
relay.lifpay.me 1.35208 103.82
relay.g1sms.fr 43.9432 2.07537
relay.snort.social 43.6532 -79.3832
relay.getsafebox.app 43.6532 -79.3832
relay.moinsen.com 50.4754 12.3683
nostr.blankfors.se 60.1699 24.9384
nostr-relay-1.trustlessenterprise.com 43.6532 -79.3832
orangesync.tech 50.1109 8.68213
relay.ru.ac.th 13.7584 100.622
orangepiller.org 60.1699 24.9384
relay.nostr.band 60.1699 24.9384
wot.soundhsa.com 34.0479 -118.256
khatru.nostrver.se 51.8933 4.42083
nostr.hifish.org 47.4043 8.57398
freelay.sovbit.host 64.1476 -21.9392
nostr.bilthon.dev 25.8128 -80.2377
nostr.night7.space 50.4754 12.3683
relay.illuminodes.com 47.6061 -122.333
relayone.soundhsa.com 34.0479 -118.256
nostr.azzamo.net 52.2633 21.0283
fenrir-s.notoshi.win 43.6532 -79.3832
nostr.spicyz.io 43.6532 -79.3832
nostrelay.memory-art.xyz 43.6532 -79.3832
nostr.coincards.com 53.5501 -113.469
53 alien.macneilmediagroup.com 43.6532 -79.3832
54 nostrings-relay-dev.fly.dev relay.unknown.cloud 41.8781 43.6532 -87.6298 -79.3832
55 relay.satlantis.io nostr.lojong.info 32.8769 43.6532 -80.0114 -79.3832
srtrelay.c-stellar.net 43.6532 -79.3832
relay.agora.social 50.7383 15.0648
relay.bitcoindistrict.org 43.6532 -79.3832
relay.0xchat.com 1.35208 103.82
wot.utxo.one 43.6532 -79.3832
relay.bitcoinartclock.com 50.4754 12.3683
56 nostr.zenon.network 43.5009 -70.4428
57 wot.codingarena.top orangesync.tech 50.4754 50.1109 12.3683 8.68213
58 nostr-02.dorafactory.org nostr.davidebtc.me 1.35208 51.5072 103.82 -0.127586
59 relay.nostromo.social internationalright-wing.org 49.4543 -22.5022 11.0746 -48.7114
60 theoutpost.life nostr.rikmeijer.nl 64.1476 50.4754 -21.9392 12.3683
61 strfry.shock.network ynostr.yael.at 41.8959 60.1699 -88.2169 24.9384
strfry.openhoofd.nl 51.9229 4.40833
itanostr.space 52.2931 4.79099
relay.barine.co 43.6532 -79.3832
nostr.middling.mydns.jp 35.8099 140.12
relay.olas.app 50.4754 12.3683
nostr.girino.org 43.6532 -79.3832
nostr-relay.online 43.6532 -79.3832
relay.evanverma.com 40.8302 -74.1299
relay.angor.io 48.1046 11.6002
relay.nostrdice.com -33.8688 151.209
ribo.us.nostria.app 41.5868 -93.625
relay01.lnfi.network 39.0997 -94.5786
nostr.namek.link 43.6532 -79.3832
relay.aloftus.io 34.0881 -118.379
62 ithurtswhenip.ee 51.223 6.78245
63 ribo.af.nostria.app relay.wellorder.net -26.2041 45.5201 28.0473 -122.99
64 shu05.shugur.net nostr.sathoarder.com 48.8566 48.5734 2.35222 7.75211
65 noxir.kpherox.dev purplerelay.com 34.8587 50.1109 135.509 8.68213
66 relay.degmods.com yabu.me 50.4754 35.6092 12.3683 139.73
relay.goodmorningbitcoin.com 43.6532 -79.3832
nostr.4rs.nl 49.0291 8.35696
nostr-relay.psfoundation.info 39.0438 -77.4874
relay.hasenpfeffr.com 39.0438 -77.4874
fanfares.nostr1.com 40.7128 -74.006
relay.nosto.re 51.8933 4.42083
nostr.liberty.fans 36.9104 -89.5875
nostr-2.21crypto.ch 47.4988 8.72369
relay-rpi.edufeed.org 49.4543 11.0746
offchain.pub 36.1809 -115.241
67 nostr.88mph.life 43.6532 -79.3832
68 nostr.luisschwab.net nostr.overmind.lol 43.6532 -79.3832
69 nostr.casa21.space rnostr.breadslice.com 43.6532 -79.3832
nostr.hekster.org 37.3986 -121.964
relay.utxo.farm 35.6916 139.768
70 zap.watch 45.5029 -73.5723
71 nostr-pub.wellorder.net wot.basspistol.org 45.5201 49.4521 -122.99 11.0767
72 wot.sovbit.host shu01.shugur.net 64.1466 21.4902 -21.9426 39.2246
73 relay.endfiat.money relay.electriclifestyle.com 43.6532 26.2897 -79.3832 -80.1293
74 nostr2.girino.org relay.mccormick.cx 43.6532 52.3563 -79.3832 4.95714
75 nostr.jerrynya.fun nostr.middling.mydns.jp 31.2304 35.8099 121.474 140.12
76 nostr.spacecitynode.com nostr.smut.cloud 29.7057 43.6532 -95.2706 -79.3832
77 satsage.xyz 37.3986 -121.964
78 srtrelay.c-stellar.net 43.6532 -79.3832
79 nostr.0x7e.xyz 47.4988 8.72369
80 shu02.shugur.net 21.4902 39.2246
81 nostrelites.org 41.8781 -87.6298
82 relay-admin.thaliyal.com 40.8218 -74.45
83 wot.soundhsa.com 34.0479 -118.256
84 nostrcheck.me 43.6532 -79.3832
85 relay.nostrhub.tech 49.4543 11.0746
86 relay.stream.labs.h3.se 59.4016 17.9455
87 nostrelay.memory-art.xyz 43.6532 -79.3832
88 nostr.n7ekb.net 47.4941 -122.294
89 relay.nosto.re 51.8933 4.42083
90 nostr.girino.org 43.6532 -79.3832
91 relay.siamdev.cc 13.9178 100.424
relay.notoshi.win 13.7829 100.546
relay.nostx.io 43.6532 -79.3832
relay.cypherflow.ai 48.8566 2.35222
relay.letsfo.com 51.098 17.0321
librerelay.aaroniumii.com 43.6532 -79.3832
gnostr.com 40.9017 29.1616
nostr.pleb.one 38.6327 -90.1961
92 nostr.mehdibekhtaoui.com 49.4939 -1.54813
93 nostr.tadryanom.me orangepiller.org 43.6532 60.1699 -79.3832 24.9384
94 relay.orangepill.ovh nostr.plantroon.com 49.1689 50.1013 -0.358841 8.62643
95 nostr.stakey.net nostr-verified.wellorder.net 52.3676 45.5201 4.90414 -122.99
96 nostr.rtvslawenia.com relay.primal.net 49.4543 43.6532 11.0746 -79.3832
97 relay.bitcoinveneto.org 64.1466 -21.9426
98 relay.hasenpfeffr.com 39.0438 -77.4874
99 strfry.openhoofd.nl 51.9229 4.40833
100 relay.aloftus.io 34.0881 -118.379
101 nostr.spaceshell.xyz 43.6532 -79.3832
102 nostr-relay-1.trustlessenterprise.com 43.6532 -79.3832
103 ribo.af.nostria.app -26.2041 28.0473
104 nostr.tac.lol 47.4748 -122.273
105 relay.satlantis.io 32.8769 -80.0114
106 nostr.azzamo.net 52.2633 21.0283
107 strfry.bonsai.com 37.8715 -122.273
108 relay.agora.social 50.7383 15.0648
109 nostr-relay.amethyst.name 39.0067 -77.4291
110 relay.toastr.net 40.8054 -74.0241
111 nostr.thebiglake.org 32.71 -96.6745
112 nostr-relay.nextblockvending.com 47.674 -122.122
113 vitor.nostr1.com 40.7057 -74.0136
114 relay.btcforplebs.com 43.6532 -79.3832
115 relay.g1sms.fr 43.9432 2.07537
116 nostr.jfischer.org 49.0291 8.35696
117 nostr.mikoshi.de 52.52 13.405
118 relay.notoshi.win 13.7829 100.546
119 pyramid.fiatjaf.com 50.1109 8.68213
120 relay.coinos.io 43.6532 -79.3832
121 relay.freeplace.nl 52.3676 4.90414
122 nostr-relay.psfoundation.info 39.0438 -77.4874
123 relay.copylaradio.com 51.223 6.78245
124 relay.exit.pub 50.4754 12.3683
125 freelay.sovbit.host 64.1476 -21.9392
126 nostr.satstralia.com 64.1476 -21.9392
127 nostr.l484.com 30.2944 -97.6223
128 nostr.rblb.it 43.4633 11.8796
129 nostr.2b9t.xyz 34.0549 -118.243
130 nostr.dlsouza.lol 50.1109 8.68213
131 strfry.shock.network 41.8959 -88.2169
132 offchain.pub 36.1809 -115.241
133 nostr-01.yakihonne.com 1.32123 103.695
134 nostr.kungfu-g.rip 33.7946 -84.4488
135 relay.letsfo.com 51.098 17.0321
136 relay.lifpay.me 1.35208 103.82
137 relay.damus.io 43.6532 -79.3832
138 dev-relay.lnfi.network relay2.angor.io 39.0997 48.1046 -94.5786 11.6002
139 slick.mjex.me relayrs.notoshi.win 39.048 43.6532 -77.4817 -79.3832
140 wot.sudocarlos.com relay2.ngengine.org 51.5072 43.6532 -0.127586 -79.3832
141 relay04.lnfi.network portal-relay.pareto.space 39.0997 49.4543 -94.5786 11.0746
142 inbox.azzamo.net 52.2633 21.0283
143 nostr-dev.wellorder.net 45.5201 -122.99
144 nostr.stakey.net 52.3676 4.90414
145 relay.13room.space 43.6532 -79.3832
146 relay.conduit.market relay.fountain.fm 43.6532 39.0997 -79.3832 -94.5786
147 black.nostrcity.club 41.8781 -87.6298
148 nostr-2.21crypto.ch 47.4988 8.72369
149 dev-nostr.bityacht.io 25.0797 121.234
150 santo.iguanatech.net 40.8302 -74.1299
151 relay.angor.io 48.1046 11.6002
152 relay.tagayasu.xyz 43.6715 -79.38
153 relay.npubhaus.com 43.6532 -79.3832
154 relay01.lnfi.network 39.0997 -94.5786
155 nostr.myshosholoza.co.za 52.3676 4.90414
156 relay02.lnfi.network 39.0997 -94.5786
157 gnostr.com 40.9017 29.1616
158 nostr.sagaciousd.com 49.2827 -123.121
159 nostr.night7.space 50.4754 12.3683
160 schnorr.me 43.6532 -79.3832
161 nostr.blankfors.se 60.1699 24.9384
162 relay.mostro.network 40.8302 -74.1299
163 purpura.cloud 43.6532 -79.3832
164 ribo.eu.nostria.app 52.3676 4.90414
165 vidono.apps.slidestr.net 48.8566 2.35222
166 wheat.happytavern.co 43.6532 -79.3832
167 nostr.faultables.net 43.6532 -79.3832
168 relay5.bitransfer.org 43.6532 -79.3832
169 relay.nostrhub.fr 48.1046 11.6002
170 nostr.thaliyal.com 40.8218 -74.45
171 relay.holzeis.me 43.6532 -79.3832
172 relay.nostriot.com 41.5695 -83.9786
173 nostr.openhoofd.nl 51.9229 4.40833
174 relay.nostr.vet 52.6467 4.7395
175 nostr.camalolo.com 24.1469 120.684
176 relay.origin.land 35.6673 139.751
177 relay.chakany.systems 43.6532 -79.3832
178 relay.0xchat.com 1.35208 103.82
179 nostr.mom 50.4754 12.3683
180 4u2ni0zjbjvni.clorecloud.net 43.6532 -79.3832
181 prl.plus 55.7623 37.6381
182 relay.moinsen.com 50.4754 12.3683
183 nostr-02.czas.top 53.471 9.88208
184 relay.sigit.io 50.4754 12.3683
185 relay.nostrcheck.me 43.6532 -79.3832
186 relay03.lnfi.network 39.0997 -94.5786
187 relay.sincensura.org 43.6532 -79.3832
188 nostr.coincards.com 53.5501 -113.469
189 nostr-03.dorafactory.org 1.35208 103.82
190 relay.credenso.cafe 43.1149 -80.7228
191 nostr.fbxl.net 48.3809 -89.2477
192 relay.bullishbounty.com 43.6532 -79.3832
193 nos.xmark.cc 50.6924 3.20113
194 x.kojira.io 43.6532 -79.3832
195 wot.sovbit.host 64.1466 -21.9426
196 shu05.shugur.net 48.8566 2.35222
197 nostr.carroarmato0.be 50.9928 3.26317
198 relay.cosmicbolt.net 37.3986 -121.964
199 r.bitcoinhold.net 43.6532 -79.3832
200 nostr.diakod.com 43.6532 -79.3832
201 nostr-relay.cbrx.io 43.6532 -79.3832
202 nostr.coincrowd.fund 39.0438 -77.4874
203 cyberspace.nostr1.com 40.7128 -74.006
204 relay.barine.co 43.6532 -79.3832
205 relay.orangepill.ovh 49.1689 -0.358841
206 no.str.cr 9.92857 -84.0528
207 nostr.casa21.space 43.6532 -79.3832
208 relay.mwaters.net 50.9871 2.12554
209 relay.magiccity.live 25.8128 -80.2377
210 relayone.soundhsa.com 34.0479 -118.256
211 slick.mjex.me 39.048 -77.4817
212 relay.utxo.farm 35.6916 139.768
213 theoutpost.life 64.1476 -21.9392
214 nostr.hekster.org 37.3986 -121.964
215 strfry.felixzieger.de 50.1013 8.62643
216 relay.mess.ch 47.3591 8.55292
217 wot.codingarena.top 50.4754 12.3683
218 nostrelay.circum.space 51.2217 6.77616
219 nostr-relay.online 43.6532 -79.3832
220 temp.iris.to 43.6532 -79.3832
221 wot.dergigi.com 64.1476 -21.9392
222 wot.brightbolt.net 47.6735 -116.781
223 nostr-rs-relay-ishosta.phamthanh.me 43.6532 -79.3832
224 wot.nostr.place 30.2672 -97.7431
225 ribo.us.nostria.app 41.5868 -93.625
226 relay.nostr.net 50.4754 12.3683
227 nostr-02.dorafactory.org 1.35208 103.82
228 relay.tapestry.ninja 40.8054 -74.0241
229 adre.su 59.9311 30.3609
230 librerelay.aaroniumii.com 43.6532 -79.3832
231 nostr-pub.wellorder.net 45.5201 -122.99
232 kitchen.zap.cooking 43.6532 -79.3832
233 nostr.21crypto.ch 47.4988 8.72369
234 nostr-02.yakihonne.com 1.32123 103.695
235 relay.javi.space 43.4633 11.8796
236 nostr.ser1.net 12.9716 77.5946
237 relay-rpi.edufeed.org 49.4543 11.0746
238 premium.primal.net 43.6532 -79.3832
239 relay.degmods.com 50.4754 12.3683
240 relay.arx-ccn.com 50.4754 12.3683
241 nostr.chaima.info 51.223 6.78245
242 relay.illuminodes.com 47.6061 -122.333
243 relay.nostx.io 43.6532 -79.3832
244 relay.puresignal.news 43.6532 -79.3832
245 fenrir-s.notoshi.win 43.6532 -79.3832
246 relay.getsafebox.app 43.6532 -79.3832
247 relay.conduit.market 43.6532 -79.3832
248 relay.jeffg.fyi 43.6532 -79.3832
249 nproxy.kristapsk.lv 60.1699 24.9384
250 relay.olas.app 50.4754 12.3683
251 relay.dwadziesciajeden.pl 52.2297 21.0122
252 relay-testnet.k8s.layer3.news 37.3387 -121.885
253 nostr.pleb.one 38.6327 -90.1961
254 relay.digitalezukunft.cyou 45.5019 -73.5674
255 relay.evanverma.com 40.8302 -74.1299
256 wot.dtonon.com 43.6532 -79.3832
257 relay.seq1.net 43.6532 -79.3832
258 nostr.kalf.org 52.3676 4.90414
259 nostr.snowbla.de 60.1699 24.9384
260 nostr.spicyz.io 43.6532 -79.3832
261 nostr-relay.zimage.com 34.282 -118.439
262 nostr.spacecitynode.com 29.7057 -95.2706
263 dev-relay.lnfi.network 39.0997 -94.5786
264 itanostr.space 52.2931 4.79099