mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 11:25:19 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
991def1deb | ||
|
|
ac7feb380e | ||
|
|
57c5d81981 | ||
|
|
fa04e801b6 | ||
|
|
3c21783eae | ||
|
|
b196a55c37 | ||
|
|
ade700e7f0 | ||
|
|
ea7b90c074 | ||
|
|
5486fc6a69 | ||
|
|
7415039183 | ||
|
|
7b73728f1d | ||
|
|
1ea90295ce | ||
|
|
c9fac83433 | ||
|
|
5b799e1a50 |
@@ -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,4 +1,4 @@
|
||||
MARKETING_VERSION = 1.4.2
|
||||
MARKETING_VERSION = 1.4.0
|
||||
CURRENT_PROJECT_VERSION = 1
|
||||
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||
|
||||
@@ -14,6 +14,7 @@ default:
|
||||
# Check prerequisites
|
||||
check:
|
||||
@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)
|
||||
@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"
|
||||
@@ -21,6 +22,8 @@ check:
|
||||
# Backup original files
|
||||
backup:
|
||||
@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/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
|
||||
@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: #check generate
|
||||
build: check generate
|
||||
@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: build
|
||||
@@ -67,7 +75,9 @@ clean: restore
|
||||
# Quick run without cleaning (for development)
|
||||
dev-run: check
|
||||
@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 "{}"
|
||||
|
||||
# Show app info
|
||||
@@ -96,9 +106,11 @@ nuke:
|
||||
@echo "🧨 Nuclear clean - removing all build artifacts and backups..."
|
||||
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 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/Info.plist.backup 2>/dev/null || true
|
||||
@# Restore iOS-specific files if they were moved
|
||||
@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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
> [!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
|
||||
|
||||
@@ -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)
|
||||
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
||||
- **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
|
||||
- **Universal App**: Native support for iOS and macOS
|
||||
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
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 */; };
|
||||
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 */; };
|
||||
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
|
||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
|
||||
@@ -28,13 +27,6 @@
|
||||
remoteGlobalIDString = AF077EA0474EDEDE2C72716C;
|
||||
remoteInfo = bitchat_iOS;
|
||||
};
|
||||
E35E7AF9854A2E72452DD34F /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 475D96681D0EA0AE57A4E06E /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 57CA17A36A2532A6CFF367BB;
|
||||
remoteInfo = bitchatShareExtension;
|
||||
};
|
||||
FF470234EF8C6BB8865B80B5 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 475D96681D0EA0AE57A4E06E /* Project object */;
|
||||
@@ -50,7 +42,6 @@
|
||||
dstPath = "";
|
||||
dstSubfolder = PlugIns;
|
||||
files = (
|
||||
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in 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; };
|
||||
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>"; };
|
||||
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; };
|
||||
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; };
|
||||
@@ -68,13 +58,6 @@
|
||||
/* End PBXFileReference 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 */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
@@ -91,20 +74,12 @@
|
||||
);
|
||||
target = 0576A29205865664C0937536 /* bitchat_macOS */;
|
||||
};
|
||||
A6E32D232E762EAB0032EA8A /* Exceptions for "bitchatShareExtension" folder in "bitchatShareExtension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
ShareViewController.swift,
|
||||
);
|
||||
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
A6E32C972E762EA70032EA8A /* bitchat */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */,
|
||||
A6E32D1C2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_iOS" target */,
|
||||
A6E32D1D2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_macOS" target */,
|
||||
);
|
||||
@@ -113,9 +88,6 @@
|
||||
};
|
||||
A6E32D212E762EAB0032EA8A /* bitchatShareExtension */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
A6E32D232E762EAB0032EA8A /* Exceptions for "bitchatShareExtension" folder in "bitchatShareExtension" target */,
|
||||
);
|
||||
path = bitchatShareExtension;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -180,7 +152,6 @@
|
||||
children = (
|
||||
96D0D41CA19EE5A772AA8434 /* bitchat.app */,
|
||||
8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */,
|
||||
61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */,
|
||||
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */,
|
||||
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */,
|
||||
);
|
||||
@@ -231,19 +202,6 @@
|
||||
productReference = 03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */;
|
||||
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 */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget "bitchatTests_iOS" */;
|
||||
@@ -274,9 +232,6 @@
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
6EB655BA5DB11909C1DEC460 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
A6E32C972E762EA70032EA8A /* bitchat */,
|
||||
);
|
||||
@@ -315,7 +270,6 @@
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
57CA17A36A2532A6CFF367BB /* bitchatShareExtension */,
|
||||
6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */,
|
||||
47FF23248747DD7CB666CB91 /* bitchatTests_macOS */,
|
||||
AF077EA0474EDEDE2C72716C /* bitchat_iOS */,
|
||||
@@ -340,11 +294,6 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
0A08E70F08F55FD5BA8C7EF3 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
files = (
|
||||
);
|
||||
};
|
||||
137ABE739BF20ACDDF8CC605 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
files = (
|
||||
@@ -373,11 +322,6 @@
|
||||
target = 0576A29205865664C0937536 /* bitchat_macOS */;
|
||||
targetProxy = FF470234EF8C6BB8865B80B5 /* PBXContainerItemProxy */;
|
||||
};
|
||||
6EB655BA5DB11909C1DEC460 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
||||
targetProxy = E35E7AF9854A2E72452DD34F /* PBXContainerItemProxy */;
|
||||
};
|
||||
D8C09F21DB7DC06E8E672C21 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = AF077EA0474EDEDE2C72716C /* bitchat_iOS */;
|
||||
@@ -462,49 +406,18 @@
|
||||
};
|
||||
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" */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
|
||||
baseConfigurationReferenceRelativePath = Debug.xcconfig;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDebug;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||
CODE_SIGNING_ALLOWED = YES;
|
||||
CODE_SIGNING_REQUIRED = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
DEVELOPMENT_TEAM = 66W2RU3J5T;
|
||||
ENABLE_PREVIEWS = NO;
|
||||
INFOPLIST_FILE = bitchat/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||
@@ -516,6 +429,7 @@
|
||||
);
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
"PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = "chat.bitchat-local";
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
@@ -564,7 +478,7 @@
|
||||
CODE_SIGNING_REQUIRED = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
DEVELOPMENT_TEAM = 66W2RU3J5T;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = bitchat/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||
@@ -576,6 +490,7 @@
|
||||
);
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
"PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = "chat.bitchat-local";
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
@@ -600,11 +515,10 @@
|
||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
DEVELOPMENT_TEAM = 66W2RU3J5T;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = bitchat/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
@@ -612,6 +526,7 @@
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
"PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "chat.bitchat-local";
|
||||
PRODUCT_NAME = bitchat;
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SDKROOT = macosx;
|
||||
@@ -684,7 +599,7 @@
|
||||
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
|
||||
baseConfigurationReferenceRelativePath = Debug.xcconfig;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDebug;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||
CODE_SIGNING_ALLOWED = YES;
|
||||
CODE_SIGNING_REQUIRED = YES;
|
||||
@@ -692,11 +607,10 @@
|
||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
DEVELOPMENT_TEAM = 66W2RU3J5T;
|
||||
ENABLE_PREVIEWS = NO;
|
||||
INFOPLIST_FILE = bitchat/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
@@ -704,6 +618,7 @@
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
"PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "chat.bitchat-local";
|
||||
PRODUCT_NAME = bitchat;
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SDKROOT = macosx;
|
||||
@@ -778,37 +693,6 @@
|
||||
};
|
||||
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 */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -852,14 +736,6 @@
|
||||
);
|
||||
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 */
|
||||
|
||||
/* 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 |
@@ -3,4 +3,4 @@
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +158,23 @@ struct IdentityCache: Codable {
|
||||
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
|
||||
|
||||
@@ -33,12 +33,18 @@
|
||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<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>
|
||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>bitchat uses the microphone to record and send short voice notes to nearby peers.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-central</string>
|
||||
|
||||
@@ -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() } }
|
||||
@@ -21,7 +21,20 @@ struct BitchatPacket: Codable {
|
||||
let payload: Data
|
||||
var signature: Data?
|
||||
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) {
|
||||
self.version = 1
|
||||
self.type = type
|
||||
@@ -74,6 +87,7 @@ struct BitchatPacket: Codable {
|
||||
// Create a copy without signature and with fixed TTL for signing
|
||||
// TTL must be excluded because it changes during relay
|
||||
let unsignedPacket = BitchatPacket(
|
||||
version: version, // Preserve packet version for signing
|
||||
type: type,
|
||||
senderID: senderID,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func resetAll() {
|
||||
queue.async(flags: .barrier) {
|
||||
self.handshakeTimestamps.removeAll()
|
||||
self.messageTimestamps.removeAll()
|
||||
self.globalHandshakeTimestamps.removeAll()
|
||||
self.globalMessageTimestamps.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Security Errors
|
||||
|
||||
@@ -308,15 +308,6 @@ final class NoiseSessionManager {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func removeAllSessions() {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
for (_, session) in sessions {
|
||||
session.reset()
|
||||
}
|
||||
sessions.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
func getEstablishedSessions() -> [String: NoiseSession] {
|
||||
return managerQueue.sync {
|
||||
|
||||
@@ -150,31 +150,13 @@ struct NostrIdentityBridge {
|
||||
|
||||
/// Clear all Nostr identity associations and current identity
|
||||
static func clearAllAssociations() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService,
|
||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||
kSecReturnAttributes as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
||||
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
|
||||
// Delete current Nostr identity
|
||||
KeychainHelper.delete(key: currentIdentityKey, service: keychainService)
|
||||
KeychainHelper.delete(key: deviceSeedKey, service: keychainService)
|
||||
|
||||
// Note: We can't efficiently delete all noise-nostr associations
|
||||
// without tracking them, but they'll be orphaned and eventually cleaned up
|
||||
// The important part is deleting the current identity so a new one is generated
|
||||
}
|
||||
|
||||
// MARK: - Per-Geohash Identities (Location Channels)
|
||||
|
||||
@@ -35,14 +35,10 @@ final class NostrRelayManager: ObservableObject {
|
||||
"wss://nostr21.com"
|
||||
// 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 isConnected = false
|
||||
|
||||
private var allowDefaultRelays: Bool = false
|
||||
private var hasMutualFavorites: Bool = false
|
||||
private var hasLocationPermission: Bool = false
|
||||
private var connections: [String: URLSessionWebSocketTask] = [:]
|
||||
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
|
||||
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 encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
private var networkService: NetworkActivationService { NetworkActivationService.shared }
|
||||
private var shouldUseTor: Bool { networkService.userTorEnabled }
|
||||
|
||||
// Exponential backoff configuration
|
||||
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
|
||||
@@ -84,54 +78,28 @@ final class NostrRelayManager: ObservableObject {
|
||||
private var connectionGeneration: Int = 0
|
||||
|
||||
init() {
|
||||
hasMutualFavorites = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
||||
hasLocationPermission = LocationChannelManager.shared.permissionState == .authorized
|
||||
applyDefaultRelayPolicy(force: true)
|
||||
// Initialize with default relays
|
||||
self.relays = Self.defaultRelays.map { Relay(url: $0) }
|
||||
// Deterministic JSON shape for outbound requests
|
||||
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
|
||||
func connect() {
|
||||
// Global network policy gate
|
||||
guard networkService.activationAllowed else { return }
|
||||
if shouldUseTor {
|
||||
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
|
||||
Task.detached {
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
await MainActor.run {
|
||||
if !ready {
|
||||
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
|
||||
for relay in self.relays {
|
||||
self.connectToRelay(relay.url)
|
||||
}
|
||||
if !TorManager.shared.isAutoStartAllowed() { return }
|
||||
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
|
||||
Task.detached {
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
await MainActor.run {
|
||||
if !ready {
|
||||
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
|
||||
for relay in self.relays {
|
||||
self.connectToRelay(relay.url)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (direct)", category: .session)
|
||||
for relay in self.relays {
|
||||
connectToRelay(relay.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,10 +120,8 @@ final class NostrRelayManager: ObservableObject {
|
||||
/// Ensure connections exist to the given relay URLs (idempotent).
|
||||
func ensureConnections(to relayUrls: [String]) {
|
||||
// Global network policy gate
|
||||
guard networkService.activationAllowed else { return }
|
||||
let targets = allowedRelayList(from: relayUrls)
|
||||
guard !targets.isEmpty else { return }
|
||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
if !TorManager.shared.isAutoStartAllowed() { return }
|
||||
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
// Defer until Tor is fully ready; avoid queuing connection attempts early
|
||||
Task.detached { [weak self] in
|
||||
guard let self = self else { return }
|
||||
@@ -164,21 +130,22 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
return
|
||||
}
|
||||
var existing = Set(relays.map { $0.url })
|
||||
for url in targets where !existing.contains(url) {
|
||||
relays.append(Relay(url: url))
|
||||
existing.insert(url)
|
||||
}
|
||||
for url in targets where connections[url] == nil {
|
||||
connectToRelay(url)
|
||||
let existing = Set(relays.map { $0.url })
|
||||
for url in Set(relayUrls) {
|
||||
if !existing.contains(url) {
|
||||
relays.append(Relay(url: url))
|
||||
}
|
||||
if connections[url] == nil {
|
||||
connectToRelay(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send an event to specified relays (or all if none specified)
|
||||
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
||||
// Global network policy gate
|
||||
guard networkService.activationAllowed else { return }
|
||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
if !TorManager.shared.isAutoStartAllowed() { return }
|
||||
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
// Defer sends until Tor is ready to avoid premature queueing
|
||||
Task.detached { [weak self] in
|
||||
guard let self = self else { return }
|
||||
@@ -187,9 +154,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
return
|
||||
}
|
||||
let requestedRelays = relayUrls ?? Self.defaultRelays
|
||||
let targetRelays = allowedRelayList(from: requestedRelays)
|
||||
guard !targetRelays.isEmpty else { return }
|
||||
let targetRelays = relayUrls ?? Self.defaultRelays
|
||||
ensureConnections(to: targetRelays)
|
||||
|
||||
// Attempt immediate send to relays with active connections; queue the rest
|
||||
@@ -255,7 +220,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
onEOSE: (() -> Void)? = nil
|
||||
) {
|
||||
// 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
|
||||
let now = Date()
|
||||
if messageHandlers[id] != nil {
|
||||
@@ -264,7 +229,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
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
|
||||
Task.detached { [weak self] in
|
||||
guard let self = self else { return }
|
||||
@@ -292,37 +257,32 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
// Target specific relays if provided; else default. Filter permanently failed relays.
|
||||
let baseUrls = relayUrls ?? Self.defaultRelays
|
||||
let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) }
|
||||
let urls = allowedRelayList(from: candidateUrls)
|
||||
let urls = baseUrls.filter { !isPermanentlyFailed($0) }
|
||||
// Always queue subscriptions; sending happens when a relay reports connected
|
||||
let existingSet = Set(relays.map { $0.url })
|
||||
for url in urls where !existingSet.contains(url) {
|
||||
relays.append(Relay(url: url))
|
||||
}
|
||||
for url in candidateUrls {
|
||||
for url in urls {
|
||||
var map = self.pendingSubscriptions[url] ?? [:]
|
||||
map[id] = messageString
|
||||
self.pendingSubscriptions[url] = map
|
||||
}
|
||||
// Initialize EOSE tracking if requested
|
||||
if let onEOSE = onEOSE {
|
||||
if urls.isEmpty {
|
||||
onEOSE()
|
||||
} else {
|
||||
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
|
||||
// Fallback timeout to avoid hanging if a relay never sends EOSE
|
||||
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
guard let self = self else { return }
|
||||
if let t = self.eoseTrackers[id] {
|
||||
t.timer?.invalidate()
|
||||
self.eoseTrackers.removeValue(forKey: id)
|
||||
onEOSE()
|
||||
}
|
||||
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
|
||||
// Fallback timeout to avoid hanging if a relay never sends EOSE
|
||||
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
guard let self = self else { return }
|
||||
if let t = self.eoseTrackers[id] {
|
||||
t.timer?.invalidate()
|
||||
self.eoseTrackers.removeValue(forKey: id)
|
||||
onEOSE()
|
||||
}
|
||||
}
|
||||
eoseTrackers[id] = tracker
|
||||
}
|
||||
eoseTrackers[id] = tracker
|
||||
}
|
||||
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
|
||||
@@ -337,55 +297,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
SecureLogger.error("❌ Failed to encode subscription request: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
func unsubscribe(id: String) {
|
||||
@@ -415,14 +326,14 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
private func connectToRelay(_ urlString: String) {
|
||||
// Global network policy gate
|
||||
guard networkService.activationAllowed else { return }
|
||||
if !TorManager.shared.isAutoStartAllowed() { return }
|
||||
guard let url = URL(string: urlString) else {
|
||||
SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -437,7 +348,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
// Attempting to connect to Nostr relay via the proxied session
|
||||
|
||||
// If Tor is enforced but not ready, delay connection until it is.
|
||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
Task.detached { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
@@ -623,7 +534,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
private func handleDisconnection(relayUrl: String, error: Error) {
|
||||
// If networking is disallowed, do not schedule reconnection
|
||||
if !networkService.activationAllowed {
|
||||
if !TorManager.shared.isAutoStartAllowed() {
|
||||
connections.removeValue(forKey: relayUrl)
|
||||
subscriptions.removeValue(forKey: relayUrl)
|
||||
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
||||
|
||||
@@ -22,11 +22,12 @@
|
||||
///
|
||||
/// ## Wire Format
|
||||
/// ```
|
||||
/// Header (Fixed 13 bytes):
|
||||
/// +--------+------+-----+-----------+-------+----------------+
|
||||
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
|
||||
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes |
|
||||
/// +--------+------+-----+-----------+-------+----------------+
|
||||
/// Header (13 bytes for v1, 15 bytes for v2):
|
||||
/// +--------+------+-----+-----------+-------+---------------+
|
||||
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
|
||||
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes (v1) |
|
||||
/// | | | | | | 4 bytes (v2) |
|
||||
/// +--------+------+-----+-----------+-------+---------------+
|
||||
///
|
||||
/// Variable sections:
|
||||
/// +----------+-------------+---------+------------+
|
||||
@@ -56,9 +57,10 @@
|
||||
/// - Bits 3-7: Reserved for future use
|
||||
///
|
||||
/// ## 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)
|
||||
/// - Minimum packet size: 21 bytes (header + sender ID)
|
||||
/// - Legacy v1 packets: max 65,535 bytes (16-bit length field)
|
||||
///
|
||||
/// ## Encoding Process
|
||||
/// 1. Construct header with fixed fields
|
||||
@@ -105,16 +107,22 @@ extension Data {
|
||||
/// their binary wire format representation.
|
||||
/// - Note: All multi-byte values use network byte order (big-endian)
|
||||
struct BinaryProtocol {
|
||||
static let headerSize = 13
|
||||
static let headerSizeV1 = 13
|
||||
static let headerSizeV2 = 15
|
||||
static let senderIDSize = 8
|
||||
static let recipientIDSize = 8
|
||||
static let signatureSize = 64
|
||||
|
||||
|
||||
struct Flags {
|
||||
static let hasRecipient: UInt8 = 0x01
|
||||
static let hasSignature: UInt8 = 0x02
|
||||
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
|
||||
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
|
||||
@@ -138,9 +146,10 @@ struct BinaryProtocol {
|
||||
} else {
|
||||
}
|
||||
|
||||
// Header
|
||||
// Header with version-dependent size
|
||||
let headerSize = getHeaderSize(packet.version)
|
||||
// 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 estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255
|
||||
data.reserveCapacity(estimated)
|
||||
@@ -166,13 +175,20 @@ struct BinaryProtocol {
|
||||
}
|
||||
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 payloadLength = UInt16(payloadDataSize)
|
||||
|
||||
|
||||
data.append(UInt8((payloadLength >> 8) & 0xFF))
|
||||
data.append(UInt8(payloadLength & 0xFF))
|
||||
if packet.version >= 2 {
|
||||
// v2+: 4 bytes big-endian
|
||||
data.append(UInt8((payloadDataSize >> 24) & 0xFF))
|
||||
data.append(UInt8((payloadDataSize >> 16) & 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)
|
||||
let senderBytes = packet.senderID.prefix(senderIDSize)
|
||||
@@ -227,8 +243,8 @@ struct BinaryProtocol {
|
||||
|
||||
// Core decoding implementation used by decode(_:) with and without padding removal
|
||||
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
|
||||
// Minimum size: header + senderID
|
||||
guard raw.count >= headerSize + senderIDSize else { return nil }
|
||||
// Minimum size: smallest header (v1) + senderID
|
||||
guard raw.count >= headerSizeV1 + senderIDSize else { return nil }
|
||||
|
||||
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
|
||||
guard let base = buf.baseAddress else { return nil }
|
||||
@@ -249,6 +265,14 @@ struct BinaryProtocol {
|
||||
offset += 2
|
||||
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
|
||||
func readData(_ n: Int) -> Data? {
|
||||
guard require(n) else { return nil }
|
||||
@@ -258,8 +282,8 @@ struct BinaryProtocol {
|
||||
return d
|
||||
}
|
||||
|
||||
// Version
|
||||
guard let version = read8(), version == 1 else { return nil }
|
||||
// Version - accept both v1 and v2+
|
||||
guard let version = read8(), version >= 1 else { return nil }
|
||||
guard let type = read8() else { return nil }
|
||||
guard let ttl = read8() else { return nil }
|
||||
|
||||
@@ -277,8 +301,15 @@ struct BinaryProtocol {
|
||||
let hasSignature = (flags & Flags.hasSignature) != 0
|
||||
let isCompressed = (flags & Flags.isCompressed) != 0
|
||||
|
||||
// Payload length
|
||||
guard let payloadLen = read16(), payloadLen <= 65535 else { return nil }
|
||||
// Payload length - version-dependent (2 bytes for v1, 4 bytes for v2+)
|
||||
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
|
||||
guard let senderID = readData(senderIDSize) else { return nil }
|
||||
@@ -317,6 +348,7 @@ struct BinaryProtocol {
|
||||
guard offset <= buf.count else { return nil }
|
||||
|
||||
return BitchatPacket(
|
||||
version: version,
|
||||
type: type,
|
||||
senderID: senderID,
|
||||
recipientID: recipientID,
|
||||
|
||||
@@ -78,6 +78,7 @@ enum MessageType: UInt8 {
|
||||
|
||||
// Fragmentation (simplified)
|
||||
case fragment = 0x20 // Single fragment type for large messages
|
||||
case fileTransfer = 0x22 // File transfer (audio/images) TLV payload
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
@@ -88,6 +89,7 @@ enum MessageType: UInt8 {
|
||||
case .noiseHandshake: return "noiseHandshake"
|
||||
case .noiseEncrypted: return "noiseEncrypted"
|
||||
case .fragment: return "fragment"
|
||||
case .fileTransfer: return "fileTransfer"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+249
-213
@@ -7,78 +7,6 @@ import CryptoKit
|
||||
import UIKit
|
||||
#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
|
||||
/// - Emits events exclusively via `BitchatDelegate` for UI.
|
||||
/// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`).
|
||||
@@ -88,7 +16,7 @@ final class BLEService: NSObject {
|
||||
// MARK: - Constants
|
||||
|
||||
#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
|
||||
static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") // mainnet
|
||||
#endif
|
||||
@@ -112,7 +40,6 @@ final class BLEService: NSObject {
|
||||
var isConnecting: Bool = false
|
||||
var isConnected: Bool = false
|
||||
var lastConnectionAttempt: Date? = nil
|
||||
var assembler = NotificationStreamAssembler()
|
||||
}
|
||||
private var peripherals: [String: PeripheralState] = [:] // UUID -> PeripheralState
|
||||
private var peerToPeripheralUUID: [String: String] = [:] // PeerID -> Peripheral UUID
|
||||
@@ -162,9 +89,8 @@ final class BLEService: NSObject {
|
||||
|
||||
var myPeerID: String = ""
|
||||
var myNickname: String = "anon"
|
||||
private var noiseService: NoiseEncryptionService
|
||||
private let noiseService: NoiseEncryptionService
|
||||
private let identityManager: SecureIdentityStateManagerProtocol
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private var myPeerIDData: Data = Data()
|
||||
|
||||
// MARK: - Advertising Privacy
|
||||
@@ -264,9 +190,7 @@ final class BLEService: NSObject {
|
||||
// MARK: - Helpers: IDs, selection, and write backpressure
|
||||
private func makeMessageID(for packet: BitchatPacket) -> String {
|
||||
let senderID = packet.senderID.hexEncodedString()
|
||||
let digest = SHA256.hash(data: packet.payload)
|
||||
let digestPrefix = digest.prefix(4).map { String(format: "%02x", $0) }.joined()
|
||||
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
|
||||
return "\(senderID)-\(packet.timestamp)-\(packet.type)"
|
||||
}
|
||||
|
||||
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) {
|
||||
self.keychain = keychain
|
||||
noiseService = NoiseEncryptionService(keychain: keychain)
|
||||
self.identityManager = identityManager
|
||||
super.init()
|
||||
|
||||
configureNoiseServiceCallbacks(for: noiseService)
|
||||
refreshPeerIdentity()
|
||||
// Derive stable peer ID from Noise static public key fingerprint (first 8 bytes → 16 hex chars)
|
||||
let fingerprint = noiseService.getIdentityFingerprint() // 64 hex chars
|
||||
self.myPeerID = String(fingerprint.prefix(16))
|
||||
self.myPeerIDData = Data(hexString: myPeerID) ?? Data()
|
||||
|
||||
// Set queue key for identification
|
||||
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)
|
||||
#if os(iOS)
|
||||
// Check initial state on main thread
|
||||
@@ -494,7 +407,10 @@ final class BLEService: NSObject {
|
||||
requestPeerDataPublish()
|
||||
|
||||
// Initialize gossip sync manager
|
||||
restartGossipManager()
|
||||
let sync = GossipSyncManager(myPeerID: myPeerID)
|
||||
sync.delegate = self
|
||||
sync.start()
|
||||
self.gossipSyncManager = sync
|
||||
}
|
||||
|
||||
func setNickname(_ nickname: String) {
|
||||
@@ -822,46 +738,6 @@ final class BLEService: NSObject {
|
||||
subscribedCentrals.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 {
|
||||
return noiseService
|
||||
@@ -1185,13 +1061,6 @@ final class BLEService: NSObject {
|
||||
// Determine last-hop link for this message to avoid echoing back
|
||||
let messageID = makeMessageID(for: packet)
|
||||
let ingressLink: LinkID? = collectionsQueue.sync { ingressByMessageID[messageID]?.link }
|
||||
let directedPeerHint: String? = {
|
||||
if let explicit = directedOnlyPeer { return explicit }
|
||||
if let recipient = packet.recipientID?.hexEncodedString(), !recipient.isEmpty {
|
||||
return recipient
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
let states = snapshotPeripheralStates()
|
||||
var minCentralWriteLen: Int?
|
||||
@@ -1245,7 +1114,7 @@ final class BLEService: NSObject {
|
||||
// Special-case control/presence messages: do NOT subset to maximize immediate coverage
|
||||
var selectedPeripheralIDs = Set(allowedPeripheralIDs)
|
||||
var selectedCentralIDs = Set(allowedCentralIDs)
|
||||
if directedPeerHint == nil
|
||||
if directedOnlyPeer == nil
|
||||
&& packet.type != MessageType.fragment.rawValue
|
||||
&& packet.type != MessageType.announce.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 let only = directedPeerHint,
|
||||
if let only = directedOnlyPeer,
|
||||
selectedPeripheralIDs.isEmpty && selectedCentralIDs.isEmpty,
|
||||
(packet.type == MessageType.noiseEncrypted.rawValue || packet.type == MessageType.noiseHandshake.rawValue) {
|
||||
spoolDirectedPacket(packet, recipientPeerID: only)
|
||||
@@ -1407,6 +1276,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) {
|
||||
// Don't process our own fragments
|
||||
@@ -1549,6 +1539,9 @@ final class BLEService: NSObject {
|
||||
|
||||
case .fragment:
|
||||
handleFragment(packet, from: senderID)
|
||||
|
||||
case .fileTransfer:
|
||||
handleFileTransfer(packet, from: senderID)
|
||||
|
||||
case .leave:
|
||||
handleLeave(packet, from: senderID)
|
||||
@@ -1591,6 +1584,72 @@ final class BLEService: NSObject {
|
||||
messageQueue.asyncAfter(deadline: .now() + .milliseconds(decision.delayMs), execute: work)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
guard let announcement = AnnouncementPacket.decode(from: packet.payload) else {
|
||||
@@ -2597,8 +2656,7 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
peerID: nil,
|
||||
isConnecting: true,
|
||||
isConnected: false,
|
||||
lastConnectionAttempt: Date(),
|
||||
assembler: NotificationStreamAssembler()
|
||||
lastConnectionAttempt: Date()
|
||||
)
|
||||
peripheral.delegate = self
|
||||
|
||||
@@ -2647,9 +2705,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
|
||||
characteristic: nil,
|
||||
peerID: nil,
|
||||
isConnecting: false,
|
||||
isConnected: true,
|
||||
lastConnectionAttempt: nil,
|
||||
assembler: NotificationStreamAssembler()
|
||||
isConnected: true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2787,8 +2843,7 @@ extension BLEService {
|
||||
peerID: nil,
|
||||
isConnecting: true,
|
||||
isConnected: false,
|
||||
lastConnectionAttempt: Date(),
|
||||
assembler: NotificationStreamAssembler()
|
||||
lastConnectionAttempt: Date()
|
||||
)
|
||||
peripheral.delegate = self
|
||||
let options: [String: Any] = [
|
||||
@@ -2925,72 +2980,53 @@ extension BLEService: CBPeripheralDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = characteristic.value, !data.isEmpty else {
|
||||
guard let data = characteristic.value else {
|
||||
SecureLogger.warning("⚠️ No data in notification", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
bufferNotificationChunk(data, from: peripheral)
|
||||
}
|
||||
|
||||
private func bufferNotificationChunk(_ chunk: Data, from peripheral: CBPeripheral) {
|
||||
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)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
// Use the packet's senderID as the peer identifier
|
||||
let senderID = packet.senderID.hexEncodedString()
|
||||
|
||||
if packet.type != MessageType.announce.rawValue {
|
||||
SecureLogger.debug("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID)", category: .session)
|
||||
}
|
||||
|
||||
// Only log non-announce packets
|
||||
if packet.type != MessageType.announce.rawValue {
|
||||
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 {
|
||||
// Only update mapping if this is a direct announce (TTL == messageTTL means not relayed)
|
||||
if packet.ttl == messageTTL {
|
||||
if var state = peripherals[peripheralUUID] {
|
||||
state.peerID = senderID
|
||||
peripherals[peripheralUUID] = state
|
||||
}
|
||||
peerToPeripheralUUID[senderID] = peripheralUUID
|
||||
// Mapping update - direct announce from peer
|
||||
}
|
||||
|
||||
// Record ingress link for last-hop suppression and process
|
||||
let msgID = makeMessageID(for: packet)
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())
|
||||
}
|
||||
// Process the announce packet regardless of whether we updated the mapping
|
||||
handleReceivedPacket(packet, from: senderID)
|
||||
} else {
|
||||
// For non-announce packets, DO NOT update mappings
|
||||
// These could be relayed packets from other peers
|
||||
// Always use the packet's original senderID
|
||||
// Record ingress link for last-hop suppression and process
|
||||
let msgID = makeMessageID(for: packet)
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())
|
||||
|
||||
@@ -282,7 +282,6 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
"com.bitchat.deviceidentity",
|
||||
"com.bitchat.noise.identity",
|
||||
"chat.bitchat.passwords",
|
||||
"chat.bitchat.nostr",
|
||||
"bitchat.keychain",
|
||||
"bitchat",
|
||||
"com.bitchat"
|
||||
|
||||
@@ -1,34 +1,5 @@
|
||||
import BitLogger
|
||||
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).
|
||||
@MainActor
|
||||
final class LocationNotesCounter: ObservableObject {
|
||||
@@ -37,45 +8,29 @@ final class LocationNotesCounter: ObservableObject {
|
||||
@Published private(set) var geohash: String? = nil
|
||||
@Published private(set) var count: Int? = 0
|
||||
@Published private(set) var initialLoadComplete: Bool = false
|
||||
@Published private(set) var relayAvailable: Bool = true
|
||||
|
||||
private var subscriptionID: String? = nil
|
||||
private var noteIDs = Set<String>()
|
||||
private let dependencies: LocationNotesCounterDependencies
|
||||
|
||||
private init(dependencies: LocationNotesCounterDependencies = .live) {
|
||||
self.dependencies = dependencies
|
||||
}
|
||||
|
||||
init(testDependencies: LocationNotesCounterDependencies) {
|
||||
self.dependencies = testDependencies
|
||||
}
|
||||
private init() {}
|
||||
|
||||
func subscribe(geohash gh: String) {
|
||||
let norm = gh.lowercased()
|
||||
if geohash == norm, subscriptionID != nil { return }
|
||||
// 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
|
||||
geohash = norm
|
||||
noteIDs.removeAll()
|
||||
initialLoadComplete = false
|
||||
relayAvailable = true
|
||||
|
||||
// Subscribe only to the building geohash (precision 8)
|
||||
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
|
||||
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 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 }
|
||||
@@ -83,17 +38,16 @@ final class LocationNotesCounter: ObservableObject {
|
||||
self.noteIDs.insert(event.id)
|
||||
self.count = self.noteIDs.count
|
||||
}
|
||||
}, { [weak self] in
|
||||
}, onEOSE: { [weak self] in
|
||||
self?.initialLoadComplete = true
|
||||
})
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
||||
if let sub = subscriptionID { NostrRelayManager.shared.unsubscribe(id: sub) }
|
||||
subscriptionID = nil
|
||||
geohash = nil
|
||||
count = 0
|
||||
noteIDs.removeAll()
|
||||
relayAvailable = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +1,10 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// Dependencies for location notes, allowing tests to stub relay/identity behavior.
|
||||
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).
|
||||
/// Persistent location notes (Nostr kind 1) scoped to a street-level geohash (precision 7).
|
||||
/// Subscribes to and publishes notes for a given geohash and provides a send API.
|
||||
@MainActor
|
||||
final class LocationNotesManager: ObservableObject {
|
||||
enum State: Equatable {
|
||||
case idle
|
||||
case loading
|
||||
case ready
|
||||
case noRelays
|
||||
}
|
||||
|
||||
struct Note: Identifiable, Equatable {
|
||||
let id: 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 geohash: String
|
||||
@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 let dependencies: LocationNotesDependencies
|
||||
|
||||
init(geohash: String, dependencies: LocationNotesDependencies = .live) {
|
||||
init(geohash: String) {
|
||||
self.geohash = geohash.lowercased()
|
||||
self.dependencies = dependencies
|
||||
subscribe()
|
||||
}
|
||||
|
||||
@@ -86,7 +35,7 @@ final class LocationNotesManager: ObservableObject {
|
||||
let norm = newGeohash.lowercased()
|
||||
guard norm != geohash else { return }
|
||||
if let sub = subscriptionID {
|
||||
dependencies.unsubscribe(sub)
|
||||
NostrRelayManager.shared.unsubscribe(id: sub)
|
||||
subscriptionID = nil
|
||||
}
|
||||
geohash = norm
|
||||
@@ -94,43 +43,15 @@ final class LocationNotesManager: ObservableObject {
|
||||
subscribe()
|
||||
}
|
||||
|
||||
func refresh() {
|
||||
if let sub = subscriptionID {
|
||||
dependencies.unsubscribe(sub)
|
||||
subscriptionID = nil
|
||||
}
|
||||
notes.removeAll()
|
||||
subscribe()
|
||||
}
|
||||
|
||||
func clearError() {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
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 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
|
||||
initialLoadComplete = false
|
||||
// For persistent notes, allow relays to return recent history without an aggressive time cutoff
|
||||
let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200)
|
||||
|
||||
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
||||
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
|
||||
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 event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||
// 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)
|
||||
self.notes.append(note)
|
||||
self.notes.sort { $0.createdAt > $1.createdAt }
|
||||
self.state = .ready
|
||||
}, { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.initialLoadComplete = true
|
||||
if self.state != .noRelays {
|
||||
self.state = .ready
|
||||
}
|
||||
}, onEOSE: { [weak self] in
|
||||
self?.initialLoadComplete = true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -155,46 +71,29 @@ final class LocationNotesManager: ObservableObject {
|
||||
func send(content: String, nickname: String) {
|
||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
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 {
|
||||
let id = try dependencies.deriveIdentity(geohash)
|
||||
let id = try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
|
||||
let event = try NostrProtocol.createGeohashTextNote(
|
||||
content: trimmed,
|
||||
geohash: geohash,
|
||||
senderIdentity: id,
|
||||
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
|
||||
let echo = Note(
|
||||
id: event.id,
|
||||
pubkey: id.publicKeyHex,
|
||||
content: trimmed,
|
||||
createdAt: dependencies.now(),
|
||||
nickname: nickname
|
||||
)
|
||||
let echo = Note(id: event.id, pubkey: id.publicKeyHex, content: trimmed, createdAt: Date(), nickname: nickname)
|
||||
self.notes.insert(echo, at: 0)
|
||||
self.state = .ready
|
||||
self.errorMessage = nil
|
||||
} catch {
|
||||
SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session)
|
||||
errorMessage = "Failed to send note. \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicitly cancel subscription and release resources.
|
||||
func cancel() {
|
||||
if let sub = subscriptionID {
|
||||
dependencies.unsubscribe(sub)
|
||||
NostrRelayManager.shared.unsubscribe(id: sub)
|
||||
subscriptionID = nil
|
||||
}
|
||||
state = .idle
|
||||
errorMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,9 @@ final class NetworkActivationService: ObservableObject {
|
||||
static let shared = NetworkActivationService()
|
||||
|
||||
@Published private(set) var activationAllowed: Bool = false
|
||||
@Published private(set) var userTorEnabled: Bool = true
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var started = false
|
||||
private let torPreferenceKey = "networkActivationService.userTorEnabled"
|
||||
private var torAutoStartDesired: Bool = false
|
||||
|
||||
private init() {}
|
||||
|
||||
@@ -23,23 +20,9 @@ final class NetworkActivationService: ObservableObject {
|
||||
guard !started else { return }
|
||||
started = true
|
||||
|
||||
if let stored = UserDefaults.standard.object(forKey: torPreferenceKey) as? Bool {
|
||||
userTorEnabled = stored
|
||||
} else {
|
||||
userTorEnabled = true
|
||||
}
|
||||
|
||||
// Initial compute
|
||||
let allowed = basePolicyAllowed()
|
||||
activationAllowed = allowed
|
||||
torAutoStartDesired = allowed && userTorEnabled
|
||||
TorManager.shared.setAutoStartAllowed(torAutoStartDesired)
|
||||
applyTorState(torDesired: torAutoStartDesired)
|
||||
if allowed {
|
||||
NostrRelayManager.shared.connect()
|
||||
} else {
|
||||
NostrRelayManager.shared.disconnect()
|
||||
}
|
||||
activationAllowed = Self.computeAllowed()
|
||||
TorManager.shared.setAutoStartAllowed(activationAllowed)
|
||||
|
||||
// React to location permission changes
|
||||
LocationChannelManager.shared.$permissionState
|
||||
@@ -58,56 +41,30 @@ final class NetworkActivationService: ObservableObject {
|
||||
.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() {
|
||||
let allowed = basePolicyAllowed()
|
||||
let torDesired = allowed && userTorEnabled
|
||||
let statusChanged = allowed != activationAllowed
|
||||
let torChanged = torDesired != torAutoStartDesired
|
||||
if statusChanged {
|
||||
let allowed = Self.computeAllowed()
|
||||
if allowed != activationAllowed {
|
||||
SecureLogger.info("NetworkActivationService: activationAllowed -> \(allowed)", category: .session)
|
||||
activationAllowed = allowed
|
||||
}
|
||||
if statusChanged || torChanged {
|
||||
torAutoStartDesired = torDesired
|
||||
TorManager.shared.setAutoStartAllowed(torDesired)
|
||||
applyTorState(torDesired: torDesired)
|
||||
}
|
||||
|
||||
if allowed {
|
||||
if torChanged {
|
||||
// Reset relay sockets when switching transport path (Tor ↔︎ direct)
|
||||
TorManager.shared.setAutoStartAllowed(allowed)
|
||||
if allowed {
|
||||
// Kick Tor + relays if we're now permitted
|
||||
TorManager.shared.startIfNeeded()
|
||||
// If app is in foreground, begin relay connections
|
||||
if TorManager.shared.isForeground() {
|
||||
NostrRelayManager.shared.connect()
|
||||
}
|
||||
} else {
|
||||
// Transitioned to disallowed: disconnect relays and shut down Tor
|
||||
NostrRelayManager.shared.disconnect()
|
||||
TorManager.shared.goDormantOnBackground()
|
||||
}
|
||||
NostrRelayManager.shared.connect()
|
||||
} else if statusChanged {
|
||||
NostrRelayManager.shared.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private func basePolicyAllowed() -> Bool {
|
||||
private static func computeAllowed() -> Bool {
|
||||
let permOK = LocationChannelManager.shared.permissionState == .authorized
|
||||
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
||||
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
|
||||
/// - **BLEService**: Calls this service for all private messages
|
||||
/// - **ChatViewModel**: Monitors encryption status for UI indicators
|
||||
/// - **NoiseHandshakeCoordinator**: Prevents handshake race conditions
|
||||
/// - **KeychainManager**: Secure storage for identity keys
|
||||
///
|
||||
/// ## Thread Safety
|
||||
@@ -525,15 +526,6 @@ final class NoiseEncryptionService {
|
||||
|
||||
SecureLogger.info(.sessionExpired(peerID: peerID))
|
||||
}
|
||||
|
||||
func clearEphemeralStateForPanic() {
|
||||
sessionManager.removeAllSessions()
|
||||
serviceQueue.sync(flags: .barrier) {
|
||||
peerFingerprints.removeAll()
|
||||
fingerprintToPeerID.removeAll()
|
||||
}
|
||||
rateLimiter.resetAll()
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ import Combine
|
||||
|
||||
// Minimal Nostr transport conforming to Transport for offline sending
|
||||
final class NostrTransport: Transport {
|
||||
func sendFileTransferTLV(_ payload: Data, recipientPeerID: String?, transferId: String, messageID: String) {
|
||||
return
|
||||
}
|
||||
|
||||
weak var delegate: BitchatDelegate?
|
||||
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||
|
||||
@@ -18,17 +18,13 @@ struct RelayController {
|
||||
isAnnounce: Bool,
|
||||
degree: Int,
|
||||
highDegreeThreshold: Int) -> RelayDecision {
|
||||
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
|
||||
|
||||
// Suppress obvious non-relays
|
||||
if ttlCap <= 1 || senderIsSelf {
|
||||
return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)
|
||||
}
|
||||
if ttl <= 1 || senderIsSelf { return RelayDecision(shouldRelay: false, newTTL: ttl, delayMs: 0) }
|
||||
|
||||
// For session-critical or directed traffic, be deterministic and reliable
|
||||
if isHandshake || isDirectedFragment || isDirectedEncrypted {
|
||||
// Always relay with no TTL cap for these types
|
||||
let newTTL = ttlCap &- 1
|
||||
let newTTL = (ttl &- 1)
|
||||
// Slight jitter to desynchronize without adding too much latency
|
||||
// Tighter for faster multi-hop handshakes and directed DMs
|
||||
let delayRange: ClosedRange<Int> = isHandshake ? 10...35 : 20...60
|
||||
@@ -36,17 +32,28 @@ struct RelayController {
|
||||
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
||||
}
|
||||
|
||||
// Degree-aware probability to reduce floods in dense graphs (broadcast/public)
|
||||
let baseProb: Double
|
||||
switch degree {
|
||||
case 0...2: baseProb = 1.0
|
||||
case 3...4: baseProb = 0.9
|
||||
case 5...6: baseProb = 0.7
|
||||
case 7...9: baseProb = 0.55
|
||||
default: baseProb = 0.45
|
||||
}
|
||||
let prob = baseProb
|
||||
let shouldRelay = Double.random(in: 0...1) <= prob
|
||||
|
||||
// TTL clamping for broadcast
|
||||
// - Dense graphs: keep lower but still allow multi-hop bridging
|
||||
// - Announces get a bit more headroom
|
||||
let ttlLimit: UInt8 = {
|
||||
if degree >= highDegreeThreshold {
|
||||
return max(UInt8(2), min(ttlCap, UInt8(5)))
|
||||
}
|
||||
let preferred = UInt8(isAnnounce ? 7 : 6)
|
||||
return max(UInt8(2), min(ttlCap, preferred))
|
||||
// - 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
|
||||
// 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)
|
||||
default: delayMs = Int.random(in: 100...220)
|
||||
}
|
||||
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
||||
return RelayDecision(shouldRelay: shouldRelay, newTTL: newTTL, delayMs: delayMs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,6 @@ final class TorManager: ObservableObject {
|
||||
private var controlMonitorStarted = false
|
||||
private var pathMonitor: NWPathMonitor?
|
||||
private var isAppForeground: Bool = true
|
||||
private var isDormant: Bool = false
|
||||
private var lastRestartAt: Date? = nil
|
||||
// Global policy gate: only allow Tor to start when true
|
||||
private(set) var allowAutoStart: Bool = false
|
||||
@@ -84,7 +83,6 @@ final class TorManager: ObservableObject {
|
||||
guard isAppForeground else { return }
|
||||
guard !didStart else { return }
|
||||
didStart = true
|
||||
isDormant = false
|
||||
isStarting = true
|
||||
lastError = nil
|
||||
// Announce initial start so UI can show a status message
|
||||
@@ -494,39 +492,18 @@ final class TorManager: ObservableObject {
|
||||
return true
|
||||
}
|
||||
if !claimed { return }
|
||||
if await self.resumeTorIfPossible() {
|
||||
await MainActor.run {
|
||||
self.restarting = false
|
||||
self.isStarting = false
|
||||
}
|
||||
return
|
||||
}
|
||||
await self.restartTor()
|
||||
await MainActor.run { self.restarting = false }
|
||||
}
|
||||
}
|
||||
|
||||
func goDormantOnBackground() {
|
||||
// Prefer Tor's DORMANT mode so we can resume on foreground without a full restart.
|
||||
// If the control port is unreachable, fall back to a hard shutdown.
|
||||
// Stricter model: fully stop Tor when app backgrounds to save power
|
||||
// and avoid half-suspended states. We'll restart cleanly on .active.
|
||||
Task.detached { [weak self] in
|
||||
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()
|
||||
await MainActor.run {
|
||||
self.isDormant = false
|
||||
self.isReady = false
|
||||
self.socksReady = false
|
||||
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 {
|
||||
await MainActor.run {
|
||||
// Announce restart so UI can notify the user
|
||||
@@ -566,28 +525,14 @@ final class TorManager: ObservableObject {
|
||||
self.bootstrapProgress = 0
|
||||
self.bootstrapSummary = ""
|
||||
self.isStarting = true
|
||||
self.isDormant = false
|
||||
self.lastRestartAt = Date()
|
||||
}
|
||||
// Prefer clean shutdown via owning controller FD; join the tor thread
|
||||
_ = tor_host_shutdown()
|
||||
// As a fallback, try control signal if needed (harmless if tor already down)
|
||||
_ = 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
|
||||
await MainActor.run { self.didStart = false }
|
||||
await MainActor.run { self.startIfNeeded() }
|
||||
}
|
||||
|
||||
@@ -653,62 +598,6 @@ final class TorManager: ObservableObject {
|
||||
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? {
|
||||
guard let cookiePath = dataDirectoryURL()?.appendingPathComponent("control_auth_cookie"),
|
||||
let cookie = try? Data(contentsOf: cookiePath) else { return nil }
|
||||
|
||||
@@ -4,5 +4,4 @@ extension Notification.Name {
|
||||
static let TorDidBecomeReady = Notification.Name("TorDidBecomeReady")
|
||||
static let TorWillRestart = Notification.Name("TorWillRestart")
|
||||
static let TorWillStart = Notification.Name("TorWillStart")
|
||||
static let TorUserPreferenceChanged = Notification.Name("TorUserPreferenceChanged")
|
||||
}
|
||||
|
||||
@@ -4,34 +4,43 @@ import CFNetwork
|
||||
#endif
|
||||
|
||||
/// Provides a shared URLSession that routes traffic via Tor's SOCKS5 proxy
|
||||
/// when Tor is enforced/ready. Allows swapping between proxied and direct
|
||||
/// sessions so UI can toggle Tor usage at runtime.
|
||||
/// when Tor is enforced/ready. Falls back to a default session only when
|
||||
/// compiled with the `BITCHAT_DEV_ALLOW_CLEARNET` flag.
|
||||
final class TorURLSession {
|
||||
static let shared = TorURLSession()
|
||||
|
||||
// Default (no proxy) session for direct Nostr access when Tor is disabled.
|
||||
private var defaultSession: URLSession = TorURLSession.makeDefaultSession()
|
||||
// Default (no proxy) session for local development when dev bypass is enabled.
|
||||
private var defaultSession: URLSession = {
|
||||
let cfg = URLSessionConfiguration.default
|
||||
cfg.waitsForConnectivity = true
|
||||
return URLSession(configuration: cfg)
|
||||
}()
|
||||
|
||||
// Proxied (SOCKS5) session that routes through Tor.
|
||||
private var torSession: URLSession = TorURLSession.makeTorSession()
|
||||
private var useTorProxy: Bool = true
|
||||
|
||||
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.
|
||||
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()
|
||||
}
|
||||
|
||||
func setProxyMode(useTor: Bool) {
|
||||
guard useTorProxy != useTor else { return }
|
||||
useTorProxy = useTor
|
||||
rebuild()
|
||||
}
|
||||
|
||||
private static func makeTorSession() -> URLSession {
|
||||
let cfg = URLSessionConfiguration.ephemeral
|
||||
cfg.waitsForConnectivity = true
|
||||
@@ -54,10 +63,4 @@ final class TorURLSession {
|
||||
#endif
|
||||
return URLSession(configuration: cfg)
|
||||
}
|
||||
|
||||
private static func makeDefaultSession() -> URLSession {
|
||||
let cfg = URLSessionConfiguration.default
|
||||
cfg.waitsForConnectivity = true
|
||||
return URLSession(configuration: cfg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ protocol Transport: AnyObject {
|
||||
func sendBroadcastAnnounce()
|
||||
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)
|
||||
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
||||
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
||||
|
||||
@@ -188,7 +188,7 @@ enum TransportConfig {
|
||||
static let uiWindowStepCount: Int = 200
|
||||
|
||||
// Share extension
|
||||
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
|
||||
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 0.3
|
||||
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
|
||||
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import CryptoKit
|
||||
// Golomb-Coded Set (GCS) filter utilities for sync.
|
||||
// Hashing:
|
||||
// - 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):
|
||||
// - 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).
|
||||
@@ -29,40 +29,25 @@ enum GCSFilter {
|
||||
|
||||
static func buildFilter(ids: [Data], maxBytes: Int, targetFpr: Double) -> Params {
|
||||
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 selected = Array(ids.prefix(cap))
|
||||
let range = max(1, hashRange(count: selected.count, p: p))
|
||||
let modulo = UInt64(range)
|
||||
|
||||
var mapped = selected
|
||||
.map { h64($0) }
|
||||
.map { mapHash($0, modulo: modulo) }
|
||||
.sorted()
|
||||
mapped = normalizeMappedValues(mapped, modulo: modulo)
|
||||
|
||||
if mapped.isEmpty {
|
||||
return Params(p: p, m: range, data: Data())
|
||||
}
|
||||
|
||||
let n = min(ids.count, cap)
|
||||
let selected = Array(ids.prefix(n))
|
||||
// Map to [0, M)
|
||||
let mInit = UInt32(n << p)
|
||||
var mapped = selected.map { id16 -> UInt64 in
|
||||
let h = h64(id16)
|
||||
return UInt64(h % UInt64(max(1, mInit)))
|
||||
}.sorted()
|
||||
var encoded = encode(sorted: mapped, p: p)
|
||||
var trimmedCount = mapped.count
|
||||
|
||||
while encoded.count > maxBytes && trimmedCount > 0 {
|
||||
if trimmedCount == 1 {
|
||||
mapped.removeAll()
|
||||
encoded = Data()
|
||||
break
|
||||
}
|
||||
trimmedCount = max(1, (trimmedCount * 9) / 10)
|
||||
mapped = Array(mapped.prefix(trimmedCount))
|
||||
var trimmedN = n
|
||||
// Trim if over budget
|
||||
while encoded.count > maxBytes && trimmedN > 0 {
|
||||
trimmedN = (trimmedN * 9) / 10 // drop ~10%
|
||||
mapped = Array(mapped.prefix(trimmedN))
|
||||
encoded = encode(sorted: mapped, p: p)
|
||||
}
|
||||
|
||||
return Params(p: p, m: range, data: encoded)
|
||||
let finalM = UInt32(max(1, trimmedN << p))
|
||||
return Params(p: p, m: finalM, data: encoded)
|
||||
}
|
||||
|
||||
static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] {
|
||||
@@ -92,12 +77,6 @@ enum GCSFilter {
|
||||
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 {
|
||||
var hasher = SHA256()
|
||||
hasher.update(data: id16)
|
||||
@@ -109,39 +88,6 @@ enum GCSFilter {
|
||||
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 {
|
||||
let writer = BitWriter()
|
||||
var prev: UInt64 = 0
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// Gossip-based sync manager using on-demand GCS filters
|
||||
final class GossipSyncManager {
|
||||
@@ -52,12 +53,6 @@ final class GossipSyncManager {
|
||||
}
|
||||
|
||||
func onPublicPacketSeen(_ packet: BitchatPacket) {
|
||||
queue.async { [weak self] in
|
||||
self?._onPublicPacketSeen(packet)
|
||||
}
|
||||
}
|
||||
|
||||
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
||||
let mt = MessageType(rawValue: packet.type)
|
||||
let isBroadcastRecipient: Bool = {
|
||||
guard let r = packet.recipientID else { return true }
|
||||
@@ -124,17 +119,18 @@ final class GossipSyncManager {
|
||||
}
|
||||
|
||||
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
|
||||
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
||||
func mightContain(_ id: Data) -> Bool {
|
||||
let bucket = GCSFilter.bucket(for: id, modulus: request.m)
|
||||
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
||||
var hasher = SHA256()
|
||||
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
|
||||
@@ -186,15 +182,9 @@ final class GossipSyncManager {
|
||||
|
||||
// Explicit removal hook for LEAVE/stale peer
|
||||
func removeAnnouncementForPeer(_ peerID: String) {
|
||||
queue.async { [weak self] in
|
||||
self?._removeAnnouncementForPeer(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
private func _removeAnnouncementForPeer(_ peerID: String) {
|
||||
let normalizedPeerID = peerID.lowercased()
|
||||
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
|
||||
|
||||
|
||||
// Remove messages from this peer
|
||||
// Collect IDs to remove first to avoid concurrent modification
|
||||
let messageIdsToRemove = messages.compactMap { (id, message) -> String? in
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,7 @@ struct InputValidator {
|
||||
|
||||
struct Limits {
|
||||
static let maxNicknameLength = 50
|
||||
// BinaryProtocol caps payload length at UInt16.max (65_535). Leave headroom
|
||||
// for headers/padding by limiting user content to 60_000 bytes.
|
||||
static let maxMessageLength = 60_000
|
||||
static let maxMessageLength = 10_000
|
||||
static let maxReasonLength = 200
|
||||
static let maxPeerIDLength = 64
|
||||
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
@@ -88,7 +88,7 @@ struct AppInfoView: View {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: { dismiss() }) {
|
||||
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)
|
||||
}
|
||||
@@ -106,29 +106,16 @@ struct AppInfoView: View {
|
||||
// Header
|
||||
VStack(alignment: .center, spacing: 8) {
|
||||
Text(Strings.appName)
|
||||
.font(.bitchatSystem(size: 32, weight: .bold, design: .monospaced))
|
||||
.font(.system(size: 32, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Text(Strings.tagline)
|
||||
.font(.bitchatSystem(size: 16, design: .monospaced))
|
||||
.font(.system(size: 16, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.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
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
SectionHeader(Strings.Features.title)
|
||||
@@ -175,13 +162,26 @@ struct AppInfoView: View {
|
||||
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
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
SectionHeader(Strings.Warning.title)
|
||||
.foregroundColor(Color.red)
|
||||
|
||||
Text(Strings.Warning.message)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(Color.red)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
@@ -211,7 +211,7 @@ struct SectionHeader: View {
|
||||
|
||||
var body: some View {
|
||||
Text(title)
|
||||
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
@@ -234,17 +234,17 @@ struct FeatureRow: View {
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.bitchatSystem(size: 20))
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(textColor)
|
||||
.frame(width: 30)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.bitchatSystem(size: 14, weight: .semibold, design: .monospaced))
|
||||
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Text(description)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
@@ -254,16 +254,6 @@ struct FeatureRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Default") {
|
||||
#Preview {
|
||||
AppInfoView()
|
||||
}
|
||||
|
||||
#Preview("Dynamic Type XXL") {
|
||||
AppInfoView()
|
||||
.environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge)
|
||||
}
|
||||
|
||||
#Preview("Dynamic Type XS") {
|
||||
AppInfoView()
|
||||
.environment(\.sizeCategory, .extraSmall)
|
||||
}
|
||||
|
||||
+340
-148
@@ -9,6 +9,7 @@
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
import PhotosUI
|
||||
#endif
|
||||
|
||||
// MARK: - Supporting Types
|
||||
@@ -30,7 +31,6 @@ struct ContentView: View {
|
||||
@State private var textFieldSelection: NSRange? = nil
|
||||
@FocusState private var isTextFieldFocused: Bool
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||
@State private var showPeerList = false
|
||||
@State private var showSidebar = false
|
||||
@State private var sidebarDragOffset: CGFloat = 0
|
||||
@@ -54,29 +54,39 @@ struct ContentView: View {
|
||||
@State private var showLocationNotes = false
|
||||
@State private var notesGeohash: String? = nil
|
||||
@State private var sheetNotesCount: Int = 0
|
||||
@ScaledMetric(relativeTo: .body) private var headerHeight: CGFloat = 44
|
||||
// Timer-based refresh removed; use LocationChannelManager live updates instead
|
||||
// Window sizes for rendering (infinite scroll up)
|
||||
@State private var windowCountPublic: Int = 300
|
||||
@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
|
||||
|
||||
private var backgroundColor: Color {
|
||||
colorScheme == .dark ? Color.black : Color.white
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private var headerLineLimit: Int? {
|
||||
dynamicTypeSize.isAccessibilitySize ? 2 : 1
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
@@ -258,6 +268,16 @@ struct ContentView: View {
|
||||
scrollThrottleTimer?.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
|
||||
@@ -311,20 +331,57 @@ struct ContentView: View {
|
||||
// Precompute heavy token scans once per row
|
||||
let cashuTokens = message.content.extractCashuTokens()
|
||||
let lightningLinks = message.content.extractLightningLinks()
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty
|
||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
// Delivery status indicator for private messages
|
||||
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) {
|
||||
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty
|
||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
// Delivery status indicator for private messages
|
||||
if message.isPrivate && message.sender == viewModel.nickname,
|
||||
let status = message.deliveryStatus {
|
||||
DeliveryStatusView(status: status, colorScheme: colorScheme)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expand/Collapse for very long messages
|
||||
@@ -334,7 +391,7 @@ struct ContentView: View {
|
||||
if isExpanded { expandedMessageIDs.remove(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)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
@@ -468,12 +525,7 @@ struct ContentView: View {
|
||||
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
|
||||
guard url.scheme == "bitchat", url.host == "geohash" else { return }
|
||||
@@ -712,7 +764,7 @@ struct ContentView: View {
|
||||
}) {
|
||||
HStack {
|
||||
Text(suggestion)
|
||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.fontWeight(.medium)
|
||||
Spacer()
|
||||
@@ -770,14 +822,14 @@ struct ContentView: View {
|
||||
HStack {
|
||||
// Show all aliases together
|
||||
Text(info.commands.joined(separator: ", "))
|
||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.fontWeight(.medium)
|
||||
|
||||
// Show syntax if any
|
||||
if let syntax = info.syntax {
|
||||
Text(syntax)
|
||||
.font(.bitchatSystem(size: 10, design: .monospaced))
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.8))
|
||||
}
|
||||
|
||||
@@ -785,7 +837,7 @@ struct ContentView: View {
|
||||
|
||||
// Show description
|
||||
Text(info.description)
|
||||
.font(.bitchatSystem(size: 10, design: .monospaced))
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
@@ -806,92 +858,171 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
TextField("type a message...", text: $messageText)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.focused($isTextFieldFocused)
|
||||
.padding(.leading, 12)
|
||||
// iOS keyboard autocomplete and capitalization enabled by default
|
||||
.onChange(of: messageText) { newValue in
|
||||
// Cancel previous debounce timer
|
||||
autocompleteDebounceTimer?.invalidate()
|
||||
|
||||
// Debounce autocomplete updates to reduce calls during rapid typing
|
||||
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
|
||||
// Get cursor position (approximate - end of text for now)
|
||||
let cursorPosition = newValue.count
|
||||
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
|
||||
ZStack(alignment: .leading) {
|
||||
// Always keep the TextField mounted so the keyboard state doesn't change
|
||||
TextField("type a message...", text: $messageText)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.focused($isTextFieldFocused)
|
||||
.padding(.leading, 12)
|
||||
.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)
|
||||
}
|
||||
|
||||
// Check for command autocomplete (instant, no debounce needed)
|
||||
if newValue.hasPrefix("/") && newValue.count >= 1 {
|
||||
// Build context-aware command list
|
||||
let isGeoPublic: Bool = {
|
||||
if case .location = locationManager.selectedChannel { return true }
|
||||
return false
|
||||
}()
|
||||
let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
|
||||
var commandDescriptions = [
|
||||
("/block", "block or list blocked peers"),
|
||||
("/clear", "clear chat messages"),
|
||||
("/hug", "send someone a warm hug"),
|
||||
("/m", "send private message"),
|
||||
("/slap", "slap someone with a trout"),
|
||||
("/unblock", "unblock a peer"),
|
||||
("/w", "see who's online")
|
||||
]
|
||||
// Only show favorites commands when not in geohash context
|
||||
if !(isGeoPublic || isGeoDM) {
|
||||
commandDescriptions.append(("/fav", "add to favorites"))
|
||||
commandDescriptions.append(("/unfav", "remove from favorites"))
|
||||
}
|
||||
|
||||
let input = newValue.lowercased()
|
||||
|
||||
// Map of aliases to primary commands
|
||||
let aliases: [String: String] = [
|
||||
"/join": "/j",
|
||||
"/msg": "/m"
|
||||
]
|
||||
|
||||
// Filter commands, but convert aliases to primary
|
||||
commandSuggestions = commandDescriptions
|
||||
.filter { $0.0.starts(with: input) }
|
||||
.map { $0.0 }
|
||||
|
||||
// Also check if input matches an alias
|
||||
for (alias, primary) in aliases {
|
||||
if alias.starts(with: input) && !commandSuggestions.contains(primary) {
|
||||
if commandDescriptions.contains(where: { $0.0 == primary }) {
|
||||
commandSuggestions.append(primary)
|
||||
}
|
||||
.padding(.leading, 12)
|
||||
.frame(height: max(28, inputFieldMeasuredHeight))
|
||||
}
|
||||
|
||||
}
|
||||
.onChange(of: messageText) { newValue in
|
||||
// Cancel previous debounce timer
|
||||
autocompleteDebounceTimer?.invalidate()
|
||||
|
||||
// Debounce autocomplete updates to reduce calls during rapid typing
|
||||
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
|
||||
// Get cursor position (approximate - end of text for now)
|
||||
let cursorPosition = newValue.count
|
||||
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
|
||||
}
|
||||
|
||||
// Check for command autocomplete (instant, no debounce needed)
|
||||
if newValue.hasPrefix("/") && newValue.count >= 1 {
|
||||
// Build context-aware command list
|
||||
let isGeoPublic: Bool = {
|
||||
if case .location = locationManager.selectedChannel { return true }
|
||||
return false
|
||||
}()
|
||||
let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
|
||||
var commandDescriptions = [
|
||||
("/block", "block or list blocked peers"),
|
||||
("/clear", "clear chat messages"),
|
||||
("/hug", "send someone a warm hug"),
|
||||
("/m", "send private message"),
|
||||
("/slap", "slap someone with a trout"),
|
||||
("/unblock", "unblock a peer"),
|
||||
("/w", "see who's online")
|
||||
]
|
||||
// Only show favorites commands when not in geohash context
|
||||
if !(isGeoPublic || isGeoDM) {
|
||||
commandDescriptions.append(("/fav", "add to favorites"))
|
||||
commandDescriptions.append(("/unfav", "remove from favorites"))
|
||||
}
|
||||
|
||||
let input = newValue.lowercased()
|
||||
|
||||
// Map of aliases to primary commands
|
||||
let aliases: [String: String] = [
|
||||
"/join": "/j",
|
||||
"/msg": "/m"
|
||||
]
|
||||
|
||||
// Filter commands, but convert aliases to primary
|
||||
commandSuggestions = commandDescriptions
|
||||
.filter { $0.0.starts(with: input) }
|
||||
.map { $0.0 }
|
||||
|
||||
// Also check if input matches an alias
|
||||
for (alias, primary) in aliases {
|
||||
if alias.starts(with: input) && !commandSuggestions.contains(primary) {
|
||||
if commandDescriptions.contains(where: { $0.0 == primary }) {
|
||||
commandSuggestions.append(primary)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates and sort
|
||||
commandSuggestions = Array(Set(commandSuggestions)).sorted()
|
||||
showCommandSuggestions = !commandSuggestions.isEmpty
|
||||
} else {
|
||||
showCommandSuggestions = false
|
||||
commandSuggestions = []
|
||||
}
|
||||
|
||||
// Remove duplicates and sort
|
||||
commandSuggestions = Array(Set(commandSuggestions)).sorted()
|
||||
showCommandSuggestions = !commandSuggestions.isEmpty
|
||||
} else {
|
||||
showCommandSuggestions = false
|
||||
commandSuggestions = []
|
||||
}
|
||||
}
|
||||
.onSubmit { 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) {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(Color.gray)
|
||||
}
|
||||
.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)
|
||||
.padding(.trailing, 12)
|
||||
.accessibilityLabel("Send message")
|
||||
.accessibilityHint("Double tap to send")
|
||||
}
|
||||
.onSubmit {
|
||||
sendMessage()
|
||||
}
|
||||
|
||||
Button(action: sendMessage) {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.bitchatSystem(size: 20))
|
||||
.foregroundColor(messageText.isEmpty ? Color.gray :
|
||||
viewModel.selectedPrivateChatPeer != nil
|
||||
? Color.orange : textColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.trailing, 12)
|
||||
.accessibilityLabel("Send message")
|
||||
.accessibilityHint(messageText.isEmpty ? "Enter a message to send" : "Double tap to send")
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.background(backgroundColor.opacity(0.95))
|
||||
@@ -911,6 +1042,67 @@ struct ContentView: View {
|
||||
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
|
||||
|
||||
private var sidebarView: some View {
|
||||
@@ -924,20 +1116,20 @@ struct ContentView: View {
|
||||
// Header - match main toolbar height
|
||||
HStack {
|
||||
Text("PEOPLE")
|
||||
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
Spacer()
|
||||
// Show QR in mesh on all platforms
|
||||
if case .mesh = locationManager.selectedChannel {
|
||||
Button(action: { showVerifySheet = true }) {
|
||||
Image(systemName: "qrcode")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Verification: show my QR or scan a friend")
|
||||
}
|
||||
}
|
||||
.frame(height: headerHeight) // Match header height
|
||||
.frame(height: 44) // Match header height
|
||||
.padding(.horizontal, 12)
|
||||
.background(backgroundColor.opacity(0.95))
|
||||
|
||||
@@ -1088,7 +1280,7 @@ struct ContentView: View {
|
||||
private var mainHeaderView: some View {
|
||||
HStack(spacing: 0) {
|
||||
Text("bitchat/")
|
||||
.font(.bitchatSystem(size: 18, weight: .medium, design: .monospaced))
|
||||
.font(.system(size: 18, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.onTapGesture(count: 3) {
|
||||
// PANIC: Triple-tap to clear all data
|
||||
@@ -1101,12 +1293,12 @@ struct ContentView: View {
|
||||
|
||||
HStack(spacing: 0) {
|
||||
Text("@")
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
|
||||
TextField("nickname", text: $viewModel.nickname)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.frame(maxWidth: 80)
|
||||
.foregroundColor(textColor)
|
||||
.focused($isNicknameFieldFocused)
|
||||
@@ -1145,7 +1337,7 @@ struct ContentView: View {
|
||||
if viewModel.hasAnyUnreadMessages {
|
||||
Button(action: { viewModel.openMostRelevantPrivateChat() }) {
|
||||
Image(systemName: "envelope.fill")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(Color.orange)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -1165,7 +1357,7 @@ struct ContentView: View {
|
||||
let currentCount = (notesCounter.count ?? 0)
|
||||
let hasNotes = (!notesCounter.initialLoadComplete ? max(currentCount, sheetNotesCount) : currentCount) > 0
|
||||
Image(systemName: "long.text.page.and.pencil")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(hasNotes ? textColor : Color.gray)
|
||||
.padding(.top, 1)
|
||||
}
|
||||
@@ -1179,7 +1371,7 @@ struct ContentView: View {
|
||||
if case .location(let ch) = locationManager.selectedChannel {
|
||||
Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) {
|
||||
Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Toggle bookmark for #\(ch.geohash)")
|
||||
@@ -1202,9 +1394,9 @@ struct ContentView: View {
|
||||
}
|
||||
}()
|
||||
Text(badgeText)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(badgeColor)
|
||||
.lineLimit(headerLineLimit)
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.layoutPriority(2)
|
||||
.accessibilityLabel("location channels")
|
||||
@@ -1216,15 +1408,15 @@ struct ContentView: View {
|
||||
HStack(spacing: 4) {
|
||||
// People icon with count
|
||||
Image(systemName: "person.2.fill")
|
||||
.font(.bitchatSystem(size: 11))
|
||||
.font(.system(size: 11))
|
||||
.accessibilityLabel("\(headerOtherPeersCount) people")
|
||||
Text("\(headerOtherPeersCount)")
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.foregroundColor(headerCountColor)
|
||||
.padding(.leading, 2)
|
||||
.lineLimit(headerLineLimit)
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
|
||||
// QR moved to the PEOPLE header in the sidebar when on mesh channel
|
||||
@@ -1241,7 +1433,7 @@ struct ContentView: View {
|
||||
.environmentObject(viewModel)
|
||||
}
|
||||
}
|
||||
.frame(height: headerHeight)
|
||||
.frame(height: 44)
|
||||
.padding(.horizontal, 12)
|
||||
.sheet(isPresented: $showLocationChannelsSheet) {
|
||||
LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
|
||||
@@ -1257,22 +1449,22 @@ struct ContentView: View {
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
Text("notes")
|
||||
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||
Spacer()
|
||||
Button(action: { showLocationNotes = false }) {
|
||||
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)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
.frame(height: headerHeight)
|
||||
.frame(height: 44)
|
||||
.padding(.horizontal, 12)
|
||||
.background(backgroundColor.opacity(0.95))
|
||||
Text("location unavailable")
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
Button("enable location") {
|
||||
LocationChannelManager.shared.enableLocationChannels()
|
||||
@@ -1423,19 +1615,19 @@ struct ContentView: View {
|
||||
case .bluetoothConnected:
|
||||
// Radio icon for mesh connection
|
||||
Image(systemName: "dot.radiowaves.left.and.right")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(textColor)
|
||||
.accessibilityLabel("Connected via mesh")
|
||||
case .meshReachable:
|
||||
// point.3 filled icon for reachable via mesh (not directly connected)
|
||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(textColor)
|
||||
.accessibilityLabel("Reachable via mesh")
|
||||
case .nostrAvailable:
|
||||
// Purple globe for Nostr
|
||||
Image(systemName: "globe")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.purple)
|
||||
.accessibilityLabel("Available via Nostr")
|
||||
case .offline:
|
||||
@@ -1445,25 +1637,25 @@ struct ContentView: View {
|
||||
} else if viewModel.meshService.isPeerReachable(headerPeerID) {
|
||||
// Fallback: reachable via mesh but not in current peer list
|
||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(textColor)
|
||||
.accessibilityLabel("Reachable via mesh")
|
||||
} else if isNostrAvailable {
|
||||
// Fallback to Nostr if peer not in list but is mutual favorite
|
||||
Image(systemName: "globe")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.purple)
|
||||
.accessibilityLabel("Available via Nostr")
|
||||
} else if viewModel.meshService.isPeerConnected(headerPeerID) || viewModel.connectedPeers.contains(headerPeerID) {
|
||||
// Fallback: if peer lookup is missing but mesh reports connected, show radio
|
||||
Image(systemName: "dot.radiowaves.left.and.right")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(textColor)
|
||||
.accessibilityLabel("Connected via mesh")
|
||||
}
|
||||
|
||||
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)
|
||||
if !privatePeerID.hasPrefix("nostr_") {
|
||||
// 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)
|
||||
if let icon = encryptionStatus.icon {
|
||||
Image(systemName: icon)
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(encryptionStatus == .noiseVerified ? textColor :
|
||||
encryptionStatus == .noiseSecured ? textColor :
|
||||
Color.red)
|
||||
@@ -1498,7 +1690,7 @@ struct ContentView: View {
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(textColor)
|
||||
.frame(width: 44, height: 44, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
@@ -1514,7 +1706,7 @@ struct ContentView: View {
|
||||
viewModel.toggleFavorite(peerID: headerPeerID)
|
||||
}) {
|
||||
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)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -1523,7 +1715,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: headerHeight)
|
||||
.frame(height: 44)
|
||||
.padding(.horizontal, 12)
|
||||
.background(backgroundColor.opacity(0.95))
|
||||
}
|
||||
@@ -1580,7 +1772,7 @@ private struct PaymentChipView: View {
|
||||
HStack(spacing: 6) {
|
||||
Text(emoji)
|
||||
Text(label)
|
||||
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 12)
|
||||
@@ -1621,20 +1813,20 @@ struct DeliveryStatusView: View {
|
||||
switch status {
|
||||
case .sending:
|
||||
Image(systemName: "circle")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
|
||||
case .sent:
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
|
||||
case .delivered(let nickname, _):
|
||||
HStack(spacing: -2) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.font(.system(size: 10))
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.font(.system(size: 10))
|
||||
}
|
||||
.foregroundColor(textColor.opacity(0.8))
|
||||
.help("Delivered to \(nickname)")
|
||||
@@ -1642,25 +1834,25 @@ struct DeliveryStatusView: View {
|
||||
case .read(let nickname, _):
|
||||
HStack(spacing: -2) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10, weight: .bold))
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
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
|
||||
.help("Read by \(nickname)")
|
||||
|
||||
case .failed(let reason):
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(Color.red.opacity(0.8))
|
||||
.help("Failed: \(reason)")
|
||||
|
||||
case .partiallyDelivered(let reached, let total):
|
||||
HStack(spacing: 1) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.font(.system(size: 10))
|
||||
Text("\(reached)/\(total)")
|
||||
.font(.bitchatSystem(size: 10, design: .monospaced))
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
}
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
.help("Delivered to \(reached) of \(total) members")
|
||||
|
||||
@@ -27,14 +27,14 @@ struct FingerprintView: View {
|
||||
// Header
|
||||
HStack {
|
||||
Text("SECURITY VERIFICATION")
|
||||
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.bitchatSystem(size: 14, weight: .semibold))
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
@@ -66,17 +66,17 @@ struct FingerprintView: View {
|
||||
HStack {
|
||||
if let icon = encryptionStatus.icon {
|
||||
Image(systemName: icon)
|
||||
.font(.bitchatSystem(size: 20))
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(encryptionStatus == .noiseVerified ? Color.green : textColor)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(peerNickname)
|
||||
.font(.bitchatSystem(size: 18, weight: .semibold, design: .monospaced))
|
||||
.font(.system(size: 18, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Text(encryptionStatus.description)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(textColor.opacity(0.7))
|
||||
}
|
||||
|
||||
@@ -89,12 +89,12 @@ struct FingerprintView: View {
|
||||
// Their fingerprint
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("THEIR FINGERPRINT:")
|
||||
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
||||
.font(.system(size: 12, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(textColor.opacity(0.7))
|
||||
|
||||
if let fingerprint = viewModel.getFingerprint(for: statusPeerID) {
|
||||
Text(formatFingerprint(fingerprint))
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(nil)
|
||||
@@ -115,7 +115,7 @@ struct FingerprintView: View {
|
||||
}
|
||||
} else {
|
||||
Text("not available - handshake in progress")
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(Color.orange)
|
||||
.padding()
|
||||
}
|
||||
@@ -124,12 +124,12 @@ struct FingerprintView: View {
|
||||
// My fingerprint
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("YOUR FINGERPRINT:")
|
||||
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
||||
.font(.system(size: 12, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(textColor.opacity(0.7))
|
||||
|
||||
let myFingerprint = viewModel.getMyFingerprint()
|
||||
Text(formatFingerprint(myFingerprint))
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(nil)
|
||||
@@ -156,14 +156,14 @@ struct FingerprintView: View {
|
||||
|
||||
VStack(spacing: 12) {
|
||||
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)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Text(isVerified ?
|
||||
"you have verified this person's identity." :
|
||||
"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))
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(nil)
|
||||
@@ -176,7 +176,7 @@ struct FingerprintView: View {
|
||||
dismiss()
|
||||
}) {
|
||||
Text("MARK AS VERIFIED")
|
||||
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
@@ -190,7 +190,7 @@ struct FingerprintView: View {
|
||||
dismiss()
|
||||
}) {
|
||||
Text("REMOVE VERIFICATION")
|
||||
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
|
||||
@@ -12,7 +12,7 @@ struct GeohashPeopleList: View {
|
||||
if viewModel.visibleGeohashPeople().isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("nobody around...")
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 12)
|
||||
@@ -51,30 +51,30 @@ struct GeohashPeopleList: View {
|
||||
let icon = teleported ? "face.dashed" : "mappin.and.ellipse"
|
||||
let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark)
|
||||
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)
|
||||
HStack(spacing: 0) {
|
||||
Text(base)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.fontWeight(isMe ? .bold : .regular)
|
||||
.foregroundColor(rowColor)
|
||||
if !suffix.isEmpty {
|
||||
let suffixColor = isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6)
|
||||
Text(suffix)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(suffixColor)
|
||||
}
|
||||
if isMe {
|
||||
Text(" (you)")
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(rowColor)
|
||||
}
|
||||
}
|
||||
if let me = myHex, person.id != me {
|
||||
if viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) {
|
||||
Image(systemName: "nosign")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.red)
|
||||
.help("Blocked in geochash")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -9,7 +9,6 @@ struct LocationChannelsSheet: View {
|
||||
@Binding var isPresented: Bool
|
||||
@ObservedObject private var manager = LocationChannelManager.shared
|
||||
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
||||
@ObservedObject private var network = NetworkActivationService.shared
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@State private var customGeohash: String = ""
|
||||
@@ -20,14 +19,10 @@ struct LocationChannelsSheet: View {
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
Text("#location channels")
|
||||
.font(.bitchatSystem(size: 18, design: .monospaced))
|
||||
Spacer()
|
||||
closeButton
|
||||
}
|
||||
Text("#location channels")
|
||||
.font(.system(size: 18, design: .monospaced))
|
||||
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)
|
||||
|
||||
Group {
|
||||
@@ -35,7 +30,7 @@ struct LocationChannelsSheet: View {
|
||||
case LocationChannelManager.PermissionState.notDetermined:
|
||||
Button(action: { manager.enableLocationChannels() }) {
|
||||
Text("get location and my geohashes")
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(standardGreen)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
@@ -46,7 +41,7 @@ struct LocationChannelsSheet: View {
|
||||
case LocationChannelManager.PermissionState.denied, LocationChannelManager.PermissionState.restricted:
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
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)
|
||||
Button("open settings") { openSystemLocationSettings() }
|
||||
.buttonStyle(.plain)
|
||||
@@ -64,9 +59,29 @@ struct LocationChannelsSheet: View {
|
||||
.background(backgroundColor)
|
||||
#if os(iOS)
|
||||
.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
|
||||
.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
|
||||
}
|
||||
#if os(iOS)
|
||||
@@ -96,16 +111,6 @@ struct LocationChannelsSheet: View {
|
||||
.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 {
|
||||
List {
|
||||
// Mesh option first (no bookmark)
|
||||
@@ -113,7 +118,6 @@ struct LocationChannelsSheet: View {
|
||||
manager.select(ChannelID.mesh)
|
||||
isPresented = false
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
|
||||
|
||||
// Nearby options
|
||||
if !manager.availableChannels.isEmpty {
|
||||
@@ -132,7 +136,7 @@ struct LocationChannelsSheet: View {
|
||||
trailingAccessory: {
|
||||
Button(action: { bookmarks.toggle(channel.geohash) }) {
|
||||
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 8)
|
||||
@@ -143,22 +147,20 @@ struct LocationChannelsSheet: View {
|
||||
manager.select(ChannelID.location(channel))
|
||||
isPresented = false
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
|
||||
}
|
||||
} else {
|
||||
HStack {
|
||||
ProgressView()
|
||||
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
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 2) {
|
||||
Text("#")
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
TextField("geohash", text: $customGeohash)
|
||||
#if os(iOS)
|
||||
@@ -166,7 +168,7 @@ struct LocationChannelsSheet: View {
|
||||
.autocorrectionDisabled(true)
|
||||
.keyboardType(.asciiCapable)
|
||||
#endif
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.onChange(of: customGeohash) { newValue in
|
||||
// Allow only geohash base32 characters, strip '#', limit length
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
@@ -194,13 +196,14 @@ struct LocationChannelsSheet: View {
|
||||
}) {
|
||||
HStack(spacing: 6) {
|
||||
Text("teleport")
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
Image(systemName: "face.dashed")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.secondary.opacity(0.12))
|
||||
.cornerRadius(6)
|
||||
@@ -209,17 +212,16 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
if let err = customError {
|
||||
Text(err)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
|
||||
|
||||
// Bookmarked geohashes
|
||||
if !bookmarks.bookmarks.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("bookmarked")
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
VStack(spacing: 6) {
|
||||
ForEach(bookmarks.bookmarks, id: \.self) { gh in
|
||||
@@ -236,7 +238,7 @@ struct LocationChannelsSheet: View {
|
||||
trailingAccessory: {
|
||||
Button(action: { bookmarks.toggle(gh) }) {
|
||||
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 8)
|
||||
@@ -260,18 +262,15 @@ struct LocationChannelsSheet: View {
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
|
||||
}
|
||||
|
||||
// Footer action inside the list
|
||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||||
torToggleSection
|
||||
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 4, trailing: 0))
|
||||
Button(action: {
|
||||
openSystemLocationSettings()
|
||||
}) {
|
||||
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))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
@@ -281,7 +280,6 @@ struct LocationChannelsSheet: View {
|
||||
.buttonStyle(.plain)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 10, trailing: 0))
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
@@ -319,12 +317,12 @@ struct LocationChannelsSheet: View {
|
||||
let parts = splitTitleAndCount(title)
|
||||
HStack(spacing: 4) {
|
||||
Text(parts.base)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.fontWeight(titleBold ? .bold : .regular)
|
||||
.foregroundColor(titleColor ?? Color.primary)
|
||||
if let count = parts.countSuffix, !count.isEmpty {
|
||||
Text(count)
|
||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -335,7 +333,7 @@ struct LocationChannelsSheet: View {
|
||||
return subtitlePrefix
|
||||
}()
|
||||
Text(subtitleFull)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
@@ -343,7 +341,7 @@ struct LocationChannelsSheet: View {
|
||||
Spacer()
|
||||
if isSelected {
|
||||
Text("✔︎")
|
||||
.font(.bitchatSystem(size: 16, design: .monospaced))
|
||||
.font(.system(size: 16, design: .monospaced))
|
||||
.foregroundColor(standardGreen)
|
||||
}
|
||||
trailingAccessory()
|
||||
@@ -411,36 +409,8 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TOR Toggle & Standardized Colors
|
||||
// MARK: - Standardized Colors
|
||||
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 {
|
||||
(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
|
||||
extension LocationChannelsSheet {
|
||||
private func coverageString(forPrecision len: Int) -> String {
|
||||
|
||||
@@ -7,7 +7,6 @@ struct LocationNotesView: View {
|
||||
let onNotesCountChanged: ((Int) -> Void)?
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var draft: String = ""
|
||||
@@ -19,24 +18,30 @@ struct LocationNotesView: View {
|
||||
_manager = StateObject(wrappedValue: LocationNotesManager(geohash: gh))
|
||||
}
|
||||
|
||||
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
|
||||
private var accentGreen: Color { colorScheme == .dark ? .green : Color(red: 0, green: 0.5, blue: 0) }
|
||||
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
|
||||
private var backgroundColor: Color {
|
||||
colorScheme == .dark ? Color.black : Color.white
|
||||
}
|
||||
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 {
|
||||
#if os(macOS)
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
headerSection
|
||||
notesContent
|
||||
}
|
||||
}
|
||||
.background(backgroundColor)
|
||||
inputSection
|
||||
header
|
||||
Divider()
|
||||
list
|
||||
Divider()
|
||||
input
|
||||
}
|
||||
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
|
||||
.background(backgroundColor)
|
||||
.foregroundColor(textColor)
|
||||
.onDisappear { manager.cancel() }
|
||||
.onChange(of: geohash) { newValue in
|
||||
manager.setGeohash(newValue)
|
||||
@@ -45,202 +50,100 @@ struct LocationNotesView: View {
|
||||
.onChange(of: manager.notes.count) { newValue in
|
||||
onNotesCountChanged?(newValue)
|
||||
}
|
||||
#else
|
||||
NavigationView {
|
||||
VStack(spacing: 0) {
|
||||
headerSection
|
||||
ScrollView {
|
||||
notesContent
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
.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 {
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
|
||||
private var headerSection: some View {
|
||||
let count = manager.notes.count
|
||||
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 {
|
||||
LazyVStack(alignment: .leading, spacing: 12) {
|
||||
if manager.state == .noRelays {
|
||||
noRelaysRow
|
||||
} else if manager.state == .loading && !manager.initialLoadComplete {
|
||||
loadingRow
|
||||
} else if manager.notes.isEmpty {
|
||||
emptyRow
|
||||
} else {
|
||||
ForEach(manager.notes) { note in
|
||||
noteRow(note)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
Text("@\(baseName)")
|
||||
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||
if !ts.isEmpty {
|
||||
Text(ts)
|
||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
Text(note.content)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
Text(message)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
Spacer()
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.frame(width: 32, height: 32)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
Button("dismiss") { manager.clearError() }
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.frame(height: 44)
|
||||
.padding(.horizontal, 12)
|
||||
.background(backgroundColor.opacity(0.95))
|
||||
}
|
||||
|
||||
private var inputSection: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
private var list: some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(manager.notes) { note in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
// Show @name without the #abcd suffix; timestamp in brackets
|
||||
HStack(spacing: 0) {
|
||||
Text("@")
|
||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
let parts = splitSuffix(from: note.displayName)
|
||||
Text(parts.0)
|
||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
let ts = timestampText(for: note.createdAt)
|
||||
Text(ts.isEmpty ? "" : "[\(ts)]")
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.8))
|
||||
}
|
||||
Text(note.content)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.background(backgroundColor)
|
||||
}
|
||||
|
||||
private var input: some View {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
TextField("add a note for this place", text: $draft, axis: .vertical)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.lineLimit(maxDraftLines, reservesSpace: true)
|
||||
.padding(.vertical, 6)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.lineLimit(3, reservesSpace: true)
|
||||
.padding(.horizontal, 12)
|
||||
|
||||
Button(action: send) {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.bitchatSystem(size: 20))
|
||||
.foregroundColor(sendButtonEnabled ? accentGreen : .secondary)
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? Color.gray : textColor)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!sendButtonEnabled)
|
||||
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
.padding(.trailing, 12)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background(backgroundColor)
|
||||
.overlay(Divider(), alignment: .top)
|
||||
.frame(minHeight: 44)
|
||||
.padding(.vertical, 8)
|
||||
.background(backgroundColor.opacity(0.95))
|
||||
}
|
||||
|
||||
private func send() {
|
||||
@@ -250,17 +153,15 @@ struct LocationNotesView: View {
|
||||
draft = ""
|
||||
}
|
||||
|
||||
private var sendButtonEnabled: Bool {
|
||||
!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && manager.state != .noRelays
|
||||
}
|
||||
|
||||
// MARK: - Timestamp Formatting
|
||||
private func timestampText(for date: Date) -> String {
|
||||
let now = Date()
|
||||
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) ?? ""
|
||||
return rel.isEmpty ? "" : "\(rel) ago"
|
||||
} else {
|
||||
// Absolute date (MMM d or MMM d, yyyy if different year)
|
||||
let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year)
|
||||
let fmt = sameYear ? Self.absDateFormatter : Self.absDateYearFormatter
|
||||
return fmt.string(from: date)
|
||||
@@ -288,3 +189,16 @@ struct LocationNotesView: View {
|
||||
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, "")
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ struct MeshPeerList: View {
|
||||
if viewModel.allPeers.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("nobody around...")
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 12)
|
||||
@@ -45,27 +45,27 @@ struct MeshPeerList: View {
|
||||
let baseColor = isMe ? Color.orange : assigned
|
||||
if isMe {
|
||||
Image(systemName: "person.fill")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
} else if peer.isConnected {
|
||||
// Mesh-connected peer: radio icon
|
||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
} else if peer.isReachable {
|
||||
// Mesh-reachable (relayed): point.3 icon
|
||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
} else if peer.isMutualFavorite {
|
||||
// Mutual favorite reachable via Nostr: globe icon (purple)
|
||||
Image(systemName: "globe")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.purple)
|
||||
} else {
|
||||
// Fallback icon for others (dimmed)
|
||||
Image(systemName: "person")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
}
|
||||
|
||||
@@ -73,19 +73,19 @@ struct MeshPeerList: View {
|
||||
let (base, suffix) = splitSuffix(from: displayName)
|
||||
HStack(spacing: 0) {
|
||||
Text(base)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(baseColor)
|
||||
if !suffix.isEmpty {
|
||||
let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6)
|
||||
Text(suffix)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(suffixColor)
|
||||
}
|
||||
}
|
||||
|
||||
if !isMe, viewModel.isPeerBlocked(peer.id) {
|
||||
Image(systemName: "nosign")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.red)
|
||||
.help("Blocked")
|
||||
}
|
||||
@@ -94,7 +94,7 @@ struct MeshPeerList: View {
|
||||
if peer.isConnected {
|
||||
if let icon = item.enc.icon {
|
||||
Image(systemName: icon)
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
}
|
||||
} else {
|
||||
@@ -102,12 +102,12 @@ struct MeshPeerList: View {
|
||||
if let fp = viewModel.getFingerprint(for: peer.id),
|
||||
viewModel.verifiedFingerprints.contains(fp) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
} else if let icon = item.enc.icon {
|
||||
// Fallback to whatever status says (likely lock if we had a past session)
|
||||
Image(systemName: icon)
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,7 @@ struct MeshPeerList: View {
|
||||
// Unread message indicator for this peer
|
||||
if !isMe, item.hasUnread {
|
||||
Image(systemName: "envelope.fill")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.orange)
|
||||
.help("New messages")
|
||||
}
|
||||
@@ -126,7 +126,7 @@ struct MeshPeerList: View {
|
||||
if !isMe {
|
||||
Button(action: { onToggleFavorite(peer.id) }) {
|
||||
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ struct MyQRView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Text("scan to verify me")
|
||||
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||
|
||||
VStack(spacing: 10) {
|
||||
QRCodeImage(data: qrString, size: 240)
|
||||
@@ -24,7 +24,7 @@ struct MyQRView: View {
|
||||
|
||||
// Non-scrolling, fully visible URL (wraps across lines)
|
||||
Text(qrString)
|
||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.multilineTextAlignment(.leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -60,7 +60,7 @@ struct QRCodeImage: View {
|
||||
.frame(width: size, height: size)
|
||||
.overlay(
|
||||
Text("qr unavailable")
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.gray)
|
||||
)
|
||||
}
|
||||
@@ -119,7 +119,7 @@ struct QRScanView: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
#else
|
||||
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)
|
||||
.frame(height: 100)
|
||||
.border(Color.gray.opacity(0.4))
|
||||
@@ -255,7 +255,7 @@ struct VerificationSheetView: View {
|
||||
// Top header (always at top)
|
||||
HStack {
|
||||
Text("VERIFY")
|
||||
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(accentColor)
|
||||
Spacer()
|
||||
Button(action: {
|
||||
@@ -263,7 +263,7 @@ struct VerificationSheetView: View {
|
||||
isPresented = false
|
||||
}) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.bitchatSystem(size: 14, weight: .semibold))
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(accentColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -279,7 +279,7 @@ struct VerificationSheetView: View {
|
||||
if showingScanner {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
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)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(accentColor)
|
||||
@@ -310,13 +310,13 @@ struct VerificationSheetView: View {
|
||||
if showingScanner {
|
||||
Button(action: { showingScanner = false }) {
|
||||
Label("show my qr", systemImage: "qrcode")
|
||||
.font(.bitchatSystem(size: 13, design: .monospaced))
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
} else {
|
||||
Button(action: { showingScanner = true }) {
|
||||
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)
|
||||
.tint(.gray)
|
||||
@@ -328,7 +328,7 @@ struct VerificationSheetView: View {
|
||||
viewModel.verifiedFingerprints.contains(fp) {
|
||||
Button(action: { viewModel.unverifyFingerprint(for: pid) }) {
|
||||
Label("remove verification", systemImage: "minus.circle")
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.gray)
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.chat.bitchat</string>
|
||||
<string>group.chat.bitchat-local</string>
|
||||
</array>
|
||||
<key>com.apple.security.device.bluetooth</key>
|
||||
<true/>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.chat.bitchat</string>
|
||||
<string>group.chat.bitchat-local</string>
|
||||
</array>
|
||||
<key>com.apple.security.device.bluetooth</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
</plist>
|
||||
|
||||
@@ -35,9 +35,8 @@ final class ShareViewController: UIViewController {
|
||||
statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
|
||||
statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)
|
||||
])
|
||||
DispatchQueue.global().async {
|
||||
self.processShare()
|
||||
}
|
||||
|
||||
processShare()
|
||||
}
|
||||
|
||||
// MARK: - Processing
|
||||
@@ -164,7 +163,7 @@ final class ShareViewController: UIViewController {
|
||||
statusLabel.text = msg
|
||||
// Complete shortly after showing status
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) {
|
||||
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,275 +1,264 @@
|
||||
Relay URL,Latitude,Longitude
|
||||
nostr.tac.lol,47.4748,-122.273
|
||||
relay.javi.space,43.4633,11.8796
|
||||
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.laantungir.net,-19.4692,-42.5315
|
||||
relay.endfiat.money,43.6532,-79.3832
|
||||
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
|
||||
relay.fr13nd5.com,52.5233,13.3426
|
||||
nostr.vulpem.com,49.4543,11.0746
|
||||
temp.iris.to,43.6532,-79.3832
|
||||
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
|
||||
relay.bitcoinartclock.com,50.4754,12.3683
|
||||
relay.nostromo.social,49.4543,11.0746
|
||||
nostr.liberty.fans,36.9104,-89.5875
|
||||
roles-az-achieving-somebody.trycloudflare.com,43.6532,-79.3832
|
||||
relay.arx-ccn.com,50.4754,12.3683
|
||||
cyberspace.nostr1.com,40.7128,-74.006
|
||||
nostr.smut.cloud,43.6532,-79.3832
|
||||
nostr-02.czas.top,53.471,9.88208
|
||||
relay.tapestry.ninja,40.8054,-74.0241
|
||||
relay.mostro.network,40.8302,-74.1299
|
||||
wot.brightbolt.net,47.6735,-116.781
|
||||
nostr.spaceshell.xyz,43.6532,-79.3832
|
||||
nostr.rikmeijer.nl,50.4754,12.3683
|
||||
relay.artx.market,43.652,-79.3633
|
||||
strfry.felixzieger.de,50.1013,8.62643
|
||||
relay.seq1.net,43.6532,-79.3832
|
||||
relay.cosmicbolt.net,37.3986,-121.964
|
||||
relay.electriclifestyle.com,26.2897,-80.1293
|
||||
r.bitcoinhold.net,43.6532,-79.3832
|
||||
nostr-relay.amethyst.name,39.0067,-77.4291
|
||||
relay.stream.labs.h3.se,59.4016,17.9455
|
||||
relay.unknown.cloud,43.6532,-79.3832
|
||||
nostr-02.yakihonne.com,1.32123,103.695
|
||||
relay.coinos.io,43.6532,-79.3832
|
||||
relay5.bitransfer.org,43.6532,-79.3832
|
||||
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
||||
relay.nostr.wirednet.jp,34.706,135.493
|
||||
nostr.einundzwanzig.space,50.1109,8.68213
|
||||
relay.21e6.cz,50.1682,14.0546
|
||||
relay04.lnfi.network,39.0997,-94.5786
|
||||
relay.chorus.community,50.1109,8.68213
|
||||
relay.nostr.place,32.7767,-96.797
|
||||
relay.vrtmrz.net,43.6532,-79.3832
|
||||
noxir.kpherox.dev,34.8587,135.509
|
||||
wot.nostr.net,43.6532,-79.3832
|
||||
relay.cypherflow.ai,48.8566,2.35222
|
||||
wot.sudocarlos.com,51.5072,-0.127586
|
||||
nostr.jerrynya.fun,31.2304,121.474
|
||||
nostr2.girino.org,43.6532,-79.3832
|
||||
nostrings-relay-dev.fly.dev,41.8781,-87.6298
|
||||
fanfares.nostr1.com,40.7128,-74.006
|
||||
nostr.red5d.dev,43.6532,-79.3832
|
||||
nostr.hifish.org,47.4043,8.57398
|
||||
nostr.now,36.55,139.733
|
||||
relay.nostr.band,60.1699,24.9384
|
||||
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
|
||||
nostream.breadslice.com,43.6532,-79.3832
|
||||
relay.fundstr.me,42.3601,-71.0589
|
||||
nostr.oxtr.dev,50.4754,12.3683
|
||||
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
|
||||
nostr.vulpem.com,49.4543,11.0746
|
||||
nostr.rohoss.com,50.1109,8.68213
|
||||
articles.layer3.news,37.3387,-121.885
|
||||
relay.digitalezukunft.cyou,45.5019,-73.5674
|
||||
nostr.notribe.net,40.8302,-74.1299
|
||||
nostr.21crypto.ch,47.4988,8.72369
|
||||
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
|
||||
nos.lol,50.4754,12.3683
|
||||
relay.artx.market,43.652,-79.3633
|
||||
wot.sebastix.social,51.8933,4.42083
|
||||
alien.macneilmediagroup.com,43.6532,-79.3832
|
||||
nostrings-relay-dev.fly.dev,41.8781,-87.6298
|
||||
relay.satlantis.io,32.8769,-80.0114
|
||||
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
|
||||
relay.unknown.cloud,43.6532,-79.3832
|
||||
nostr.lojong.info,43.6532,-79.3832
|
||||
nostr.zenon.network,43.5009,-70.4428
|
||||
wot.codingarena.top,50.4754,12.3683
|
||||
nostr-02.dorafactory.org,1.35208,103.82
|
||||
relay.nostromo.social,49.4543,11.0746
|
||||
theoutpost.life,64.1476,-21.9392
|
||||
strfry.shock.network,41.8959,-88.2169
|
||||
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
|
||||
orangesync.tech,50.1109,8.68213
|
||||
nostr.davidebtc.me,51.5072,-0.127586
|
||||
internationalright-wing.org,-22.5022,-48.7114
|
||||
nostr.rikmeijer.nl,50.4754,12.3683
|
||||
ynostr.yael.at,60.1699,24.9384
|
||||
ithurtswhenip.ee,51.223,6.78245
|
||||
ribo.af.nostria.app,-26.2041,28.0473
|
||||
shu05.shugur.net,48.8566,2.35222
|
||||
noxir.kpherox.dev,34.8587,135.509
|
||||
relay.degmods.com,50.4754,12.3683
|
||||
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
|
||||
relay.wellorder.net,45.5201,-122.99
|
||||
nostr.sathoarder.com,48.5734,7.75211
|
||||
purplerelay.com,50.1109,8.68213
|
||||
yabu.me,35.6092,139.73
|
||||
nostr.88mph.life,43.6532,-79.3832
|
||||
nostr.luisschwab.net,43.6532,-79.3832
|
||||
nostr.casa21.space,43.6532,-79.3832
|
||||
nostr.hekster.org,37.3986,-121.964
|
||||
relay.utxo.farm,35.6916,139.768
|
||||
nostr.overmind.lol,43.6532,-79.3832
|
||||
rnostr.breadslice.com,43.6532,-79.3832
|
||||
zap.watch,45.5029,-73.5723
|
||||
nostr-pub.wellorder.net,45.5201,-122.99
|
||||
wot.sovbit.host,64.1466,-21.9426
|
||||
relay.endfiat.money,43.6532,-79.3832
|
||||
nostr2.girino.org,43.6532,-79.3832
|
||||
nostr.jerrynya.fun,31.2304,121.474
|
||||
nostr.spacecitynode.com,29.7057,-95.2706
|
||||
wot.basspistol.org,49.4521,11.0767
|
||||
shu01.shugur.net,21.4902,39.2246
|
||||
relay.electriclifestyle.com,26.2897,-80.1293
|
||||
relay.mccormick.cx,52.3563,4.95714
|
||||
nostr.middling.mydns.jp,35.8099,140.12
|
||||
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.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.tadryanom.me,43.6532,-79.3832
|
||||
relay.orangepill.ovh,49.1689,-0.358841
|
||||
nostr.stakey.net,52.3676,4.90414
|
||||
nostr.rtvslawenia.com,49.4543,11.0746
|
||||
orangepiller.org,60.1699,24.9384
|
||||
nostr.plantroon.com,50.1013,8.62643
|
||||
nostr-verified.wellorder.net,45.5201,-122.99
|
||||
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
|
||||
dev-relay.lnfi.network,39.0997,-94.5786
|
||||
slick.mjex.me,39.048,-77.4817
|
||||
wot.sudocarlos.com,51.5072,-0.127586
|
||||
relay04.lnfi.network,39.0997,-94.5786
|
||||
relay2.angor.io,48.1046,11.6002
|
||||
relayrs.notoshi.win,43.6532,-79.3832
|
||||
relay2.ngengine.org,43.6532,-79.3832
|
||||
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.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
|
||||
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
|
||||
|
||||
|
Reference in New Issue
Block a user