mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 22:45:20 +00:00
Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41c297b889 | ||
|
|
45fec95af2 | ||
|
|
1a569cfb80 | ||
|
|
3ad9cbe48f | ||
|
|
4f62364bf4 | ||
|
|
fb003ba25e | ||
|
|
52c51c9be6 | ||
|
|
96a580491a | ||
|
|
fec5769fd2 | ||
|
|
12d3e91182 | ||
|
|
cf6d169337 | ||
|
|
302c741d58 | ||
|
|
7ec84857eb | ||
|
|
68482c67d7 | ||
|
|
551a843691 | ||
|
|
fbc15ea08f | ||
|
|
0f23ed0a99 | ||
|
|
c583949031 | ||
|
|
d75700fa2b | ||
|
|
7149182c56 | ||
|
|
13b58aeaa9 | ||
|
|
b5b05977fb | ||
|
|
af6703cf24 | ||
|
|
eb13eec4c5 | ||
|
|
81c90b2924 | ||
|
|
8c368233c4 | ||
|
|
13ebaad42f | ||
|
|
10e3f574ab | ||
|
|
5f44c56a90 | ||
|
|
01ec4573f8 | ||
|
|
f86ae5e2ea | ||
|
|
2673d28686 | ||
|
|
03c357f048 | ||
|
|
64f91bb1d6 | ||
|
|
9ecda048e7 | ||
|
|
bdc31d3be3 | ||
|
|
6846b19d83 | ||
|
|
524a7e916b | ||
|
|
f2473f857b | ||
|
|
8cfee095d3 | ||
|
|
e4ec2ef3fe | ||
|
|
bd11940151 | ||
|
|
90273cee2d | ||
|
|
faae625e53 | ||
|
|
3a35b3acc2 | ||
|
|
787386c66d | ||
|
|
aeec15f054 | ||
|
|
f004f3e7ea | ||
|
|
8d938e8518 | ||
|
|
ea72212f66 | ||
|
|
3183703a63 | ||
|
|
2ffa709cca | ||
|
|
bf712a0610 | ||
|
|
78fb3f1bf6 | ||
|
|
f5af00be88 | ||
|
|
b2214e30f5 | ||
|
|
85063e9359 | ||
|
|
f67f728d79 | ||
|
|
b873b19104 | ||
|
|
0f7bcf17ff | ||
|
|
da2a12296e | ||
|
|
56bd100944 | ||
|
|
eb5bc96a3c | ||
|
|
d01538a2ea | ||
|
|
9abfab3248 | ||
|
|
0ae09f73d8 | ||
|
|
56dd12f3b1 | ||
|
|
f5caa1751a | ||
|
|
de906cb97c | ||
|
|
a41ec65f58 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,12 @@
|
||||
---
|
||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/Swift.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||
dependencies:
|
||||
- mtime: 1757258659000000000
|
||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/Swift.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||
size: 14166264
|
||||
- mtime: 1754189697000000000
|
||||
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||
size: 2261306
|
||||
sdk_relative: true
|
||||
version: 1
|
||||
...
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||
dependencies:
|
||||
- mtime: 1757258662000000000
|
||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||
size: 18068
|
||||
- mtime: 1754189697000000000
|
||||
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||
size: 2261306
|
||||
sdk_relative: true
|
||||
- mtime: 1754191141000000000
|
||||
path: 'usr/lib/swift/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||
size: 1224
|
||||
sdk_relative: true
|
||||
version: 1
|
||||
...
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_Concurrency.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||
dependencies:
|
||||
- mtime: 1757258669000000000
|
||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_Concurrency.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||
size: 699544
|
||||
- mtime: 1754189697000000000
|
||||
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||
size: 2261306
|
||||
sdk_relative: true
|
||||
- mtime: 1754192470000000000
|
||||
path: 'usr/lib/swift/_Concurrency.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||
size: 364219
|
||||
sdk_relative: true
|
||||
version: 1
|
||||
...
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||
dependencies:
|
||||
- mtime: 1757258664000000000
|
||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||
size: 83568
|
||||
- mtime: 1754189697000000000
|
||||
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||
size: 2261306
|
||||
sdk_relative: true
|
||||
- mtime: 1754192532000000000
|
||||
path: 'usr/lib/swift/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||
size: 24507
|
||||
sdk_relative: true
|
||||
version: 1
|
||||
...
|
||||
@@ -24,4 +24,4 @@ jobs:
|
||||
run: swift build
|
||||
|
||||
- name: Run Tests
|
||||
run: swift test --parallel --disable-swift-testing # so it only runs xctests: https://github.com/swiftlang/swift-package-manager/issues/8529#issuecomment-2815711345
|
||||
run: swift test --parallel
|
||||
|
||||
+2
-4
@@ -9,9 +9,6 @@ plans/
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
|
||||
## User settings
|
||||
xcuserdata/
|
||||
|
||||
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
||||
*.xcscmblueprint
|
||||
*.xccheckout
|
||||
@@ -57,7 +54,8 @@ iOSInjectionProject/
|
||||
|
||||
## Xcode project
|
||||
*.xcodeproj/project.xcworkspace/
|
||||
*.xcodeproj/xcshareddata/
|
||||
## Xcode User settings
|
||||
xcuserdata/
|
||||
|
||||
## Python
|
||||
__pycache__/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
MARKETING_VERSION = 1.4.2
|
||||
MARKETING_VERSION = 1.4.4
|
||||
CURRENT_PROJECT_VERSION = 1
|
||||
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||
|
||||
+10
-15
@@ -4,8 +4,9 @@ import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "bitchat",
|
||||
defaultLocalization: "en",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.iOS(.v17),
|
||||
.macOS(.v13)
|
||||
],
|
||||
products: [
|
||||
@@ -15,6 +16,7 @@ let package = Package(
|
||||
),
|
||||
],
|
||||
dependencies:[
|
||||
.package(path: "localPackages/Tor"),
|
||||
.package(path: "localPackages/BitLogger"),
|
||||
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
||||
],
|
||||
@@ -24,8 +26,7 @@ let package = Package(
|
||||
dependencies: [
|
||||
.product(name: "P256K", package: "swift-secp256k1"),
|
||||
.product(name: "BitLogger", package: "BitLogger"),
|
||||
.target(name: "TorC"),
|
||||
.target(name: "tor-nolzma")
|
||||
.product(name: "Tor", package: "Tor")
|
||||
],
|
||||
path: "bitchat",
|
||||
exclude: [
|
||||
@@ -33,21 +34,12 @@ let package = Package(
|
||||
"Assets.xcassets",
|
||||
"bitchat.entitlements",
|
||||
"bitchat-macOS.entitlements",
|
||||
"LaunchScreen.storyboard",
|
||||
"Services/Tor/C/"
|
||||
"LaunchScreen.storyboard"
|
||||
],
|
||||
linkerSettings: [
|
||||
.linkedLibrary("z")
|
||||
resources: [
|
||||
.process("Localizable.xcstrings")
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "TorC",
|
||||
path: "bitchat/Services/Tor/C"
|
||||
),
|
||||
.binaryTarget(
|
||||
name: "tor-nolzma",
|
||||
path: "Frameworks/tor-nolzma.xcframework"
|
||||
),
|
||||
.testTarget(
|
||||
name: "bitchatTests",
|
||||
dependencies: ["bitchat"],
|
||||
@@ -55,6 +47,9 @@ let package = Package(
|
||||
exclude: [
|
||||
"Info.plist",
|
||||
"README.md"
|
||||
],
|
||||
resources: [
|
||||
.process("Localization")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
@@ -116,3 +116,10 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
|
||||
|
||||
Want to try this on macos: `just run` will set it up and run from source.
|
||||
Run `just clean` afterwards to restore things to original state for mobile app building and development.
|
||||
|
||||
## Localization
|
||||
|
||||
- Base app resources live under `bitchat/Localization/Base.lproj/`. Add new copy to `Localizable.strings` and plural rules to `Localizable.stringsdict`.
|
||||
- Share extension strings are separate in `bitchatShareExtension/Localization/Base.lproj/Localizable.strings`.
|
||||
- Prefer keys that describe intent (`app_info.features.offline.title`) and reuse existing ones where possible.
|
||||
- Run `xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build` to compile-check any localization updates.
|
||||
|
||||
@@ -184,6 +184,28 @@ To minimize bandwidth, `BitchatPacket`s are serialized into a compact binary for
|
||||
|
||||
**Padding:** All packets are padded to the next standard block size (256, 512, 1024, or 2048 bytes) using a PKCS#7-style scheme to obscure the true message length from network observers.
|
||||
|
||||
```mermaid
|
||||
---
|
||||
config:
|
||||
theme: dark
|
||||
---
|
||||
---
|
||||
title: "BitchatPacket"
|
||||
---
|
||||
packet
|
||||
+8: "Version"
|
||||
+8: "Type"
|
||||
+8: "TTL"
|
||||
+64: "Timestamp"
|
||||
+8: "Flags"
|
||||
+16: "Payload Length"
|
||||
+64: "Sender ID"
|
||||
+64: "Recipient ID (optional)"
|
||||
+48: "Payload (variable)"
|
||||
+64: "Signature (optional)"
|
||||
```
|
||||
_A representation of the sizes of the fields in `BitchatPacket`_
|
||||
|
||||
### 6.2. Application Message Format (`BitchatMessage`)
|
||||
|
||||
For packets of type `message`, the payload is a binary-serialized `BitchatMessage` containing the chat content.
|
||||
@@ -198,6 +220,25 @@ For packets of type `message`, the payload is a binary-serialized `BitchatMessag
|
||||
| Original Sender | 1 + len (opt)| Nickname of the original sender if the message is a relay. |
|
||||
| Recipient Nickname | 1 + len (opt)| Nickname of the recipient for private messages. |
|
||||
|
||||
```mermaid
|
||||
---
|
||||
config:
|
||||
theme: dark
|
||||
---
|
||||
---
|
||||
title: "BitchatMessage"
|
||||
---
|
||||
packet
|
||||
+8: "Flags"
|
||||
+64: "Timestamp"
|
||||
+24: "ID (variable)"
|
||||
+32: "Sender (variable)"
|
||||
+32: "Content (variable)"
|
||||
+32: "Original Sender (variable) (optional)"
|
||||
+32: "Recipient Nickname (variable) (optional)"
|
||||
```
|
||||
_A representation of the sizes of the fields in `BitchatMessage`_
|
||||
|
||||
---
|
||||
|
||||
## 7. Message Routing and Propagation
|
||||
|
||||
Generated
+44
-21
@@ -7,15 +7,14 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; };
|
||||
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 */; };
|
||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; };
|
||||
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; };
|
||||
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA802E7706A80032EA8A /* Tor */; };
|
||||
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */; };
|
||||
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||
/* End PBXBuildFile section */
|
||||
@@ -58,11 +57,10 @@
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
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; };
|
||||
A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = "tor-nolzma.xcframework"; path = "localPackages/Tor/Frameworks/tor-nolzma.xcframework"; sourceTree = "<group>"; };
|
||||
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = relays/online_relays_gps.csv; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
@@ -87,7 +85,6 @@
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
LaunchScreen.storyboard,
|
||||
Services/Tor/C/include/.gitkeep,
|
||||
);
|
||||
target = 0576A29205865664C0937536 /* bitchat_macOS */;
|
||||
};
|
||||
@@ -136,32 +133,22 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
files = (
|
||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
|
||||
048A88812E76FD18000FBCDD /* libz.tbd in Frameworks */,
|
||||
0481A3A02E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */,
|
||||
3EE336D150427F736F32B56C /* P256K in Frameworks */,
|
||||
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
|
||||
);
|
||||
};
|
||||
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
files = (
|
||||
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */,
|
||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
||||
0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */,
|
||||
0481A3A12E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */,
|
||||
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
||||
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
|
||||
);
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
0481A3542E6D877600FC845E /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */,
|
||||
0481A35C2E6DA18600FC845E /* libz.tbd */,
|
||||
);
|
||||
path = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
18198ED912AAF495D8AF7763 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -170,8 +157,8 @@
|
||||
A6E32D212E762EAB0032EA8A /* bitchatShareExtension */,
|
||||
A6E32D412E762EAE0032EA8A /* bitchatTests */,
|
||||
A6E367C92E76469E0032EA8A /* Configs */,
|
||||
0481A3542E6D877600FC845E /* Frameworks */,
|
||||
9F37F9F2C353B58AC809E93B /* Products */,
|
||||
A6F183FB2E948783006A9046 /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -187,6 +174,14 @@
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A6F183FB2E948783006A9046 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -207,6 +202,7 @@
|
||||
packageProductDependencies = (
|
||||
B1D9136AA0083366353BFA2F /* P256K */,
|
||||
A6E3E5712E7703760032EA8A /* BitLogger */,
|
||||
A6E3EA802E7706A80032EA8A /* Tor */,
|
||||
);
|
||||
productName = bitchat_macOS;
|
||||
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
||||
@@ -284,6 +280,7 @@
|
||||
packageProductDependencies = (
|
||||
4EB6BA1B8464F1EA38F4E286 /* P256K */,
|
||||
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
||||
A6E3EA7E2E7706720032EA8A /* Tor */,
|
||||
);
|
||||
productName = bitchat_iOS;
|
||||
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
||||
@@ -304,12 +301,26 @@
|
||||
knownRegions = (
|
||||
Base,
|
||||
en,
|
||||
es,
|
||||
ar,
|
||||
de,
|
||||
fr,
|
||||
he,
|
||||
id,
|
||||
it,
|
||||
ja,
|
||||
ne,
|
||||
"pt-BR",
|
||||
ru,
|
||||
uk,
|
||||
"zh-Hans",
|
||||
);
|
||||
mainGroup = 18198ED912AAF495D8AF7763;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
||||
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 90;
|
||||
projectDirPath = "";
|
||||
@@ -867,6 +878,10 @@
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = localPackages/BitLogger;
|
||||
};
|
||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = localPackages/Tor;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
@@ -894,6 +909,14 @@
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = BitLogger;
|
||||
};
|
||||
A6E3EA7E2E7706720032EA8A /* Tor */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Tor;
|
||||
};
|
||||
A6E3EA802E7706A80032EA8A /* Tor */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Tor;
|
||||
};
|
||||
B1D9136AA0083366353BFA2F /* P256K */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1640"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "57CA17A36A2532A6CFF367BB"
|
||||
BuildableName = "bitchatShareExtension.appex"
|
||||
BlueprintName = "bitchatShareExtension"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
codeCoverageEnabled = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<CodeCoverageTargets>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</CodeCoverageTargets>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES"
|
||||
testExecutionOrdering = "random">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "6CB97DF2EA57234CB3E563B8"
|
||||
BuildableName = "bitchatTests_iOS.xctest"
|
||||
BlueprintName = "bitchatTests_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "-DBITCHAT_DEV_ALLOW_CLEARNET"
|
||||
value = ""
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,105 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1640"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_macOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_macOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "47FF23248747DD7CB666CB91"
|
||||
BuildableName = "bitchatTests_macOS.xctest"
|
||||
BlueprintName = "bitchatTests_macOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_macOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "BITCHAT_LOG_LEVEL"
|
||||
value = "debug"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_macOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -6,6 +6,7 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Tor
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ import Foundation
|
||||
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
|
||||
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
|
||||
struct EphemeralIdentity {
|
||||
let peerID: String // 8 random bytes
|
||||
let peerID: PeerID // 8 random bytes
|
||||
let sessionStart: Date
|
||||
var handshakeState: HandshakeState
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ protocol SecureIdentityStateManagerProtocol {
|
||||
|
||||
// MARK: Cryptographic Identities
|
||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?)
|
||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity]
|
||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity]
|
||||
func updateSocialIdentity(_ identity: SocialIdentity)
|
||||
|
||||
// MARK: Favorites Management
|
||||
@@ -121,12 +121,12 @@ protocol SecureIdentityStateManagerProtocol {
|
||||
func getBlockedNostrPubkeys() -> Set<String>
|
||||
|
||||
// MARK: Ephemeral Session Management
|
||||
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState)
|
||||
func updateHandshakeState(peerID: String, state: HandshakeState)
|
||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState)
|
||||
func updateHandshakeState(peerID: PeerID, state: HandshakeState)
|
||||
|
||||
// MARK: Cleanup
|
||||
func clearAllIdentityData()
|
||||
func removeEphemeralSession(peerID: String)
|
||||
func removeEphemeralSession(peerID: PeerID)
|
||||
|
||||
// MARK: Verification
|
||||
func setVerified(fingerprint: String, verified: Bool)
|
||||
@@ -143,7 +143,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
private let encryptionKeyName = "identityCacheEncryptionKey"
|
||||
|
||||
// In-memory state
|
||||
private var ephemeralSessions: [String: EphemeralIdentity] = [:]
|
||||
private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
|
||||
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
||||
private var cache: IdentityCache = IdentityCache()
|
||||
|
||||
@@ -321,11 +321,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
/// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID
|
||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
|
||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity] {
|
||||
queue.sync {
|
||||
// Defensive: ensure hex and correct length
|
||||
guard peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] }
|
||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
|
||||
guard peerID.isShort else { return [] }
|
||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,7 +455,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
// MARK: - Ephemeral Session Management
|
||||
|
||||
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState = .none) {
|
||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
||||
peerID: peerID,
|
||||
@@ -465,7 +465,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
func updateHandshakeState(peerID: String, state: HandshakeState) {
|
||||
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.ephemeralSessions[peerID]?.handshakeState = state
|
||||
|
||||
@@ -493,7 +493,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
func removeEphemeralSession(peerID: String) {
|
||||
func removeEphemeralSession(peerID: PeerID) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.ephemeralSessions.removeValue(forKey: peerID)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,7 @@ final class BitchatMessage: Codable {
|
||||
let originalSender: String?
|
||||
let isPrivate: Bool
|
||||
let recipientNickname: String?
|
||||
let senderPeerID: String?
|
||||
let senderPeerID: PeerID?
|
||||
let mentions: [String]? // Array of mentioned nicknames
|
||||
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
||||
|
||||
@@ -42,7 +42,19 @@ final class BitchatMessage: Codable {
|
||||
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
|
||||
}
|
||||
|
||||
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil) {
|
||||
init(
|
||||
id: String? = nil,
|
||||
sender: String,
|
||||
content: String,
|
||||
timestamp: Date,
|
||||
isRelay: Bool,
|
||||
originalSender: String? = nil,
|
||||
isPrivate: Bool = false,
|
||||
recipientNickname: String? = nil,
|
||||
senderPeerID: PeerID? = nil,
|
||||
mentions: [String]? = nil,
|
||||
deliveryStatus: DeliveryStatus? = nil
|
||||
) {
|
||||
self.id = id ?? UUID().uuidString
|
||||
self.sender = sender
|
||||
self.content = content
|
||||
@@ -151,7 +163,7 @@ extension BitchatMessage {
|
||||
data.append(recipData.prefix(255))
|
||||
}
|
||||
|
||||
if let senderPeerID = senderPeerID, let peerData = senderPeerID.data(using: .utf8) {
|
||||
if let peerData = senderPeerID?.id.data(using: .utf8) {
|
||||
data.append(UInt8(min(peerData.count, 255)))
|
||||
data.append(peerData.prefix(255))
|
||||
}
|
||||
@@ -264,11 +276,11 @@ extension BitchatMessage {
|
||||
}
|
||||
}
|
||||
|
||||
var senderPeerID: String?
|
||||
var senderPeerID: PeerID?
|
||||
if hasSenderPeerID && offset < dataCopy.count {
|
||||
let length = Int(dataCopy[offset]); offset += 1
|
||||
if offset + length <= dataCopy.count {
|
||||
senderPeerID = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
|
||||
senderPeerID = PeerID(data: dataCopy[offset..<offset+length])
|
||||
offset += length
|
||||
}
|
||||
}
|
||||
@@ -323,6 +335,20 @@ extension BitchatMessage {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - System Message Factory
|
||||
|
||||
extension BitchatMessage {
|
||||
/// Creates a system message with default values
|
||||
static func system(_ content: String, timestamp: Date = Date()) -> BitchatMessage {
|
||||
return BitchatMessage(
|
||||
sender: "system",
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
isRelay: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element == BitchatMessage {
|
||||
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
|
||||
func cleanedAndDeduped() -> [Element] {
|
||||
|
||||
@@ -34,12 +34,12 @@ struct BitchatPacket: Codable {
|
||||
}
|
||||
|
||||
// Convenience initializer for new binary format
|
||||
init(type: UInt8, ttl: UInt8, senderID: String, payload: Data) {
|
||||
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data) {
|
||||
self.version = 1
|
||||
self.type = type
|
||||
// Convert hex string peer ID to binary data (8 bytes)
|
||||
var senderData = Data()
|
||||
var tempID = senderID
|
||||
var tempID = senderID.id
|
||||
while tempID.count >= 2 {
|
||||
let hexByte = String(tempID.prefix(2))
|
||||
if let byte = UInt8(hexByte, radix: 16) {
|
||||
|
||||
@@ -2,8 +2,8 @@ import Foundation
|
||||
import CoreBluetooth
|
||||
|
||||
/// Represents a peer in the BitChat network with all associated metadata
|
||||
struct BitchatPeer: Identifiable, Equatable {
|
||||
let id: String // Hex-encoded peer ID
|
||||
struct BitchatPeer: Equatable {
|
||||
let peerID: PeerID // Hex-encoded peer ID
|
||||
let noisePublicKey: Data
|
||||
let nickname: String
|
||||
let lastSeen: Date
|
||||
@@ -51,7 +51,7 @@ struct BitchatPeer: Identifiable, Equatable {
|
||||
|
||||
// Display helpers
|
||||
var displayName: String {
|
||||
nickname.isEmpty ? String(id.prefix(8)) : nickname
|
||||
nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname
|
||||
}
|
||||
|
||||
var statusIcon: String {
|
||||
@@ -73,14 +73,14 @@ struct BitchatPeer: Identifiable, Equatable {
|
||||
|
||||
// Initialize from mesh service data
|
||||
init(
|
||||
id: String,
|
||||
peerID: PeerID,
|
||||
noisePublicKey: Data,
|
||||
nickname: String,
|
||||
lastSeen: Date = Date(),
|
||||
isConnected: Bool = false,
|
||||
isReachable: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.peerID = peerID
|
||||
self.noisePublicKey = noisePublicKey
|
||||
self.nickname = nickname
|
||||
self.lastSeen = lastSeen
|
||||
@@ -93,8 +93,6 @@ struct BitchatPeer: Identifiable, Equatable {
|
||||
}
|
||||
|
||||
static func == (lhs: BitchatPeer, rhs: BitchatPeer) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
lhs.peerID == rhs.peerID
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// GeoPerson.swift
|
||||
// bitchat
|
||||
//
|
||||
// Model representing a participant in a geohash channel
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Represents a person participating in a geohash-based location channel
|
||||
struct GeoPerson: Identifiable, Equatable {
|
||||
let id: String // pubkey hex (lowercased)
|
||||
let displayName: String
|
||||
let lastSeen: Date
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
//
|
||||
// PeerID.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct PeerID: Equatable, Hashable {
|
||||
enum Prefix: String, CaseIterable {
|
||||
/// When no prefix is provided
|
||||
case empty = ""
|
||||
/// `"mesh:"`
|
||||
case mesh = "mesh:"
|
||||
/// `"name:"`
|
||||
case name = "name:"
|
||||
/// `"noise:"` (+ 64 characters hex)
|
||||
case noise = "noise:"
|
||||
/// `"nostr_"` (+ 16 characters hex)
|
||||
case geoDM = "nostr_"
|
||||
/// `"nostr:"` (+ 8 characters hex)
|
||||
case geoChat = "nostr:"
|
||||
}
|
||||
|
||||
let prefix: Prefix
|
||||
|
||||
/// Returns the actual value without any prefix
|
||||
let bare: String
|
||||
|
||||
/// Returns the full `id` value by combining `(prefix + bare)`
|
||||
var id: String { prefix.rawValue + bare }
|
||||
|
||||
// Private so the callers have to go through a convenience init
|
||||
private init(prefix: Prefix, bare: any StringProtocol) {
|
||||
self.prefix = prefix
|
||||
self.bare = String(bare)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience Inits
|
||||
|
||||
extension PeerID {
|
||||
/// Convenience init to create GeoDM PeerID by appending `"nostr_"` to the first 16 characters of `pubKey`
|
||||
init(nostr_ pubKey: String) {
|
||||
self.init(prefix: .geoDM, bare: pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))
|
||||
}
|
||||
|
||||
/// Convenience init to create GeoChat PeerID by appending `"nostr:"` to the first 8 characters of `pubKey`
|
||||
init(nostr pubKey: String) {
|
||||
self.init(prefix: .geoChat, bare: pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))
|
||||
}
|
||||
|
||||
/// Convenience init to create PeerID from String/Substring by splitting it into prefix and bare parts
|
||||
init(str: any StringProtocol) {
|
||||
if let prefix = Prefix.allCases.first(where: { $0 != .empty && str.hasPrefix($0.rawValue) }) {
|
||||
self.init(prefix: prefix, bare: String(str).dropFirst(prefix.rawValue.count))
|
||||
} else {
|
||||
self.init(prefix: .empty, bare: str)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience init to handle `Optional<String>`
|
||||
init?(str: (any StringProtocol)?) {
|
||||
guard let str else { return nil }
|
||||
self.init(str: str)
|
||||
}
|
||||
|
||||
/// Convenience init to create PeerID by converting Data to String
|
||||
init?(data: Data) {
|
||||
self.init(str: String(data: data, encoding: .utf8))
|
||||
}
|
||||
|
||||
/// Convenience init to "hide" hex-encoding implementation detail
|
||||
init(hexData: Data) {
|
||||
self.init(str: hexData.hexEncodedString())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Noise Public Key Helpers
|
||||
|
||||
extension PeerID {
|
||||
/// Derive the stable 16-hex peer ID from a Noise static public key
|
||||
init(publicKey: Data) {
|
||||
self.init(str: publicKey.sha256Fingerprint().prefix(16))
|
||||
}
|
||||
|
||||
/// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed
|
||||
func toShort() -> PeerID {
|
||||
if let noiseKey {
|
||||
return PeerID(publicKey: noiseKey)
|
||||
}
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Codable
|
||||
|
||||
extension PeerID: Codable {
|
||||
init(from decoder: any Decoder) throws {
|
||||
self.init(str: try decoder.singleValueContainer().decode(String.self))
|
||||
}
|
||||
|
||||
func encode(to encoder: any Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
try container.encode(id)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
extension PeerID {
|
||||
var isEmpty: Bool {
|
||||
id.isEmpty
|
||||
}
|
||||
|
||||
/// Returns true if `id` starts with "`nostr:`"
|
||||
var isGeoChat: Bool {
|
||||
prefix == .geoChat
|
||||
}
|
||||
|
||||
/// Returns true if `id` starts with "`nostr_`"
|
||||
var isGeoDM: Bool {
|
||||
prefix == .geoDM
|
||||
}
|
||||
|
||||
func toPercentEncoded() -> String {
|
||||
id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Validation
|
||||
|
||||
extension PeerID {
|
||||
private enum Constants {
|
||||
static let maxIDLength = 64
|
||||
static let hexIDLength = 16 // 8 bytes = 16 hex chars
|
||||
}
|
||||
|
||||
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
|
||||
var isValid: Bool {
|
||||
if prefix != .empty {
|
||||
return PeerID(str: bare).isValid
|
||||
}
|
||||
|
||||
// Accept short routing IDs (exact 16-hex) or Full Noise key hex (exact 64-hex)
|
||||
if isShort || isNoiseKeyHex {
|
||||
return true
|
||||
}
|
||||
|
||||
// If length equals short or full but isn't valid hex, reject
|
||||
if id.count == Constants.hexIDLength || id.count == Constants.maxIDLength {
|
||||
return false
|
||||
}
|
||||
|
||||
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
|
||||
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
||||
return !id.isEmpty &&
|
||||
id.count < Constants.maxIDLength &&
|
||||
id.rangeOfCharacter(from: validCharset.inverted) == nil
|
||||
}
|
||||
|
||||
/// Short routing IDs (exact 16-hex)
|
||||
var isShort: Bool {
|
||||
bare.count == Constants.hexIDLength && Data(hexString: bare) != nil
|
||||
}
|
||||
|
||||
/// Full Noise key hex (exact 64-hex)
|
||||
var isNoiseKeyHex: Bool {
|
||||
noiseKey != nil
|
||||
}
|
||||
|
||||
/// Full Noise key (exact 64-hex) as Data
|
||||
var noiseKey: Data? {
|
||||
guard bare.count == Constants.maxIDLength else { return nil }
|
||||
return Data(hexString: bare)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Comparable
|
||||
|
||||
extension PeerID: Comparable {
|
||||
static func < (lhs: PeerID, rhs: PeerID) -> Bool {
|
||||
lhs.id < rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - String Interop Helpers
|
||||
|
||||
// MARK: CustomStringConvertible
|
||||
|
||||
extension PeerID: CustomStringConvertible {
|
||||
/// So it returns the actual `id` like before even inside another String
|
||||
var description: String {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Custom Equatable w/ String & Optionality
|
||||
|
||||
// PeerID <> String
|
||||
extension Optional where Wrapped == PeerID {
|
||||
static func ==(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id == rhs }
|
||||
static func !=(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id != rhs }
|
||||
}
|
||||
|
||||
// String <> PeerID
|
||||
extension Optional where Wrapped == String {
|
||||
static func ==(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs == rhs?.id }
|
||||
static func !=(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs != rhs?.id }
|
||||
}
|
||||
@@ -79,7 +79,7 @@ struct ReadReceipt: Codable {
|
||||
|
||||
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
||||
let readerID = readerIDData.hexEncodedString()
|
||||
guard InputValidator.validatePeerID(readerID) else { return nil }
|
||||
guard PeerID(str: readerID).isValid else { return nil }
|
||||
|
||||
guard let timestamp = dataCopy.readDate(at: &offset),
|
||||
InputValidator.validateTimestamp(timestamp),
|
||||
|
||||
@@ -397,7 +397,7 @@ final class NoiseSymmetricState {
|
||||
if nameData.count <= 32 {
|
||||
self.hash = nameData + Data(repeating: 0, count: 32 - nameData.count)
|
||||
} else {
|
||||
self.hash = Data(SHA256.hash(data: nameData))
|
||||
self.hash = nameData.sha256Hash()
|
||||
}
|
||||
self.chainingKey = self.hash
|
||||
}
|
||||
@@ -410,7 +410,7 @@ final class NoiseSymmetricState {
|
||||
}
|
||||
|
||||
func mixHash(_ data: Data) {
|
||||
hash = Data(SHA256.hash(data: hash + data))
|
||||
hash = (hash + data).sha256Hash()
|
||||
}
|
||||
|
||||
func mixKeyAndHash(_ inputKeyMaterial: Data) {
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Security Constants
|
||||
|
||||
@@ -53,11 +52,6 @@ struct NoiseSecurityValidator {
|
||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
||||
}
|
||||
|
||||
/// Validate peer ID format using unified validator
|
||||
static func validatePeerID(_ peerID: String) -> Bool {
|
||||
return InputValidator.validatePeerID(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Enhanced Noise Session with Security
|
||||
@@ -137,8 +131,8 @@ final class SecureNoiseSession: NoiseSession {
|
||||
// MARK: - Rate Limiter
|
||||
|
||||
final class NoiseRateLimiter {
|
||||
private var handshakeTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
||||
private var messageTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
||||
private var handshakeTimestamps: [PeerID: [Date]] = [:]
|
||||
private var messageTimestamps: [PeerID: [Date]] = [:]
|
||||
|
||||
// Global rate limiting
|
||||
private var globalHandshakeTimestamps: [Date] = []
|
||||
@@ -146,7 +140,7 @@ final class NoiseRateLimiter {
|
||||
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
||||
|
||||
func allowHandshake(from peerID: String) -> Bool {
|
||||
func allowHandshake(from peerID: PeerID) -> Bool {
|
||||
return queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
let oneMinuteAgo = now.addingTimeInterval(-60)
|
||||
@@ -175,7 +169,7 @@ final class NoiseRateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
func allowMessage(from peerID: String) -> Bool {
|
||||
func allowMessage(from peerID: PeerID) -> Bool {
|
||||
return queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
let oneSecondAgo = now.addingTimeInterval(-1)
|
||||
@@ -204,7 +198,7 @@ final class NoiseRateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
func reset(for peerID: String) {
|
||||
func reset(for peerID: PeerID) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.handshakeTimestamps.removeValue(forKey: peerID)
|
||||
self.messageTimestamps.removeValue(forKey: peerID)
|
||||
|
||||
@@ -10,32 +10,8 @@ import BitLogger
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Noise Session State
|
||||
|
||||
enum NoiseSessionState: Equatable {
|
||||
case uninitialized
|
||||
case handshaking
|
||||
case established
|
||||
case failed(Error)
|
||||
|
||||
static func == (lhs: NoiseSessionState, rhs: NoiseSessionState) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.uninitialized, .uninitialized),
|
||||
(.handshaking, .handshaking),
|
||||
(.established, .established):
|
||||
return true
|
||||
case (.failed, .failed):
|
||||
return true // We don't compare the errors
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Noise Session
|
||||
|
||||
class NoiseSession {
|
||||
let peerID: String
|
||||
let peerID: PeerID
|
||||
let role: NoiseRole
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private var state: NoiseSessionState = .uninitialized
|
||||
@@ -55,7 +31,7 @@ class NoiseSession {
|
||||
private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent)
|
||||
|
||||
init(
|
||||
peerID: String,
|
||||
peerID: PeerID,
|
||||
role: NoiseRole,
|
||||
keychain: KeychainManagerProtocol,
|
||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
||||
@@ -141,7 +117,7 @@ class NoiseSession {
|
||||
handshakeState = nil // Clear handshake state
|
||||
|
||||
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established")
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID))
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||
|
||||
return nil
|
||||
} else {
|
||||
@@ -167,7 +143,7 @@ class NoiseSession {
|
||||
handshakeState = nil // Clear handshake state
|
||||
|
||||
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established")
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID))
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||
}
|
||||
|
||||
return response
|
||||
@@ -252,249 +228,8 @@ class NoiseSession {
|
||||
handshakeHash = nil
|
||||
|
||||
if wasEstablished {
|
||||
SecureLogger.info(.sessionExpired(peerID: peerID))
|
||||
SecureLogger.info(.sessionExpired(peerID: peerID.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session Manager
|
||||
|
||||
final class NoiseSessionManager {
|
||||
private var sessions: [String: NoiseSession] = [:]
|
||||
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||
|
||||
// Callbacks
|
||||
var onSessionEstablished: ((String, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
||||
var onSessionFailed: ((String, Error) -> Void)?
|
||||
|
||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||
self.localStaticKey = localStaticKey
|
||||
self.keychain = keychain
|
||||
}
|
||||
|
||||
// MARK: - Session Management
|
||||
|
||||
func createSession(for peerID: String, role: NoiseRole) -> NoiseSession {
|
||||
return managerQueue.sync(flags: .barrier) {
|
||||
let session = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: role,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = session
|
||||
return session
|
||||
}
|
||||
}
|
||||
|
||||
func getSession(for peerID: String) -> NoiseSession? {
|
||||
return managerQueue.sync {
|
||||
return sessions[peerID]
|
||||
}
|
||||
}
|
||||
|
||||
func removeSession(for peerID: String) {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
if let session = sessions[peerID] {
|
||||
if session.isEstablished() {
|
||||
SecureLogger.info(.sessionExpired(peerID: peerID))
|
||||
}
|
||||
// Clear sensitive data before removing
|
||||
session.reset()
|
||||
}
|
||||
_ = 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 {
|
||||
return sessions.filter { $0.value.isEstablished() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Handshake Helpers
|
||||
|
||||
func initiateHandshake(with peerID: String) throws -> Data {
|
||||
return try managerQueue.sync(flags: .barrier) {
|
||||
// Check if we already have an established session
|
||||
if let existingSession = sessions[peerID], existingSession.isEstablished() {
|
||||
// Session already established, don't recreate
|
||||
throw NoiseSessionError.alreadyEstablished
|
||||
}
|
||||
|
||||
// Remove any existing non-established session
|
||||
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
}
|
||||
|
||||
// Create new initiator session
|
||||
let session = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: .initiator,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = session
|
||||
|
||||
do {
|
||||
let handshakeData = try session.startHandshake()
|
||||
return handshakeData
|
||||
} catch {
|
||||
// Clean up failed session
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
SecureLogger.error(.handshakeFailed(peerID: peerID, error: error.localizedDescription))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleIncomingHandshake(from peerID: String, message: Data) throws -> Data? {
|
||||
// Process everything within the synchronized block to prevent race conditions
|
||||
return try managerQueue.sync(flags: .barrier) {
|
||||
var shouldCreateNew = false
|
||||
var existingSession: NoiseSession? = nil
|
||||
|
||||
if let existing = sessions[peerID] {
|
||||
// If we have an established session, the peer must have cleared their session
|
||||
// for a good reason (e.g., decryption failure, restart, etc.)
|
||||
// We should accept the new handshake to re-establish encryption
|
||||
if existing.isEstablished() {
|
||||
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
shouldCreateNew = true
|
||||
} else {
|
||||
// If we're in the middle of a handshake and receive a new initiation,
|
||||
// reset and start fresh (the other side may have restarted)
|
||||
if existing.getState() == .handshaking && message.count == 32 {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
shouldCreateNew = true
|
||||
} else {
|
||||
existingSession = existing
|
||||
}
|
||||
}
|
||||
} else {
|
||||
shouldCreateNew = true
|
||||
}
|
||||
|
||||
// Get or create session
|
||||
let session: NoiseSession
|
||||
if shouldCreateNew {
|
||||
let newSession = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: .responder,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = newSession
|
||||
session = newSession
|
||||
} else {
|
||||
session = existingSession!
|
||||
}
|
||||
|
||||
// Process the handshake message within the synchronized block
|
||||
do {
|
||||
let response = try session.processHandshakeMessage(message)
|
||||
|
||||
// Check if session is established after processing
|
||||
if session.isEstablished() {
|
||||
if let remoteKey = session.getRemoteStaticPublicKey() {
|
||||
// Schedule callback outside the synchronized block to prevent deadlock
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.onSessionEstablished?(peerID, remoteKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
} catch {
|
||||
// Reset the session on handshake failure so next attempt can start fresh
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
|
||||
// Schedule callback outside the synchronized block to prevent deadlock
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.onSessionFailed?(peerID, error)
|
||||
}
|
||||
|
||||
SecureLogger.error(.handshakeFailed(peerID: peerID, error: error.localizedDescription))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Encryption/Decryption
|
||||
|
||||
func encrypt(_ plaintext: Data, for peerID: String) throws -> Data {
|
||||
guard let session = getSession(for: peerID) else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
}
|
||||
|
||||
return try session.encrypt(plaintext)
|
||||
}
|
||||
|
||||
func decrypt(_ ciphertext: Data, from peerID: String) throws -> Data {
|
||||
guard let session = getSession(for: peerID) else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
}
|
||||
|
||||
return try session.decrypt(ciphertext)
|
||||
}
|
||||
|
||||
// MARK: - Key Management
|
||||
|
||||
func getRemoteStaticKey(for peerID: String) -> Curve25519.KeyAgreement.PublicKey? {
|
||||
return getSession(for: peerID)?.getRemoteStaticPublicKey()
|
||||
}
|
||||
|
||||
func getHandshakeHash(for peerID: String) -> Data? {
|
||||
return getSession(for: peerID)?.getHandshakeHash()
|
||||
}
|
||||
|
||||
// MARK: - Session Rekeying
|
||||
|
||||
func getSessionsNeedingRekey() -> [(peerID: String, needsRekey: Bool)] {
|
||||
return managerQueue.sync {
|
||||
var needingRekey: [(peerID: String, needsRekey: Bool)] = []
|
||||
|
||||
for (peerID, session) in sessions {
|
||||
if let secureSession = session as? SecureNoiseSession,
|
||||
secureSession.isEstablished(),
|
||||
secureSession.needsRenegotiation() {
|
||||
needingRekey.append((peerID: peerID, needsRekey: true))
|
||||
}
|
||||
}
|
||||
|
||||
return needingRekey
|
||||
}
|
||||
}
|
||||
|
||||
func initiateRekey(for peerID: String) throws {
|
||||
// Remove old session
|
||||
removeSession(for: peerID)
|
||||
|
||||
// Initiate new handshake
|
||||
_ = try initiateHandshake(with: peerID)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum NoiseSessionError: Error {
|
||||
case invalidState
|
||||
case notEstablished
|
||||
case sessionNotFound
|
||||
case handshakeFailed(Error)
|
||||
case alreadyEstablished
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// NoiseSessionError.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
enum NoiseSessionError: Error {
|
||||
case invalidState
|
||||
case notEstablished
|
||||
case sessionNotFound
|
||||
case handshakeFailed(Error)
|
||||
case alreadyEstablished
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//
|
||||
// NoiseSessionManager.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
final class NoiseSessionManager {
|
||||
private var sessions: [PeerID: NoiseSession] = [:]
|
||||
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||
|
||||
// Callbacks
|
||||
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
||||
var onSessionFailed: ((PeerID, Error) -> Void)?
|
||||
|
||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||
self.localStaticKey = localStaticKey
|
||||
self.keychain = keychain
|
||||
}
|
||||
|
||||
// MARK: - Session Management
|
||||
|
||||
func createSession(for peerID: PeerID, role: NoiseRole) -> NoiseSession {
|
||||
return managerQueue.sync(flags: .barrier) {
|
||||
let session = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: role,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = session
|
||||
return session
|
||||
}
|
||||
}
|
||||
|
||||
func getSession(for peerID: PeerID) -> NoiseSession? {
|
||||
return managerQueue.sync {
|
||||
return sessions[peerID]
|
||||
}
|
||||
}
|
||||
|
||||
func removeSession(for peerID: PeerID) {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
if let session = sessions[peerID] {
|
||||
if session.isEstablished() {
|
||||
SecureLogger.info(.sessionExpired(peerID: peerID.id))
|
||||
}
|
||||
// Clear sensitive data before removing
|
||||
session.reset()
|
||||
}
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func removeAllSessions() {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
for (_, session) in sessions {
|
||||
session.reset()
|
||||
}
|
||||
sessions.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
func getEstablishedSessions() -> [PeerID: NoiseSession] {
|
||||
return managerQueue.sync {
|
||||
return sessions.filter { $0.value.isEstablished() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Handshake Helpers
|
||||
|
||||
func initiateHandshake(with peerID: PeerID) throws -> Data {
|
||||
return try managerQueue.sync(flags: .barrier) {
|
||||
// Check if we already have an established session
|
||||
if let existingSession = sessions[peerID], existingSession.isEstablished() {
|
||||
// Session already established, don't recreate
|
||||
throw NoiseSessionError.alreadyEstablished
|
||||
}
|
||||
|
||||
// Remove any existing non-established session
|
||||
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
}
|
||||
|
||||
// Create new initiator session
|
||||
let session = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: .initiator,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = session
|
||||
|
||||
do {
|
||||
let handshakeData = try session.startHandshake()
|
||||
return handshakeData
|
||||
} catch {
|
||||
// Clean up failed session
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
||||
// Process everything within the synchronized block to prevent race conditions
|
||||
return try managerQueue.sync(flags: .barrier) {
|
||||
var shouldCreateNew = false
|
||||
var existingSession: NoiseSession? = nil
|
||||
|
||||
if let existing = sessions[peerID] {
|
||||
// If we have an established session, the peer must have cleared their session
|
||||
// for a good reason (e.g., decryption failure, restart, etc.)
|
||||
// We should accept the new handshake to re-establish encryption
|
||||
if existing.isEstablished() {
|
||||
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
shouldCreateNew = true
|
||||
} else {
|
||||
// If we're in the middle of a handshake and receive a new initiation,
|
||||
// reset and start fresh (the other side may have restarted)
|
||||
if existing.getState() == .handshaking && message.count == 32 {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
shouldCreateNew = true
|
||||
} else {
|
||||
existingSession = existing
|
||||
}
|
||||
}
|
||||
} else {
|
||||
shouldCreateNew = true
|
||||
}
|
||||
|
||||
// Get or create session
|
||||
let session: NoiseSession
|
||||
if shouldCreateNew {
|
||||
let newSession = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: .responder,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = newSession
|
||||
session = newSession
|
||||
} else {
|
||||
session = existingSession!
|
||||
}
|
||||
|
||||
// Process the handshake message within the synchronized block
|
||||
do {
|
||||
let response = try session.processHandshakeMessage(message)
|
||||
|
||||
// Check if session is established after processing
|
||||
if session.isEstablished() {
|
||||
if let remoteKey = session.getRemoteStaticPublicKey() {
|
||||
// Schedule callback outside the synchronized block to prevent deadlock
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.onSessionEstablished?(peerID, remoteKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
} catch {
|
||||
// Reset the session on handshake failure so next attempt can start fresh
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
|
||||
// Schedule callback outside the synchronized block to prevent deadlock
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.onSessionFailed?(peerID, error)
|
||||
}
|
||||
|
||||
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Encryption/Decryption
|
||||
|
||||
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
|
||||
guard let session = getSession(for: peerID) else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
}
|
||||
|
||||
return try session.encrypt(plaintext)
|
||||
}
|
||||
|
||||
func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
|
||||
guard let session = getSession(for: peerID) else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
}
|
||||
|
||||
return try session.decrypt(ciphertext)
|
||||
}
|
||||
|
||||
// MARK: - Key Management
|
||||
|
||||
func getRemoteStaticKey(for peerID: PeerID) -> Curve25519.KeyAgreement.PublicKey? {
|
||||
return getSession(for: peerID)?.getRemoteStaticPublicKey()
|
||||
}
|
||||
|
||||
func getHandshakeHash(for peerID: PeerID) -> Data? {
|
||||
return getSession(for: peerID)?.getHandshakeHash()
|
||||
}
|
||||
|
||||
// MARK: - Session Rekeying
|
||||
|
||||
func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
|
||||
return managerQueue.sync {
|
||||
var needingRekey: [(peerID: PeerID, needsRekey: Bool)] = []
|
||||
|
||||
for (peerID, session) in sessions {
|
||||
if let secureSession = session as? SecureNoiseSession,
|
||||
secureSession.isEstablished(),
|
||||
secureSession.needsRenegotiation() {
|
||||
needingRekey.append((peerID: peerID, needsRekey: true))
|
||||
}
|
||||
}
|
||||
|
||||
return needingRekey
|
||||
}
|
||||
}
|
||||
|
||||
func initiateRekey(for peerID: PeerID) throws {
|
||||
// Remove old session
|
||||
removeSession(for: peerID)
|
||||
|
||||
// Initiate new handshake
|
||||
_ = try initiateHandshake(with: peerID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//
|
||||
// NoiseSessionState.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
enum NoiseSessionState: Equatable {
|
||||
case uninitialized
|
||||
case handshaking
|
||||
case established
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import Tor
|
||||
|
||||
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
||||
@MainActor
|
||||
|
||||
@@ -100,7 +100,7 @@ struct NostrEmbeddedBitChat {
|
||||
if let maybeData = Data(hexString: recipientPeerID) {
|
||||
if maybeData.count == 32 {
|
||||
// Treat as Noise static public key; derive peerID from fingerprint
|
||||
return PeerIDUtils.derivePeerID(fromPublicKey: maybeData)
|
||||
return PeerID(publicKey: maybeData).id
|
||||
} else if maybeData.count == 8 {
|
||||
// Already an 8-byte peer ID
|
||||
return recipientPeerID
|
||||
|
||||
@@ -226,10 +226,7 @@ struct NostrIdentityBridge {
|
||||
}
|
||||
}
|
||||
// As a final fallback, hash the seed+msg and try again
|
||||
var combined = Data()
|
||||
combined.append(seed)
|
||||
combined.append(msg)
|
||||
let fallback = Data(CryptoKit.SHA256.hash(data: combined))
|
||||
let fallback = (seed + msg).sha256Hash()
|
||||
return try NostrIdentity(privateKeyData: fallback)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,10 +521,7 @@ struct NostrEvent: Codable {
|
||||
] as [Any]
|
||||
|
||||
let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
||||
let hash = CryptoKit.SHA256.hash(data: data)
|
||||
let hashData = Data(hash)
|
||||
let hashHex = hash.compactMap { String(format: "%02x", $0) }.joined()
|
||||
return (hashHex, hashData)
|
||||
return (data.sha256Fingerprint(), data.sha256Hash())
|
||||
}
|
||||
|
||||
func jsonString() throws -> String {
|
||||
|
||||
@@ -2,6 +2,7 @@ import BitLogger
|
||||
import Foundation
|
||||
import Network
|
||||
import Combine
|
||||
import Tor
|
||||
|
||||
/// Manages WebSocket connections to Nostr relays
|
||||
@MainActor
|
||||
@@ -108,7 +109,20 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
|
||||
deinit {
|
||||
// Clean up timers and active connections
|
||||
reconnectionTimer?.invalidate()
|
||||
for (_, tracker) in eoseTrackers {
|
||||
tracker.timer?.invalidate()
|
||||
}
|
||||
for (_, task) in connections {
|
||||
task.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
cancellables.removeAll()
|
||||
SecureLogger.debug("NostrRelayManager deinitialized", category: .session)
|
||||
}
|
||||
|
||||
/// Connect to all configured relays
|
||||
func connect() {
|
||||
// Global network policy gate
|
||||
|
||||
@@ -197,7 +197,7 @@ extension Data {
|
||||
offset += 16
|
||||
|
||||
// Convert 16 bytes to UUID string format
|
||||
let uuid = uuidData.map { String(format: "%02x", $0) }.joined()
|
||||
let uuid = uuidData.hexEncodedString()
|
||||
|
||||
// Insert hyphens at proper positions: 8-4-4-4-12
|
||||
var result = ""
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
///
|
||||
|
||||
import Foundation
|
||||
import CoreBluetooth
|
||||
|
||||
// MARK: - Message Types
|
||||
|
||||
@@ -131,7 +132,7 @@ enum LazyHandshakeState {
|
||||
// MARK: - Delivery Status
|
||||
|
||||
// Delivery status for messages
|
||||
enum DeliveryStatus: Codable, Equatable {
|
||||
enum DeliveryStatus: Codable, Equatable, Hashable {
|
||||
case sending
|
||||
case sent // Left our device
|
||||
case delivered(to: String, at: Date) // Confirmed by recipient
|
||||
@@ -161,18 +162,21 @@ enum DeliveryStatus: Codable, Equatable {
|
||||
|
||||
protocol BitchatDelegate: AnyObject {
|
||||
func didReceiveMessage(_ message: BitchatMessage)
|
||||
func didConnectToPeer(_ peerID: String)
|
||||
func didDisconnectFromPeer(_ peerID: String)
|
||||
func didUpdatePeerList(_ peers: [String])
|
||||
|
||||
func didConnectToPeer(_ peerID: PeerID)
|
||||
func didDisconnectFromPeer(_ peerID: PeerID)
|
||||
func didUpdatePeerList(_ peers: [PeerID])
|
||||
|
||||
// Optional method to check if a fingerprint belongs to a favorite peer
|
||||
func isFavorite(fingerprint: String) -> Bool
|
||||
|
||||
|
||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
|
||||
|
||||
// Low-level events for better separation of concerns
|
||||
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date)
|
||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||
|
||||
// Bluetooth state updates for user notifications
|
||||
func didUpdateBluetoothState(_ state: CBManagerState)
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date)
|
||||
}
|
||||
|
||||
// Provide default implementation to make it effectively optional
|
||||
@@ -185,11 +189,11 @@ extension BitchatDelegate {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
|
||||
// Default empty implementation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,14 @@ enum Geohash {
|
||||
return map
|
||||
}()
|
||||
|
||||
/// Validates a geohash string for building-level precision (8 characters).
|
||||
/// - Parameter geohash: The geohash string to validate
|
||||
/// - Returns: true if valid 8-character base32 geohash, false otherwise
|
||||
static func isValidBuildingGeohash(_ geohash: String) -> Bool {
|
||||
guard geohash.count == 8 else { return false }
|
||||
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
|
||||
}
|
||||
|
||||
/// Encodes the provided coordinates into a geohash string.
|
||||
/// - Parameters:
|
||||
/// - latitude: Latitude in degrees (-90...90)
|
||||
|
||||
@@ -23,15 +23,21 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .building: return "Building"
|
||||
case .block: return "Block"
|
||||
case .neighborhood: return "Neighborhood"
|
||||
case .city: return "City"
|
||||
case .province: return "Province"
|
||||
case .region: return "Region"
|
||||
case .building:
|
||||
return String(localized: "location_levels.building", comment: "Name for building-level location channel")
|
||||
case .block:
|
||||
return String(localized: "location_levels.block", comment: "Name for block-level location channel")
|
||||
case .neighborhood:
|
||||
return String(localized: "location_levels.neighborhood", comment: "Name for neighborhood-level location channel")
|
||||
case .city:
|
||||
return String(localized: "location_levels.city", comment: "Name for city-level location channel")
|
||||
case .province:
|
||||
return String(localized: "location_levels.province", comment: "Name for province-level location channel")
|
||||
case .region:
|
||||
return String(localized: "location_levels.region", comment: "Name for region-level location channel")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Backward-compatible Codable for renamed cases
|
||||
extension GeohashChannelLevel {
|
||||
init(from decoder: Decoder) throws {
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Peer ID Utilities
|
||||
|
||||
struct PeerIDUtils {
|
||||
/// Derive the stable 16-hex peer ID from a Noise static public key
|
||||
static func derivePeerID(fromPublicKey publicKey: Data) -> String {
|
||||
let digest = SHA256.hash(data: publicKey)
|
||||
let hex = digest.map { String(format: "%02x", $0) }.joined()
|
||||
return String(hex.prefix(16))
|
||||
}
|
||||
}
|
||||
|
||||
+1989
-2039
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,328 @@
|
||||
//
|
||||
// ColorPaletteService.swift
|
||||
// bitchat
|
||||
//
|
||||
// Manages consistent color assignment for peers using minimal-distance algorithm
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Service that assigns consistent, visually distinct colors to peers
|
||||
/// Uses a minimal-distance hue assignment algorithm to maximize color separation
|
||||
final class ColorPaletteService {
|
||||
|
||||
// MARK: - Palette State
|
||||
|
||||
private var peerPaletteLight: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||
private var peerPaletteDark: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||
private var peerPaletteSeeds: [String: String] = [:] // peerID -> seed used
|
||||
|
||||
private var nostrPaletteLight: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||
private var nostrPaletteDark: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||
private var nostrPaletteSeeds: [String: String] = [:] // pubkey -> seed used
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
private let slotCount: Int
|
||||
private let avoidCenter: Double // Hue to avoid (typically orange for self)
|
||||
private let avoidDelta: Double
|
||||
private let saturationDark: Double
|
||||
private let saturationLight: Double
|
||||
private let baseBrightnessDark: Double
|
||||
private let baseBrightnessLight: Double
|
||||
private let ringDeltaDark: Double
|
||||
private let ringDeltaLight: Double
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
slotCount: Int = max(8, TransportConfig.uiPeerPaletteSlots),
|
||||
avoidCenter: Double = 30.0 / 360.0, // Orange hue
|
||||
avoidDelta: Double = TransportConfig.uiColorHueAvoidanceDelta,
|
||||
saturationDark: Double = 0.80,
|
||||
saturationLight: Double = 0.70,
|
||||
baseBrightnessDark: Double = 0.75,
|
||||
baseBrightnessLight: Double = 0.45,
|
||||
ringDeltaDark: Double = TransportConfig.uiPeerPaletteRingBrightnessDeltaDark,
|
||||
ringDeltaLight: Double = TransportConfig.uiPeerPaletteRingBrightnessDeltaLight
|
||||
) {
|
||||
self.slotCount = slotCount
|
||||
self.avoidCenter = avoidCenter
|
||||
self.avoidDelta = avoidDelta
|
||||
self.saturationDark = saturationDark
|
||||
self.saturationLight = saturationLight
|
||||
self.baseBrightnessDark = baseBrightnessDark
|
||||
self.baseBrightnessLight = baseBrightnessLight
|
||||
self.ringDeltaDark = ringDeltaDark
|
||||
self.ringDeltaLight = ringDeltaLight
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Get color for a mesh peer
|
||||
func colorForMeshPeer(
|
||||
peerID: String,
|
||||
isDark: Bool,
|
||||
myPeerID: String,
|
||||
allPeers: [BitchatPeer],
|
||||
getNoiseKeyForShortID: (String) -> String?
|
||||
) -> Color {
|
||||
// Ensure palette is up to date
|
||||
rebuildPeerPaletteIfNeeded(
|
||||
myPeerID: myPeerID,
|
||||
allPeers: allPeers,
|
||||
getNoiseKeyForShortID: getNoiseKeyForShortID
|
||||
)
|
||||
|
||||
let entry = (isDark ? peerPaletteDark[peerID] : peerPaletteLight[peerID])
|
||||
let orange = Color.orange
|
||||
if peerID == myPeerID { return orange }
|
||||
|
||||
let saturation: Double = isDark ? saturationDark : saturationLight
|
||||
let baseBrightness: Double = isDark ? baseBrightnessDark : baseBrightnessLight
|
||||
let ringDelta = isDark ? ringDeltaDark : ringDeltaLight
|
||||
|
||||
if let e = entry {
|
||||
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(e.ring)))
|
||||
return Color(hue: e.hue, saturation: saturation, brightness: brightness)
|
||||
}
|
||||
|
||||
// Fallback to seed color if not in palette
|
||||
let seed = meshSeed(for: peerID, getNoiseKeyForShortID: getNoiseKeyForShortID)
|
||||
return Color(peerSeed: seed, isDark: isDark)
|
||||
}
|
||||
|
||||
/// Get color for a Nostr participant
|
||||
func colorForNostrPubkey(
|
||||
pubkeyHexLowercased: String,
|
||||
isDark: Bool,
|
||||
myNostrPubkey: String?,
|
||||
geohashPeople: [(id: String, seed: String)]
|
||||
) -> Color {
|
||||
rebuildNostrPaletteIfNeeded(
|
||||
myNostrPubkey: myNostrPubkey,
|
||||
geohashPeople: geohashPeople
|
||||
)
|
||||
|
||||
let entry = (isDark ? nostrPaletteDark[pubkeyHexLowercased] : nostrPaletteLight[pubkeyHexLowercased])
|
||||
if let me = myNostrPubkey, pubkeyHexLowercased == me { return .orange }
|
||||
|
||||
let saturation: Double = isDark ? saturationDark : saturationLight
|
||||
let baseBrightness: Double = isDark ? baseBrightnessDark : baseBrightnessLight
|
||||
let ringDelta = isDark ? ringDeltaDark : ringDeltaLight
|
||||
|
||||
if let e = entry {
|
||||
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(e.ring)))
|
||||
return Color(hue: e.hue, saturation: saturation, brightness: brightness)
|
||||
}
|
||||
|
||||
// Fallback to seed color
|
||||
return Color(peerSeed: "nostr:" + pubkeyHexLowercased, isDark: isDark)
|
||||
}
|
||||
|
||||
/// Get color for a message sender (auto-detects type)
|
||||
func peerColor(
|
||||
for message: BitchatMessage,
|
||||
isDark: Bool,
|
||||
myPeerID: String,
|
||||
myNostrPubkey: String?,
|
||||
nostrKeyMapping: [String: String],
|
||||
allPeers: [BitchatPeer],
|
||||
geohashPeople: [(id: String, seed: String)],
|
||||
getNoiseKeyForShortID: (String) -> String?
|
||||
) -> Color {
|
||||
if let spid = message.senderPeerID?.id {
|
||||
if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") {
|
||||
let bare: String = {
|
||||
if spid.hasPrefix("nostr:") { return String(spid.dropFirst(6)) }
|
||||
if spid.hasPrefix("nostr_") { return String(spid.dropFirst(6)) }
|
||||
return spid
|
||||
}()
|
||||
let full = nostrKeyMapping[spid]?.lowercased() ?? bare.lowercased()
|
||||
return colorForNostrPubkey(
|
||||
pubkeyHexLowercased: full,
|
||||
isDark: isDark,
|
||||
myNostrPubkey: myNostrPubkey,
|
||||
geohashPeople: geohashPeople
|
||||
)
|
||||
} else if spid.count == 16 {
|
||||
return colorForMeshPeer(
|
||||
peerID: spid,
|
||||
isDark: isDark,
|
||||
myPeerID: myPeerID,
|
||||
allPeers: allPeers,
|
||||
getNoiseKeyForShortID: getNoiseKeyForShortID
|
||||
)
|
||||
} else {
|
||||
return colorForMeshPeer(
|
||||
peerID: spid.lowercased(),
|
||||
isDark: isDark,
|
||||
myPeerID: myPeerID,
|
||||
allPeers: allPeers,
|
||||
getNoiseKeyForShortID: getNoiseKeyForShortID
|
||||
)
|
||||
}
|
||||
}
|
||||
// Fallback when we only have a display name
|
||||
return Color(peerSeed: message.sender.lowercased(), isDark: isDark)
|
||||
}
|
||||
|
||||
/// Reset all palette state (useful for testing)
|
||||
func reset() {
|
||||
peerPaletteLight.removeAll()
|
||||
peerPaletteDark.removeAll()
|
||||
peerPaletteSeeds.removeAll()
|
||||
nostrPaletteLight.removeAll()
|
||||
nostrPaletteDark.removeAll()
|
||||
nostrPaletteSeeds.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func meshSeed(for peerID: String, getNoiseKeyForShortID: (String) -> String?) -> String {
|
||||
if let full = getNoiseKeyForShortID(peerID)?.lowercased() {
|
||||
return "noise:" + full
|
||||
}
|
||||
return peerID.lowercased()
|
||||
}
|
||||
|
||||
private func rebuildPeerPaletteIfNeeded(
|
||||
myPeerID: String,
|
||||
allPeers: [BitchatPeer],
|
||||
getNoiseKeyForShortID: (String) -> String?
|
||||
) {
|
||||
// Build current peer->seed map (excluding self)
|
||||
var currentSeeds: [String: String] = [:]
|
||||
for p in allPeers where p.peerID.id != myPeerID {
|
||||
currentSeeds[p.peerID.id] = meshSeed(for: p.peerID.id, getNoiseKeyForShortID: getNoiseKeyForShortID)
|
||||
}
|
||||
|
||||
// If seeds unchanged and palette exists for both themes, skip
|
||||
if currentSeeds == peerPaletteSeeds,
|
||||
peerPaletteLight.keys.count == currentSeeds.count,
|
||||
peerPaletteDark.keys.count == currentSeeds.count {
|
||||
return
|
||||
}
|
||||
peerPaletteSeeds = currentSeeds
|
||||
|
||||
// Generate palette
|
||||
let mapping = assignColorsMinimalDistance(seeds: currentSeeds, previousMapping: peerPaletteLight)
|
||||
peerPaletteLight = mapping
|
||||
peerPaletteDark = mapping
|
||||
}
|
||||
|
||||
private func rebuildNostrPaletteIfNeeded(
|
||||
myNostrPubkey: String?,
|
||||
geohashPeople: [(id: String, seed: String)]
|
||||
) {
|
||||
// Build seeds map from currently visible geohash people (excluding self)
|
||||
var currentSeeds: [String: String] = [:]
|
||||
for p in geohashPeople where p.id != myNostrPubkey {
|
||||
currentSeeds[p.id] = p.seed
|
||||
}
|
||||
|
||||
if currentSeeds == nostrPaletteSeeds,
|
||||
nostrPaletteLight.keys.count == currentSeeds.count,
|
||||
nostrPaletteDark.keys.count == currentSeeds.count {
|
||||
return
|
||||
}
|
||||
nostrPaletteSeeds = currentSeeds
|
||||
|
||||
let mapping = assignColorsMinimalDistance(seeds: currentSeeds, previousMapping: nostrPaletteLight)
|
||||
nostrPaletteLight = mapping
|
||||
nostrPaletteDark = mapping
|
||||
}
|
||||
|
||||
// MARK: - Minimal-Distance Color Assignment Algorithm
|
||||
|
||||
private func assignColorsMinimalDistance(
|
||||
seeds: [String: String],
|
||||
previousMapping: [String: (slot: Int, ring: Int, hue: Double)]
|
||||
) -> [String: (slot: Int, ring: Int, hue: Double)] {
|
||||
// Generate evenly spaced hue slots avoiding self-orange range
|
||||
var slots: [Double] = []
|
||||
for i in 0..<slotCount {
|
||||
let hue = Double(i) / Double(slotCount)
|
||||
if abs(hue - avoidCenter) < avoidDelta { continue }
|
||||
slots.append(hue)
|
||||
}
|
||||
if slots.isEmpty {
|
||||
// Safety: if avoidance consumed all (shouldn't happen), fall back to full slots
|
||||
for i in 0..<slotCount { slots.append(Double(i) / Double(slotCount)) }
|
||||
}
|
||||
|
||||
// Helper to compute circular distance
|
||||
func circDist(_ a: Double, _ b: Double) -> Double {
|
||||
let d = abs(a - b)
|
||||
return d > 0.5 ? 1.0 - d : d
|
||||
}
|
||||
|
||||
// Assign slots to peers to maximize minimal distance, deterministically
|
||||
let peers = seeds.keys.sorted() // stable order
|
||||
|
||||
// Preferred slot index by seed (wrapping to available slots)
|
||||
let prefIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peers.map { id in
|
||||
let h = (seeds[id] ?? id).djb2()
|
||||
let idx = Int(h % UInt64(slots.count))
|
||||
return (id, idx)
|
||||
})
|
||||
|
||||
var mapping: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||
var usedSlots = Set<Int>()
|
||||
var usedHues: [Double] = []
|
||||
|
||||
// Keep previous assignments if still valid to minimize churn
|
||||
for (id, entry) in previousMapping {
|
||||
if seeds.keys.contains(id), entry.slot < slots.count { // slot index still valid
|
||||
mapping[id] = (entry.slot, entry.ring, slots[entry.slot])
|
||||
usedSlots.insert(entry.slot)
|
||||
usedHues.append(slots[entry.slot])
|
||||
}
|
||||
}
|
||||
|
||||
// First ring assignment using free slots
|
||||
let unassigned = peers.filter { mapping[$0] == nil }
|
||||
for id in unassigned {
|
||||
// If a preferred slot free, take it
|
||||
let preferred = prefIndex[id] ?? 0
|
||||
if !usedSlots.contains(preferred) && preferred < slots.count {
|
||||
mapping[id] = (preferred, 0, slots[preferred])
|
||||
usedSlots.insert(preferred)
|
||||
usedHues.append(slots[preferred])
|
||||
continue
|
||||
}
|
||||
// Choose free slot maximizing minimal distance to used hues
|
||||
var bestSlot: Int? = nil
|
||||
var bestScore: Double = -1
|
||||
for sIdx in 0..<slots.count where !usedSlots.contains(sIdx) {
|
||||
let hue = slots[sIdx]
|
||||
let minDist = usedHues.isEmpty ? 1.0 : usedHues.map { circDist(hue, $0) }.min() ?? 1.0
|
||||
// Bias toward preferred index for stability
|
||||
let bias = 1.0 - (Double((abs(sIdx - (prefIndex[id] ?? 0)) % slots.count)) / Double(slots.count))
|
||||
let score = minDist + 0.05 * bias
|
||||
if score > bestScore { bestScore = score; bestSlot = sIdx }
|
||||
}
|
||||
if let s = bestSlot {
|
||||
mapping[id] = (s, 0, slots[s])
|
||||
usedSlots.insert(s)
|
||||
usedHues.append(slots[s])
|
||||
}
|
||||
}
|
||||
|
||||
// Overflow peers: assign additional rings by reusing slots with stable preference
|
||||
let stillUnassigned = peers.filter { mapping[$0] == nil }
|
||||
if !stillUnassigned.isEmpty {
|
||||
for (idx, id) in stillUnassigned.enumerated() {
|
||||
let preferred = prefIndex[id] ?? 0
|
||||
// Spread over slots by rotating from preferred with a golden-step
|
||||
let goldenStep = 7 // small prime step for dispersion
|
||||
let s = (preferred + idx * goldenStep) % slots.count
|
||||
mapping[id] = (s, 1, slots[s])
|
||||
}
|
||||
}
|
||||
|
||||
return mapping
|
||||
}
|
||||
}
|
||||
@@ -148,10 +148,10 @@ final class CommandProcessor {
|
||||
|
||||
if chatViewModel?.selectedPrivateChatPeer != nil {
|
||||
// In private chat
|
||||
if let peerNickname = meshService?.peerNickname(peerID: targetPeerID) {
|
||||
if let peerNickname = meshService?.peerNickname(peerID: PeerID(str: targetPeerID)) {
|
||||
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
|
||||
meshService?.sendPrivateMessage(personalMessage, to: targetPeerID,
|
||||
recipientNickname: peerNickname,
|
||||
meshService?.sendPrivateMessage(personalMessage, to: PeerID(str: targetPeerID),
|
||||
recipientNickname: peerNickname,
|
||||
messageID: UUID().uuidString)
|
||||
// Also add a local system message so the sender sees a natural-language confirmation
|
||||
let pastAction: String = {
|
||||
@@ -214,7 +214,7 @@ final class CommandProcessor {
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||
let fingerprint = meshService?.getFingerprint(for: PeerID(str: peerID)) {
|
||||
if identityManager.isBlocked(fingerprint: fingerprint) {
|
||||
return .success(message: "\(nickname) is already blocked")
|
||||
}
|
||||
@@ -258,7 +258,7 @@ final class CommandProcessor {
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||
let fingerprint = meshService?.getFingerprint(for: PeerID(str: peerID)) {
|
||||
if !identityManager.isBlocked(fingerprint: fingerprint) {
|
||||
return .success(message: "\(nickname) is not blocked")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
//
|
||||
// DeliveryTrackingService.swift
|
||||
// bitchat
|
||||
//
|
||||
// Service for tracking message delivery and read status
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// Service that manages delivery status updates for messages
|
||||
/// Prevents status downgrades (e.g., read → delivered) and maintains consistency
|
||||
final class DeliveryTrackingService {
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Update delivery status for a message, preventing downgrades
|
||||
/// - Parameters:
|
||||
/// - messageID: The message ID to update
|
||||
/// - status: The new delivery status
|
||||
/// - messages: Array of public messages (inout for mutation)
|
||||
/// - privateChats: Dictionary of private chats (inout for mutation)
|
||||
/// - notifyChange: Closure to trigger UI update
|
||||
func updateStatus(
|
||||
messageID: String,
|
||||
status: DeliveryStatus,
|
||||
messages: inout [BitchatMessage],
|
||||
privateChats: inout [String: [BitchatMessage]],
|
||||
notifyChange: @escaping () -> Void
|
||||
) {
|
||||
// Update in main messages
|
||||
if let index = messages.firstIndex(where: { $0.id == messageID }) {
|
||||
let currentStatus = messages[index].deliveryStatus
|
||||
if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) {
|
||||
messages[index].deliveryStatus = status
|
||||
}
|
||||
}
|
||||
|
||||
// Update in private chats
|
||||
for (peerID, chatMessages) in privateChats {
|
||||
guard let index = chatMessages.firstIndex(where: { $0.id == messageID }) else { continue }
|
||||
|
||||
let currentStatus = chatMessages[index].deliveryStatus
|
||||
guard !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) else { continue }
|
||||
|
||||
// Update delivery status
|
||||
privateChats[peerID]?[index].deliveryStatus = status
|
||||
}
|
||||
|
||||
// Trigger UI update
|
||||
DispatchQueue.main.async {
|
||||
notifyChange()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
/// Check if we should skip a status update to prevent downgrades
|
||||
private func shouldSkipUpdate(currentStatus: DeliveryStatus?, newStatus: DeliveryStatus) -> Bool {
|
||||
guard let current = currentStatus else { return false }
|
||||
|
||||
// Don't downgrade from read to delivered or sent
|
||||
switch (current, newStatus) {
|
||||
case (.read, .delivered):
|
||||
return true
|
||||
case (.read, .sent):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,13 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
}
|
||||
.assign(to: &$mutualFavorites)
|
||||
}
|
||||
|
||||
|
||||
deinit {
|
||||
// Clean up Combine subscriptions
|
||||
cancellables.removeAll()
|
||||
SecureLogger.debug("FavoritesPersistenceService deinitialized", category: .session)
|
||||
}
|
||||
|
||||
/// Add or update a favorite
|
||||
func addFavorite(
|
||||
peerNoisePublicKey: Data,
|
||||
@@ -179,125 +185,15 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
|
||||
/// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey)
|
||||
/// Falls back to scanning favorites and matching on derived peer ID.
|
||||
func getFavoriteStatus(forPeerID peerID: String) -> FavoriteRelationship? {
|
||||
func getFavoriteStatus(forPeerID peerID: PeerID) -> FavoriteRelationship? {
|
||||
// Quick sanity: peerID should be 16 hex chars (8 bytes)
|
||||
guard peerID.count == 16 else { return nil }
|
||||
for (pubkey, rel) in favorites {
|
||||
let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey)
|
||||
if derived == peerID { return rel }
|
||||
guard peerID.isShort else { return nil }
|
||||
for (pubkey, rel) in favorites where PeerID(publicKey: pubkey) == peerID {
|
||||
return rel
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Update Nostr public key for a peer
|
||||
func updateNostrPublicKey(for peerNoisePublicKey: Data, nostrPubkey: String) {
|
||||
guard let existing = favorites[peerNoisePublicKey] else { return }
|
||||
|
||||
let updated = FavoriteRelationship(
|
||||
peerNoisePublicKey: existing.peerNoisePublicKey,
|
||||
peerNostrPublicKey: nostrPubkey,
|
||||
peerNickname: existing.peerNickname,
|
||||
isFavorite: existing.isFavorite,
|
||||
theyFavoritedUs: existing.theyFavoritedUs,
|
||||
favoritedAt: existing.favoritedAt,
|
||||
lastUpdated: Date()
|
||||
)
|
||||
|
||||
favorites[peerNoisePublicKey] = updated
|
||||
saveFavorites()
|
||||
}
|
||||
|
||||
/// Update nickname for an existing favorite
|
||||
func updateNickname(for peerNoisePublicKey: Data, newNickname: String) {
|
||||
guard let existing = favorites[peerNoisePublicKey] else { return }
|
||||
|
||||
// Skip if nickname hasn't changed
|
||||
if existing.peerNickname == newNickname { return }
|
||||
|
||||
// Updating nickname for favorite
|
||||
|
||||
let updated = FavoriteRelationship(
|
||||
peerNoisePublicKey: existing.peerNoisePublicKey,
|
||||
peerNostrPublicKey: existing.peerNostrPublicKey,
|
||||
peerNickname: newNickname,
|
||||
isFavorite: existing.isFavorite,
|
||||
theyFavoritedUs: existing.theyFavoritedUs,
|
||||
favoritedAt: existing.favoritedAt,
|
||||
lastUpdated: Date()
|
||||
)
|
||||
|
||||
favorites[peerNoisePublicKey] = updated
|
||||
saveFavorites()
|
||||
|
||||
// Notify observers
|
||||
NotificationCenter.default.post(
|
||||
name: .favoriteStatusChanged,
|
||||
object: nil,
|
||||
userInfo: ["peerPublicKey": peerNoisePublicKey]
|
||||
)
|
||||
}
|
||||
|
||||
/// Update noise public key when peer reconnects with new ID
|
||||
func updateNoisePublicKey(from oldKey: Data, to newKey: Data, peerNickname: String) {
|
||||
guard let existing = favorites[oldKey] else {
|
||||
SecureLogger.warning("⚠️ Cannot update noise key - no favorite found for \(oldKey.hexEncodedString())", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we already have a favorite with the new key
|
||||
if favorites[newKey] != nil {
|
||||
SecureLogger.warning("⚠️ Favorite already exists with new key \(newKey.hexEncodedString()), removing old entry", category: .session)
|
||||
favorites.removeValue(forKey: oldKey)
|
||||
saveFavorites()
|
||||
return
|
||||
}
|
||||
|
||||
// Updating noise public key
|
||||
|
||||
// Remove old entry
|
||||
favorites.removeValue(forKey: oldKey)
|
||||
|
||||
// Add with new key
|
||||
let updated = FavoriteRelationship(
|
||||
peerNoisePublicKey: newKey,
|
||||
peerNostrPublicKey: existing.peerNostrPublicKey,
|
||||
peerNickname: peerNickname,
|
||||
isFavorite: existing.isFavorite,
|
||||
theyFavoritedUs: existing.theyFavoritedUs,
|
||||
favoritedAt: existing.favoritedAt,
|
||||
lastUpdated: Date()
|
||||
)
|
||||
|
||||
favorites[newKey] = updated
|
||||
saveFavorites()
|
||||
|
||||
// Notify observers with both old and new keys
|
||||
NotificationCenter.default.post(
|
||||
name: .favoriteStatusChanged,
|
||||
object: nil,
|
||||
userInfo: [
|
||||
"peerPublicKey": newKey,
|
||||
"oldPeerPublicKey": oldKey,
|
||||
"isKeyUpdate": true
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
/// Get all favorites (including non-mutual)
|
||||
func getAllFavorites() -> [FavoriteRelationship] {
|
||||
favorites.values.filter { $0.isFavorite }
|
||||
}
|
||||
|
||||
/// Get only mutual favorites
|
||||
func getMutualFavorites() -> [FavoriteRelationship] {
|
||||
favorites.values.filter { $0.isMutual }
|
||||
}
|
||||
|
||||
/// Get all favorite relationships (including where they favorited us)
|
||||
func getAllRelationships() -> [FavoriteRelationship] {
|
||||
Array(favorites.values)
|
||||
}
|
||||
|
||||
/// Clear all favorites - used for panic mode
|
||||
func clearAllFavorites() {
|
||||
SecureLogger.warning("🧹 Clearing all favorites (panic mode)", category: .session)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import Combine
|
||||
#if os(iOS) || os(macOS)
|
||||
import CoreLocation
|
||||
#endif
|
||||
|
||||
/// Stores a user-maintained list of bookmarked geohash channels.
|
||||
/// - Persistence: UserDefaults (JSON string array)
|
||||
@@ -16,15 +15,22 @@ final class GeohashBookmarksStore: ObservableObject {
|
||||
private let storeKey = "locationChannel.bookmarks"
|
||||
private let namesStoreKey = "locationChannel.bookmarkNames"
|
||||
private var membership: Set<String> = []
|
||||
#if os(iOS) || os(macOS)
|
||||
private let geocoder = CLGeocoder()
|
||||
private var resolving: Set<String> = []
|
||||
#endif
|
||||
|
||||
private let storage: UserDefaults
|
||||
|
||||
private init() {
|
||||
init(storage: UserDefaults = .standard) {
|
||||
self.storage = storage
|
||||
load()
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Cancel any pending geocoding operations
|
||||
geocoder.cancelGeocode()
|
||||
SecureLogger.debug("GeohashBookmarksStore deinitialized", category: .session)
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
func isBookmarked(_ geohash: String) -> Bool {
|
||||
return membership.contains(Self.normalize(geohash))
|
||||
@@ -64,7 +70,7 @@ final class GeohashBookmarksStore: ObservableObject {
|
||||
|
||||
// MARK: - Persistence
|
||||
private func load() {
|
||||
guard let data = UserDefaults.standard.data(forKey: storeKey) else { return }
|
||||
guard let data = storage.data(forKey: storeKey) else { return }
|
||||
if let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||
// Sanitize, normalize, dedupe while preserving order (first occurrence wins)
|
||||
var seen = Set<String>()
|
||||
@@ -81,7 +87,7 @@ final class GeohashBookmarksStore: ObservableObject {
|
||||
membership = seen
|
||||
}
|
||||
// Load any saved names
|
||||
if let namesData = UserDefaults.standard.data(forKey: namesStoreKey),
|
||||
if let namesData = storage.data(forKey: namesStoreKey),
|
||||
let dict = try? JSONDecoder().decode([String: String].self, from: namesData) {
|
||||
bookmarkNames = dict
|
||||
}
|
||||
@@ -89,13 +95,13 @@ final class GeohashBookmarksStore: ObservableObject {
|
||||
|
||||
private func persist() {
|
||||
if let data = try? JSONEncoder().encode(bookmarks) {
|
||||
UserDefaults.standard.set(data, forKey: storeKey)
|
||||
storage.set(data, forKey: storeKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func persistNames() {
|
||||
if let data = try? JSONEncoder().encode(bookmarkNames) {
|
||||
UserDefaults.standard.set(data, forKey: namesStoreKey)
|
||||
storage.set(data, forKey: namesStoreKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +121,6 @@ final class GeohashBookmarksStore: ObservableObject {
|
||||
let gh = Self.normalize(geohash)
|
||||
guard !gh.isEmpty else { return }
|
||||
if bookmarkNames[gh] != nil { return }
|
||||
#if os(iOS) || os(macOS)
|
||||
if resolving.contains(gh) { return }
|
||||
resolving.insert(gh)
|
||||
// For very coarse geohashes, sample multiple points to capture multiple admin areas
|
||||
@@ -146,10 +151,8 @@ final class GeohashBookmarksStore: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
|
||||
var uniqueAdmins = OrderedSet<String>()
|
||||
var idx = 0
|
||||
@@ -212,7 +215,6 @@ final class GeohashBookmarksStore: ObservableObject {
|
||||
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if DEBUG
|
||||
/// Testing-only reset helper
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//
|
||||
// GeohashParticipantsService.swift
|
||||
// bitchat
|
||||
//
|
||||
// Manages tracking of participants in geohash-based location channels
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// Service for tracking and managing participants in geohash channels
|
||||
/// Handles automatic expiration, refresh timers, and participant list management
|
||||
final class GeohashParticipantsService: ObservableObject {
|
||||
|
||||
// MARK: - Published Properties
|
||||
|
||||
@Published private(set) var geohashPeople: [GeoPerson] = []
|
||||
|
||||
// MARK: - Private State
|
||||
|
||||
private var geoParticipants: [String: [String: Date]] = [:] // geohash -> [pubkeyHex -> lastSeen]
|
||||
private var geoParticipantsTimer: Timer? = nil
|
||||
private var currentGeohash: String? = nil
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let identityManager: SecureIdentityStateManagerProtocol
|
||||
private let displayNameProvider: (String) -> String
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
private let activityWindowSeconds: TimeInterval
|
||||
private let refreshIntervalSeconds: TimeInterval
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
identityManager: SecureIdentityStateManagerProtocol,
|
||||
displayNameProvider: @escaping (String) -> String,
|
||||
activityWindowSeconds: TimeInterval = TransportConfig.uiRecentCutoffFiveMinutesSeconds,
|
||||
refreshIntervalSeconds: TimeInterval = 30.0
|
||||
) {
|
||||
self.identityManager = identityManager
|
||||
self.displayNameProvider = displayNameProvider
|
||||
self.activityWindowSeconds = activityWindowSeconds
|
||||
self.refreshIntervalSeconds = refreshIntervalSeconds
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Note: deinit cannot call @MainActor methods
|
||||
// Timer cleanup will happen automatically when service is deallocated
|
||||
SecureLogger.debug("GeohashParticipantsService deinitialized", category: .session)
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Set the current geohash being tracked (starts/stops timer accordingly)
|
||||
func setCurrentGeohash(_ geohash: String?) {
|
||||
if currentGeohash != geohash {
|
||||
currentGeohash = geohash
|
||||
refreshPeopleList()
|
||||
|
||||
if geohash != nil {
|
||||
startTimer()
|
||||
} else {
|
||||
stopTimer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a participant activity in the current geohash
|
||||
func recordParticipant(pubkeyHex: String) {
|
||||
guard let gh = currentGeohash else { return }
|
||||
recordParticipant(pubkeyHex: pubkeyHex, geohash: gh)
|
||||
}
|
||||
|
||||
/// Record a participant activity in a specific geohash
|
||||
func recordParticipant(pubkeyHex: String, geohash: String) {
|
||||
let key = pubkeyHex.lowercased()
|
||||
var map = geoParticipants[geohash] ?? [:]
|
||||
map[key] = Date()
|
||||
geoParticipants[geohash] = map
|
||||
|
||||
// Only refresh list if this geohash is currently selected
|
||||
if currentGeohash == geohash {
|
||||
refreshPeopleList()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get visible people for the current geohash (without mutating state)
|
||||
func visiblePeople() -> [GeoPerson] {
|
||||
guard let gh = currentGeohash else { return [] }
|
||||
return visiblePeople(for: gh)
|
||||
}
|
||||
|
||||
/// Get visible people for a specific geohash
|
||||
func visiblePeople(for geohash: String) -> [GeoPerson] {
|
||||
let cutoff = Date().addingTimeInterval(-activityWindowSeconds)
|
||||
let map = (geoParticipants[geohash] ?? [:])
|
||||
.filter { $0.value >= cutoff }
|
||||
.filter { !identityManager.isNostrBlocked(pubkeyHexLowercased: $0.key) }
|
||||
let people = map
|
||||
.map { (pub, seen) in
|
||||
GeoPerson(id: pub, displayName: displayNameProvider(pub), lastSeen: seen)
|
||||
}
|
||||
.sorted { $0.lastSeen > $1.lastSeen }
|
||||
return people
|
||||
}
|
||||
|
||||
/// Get participant count for a specific geohash (using activity window)
|
||||
func participantCount(for geohash: String) -> Int {
|
||||
let cutoff = Date().addingTimeInterval(-activityWindowSeconds)
|
||||
let map = geoParticipants[geohash] ?? [:]
|
||||
return map.values.filter { $0 >= cutoff }.count
|
||||
}
|
||||
|
||||
/// Remove a participant from all geohashes (e.g., when blocked)
|
||||
func removeParticipant(pubkeyHexLowercased: String) {
|
||||
let hex = pubkeyHexLowercased.lowercased()
|
||||
for (gh, var map) in geoParticipants {
|
||||
map.removeValue(forKey: hex)
|
||||
geoParticipants[gh] = map
|
||||
}
|
||||
refreshPeopleList()
|
||||
}
|
||||
|
||||
/// Clear all participant data (for testing or reset)
|
||||
func reset() {
|
||||
stopTimer()
|
||||
geoParticipants.removeAll()
|
||||
geohashPeople.removeAll()
|
||||
currentGeohash = nil
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func refreshPeopleList() {
|
||||
guard let gh = currentGeohash else {
|
||||
geohashPeople = []
|
||||
return
|
||||
}
|
||||
|
||||
let cutoff = Date().addingTimeInterval(-activityWindowSeconds)
|
||||
var map = geoParticipants[gh] ?? [:]
|
||||
|
||||
// Prune expired entries
|
||||
map = map.filter { $0.value >= cutoff }
|
||||
|
||||
// Remove blocked Nostr pubkeys
|
||||
map = map.filter { !identityManager.isNostrBlocked(pubkeyHexLowercased: $0.key) }
|
||||
|
||||
// Update cleaned map
|
||||
geoParticipants[gh] = map
|
||||
|
||||
// Build display list
|
||||
let people = map
|
||||
.map { (pub, seen) in
|
||||
GeoPerson(id: pub, displayName: displayNameProvider(pub), lastSeen: seen)
|
||||
}
|
||||
.sorted { $0.lastSeen > $1.lastSeen }
|
||||
|
||||
geohashPeople = people
|
||||
}
|
||||
|
||||
private func startTimer() {
|
||||
stopTimer()
|
||||
geoParticipantsTimer = Timer.scheduledTimer(withTimeInterval: refreshIntervalSeconds, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.refreshPeopleList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopTimer() {
|
||||
geoParticipantsTimer?.invalidate()
|
||||
geoParticipantsTimer = nil
|
||||
}
|
||||
}
|
||||
@@ -51,9 +51,20 @@ final class LocationNotesCounter: ObservableObject {
|
||||
self.dependencies = testDependencies
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Note: deinit cannot call @MainActor functions
|
||||
// Subscription cleanup will happen automatically when counter is deallocated
|
||||
SecureLogger.debug("LocationNotesCounter deinitialized", category: .session)
|
||||
}
|
||||
|
||||
func subscribe(geohash gh: String) {
|
||||
let norm = gh.lowercased()
|
||||
if geohash == norm, subscriptionID != nil { return }
|
||||
// Validate geohash (building-level precision: 8 chars)
|
||||
guard Geohash.isValidBuildingGeohash(norm) else {
|
||||
SecureLogger.warning("LocationNotesCounter: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||
return
|
||||
}
|
||||
// Unsubscribe previous without clearing count to avoid flicker
|
||||
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
||||
subscriptionID = nil
|
||||
@@ -74,7 +85,7 @@ final class LocationNotesCounter: ObservableObject {
|
||||
}
|
||||
|
||||
subscriptionID = subID
|
||||
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 500)
|
||||
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 200)
|
||||
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
||||
guard let self = self else { return }
|
||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||
|
||||
@@ -74,23 +74,58 @@ final class LocationNotesManager: ObservableObject {
|
||||
@Published private(set) var state: State = .loading
|
||||
@Published private(set) var errorMessage: String?
|
||||
private var subscriptionID: String?
|
||||
private var noteIDs = Set<String>() // O(1) duplicate detection
|
||||
private let dependencies: LocationNotesDependencies
|
||||
private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200)
|
||||
|
||||
private enum Strings {
|
||||
static let noRelays = String(localized: "location_notes.error.no_relays", comment: "Shown when no geo relays are available near the selected location")
|
||||
|
||||
static func failedToSend(_ detail: String) -> String {
|
||||
String(
|
||||
format: String(localized: "location_notes.error.failed_to_send", comment: "Shown when a location note fails to send"),
|
||||
locale: .current,
|
||||
detail
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
init(geohash: String, dependencies: LocationNotesDependencies = .live) {
|
||||
self.geohash = geohash.lowercased()
|
||||
let norm = geohash.lowercased()
|
||||
self.geohash = norm
|
||||
self.dependencies = dependencies
|
||||
// Validate geohash (building-level precision: 8 chars)
|
||||
if !Geohash.isValidBuildingGeohash(norm) {
|
||||
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||
}
|
||||
subscribe()
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Note: deinit cannot call @MainActor functions
|
||||
// Subscription cleanup will happen automatically when manager is deallocated
|
||||
SecureLogger.debug("LocationNotesManager deinitialized", category: .session)
|
||||
}
|
||||
|
||||
func setGeohash(_ newGeohash: String) {
|
||||
let norm = newGeohash.lowercased()
|
||||
guard norm != geohash else { return }
|
||||
// Validate geohash (building-level precision: 8 chars)
|
||||
guard Geohash.isValidBuildingGeohash(norm) else {
|
||||
SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||
return
|
||||
}
|
||||
if let sub = subscriptionID {
|
||||
dependencies.unsubscribe(sub)
|
||||
subscriptionID = nil
|
||||
}
|
||||
// Set loading state before clearing to prevent empty state flicker
|
||||
state = .loading
|
||||
initialLoadComplete = false
|
||||
errorMessage = nil
|
||||
geohash = norm
|
||||
notes.removeAll()
|
||||
noteIDs.removeAll()
|
||||
subscribe()
|
||||
}
|
||||
|
||||
@@ -99,7 +134,12 @@ final class LocationNotesManager: ObservableObject {
|
||||
dependencies.unsubscribe(sub)
|
||||
subscriptionID = nil
|
||||
}
|
||||
// Set loading state before clearing to prevent empty state flicker
|
||||
state = .loading
|
||||
initialLoadComplete = false
|
||||
errorMessage = nil
|
||||
notes.removeAll()
|
||||
noteIDs.removeAll()
|
||||
subscribe()
|
||||
}
|
||||
|
||||
@@ -120,7 +160,7 @@ final class LocationNotesManager: ObservableObject {
|
||||
subscriptionID = nil
|
||||
initialLoadComplete = true
|
||||
state = .noRelays
|
||||
errorMessage = "No geo relays available near this location. Try again soon."
|
||||
errorMessage = Strings.noRelays
|
||||
SecureLogger.warning("LocationNotesManager: no geo relays for geohash=\(geohash)", category: .session)
|
||||
return
|
||||
}
|
||||
@@ -135,12 +175,14 @@ final class LocationNotesManager: ObservableObject {
|
||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||
// Ensure matching tag
|
||||
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) else { return }
|
||||
if self.notes.contains(where: { $0.id == event.id }) { return }
|
||||
guard !self.noteIDs.contains(event.id) else { return }
|
||||
self.noteIDs.insert(event.id)
|
||||
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
|
||||
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
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.enforceMemoryCap()
|
||||
self.state = .ready
|
||||
}, { [weak self] in
|
||||
guard let self = self else { return }
|
||||
@@ -158,7 +200,7 @@ final class LocationNotesManager: ObservableObject {
|
||||
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||
guard !relays.isEmpty else {
|
||||
state = .noRelays
|
||||
errorMessage = "No geo relays available near this location. Try again soon."
|
||||
errorMessage = Strings.noRelays
|
||||
SecureLogger.warning("LocationNotesManager: send blocked, no geo relays for geohash=\(geohash)", category: .session)
|
||||
return
|
||||
}
|
||||
@@ -176,15 +218,26 @@ final class LocationNotesManager: ObservableObject {
|
||||
id: event.id,
|
||||
pubkey: id.publicKeyHex,
|
||||
content: trimmed,
|
||||
createdAt: dependencies.now(),
|
||||
createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
|
||||
nickname: nickname
|
||||
)
|
||||
self.noteIDs.insert(event.id)
|
||||
self.notes.insert(echo, at: 0)
|
||||
self.enforceMemoryCap()
|
||||
self.state = .ready
|
||||
self.errorMessage = nil
|
||||
} catch {
|
||||
SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session)
|
||||
errorMessage = "Failed to send note. \(error.localizedDescription)"
|
||||
errorMessage = Strings.failedToSend(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// Enforces defensive memory cap on notes array (keeps newest).
|
||||
private func enforceMemoryCap() {
|
||||
if notes.count > maxNotesInMemory {
|
||||
let removed = notes.count - maxNotesInMemory
|
||||
notes = Array(notes.prefix(maxNotesInMemory))
|
||||
SecureLogger.debug("LocationNotesManager: trimmed \(removed) old notes (cap: \(maxNotesInMemory))", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,618 @@
|
||||
//
|
||||
// MessageFormattingService.swift
|
||||
// bitchat
|
||||
//
|
||||
// Service for formatting chat messages with syntax highlighting
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Service that formats BitchatMessages into styled AttributedStrings
|
||||
/// Handles hashtags, mentions, links, payment tokens, and more
|
||||
final class MessageFormattingService {
|
||||
|
||||
// MARK: - Precompiled Regexes
|
||||
|
||||
private enum Regexes {
|
||||
static let hashtag: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "#([a-zA-Z0-9_]+)", options: [])
|
||||
}()
|
||||
static let mention: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)", options: [])
|
||||
}()
|
||||
static let cashu: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
||||
}()
|
||||
static let bolt11: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
|
||||
}()
|
||||
static let lnurl: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
|
||||
}()
|
||||
static let lightningScheme: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
|
||||
}()
|
||||
static let linkDetector: NSDataDetector? = {
|
||||
try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
}()
|
||||
static let quickCashuPresence: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
||||
}()
|
||||
}
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let colorPalette: ColorPaletteService
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(colorPalette: ColorPaletteService) {
|
||||
self.colorPalette = colorPalette
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Format a message with full syntax highlighting (hashtags, mentions, links, payments)
|
||||
/// This is the primary formatter used in the main chat view
|
||||
func formatMessageAsText(
|
||||
_ message: BitchatMessage,
|
||||
colorScheme: ColorScheme,
|
||||
nickname: String,
|
||||
myPeerID: String,
|
||||
myNostrPubkey: String?,
|
||||
activeChannel: ChannelID,
|
||||
nostrKeyMapping: [String: String],
|
||||
allPeers: [BitchatPeer],
|
||||
geohashPeople: [GeoPerson],
|
||||
getNoiseKeyForShortID: @escaping (String) -> String?
|
||||
) -> AttributedString {
|
||||
// Determine if this message was sent by self
|
||||
let isSelf = isSelfMessage(
|
||||
message,
|
||||
nickname: nickname,
|
||||
myPeerID: myPeerID,
|
||||
myNostrPubkey: myNostrPubkey,
|
||||
activeChannel: activeChannel
|
||||
)
|
||||
|
||||
// Check cache first
|
||||
let isDark = colorScheme == .dark
|
||||
if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf) {
|
||||
return cachedText
|
||||
}
|
||||
|
||||
// Not cached, format the message
|
||||
var result = AttributedString()
|
||||
|
||||
let baseColor: Color = isSelf ? .orange : colorPalette.peerColor(
|
||||
for: message,
|
||||
isDark: isDark,
|
||||
myPeerID: myPeerID,
|
||||
myNostrPubkey: myNostrPubkey,
|
||||
nostrKeyMapping: nostrKeyMapping,
|
||||
allPeers: allPeers,
|
||||
geohashPeople: geohashPeople.map { (id: $0.id, seed: "nostr:" + $0.id) },
|
||||
getNoiseKeyForShortID: getNoiseKeyForShortID
|
||||
)
|
||||
|
||||
if message.sender != "system" {
|
||||
// Sender (at the beginning) with light-gray suffix styling if present
|
||||
let (baseName, suffix) = message.sender.splitSuffix()
|
||||
var senderStyle = AttributeContainer()
|
||||
senderStyle.foregroundColor = baseColor
|
||||
let fontWeight: Font.Weight = isSelf ? .bold : .medium
|
||||
senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: .monospaced)
|
||||
|
||||
// Make sender clickable: encode senderPeerID into a custom URL
|
||||
if let spid = message.senderPeerID?.id,
|
||||
let url = URL(string: "bitchat://user/\(spid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid)") {
|
||||
senderStyle.link = url
|
||||
}
|
||||
|
||||
// Format: <@name#suffix>
|
||||
result.append(AttributedString("<@").mergingAttributes(senderStyle))
|
||||
result.append(AttributedString(baseName).mergingAttributes(senderStyle))
|
||||
if !suffix.isEmpty {
|
||||
var suffixStyle = senderStyle
|
||||
suffixStyle.foregroundColor = baseColor.opacity(0.6)
|
||||
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
|
||||
}
|
||||
result.append(AttributedString("> ").mergingAttributes(senderStyle))
|
||||
|
||||
// Process content with syntax highlighting
|
||||
let content = message.content
|
||||
let nsContent = content as NSString
|
||||
let nsLen = nsContent.length
|
||||
|
||||
// Check for Cashu presence early to decide rendering strategy
|
||||
let containsCashuEarly = Regexes.quickCashuPresence.numberOfMatches(
|
||||
in: content,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: nsLen)
|
||||
) > 0
|
||||
|
||||
// For extremely long content, render as plain text (unless has Cashu)
|
||||
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
|
||||
var plainStyle = AttributeContainer()
|
||||
plainStyle.foregroundColor = baseColor
|
||||
plainStyle.font = isSelf
|
||||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||
: .bitchatSystem(size: 14, design: .monospaced)
|
||||
result.append(AttributedString(content).mergingAttributes(plainStyle))
|
||||
} else {
|
||||
// Full syntax highlighting
|
||||
result.append(formatContent(
|
||||
content,
|
||||
nsContent: nsContent,
|
||||
nsLen: nsLen,
|
||||
message: message,
|
||||
baseColor: baseColor,
|
||||
isSelf: isSelf,
|
||||
isDark: isDark,
|
||||
nickname: nickname,
|
||||
myPeerID: myPeerID,
|
||||
myNostrPubkey: myNostrPubkey,
|
||||
activeChannel: activeChannel
|
||||
))
|
||||
}
|
||||
|
||||
// Add timestamp
|
||||
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
|
||||
var timestampStyle = AttributeContainer()
|
||||
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
|
||||
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
|
||||
result.append(timestamp.mergingAttributes(timestampStyle))
|
||||
} else {
|
||||
// System message
|
||||
var contentStyle = AttributeContainer()
|
||||
contentStyle.foregroundColor = Color.gray
|
||||
let content = AttributedString("* \(message.content) *")
|
||||
contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic()
|
||||
result.append(content.mergingAttributes(contentStyle))
|
||||
|
||||
// Add timestamp
|
||||
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
|
||||
var timestampStyle = AttributeContainer()
|
||||
timestampStyle.foregroundColor = Color.gray.opacity(0.5)
|
||||
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
|
||||
result.append(timestamp.mergingAttributes(timestampStyle))
|
||||
}
|
||||
|
||||
// Cache the formatted text
|
||||
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Simpler message formatter (used in legacy contexts)
|
||||
func formatMessage(
|
||||
_ message: BitchatMessage,
|
||||
colorScheme: ColorScheme,
|
||||
nickname: String
|
||||
) -> AttributedString {
|
||||
var result = AttributedString()
|
||||
|
||||
let isDark = colorScheme == .dark
|
||||
let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||
|
||||
if message.sender == "system" {
|
||||
let content = AttributedString("* \(message.content) *")
|
||||
var contentStyle = AttributeContainer()
|
||||
contentStyle.foregroundColor = Color.gray
|
||||
contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic()
|
||||
result.append(content.mergingAttributes(contentStyle))
|
||||
|
||||
// Add timestamp
|
||||
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
|
||||
var timestampStyle = AttributeContainer()
|
||||
timestampStyle.foregroundColor = Color.gray.opacity(0.5)
|
||||
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
|
||||
result.append(timestamp.mergingAttributes(timestampStyle))
|
||||
} else {
|
||||
let sender = AttributedString("<@\(message.sender)> ")
|
||||
var senderStyle = AttributeContainer()
|
||||
senderStyle.foregroundColor = primaryColor
|
||||
let fontWeight: Font.Weight = message.sender == nickname ? .bold : .medium
|
||||
senderStyle.font = .bitchatSystem(size: 12, weight: fontWeight, design: .monospaced)
|
||||
result.append(sender.mergingAttributes(senderStyle))
|
||||
|
||||
// Process content to highlight mentions
|
||||
let contentText = message.content
|
||||
let pattern = "@([\\p{L}0-9_]+)"
|
||||
let regex = try? NSRegularExpression(pattern: pattern, options: [])
|
||||
let nsContent = contentText as NSString
|
||||
let nsLen = nsContent.length
|
||||
let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
|
||||
|
||||
var processedContent = AttributedString()
|
||||
var lastEndIndex = contentText.startIndex
|
||||
|
||||
for match in matches {
|
||||
if let range = Range(match.range(at: 0), in: contentText) {
|
||||
// Add text before mention
|
||||
if lastEndIndex < range.lowerBound {
|
||||
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
|
||||
if !beforeText.isEmpty {
|
||||
var normalStyle = AttributeContainer()
|
||||
normalStyle.font = .bitchatSystem(size: 14, design: .monospaced)
|
||||
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
||||
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
|
||||
}
|
||||
}
|
||||
|
||||
// Add the mention with highlight
|
||||
let mentionText = String(contentText[range])
|
||||
var mentionStyle = AttributeContainer()
|
||||
mentionStyle.font = .bitchatSystem(size: 14, weight: .semibold, design: .monospaced)
|
||||
mentionStyle.foregroundColor = Color.orange
|
||||
processedContent.append(AttributedString(mentionText).mergingAttributes(mentionStyle))
|
||||
|
||||
if lastEndIndex < range.upperBound { lastEndIndex = range.upperBound }
|
||||
}
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if lastEndIndex < contentText.endIndex {
|
||||
let remainingText = String(contentText[lastEndIndex...])
|
||||
var normalStyle = AttributeContainer()
|
||||
normalStyle.font = .bitchatSystem(size: 14, design: .monospaced)
|
||||
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
||||
processedContent.append(AttributedString(remainingText).mergingAttributes(normalStyle))
|
||||
}
|
||||
|
||||
result.append(processedContent)
|
||||
|
||||
if message.isRelay, let originalSender = message.originalSender {
|
||||
let relay = AttributedString(" (via \(originalSender))")
|
||||
var relayStyle = AttributeContainer()
|
||||
relayStyle.foregroundColor = primaryColor.opacity(0.7)
|
||||
relayStyle.font = .bitchatSystem(size: 11, design: .monospaced)
|
||||
result.append(relay.mergingAttributes(relayStyle))
|
||||
}
|
||||
|
||||
// Add timestamp
|
||||
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
|
||||
var timestampStyle = AttributeContainer()
|
||||
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
|
||||
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
|
||||
result.append(timestamp.mergingAttributes(timestampStyle))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func isSelfMessage(
|
||||
_ message: BitchatMessage,
|
||||
nickname: String,
|
||||
myPeerID: String,
|
||||
myNostrPubkey: String?,
|
||||
activeChannel: ChannelID
|
||||
) -> Bool {
|
||||
if let spid = message.senderPeerID?.id {
|
||||
// In geohash channels, compare against our per-geohash nostr short ID
|
||||
if case .location = activeChannel, spid.hasPrefix("nostr:"),
|
||||
let myGeo = myNostrPubkey {
|
||||
return spid == "nostr:\(myGeo.prefix(TransportConfig.nostrShortKeyDisplayLength))"
|
||||
}
|
||||
return spid == myPeerID
|
||||
}
|
||||
// Fallback by nickname
|
||||
if message.sender == nickname { return true }
|
||||
if message.sender.hasPrefix(nickname + "#") { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private func formatContent(
|
||||
_ content: String,
|
||||
nsContent: NSString,
|
||||
nsLen: Int,
|
||||
message: BitchatMessage,
|
||||
baseColor: Color,
|
||||
isSelf: Bool,
|
||||
isDark: Bool,
|
||||
nickname: String,
|
||||
myPeerID: String,
|
||||
myNostrPubkey: String?,
|
||||
activeChannel: ChannelID
|
||||
) -> AttributedString {
|
||||
// Extract all matches
|
||||
let hasMentionsHint = content.contains("@")
|
||||
let hasHashtagsHint = content.contains("#")
|
||||
let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http")
|
||||
let hasLightningHint = content.lowercased().contains("ln") || content.lowercased().contains("lightning:")
|
||||
let hasCashuHint = content.lowercased().contains("cashu")
|
||||
|
||||
let hashtagMatches = hasHashtagsHint ? Regexes.hashtag.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||
let mentionMatches = hasMentionsHint ? Regexes.mention.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||
let urlMatches = hasURLHint ? (Regexes.linkDetector?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []) : []
|
||||
let cashuMatches = hasCashuHint ? Regexes.cashu.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||
let lightningMatches = hasLightningHint ? Regexes.lightningScheme.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||
let bolt11Matches = hasLightningHint ? Regexes.bolt11.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||
let lnurlMatches = hasLightningHint ? Regexes.lnurl.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||
|
||||
// Combine and sort matches, excluding hashtags/URLs overlapping mentions
|
||||
let mentionRanges = mentionMatches.map { $0.range(at: 0) }
|
||||
|
||||
func overlapsMention(_ r: NSRange) -> Bool {
|
||||
for mr in mentionRanges {
|
||||
if NSIntersectionRange(r, mr).length > 0 { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func attachedToMention(_ r: NSRange) -> Bool {
|
||||
if let nsRange = Range(r, in: content), nsRange.lowerBound > content.startIndex {
|
||||
var i = content.index(before: nsRange.lowerBound)
|
||||
while true {
|
||||
let ch = content[i]
|
||||
if ch.isWhitespace || ch.isNewline { break }
|
||||
if ch == "@" { return true }
|
||||
if i == content.startIndex { break }
|
||||
i = content.index(before: i)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isStandaloneHashtag(_ r: NSRange) -> Bool {
|
||||
guard let nsRange = Range(r, in: content) else { return false }
|
||||
if nsRange.lowerBound == content.startIndex { return true }
|
||||
let prev = content.index(before: nsRange.lowerBound)
|
||||
return content[prev].isWhitespace || content[prev].isNewline
|
||||
}
|
||||
|
||||
var allMatches: [(range: NSRange, type: String)] = []
|
||||
for match in hashtagMatches where !overlapsMention(match.range(at: 0)) && !attachedToMention(match.range(at: 0)) && isStandaloneHashtag(match.range(at: 0)) {
|
||||
allMatches.append((match.range(at: 0), "hashtag"))
|
||||
}
|
||||
for match in mentionMatches {
|
||||
allMatches.append((match.range(at: 0), "mention"))
|
||||
}
|
||||
for match in urlMatches where !overlapsMention(match.range) {
|
||||
allMatches.append((match.range, "url"))
|
||||
}
|
||||
for match in cashuMatches where !overlapsMention(match.range(at: 0)) {
|
||||
allMatches.append((match.range(at: 0), "cashu"))
|
||||
}
|
||||
for match in lightningMatches where !overlapsMention(match.range(at: 0)) {
|
||||
allMatches.append((match.range(at: 0), "lightning"))
|
||||
}
|
||||
|
||||
// Exclude overlaps with lightning/url for bolt11/lnurl
|
||||
let occupied: [NSRange] = urlMatches.map { $0.range } + lightningMatches.map { $0.range(at: 0) }
|
||||
func overlapsOccupied(_ r: NSRange) -> Bool {
|
||||
for or in occupied {
|
||||
if NSIntersectionRange(r, or).length > 0 { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
for match in bolt11Matches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
|
||||
allMatches.append((match.range(at: 0), "bolt11"))
|
||||
}
|
||||
for match in lnurlMatches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
|
||||
allMatches.append((match.range(at: 0), "lnurl"))
|
||||
}
|
||||
allMatches.sort { $0.range.location < $1.range.location }
|
||||
|
||||
// Build content with styling
|
||||
var processedContent = AttributedString()
|
||||
var lastEnd = content.startIndex
|
||||
let isMentioned = message.mentions?.contains(nickname) ?? false
|
||||
|
||||
for (range, type) in allMatches {
|
||||
if let nsRange = Range(range, in: content) {
|
||||
// Add text before match
|
||||
if lastEnd < nsRange.lowerBound {
|
||||
let beforeText = String(content[lastEnd..<nsRange.lowerBound])
|
||||
if !beforeText.isEmpty {
|
||||
var beforeStyle = AttributeContainer()
|
||||
beforeStyle.foregroundColor = baseColor
|
||||
beforeStyle.font = isSelf
|
||||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||
: .bitchatSystem(size: 14, design: .monospaced)
|
||||
if isMentioned {
|
||||
beforeStyle.font = beforeStyle.font?.bold()
|
||||
}
|
||||
processedContent.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
|
||||
}
|
||||
}
|
||||
|
||||
// Add styled match
|
||||
let matchText = String(content[nsRange])
|
||||
processedContent.append(formatMatch(
|
||||
matchText,
|
||||
type: type,
|
||||
baseColor: baseColor,
|
||||
isSelf: isSelf,
|
||||
isDark: isDark,
|
||||
nickname: nickname,
|
||||
myPeerID: myPeerID,
|
||||
myNostrPubkey: myNostrPubkey,
|
||||
activeChannel: activeChannel
|
||||
))
|
||||
|
||||
lastEnd = nsRange.upperBound
|
||||
}
|
||||
}
|
||||
|
||||
// Add remaining text after last match
|
||||
if lastEnd < content.endIndex {
|
||||
let remainingText = String(content[lastEnd...])
|
||||
var remainingStyle = AttributeContainer()
|
||||
remainingStyle.foregroundColor = baseColor
|
||||
remainingStyle.font = isSelf
|
||||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||
: .bitchatSystem(size: 14, design: .monospaced)
|
||||
if isMentioned {
|
||||
remainingStyle.font = remainingStyle.font?.bold()
|
||||
}
|
||||
processedContent.append(AttributedString(remainingText).mergingAttributes(remainingStyle))
|
||||
}
|
||||
|
||||
return processedContent
|
||||
}
|
||||
|
||||
private func formatMatch(
|
||||
_ matchText: String,
|
||||
type: String,
|
||||
baseColor: Color,
|
||||
isSelf: Bool,
|
||||
isDark: Bool,
|
||||
nickname: String,
|
||||
myPeerID: String,
|
||||
myNostrPubkey: String?,
|
||||
activeChannel: ChannelID
|
||||
) -> AttributedString {
|
||||
switch type {
|
||||
case "mention":
|
||||
return formatMention(
|
||||
matchText,
|
||||
baseColor: baseColor,
|
||||
isSelf: isSelf,
|
||||
nickname: nickname,
|
||||
myPeerID: myPeerID,
|
||||
myNostrPubkey: myNostrPubkey,
|
||||
activeChannel: activeChannel
|
||||
)
|
||||
case "hashtag":
|
||||
return formatHashtag(matchText, isDark: isDark, baseColor: baseColor, activeChannel: activeChannel)
|
||||
case "url":
|
||||
return formatURL(matchText, baseColor: baseColor, isSelf: isSelf)
|
||||
case "cashu", "bolt11", "lnurl", "lightning":
|
||||
return formatPayment(matchText, type: type, baseColor: baseColor, isSelf: isSelf)
|
||||
default:
|
||||
return AttributedString(matchText)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatMention(
|
||||
_ matchText: String,
|
||||
baseColor: Color,
|
||||
isSelf: Bool,
|
||||
nickname: String,
|
||||
myPeerID: String,
|
||||
myNostrPubkey: String?,
|
||||
activeChannel: ChannelID
|
||||
) -> AttributedString {
|
||||
// Split optional '#abcd' suffix and color suffix light grey
|
||||
let (mBase, mSuffix) = matchText.splitSuffix()
|
||||
|
||||
// Determine if this mention targets me
|
||||
let mySuffix: String? = {
|
||||
if case .location = activeChannel, let myGeo = myNostrPubkey {
|
||||
return String(myGeo.suffix(4))
|
||||
}
|
||||
return String(myPeerID.prefix(4))
|
||||
}()
|
||||
|
||||
let isMentionToMe: Bool = {
|
||||
if mBase == nickname {
|
||||
if let suf = mySuffix, !mSuffix.isEmpty {
|
||||
return mSuffix == "#\(suf)"
|
||||
}
|
||||
return mSuffix.isEmpty
|
||||
}
|
||||
return false
|
||||
}()
|
||||
|
||||
var mentionStyle = AttributeContainer()
|
||||
mentionStyle.font = .bitchatSystem(size: 14, weight: .semibold, design: .monospaced)
|
||||
mentionStyle.foregroundColor = isMentionToMe ? .orange : baseColor
|
||||
|
||||
var result = AttributedString()
|
||||
result.append(AttributedString(mBase).mergingAttributes(mentionStyle))
|
||||
|
||||
if !mSuffix.isEmpty {
|
||||
var suffixStyle = mentionStyle
|
||||
suffixStyle.foregroundColor = (isMentionToMe ? Color.orange : baseColor).opacity(0.5)
|
||||
result.append(AttributedString(mSuffix).mergingAttributes(suffixStyle))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private func formatHashtag(
|
||||
_ matchText: String,
|
||||
isDark: Bool,
|
||||
baseColor: Color,
|
||||
activeChannel: ChannelID
|
||||
) -> AttributedString {
|
||||
var hashtagStyle = AttributeContainer()
|
||||
hashtagStyle.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
|
||||
|
||||
// Determine if this hashtag represents the active channel
|
||||
let isActiveChannel: Bool = {
|
||||
if matchText.count > 1 {
|
||||
let tag = String(matchText.dropFirst()) // Remove '#'
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
return tag.lowercased() == "mesh"
|
||||
case .location(let ch):
|
||||
return tag.lowercased() == ch.geohash.lowercased()
|
||||
}
|
||||
}
|
||||
return false
|
||||
}()
|
||||
|
||||
if isActiveChannel {
|
||||
// Highlight active channel hashtag in green
|
||||
hashtagStyle.foregroundColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||
hashtagStyle.font = .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||
} else {
|
||||
// Link to geohash if valid
|
||||
if matchText.count > 1 {
|
||||
let tag = String(matchText.dropFirst())
|
||||
if tag.count >= 2, tag.count <= 12,
|
||||
tag.allSatisfy({ "0123456789bcdefghjkmnpqrstuvwxyz".contains($0) }) {
|
||||
if let url = URL(string: "bitchat://geohash/\(tag)") {
|
||||
hashtagStyle.link = url
|
||||
}
|
||||
}
|
||||
}
|
||||
hashtagStyle.foregroundColor = baseColor.opacity(0.8)
|
||||
}
|
||||
|
||||
return AttributedString(matchText).mergingAttributes(hashtagStyle)
|
||||
}
|
||||
|
||||
private func formatURL(_ matchText: String, baseColor: Color, isSelf: Bool) -> AttributedString {
|
||||
var urlStyle = AttributeContainer()
|
||||
if let url = URL(string: matchText) {
|
||||
urlStyle.link = url
|
||||
}
|
||||
urlStyle.foregroundColor = baseColor
|
||||
urlStyle.font = isSelf
|
||||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||
: .bitchatSystem(size: 14, design: .monospaced)
|
||||
urlStyle.underlineStyle = .single
|
||||
return AttributedString(matchText).mergingAttributes(urlStyle)
|
||||
}
|
||||
|
||||
private func formatPayment(
|
||||
_ matchText: String,
|
||||
type: String,
|
||||
baseColor: Color,
|
||||
isSelf: Bool
|
||||
) -> AttributedString {
|
||||
var paymentStyle = AttributeContainer()
|
||||
paymentStyle.foregroundColor = baseColor
|
||||
paymentStyle.font = isSelf
|
||||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||
: .bitchatSystem(size: 14, design: .monospaced)
|
||||
|
||||
// Make payment tokens tappable
|
||||
if type == "cashu", let url = URL(string: "cashu:\(matchText)") {
|
||||
paymentStyle.link = url
|
||||
} else if type == "lightning" || type == "bolt11" || type == "lnurl" {
|
||||
if let url = URL(string: matchText.lowercased().hasPrefix("lightning:") ? matchText : "lightning:\(matchText)") {
|
||||
paymentStyle.link = url
|
||||
}
|
||||
}
|
||||
|
||||
return AttributedString(matchText).mergingAttributes(paymentStyle)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import Foundation
|
||||
final class MessageRouter {
|
||||
private let mesh: Transport
|
||||
private let nostr: NostrTransport
|
||||
private var outbox: [String: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
|
||||
private var outbox: [PeerID: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
|
||||
|
||||
init(mesh: Transport, nostr: NostrTransport) {
|
||||
self.mesh = mesh
|
||||
@@ -21,7 +21,7 @@ final class MessageRouter {
|
||||
) { [weak self] note in
|
||||
guard let self = self else { return }
|
||||
if let data = note.userInfo?["peerPublicKey"] as? Data {
|
||||
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: data)
|
||||
let peerID = PeerID(publicKey: data)
|
||||
Task { @MainActor in
|
||||
self.flushOutbox(for: peerID)
|
||||
}
|
||||
@@ -29,7 +29,7 @@ final class MessageRouter {
|
||||
// Handle key updates
|
||||
if let newKey = note.userInfo?["peerPublicKey"] as? Data,
|
||||
let _ = note.userInfo?["isKeyUpdate"] as? Bool {
|
||||
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: newKey)
|
||||
let peerID = PeerID(publicKey: newKey)
|
||||
Task { @MainActor in
|
||||
self.flushOutbox(for: peerID)
|
||||
}
|
||||
@@ -37,44 +37,44 @@ final class MessageRouter {
|
||||
}
|
||||
}
|
||||
|
||||
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
||||
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
let reachableMesh = mesh.isPeerReachable(peerID)
|
||||
if reachableMesh {
|
||||
SecureLogger.debug("Routing PM via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("Routing PM via mesh (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
// BLEService will initiate a handshake if needed and queue the message
|
||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
} else if canSendViaNostr(peerID: peerID) {
|
||||
SecureLogger.debug("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("Routing PM via Nostr to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
} else {
|
||||
// Queue for later (when mesh connects or Nostr mapping appears)
|
||||
if outbox[peerID] == nil { outbox[peerID] = [] }
|
||||
outbox[peerID]?.append((content, recipientNickname, messageID))
|
||||
SecureLogger.debug("Queued PM for \(peerID.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))…", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
// Prefer mesh for reachable peers; BLE will queue if handshake is needed
|
||||
if mesh.isPeerReachable(peerID) {
|
||||
SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||
mesh.sendReadReceipt(receipt, to: peerID)
|
||||
} else {
|
||||
SecureLogger.debug("Routing READ ack via Nostr to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("Routing READ ack via Nostr to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||
nostr.sendReadReceipt(receipt, to: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func sendDeliveryAck(_ messageID: String, to peerID: String) {
|
||||
func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
|
||||
if mesh.isPeerReachable(peerID) {
|
||||
SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
mesh.sendDeliveryAck(for: messageID, to: peerID)
|
||||
} else {
|
||||
nostr.sendDeliveryAck(for: messageID, to: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||
// Route via mesh when connected; else use Nostr
|
||||
if mesh.isPeerConnected(peerID) {
|
||||
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
@@ -84,16 +84,16 @@ final class MessageRouter {
|
||||
}
|
||||
|
||||
// MARK: - Outbox Management
|
||||
private func canSendViaNostr(peerID: String) -> Bool {
|
||||
private func canSendViaNostr(peerID: PeerID) -> Bool {
|
||||
// Two forms are supported:
|
||||
// - 64-hex Noise public key (32 bytes)
|
||||
// - 16-hex short peer ID (derived from Noise pubkey)
|
||||
if peerID.count == 64, let noiseKey = Data(hexString: peerID) {
|
||||
if let noiseKey = peerID.noiseKey {
|
||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
fav.peerNostrPublicKey != nil {
|
||||
return true
|
||||
}
|
||||
} else if peerID.count == 16 {
|
||||
} else if peerID.isShort {
|
||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||
fav.peerNostrPublicKey != nil {
|
||||
return true
|
||||
@@ -102,17 +102,17 @@ final class MessageRouter {
|
||||
return false
|
||||
}
|
||||
|
||||
func flushOutbox(for peerID: String) {
|
||||
func flushOutbox(for peerID: PeerID) {
|
||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
||||
SecureLogger.debug("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)", category: .session)
|
||||
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
||||
var remaining: [(content: String, nickname: String, messageID: String)] = []
|
||||
// Prefer mesh if connected; else try Nostr if mapping exists
|
||||
for (content, nickname, messageID) in queued {
|
||||
if mesh.isPeerReachable(peerID) {
|
||||
SecureLogger.debug("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("Outbox -> mesh for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||
} else if canSendViaNostr(peerID: peerID) {
|
||||
SecureLogger.debug("Outbox -> Nostr for \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("Outbox -> Nostr for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||
} else {
|
||||
// Keep unsent items queued
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
import BitLogger
|
||||
import Combine
|
||||
import Tor
|
||||
|
||||
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
|
||||
/// Policy: permit start when either location permissions are authorized OR
|
||||
@@ -19,6 +20,12 @@ final class NetworkActivationService: ObservableObject {
|
||||
|
||||
private init() {}
|
||||
|
||||
deinit {
|
||||
// Clean up Combine subscriptions
|
||||
cancellables.removeAll()
|
||||
SecureLogger.debug("NetworkActivationService deinitialized", category: .session)
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard !started else { return }
|
||||
started = true
|
||||
|
||||
@@ -115,15 +115,30 @@ enum EncryptionStatus: Equatable {
|
||||
var description: String {
|
||||
switch self {
|
||||
case .none:
|
||||
return "Encryption failed"
|
||||
return String(localized: "encryption.status.failed", comment: "Status text when encryption failed")
|
||||
case .noHandshake:
|
||||
return "Not encrypted"
|
||||
return String(localized: "encryption.status.not_encrypted", comment: "Status text when no encryption handshake happened")
|
||||
case .noiseHandshaking:
|
||||
return "Establishing encryption..."
|
||||
return String(localized: "encryption.status.establishing", comment: "Status text when encryption is being established")
|
||||
case .noiseSecured:
|
||||
return "Encrypted"
|
||||
return String(localized: "encryption.status.secured", comment: "Status text when encryption is secured but not verified")
|
||||
case .noiseVerified:
|
||||
return "Encrypted & Verified"
|
||||
return String(localized: "encryption.status.verified", comment: "Status text when encryption is verified")
|
||||
}
|
||||
}
|
||||
|
||||
var accessibilityDescription: String {
|
||||
switch self {
|
||||
case .none:
|
||||
return String(localized: "encryption.accessibility.failed", comment: "Accessibility text when encryption failed")
|
||||
case .noHandshake:
|
||||
return String(localized: "encryption.accessibility.not_encrypted", comment: "Accessibility text when encryption is not established")
|
||||
case .noiseHandshaking:
|
||||
return String(localized: "encryption.accessibility.establishing", comment: "Accessibility text when encryption is being established")
|
||||
case .noiseSecured:
|
||||
return String(localized: "encryption.accessibility.secured", comment: "Accessibility text when encryption is secured")
|
||||
case .noiseVerified:
|
||||
return String(localized: "encryption.accessibility.verified", comment: "Accessibility text when encryption is verified")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,8 +162,8 @@ final class NoiseEncryptionService {
|
||||
private let sessionManager: NoiseSessionManager
|
||||
|
||||
// Peer fingerprints (SHA256 hash of static public key)
|
||||
private var peerFingerprints: [String: String] = [:] // peerID -> fingerprint
|
||||
private var fingerprintToPeerID: [String: String] = [:] // fingerprint -> peerID
|
||||
private var peerFingerprints: [PeerID: String] = [:]
|
||||
private var fingerprintToPeerID: [String: PeerID] = [:]
|
||||
|
||||
// Thread safety
|
||||
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
|
||||
@@ -163,7 +178,7 @@ final class NoiseEncryptionService {
|
||||
|
||||
// Callbacks
|
||||
private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication
|
||||
var onHandshakeRequired: ((String) -> Void)? // peerID needs handshake
|
||||
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
||||
|
||||
// Add a handler for peer authentication
|
||||
func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) {
|
||||
@@ -257,12 +272,11 @@ final class NoiseEncryptionService {
|
||||
|
||||
/// Get our identity fingerprint
|
||||
func getIdentityFingerprint() -> String {
|
||||
let hash = SHA256.hash(data: staticIdentityPublicKey.rawRepresentation)
|
||||
return hash.map { String(format: "%02x", $0) }.joined()
|
||||
staticIdentityPublicKey.rawRepresentation.sha256Fingerprint()
|
||||
}
|
||||
|
||||
/// Get peer's public key data
|
||||
func getPeerPublicKeyData(_ peerID: String) -> Data? {
|
||||
func getPeerPublicKeyData(_ peerID: PeerID) -> Data? {
|
||||
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
|
||||
}
|
||||
|
||||
@@ -390,11 +404,11 @@ final class NoiseEncryptionService {
|
||||
// MARK: - Handshake Management
|
||||
|
||||
/// Initiate a Noise handshake with a peer
|
||||
func initiateHandshake(with peerID: String) throws -> Data {
|
||||
func initiateHandshake(with peerID: PeerID) throws -> Data {
|
||||
|
||||
// Validate peer ID
|
||||
guard NoiseSecurityValidator.validatePeerID(peerID) else {
|
||||
SecureLogger.warning(.authenticationFailed(peerID: peerID))
|
||||
guard peerID.isValid else {
|
||||
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||
throw NoiseSecurityError.invalidPeerID
|
||||
}
|
||||
|
||||
@@ -404,7 +418,7 @@ final class NoiseEncryptionService {
|
||||
throw NoiseSecurityError.rateLimitExceeded
|
||||
}
|
||||
|
||||
SecureLogger.info(.handshakeStarted(peerID: peerID))
|
||||
SecureLogger.info(.handshakeStarted(peerID: peerID.id))
|
||||
|
||||
// Return raw handshake data without wrapper
|
||||
// The Noise protocol handles its own message format
|
||||
@@ -413,17 +427,17 @@ final class NoiseEncryptionService {
|
||||
}
|
||||
|
||||
/// Process an incoming handshake message
|
||||
func processHandshakeMessage(from peerID: String, message: Data) throws -> Data? {
|
||||
func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
|
||||
|
||||
// Validate peer ID
|
||||
guard NoiseSecurityValidator.validatePeerID(peerID) else {
|
||||
SecureLogger.warning(.authenticationFailed(peerID: peerID))
|
||||
guard peerID.isValid else {
|
||||
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||
throw NoiseSecurityError.invalidPeerID
|
||||
}
|
||||
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else {
|
||||
SecureLogger.warning(.handshakeFailed(peerID: peerID, error: "Message too large"))
|
||||
SecureLogger.warning(.handshakeFailed(peerID: peerID.id, error: "Message too large"))
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
@@ -443,19 +457,19 @@ final class NoiseEncryptionService {
|
||||
}
|
||||
|
||||
/// Check if we have an established session with a peer
|
||||
func hasEstablishedSession(with peerID: String) -> Bool {
|
||||
func hasEstablishedSession(with peerID: PeerID) -> Bool {
|
||||
return sessionManager.getSession(for: peerID)?.isEstablished() ?? false
|
||||
}
|
||||
|
||||
/// Check if we have a session (established or handshaking) with a peer
|
||||
func hasSession(with peerID: String) -> Bool {
|
||||
func hasSession(with peerID: PeerID) -> Bool {
|
||||
return sessionManager.getSession(for: peerID) != nil
|
||||
}
|
||||
|
||||
// MARK: - Encryption/Decryption
|
||||
|
||||
/// Encrypt data for a specific peer
|
||||
func encrypt(_ data: Data, for peerID: String) throws -> Data {
|
||||
func encrypt(_ data: Data, for peerID: PeerID) throws -> Data {
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
@@ -477,7 +491,7 @@ final class NoiseEncryptionService {
|
||||
}
|
||||
|
||||
/// Decrypt data from a specific peer
|
||||
func decrypt(_ data: Data, from peerID: String) throws -> Data {
|
||||
func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
@@ -499,32 +513,11 @@ final class NoiseEncryptionService {
|
||||
// MARK: - Peer Management
|
||||
|
||||
/// Get fingerprint for a peer
|
||||
func getPeerFingerprint(_ peerID: String) -> String? {
|
||||
func getPeerFingerprint(_ peerID: PeerID) -> String? {
|
||||
return serviceQueue.sync {
|
||||
return peerFingerprints[peerID]
|
||||
}
|
||||
}
|
||||
|
||||
/// Get peer ID for a fingerprint
|
||||
func getPeerID(for fingerprint: String) -> String? {
|
||||
return serviceQueue.sync {
|
||||
return fingerprintToPeerID[fingerprint]
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a peer session
|
||||
func removePeer(_ peerID: String) {
|
||||
sessionManager.removeSession(for: peerID)
|
||||
|
||||
serviceQueue.sync(flags: .barrier) {
|
||||
if let fingerprint = peerFingerprints[peerID] {
|
||||
fingerprintToPeerID.removeValue(forKey: fingerprint)
|
||||
}
|
||||
peerFingerprints.removeValue(forKey: peerID)
|
||||
}
|
||||
|
||||
SecureLogger.info(.sessionExpired(peerID: peerID))
|
||||
}
|
||||
|
||||
func clearEphemeralStateForPanic() {
|
||||
sessionManager.removeAllSessions()
|
||||
@@ -537,9 +530,9 @@ final class NoiseEncryptionService {
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func handleSessionEstablished(peerID: String, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
|
||||
private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
|
||||
// Calculate fingerprint
|
||||
let fingerprint = calculateFingerprint(for: remoteStaticKey)
|
||||
let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint()
|
||||
|
||||
// Store fingerprint mapping
|
||||
serviceQueue.sync(flags: .barrier) {
|
||||
@@ -548,20 +541,15 @@ final class NoiseEncryptionService {
|
||||
}
|
||||
|
||||
// Log security event
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID))
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||
|
||||
// Notify all handlers about authentication
|
||||
serviceQueue.async { [weak self] in
|
||||
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
||||
handler(peerID, fingerprint)
|
||||
handler(peerID.id, fingerprint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func calculateFingerprint(for publicKey: Curve25519.KeyAgreement.PublicKey) -> String {
|
||||
let hash = SHA256.hash(data: publicKey.rawRepresentation)
|
||||
return hash.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
// MARK: - Session Maintenance
|
||||
|
||||
|
||||
@@ -4,46 +4,49 @@ import Combine
|
||||
|
||||
// Minimal Nostr transport conforming to Transport for offline sending
|
||||
final class NostrTransport: Transport {
|
||||
weak var delegate: BitchatDelegate?
|
||||
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||
Just([]).eraseToAnyPublisher()
|
||||
}
|
||||
func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] }
|
||||
|
||||
// Provide BLE short peer ID for BitChat embedding
|
||||
var senderPeerID: String = ""
|
||||
var senderPeerID = PeerID(str: "")
|
||||
|
||||
// Throttle READ receipts to avoid relay rate limits
|
||||
private struct QueuedRead {
|
||||
let receipt: ReadReceipt
|
||||
let peerID: String
|
||||
let peerID: PeerID
|
||||
}
|
||||
private var readQueue: [QueuedRead] = []
|
||||
private var isSendingReadAcks = false
|
||||
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
||||
private let keychain: KeychainManagerProtocol
|
||||
|
||||
var myPeerID: String { senderPeerID }
|
||||
var myNickname: String { "" }
|
||||
func setNickname(_ nickname: String) { /* not used for Nostr */ }
|
||||
|
||||
init(keychain: KeychainManagerProtocol) {
|
||||
self.keychain = keychain
|
||||
}
|
||||
|
||||
// MARK: - Transport Protocol Conformance
|
||||
|
||||
weak var delegate: BitchatDelegate?
|
||||
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||
|
||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||
Just([]).eraseToAnyPublisher()
|
||||
}
|
||||
func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] }
|
||||
|
||||
var myPeerID: PeerID { senderPeerID }
|
||||
var myNickname: String { "" }
|
||||
func setNickname(_ nickname: String) { /* not used for Nostr */ }
|
||||
|
||||
func startServices() { /* no-op */ }
|
||||
func stopServices() { /* no-op */ }
|
||||
func emergencyDisconnectAll() { /* no-op */ }
|
||||
|
||||
func isPeerConnected(_ peerID: String) -> Bool { false }
|
||||
func isPeerReachable(_ peerID: String) -> Bool { false }
|
||||
func peerNickname(peerID: String) -> String? { nil }
|
||||
func getPeerNicknames() -> [String : String] { [:] }
|
||||
func isPeerConnected(_ peerID: PeerID) -> Bool { false }
|
||||
func isPeerReachable(_ peerID: PeerID) -> Bool { false }
|
||||
func peerNickname(peerID: PeerID) -> String? { nil }
|
||||
func getPeerNicknames() -> [PeerID : String] { [:] }
|
||||
|
||||
func getFingerprint(for peerID: String) -> String? { nil }
|
||||
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState { .none }
|
||||
func triggerHandshake(with peerID: String) { /* no-op */ }
|
||||
func getFingerprint(for peerID: PeerID) -> String? { nil }
|
||||
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
|
||||
func triggerHandshake(with peerID: PeerID) { /* no-op */ }
|
||||
|
||||
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
|
||||
private static var cachedNoiseService: NoiseEncryptionService?
|
||||
@@ -59,11 +62,11 @@ final class NostrTransport: Transport {
|
||||
// Public broadcast not supported over Nostr here
|
||||
func sendMessage(_ content: String, mentions: [String]) { /* no-op */ }
|
||||
|
||||
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
// Convert recipient npub -> hex (x-only)
|
||||
let recipientHex: String
|
||||
do {
|
||||
@@ -77,7 +80,7 @@ final class NostrTransport: Transport {
|
||||
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
||||
return
|
||||
}
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||
return
|
||||
}
|
||||
@@ -90,12 +93,113 @@ final class NostrTransport: Transport {
|
||||
}
|
||||
}
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
// Enqueue and process with throttling to avoid relay rate limits
|
||||
readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
|
||||
processReadQueueIfNeeded()
|
||||
}
|
||||
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
||||
// Convert recipient npub -> hex
|
||||
let recipientHex: String
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||
guard hrp == "npub" else { return }
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch { return }
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event for favorite notification", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||
let recipientHex: String
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||
guard hrp == "npub" else { return }
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch { return }
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event for DELIVERED ack", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geohash Helpers
|
||||
|
||||
extension NostrTransport {
|
||||
|
||||
// MARK: Geohash ACK helpers
|
||||
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
Task { @MainActor in
|
||||
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID.id) else { return }
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
Task { @MainActor in
|
||||
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID.id) else { return }
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Geohash DMs (per-geohash identity)
|
||||
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
||||
Task { @MainActor in
|
||||
guard !recipientHex.isEmpty else { return }
|
||||
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||
// Build embedded BitChat packet without recipient peer ID
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID.id) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event for geohash PM", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
extension NostrTransport {
|
||||
private func processReadQueueIfNeeded() {
|
||||
guard !isSendingReadAcks else { return }
|
||||
guard !readQueue.isEmpty else { return }
|
||||
@@ -117,7 +221,7 @@ final class NostrTransport: Transport {
|
||||
guard hrp == "npub" else { scheduleNextReadAck(); return }
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch { scheduleNextReadAck(); return }
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID.id, senderPeerID: senderPeerID.id) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||
scheduleNextReadAck(); return
|
||||
}
|
||||
@@ -139,111 +243,18 @@ final class NostrTransport: Transport {
|
||||
}
|
||||
}
|
||||
|
||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
||||
// Convert recipient npub -> hex
|
||||
let recipientHex: String
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||
guard hrp == "npub" else { return }
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch { return }
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event for favorite notification", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
@MainActor
|
||||
private func resolveRecipientNpub(for peerID: String) -> String? {
|
||||
if let noiseKey = Data(hexString: peerID),
|
||||
private func resolveRecipientNpub(for peerID: PeerID) -> String? {
|
||||
if let noiseKey = Data(hexString: peerID.id),
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
let npub = fav.peerNostrPublicKey {
|
||||
return npub
|
||||
}
|
||||
if peerID.count == 16,
|
||||
if peerID.id.count == 16,
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||
let npub = fav.peerNostrPublicKey {
|
||||
return npub
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
||||
func sendDeliveryAck(for messageID: String, to peerID: String) {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||
let recipientHex: String
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||
guard hrp == "npub" else { return }
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch { return }
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event for DELIVERED ack", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geohash ACK helpers
|
||||
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
Task { @MainActor in
|
||||
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
Task { @MainActor in
|
||||
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geohash DMs (per-geohash identity)
|
||||
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
||||
Task { @MainActor in
|
||||
guard !recipientHex.isEmpty else { return }
|
||||
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||
// Build embedded BitChat packet without recipient peer ID
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event for geohash PM", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,26 +70,6 @@ final class NotificationService {
|
||||
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
|
||||
}
|
||||
|
||||
func sendFavoriteOnlineNotification(nickname: String) {
|
||||
// Send directly without checking app state for favorites
|
||||
DispatchQueue.main.async {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "⭐ \(nickname) is online!"
|
||||
content.body = "wanna get in there?"
|
||||
content.sound = .default
|
||||
|
||||
let request = UNNotificationRequest(
|
||||
identifier: "favorite-online-\(UUID().uuidString)",
|
||||
content: content,
|
||||
trigger: nil
|
||||
)
|
||||
|
||||
UNUserNotificationCenter.current().add(request) { _ in
|
||||
// Notification added
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Geohash public chat notification with deep link to a specific geohash
|
||||
func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) {
|
||||
let title = "\(titlePrefix)\(geohash)"
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//
|
||||
// NotificationStreamAssembler.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,10 @@ final class PrivateChatManager: ObservableObject {
|
||||
self.meshService = meshService
|
||||
}
|
||||
|
||||
deinit {
|
||||
SecureLogger.debug("PrivateChatManager deinitialized", category: .session)
|
||||
}
|
||||
|
||||
// Cap for messages stored per private chat
|
||||
private let privateChatCap = TransportConfig.privateChatCap
|
||||
|
||||
@@ -35,7 +39,7 @@ final class PrivateChatManager: ObservableObject {
|
||||
selectedPeer = peerID
|
||||
|
||||
// Store fingerprint for persistence across reconnections
|
||||
if let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||
if let fingerprint = meshService?.getFingerprint(for: PeerID(str: peerID)) {
|
||||
selectedPeerFingerprint = fingerprint
|
||||
}
|
||||
|
||||
@@ -53,104 +57,28 @@ final class PrivateChatManager: ObservableObject {
|
||||
selectedPeer = nil
|
||||
selectedPeerFingerprint = nil
|
||||
}
|
||||
|
||||
/// Send a private message
|
||||
func sendMessage(_ content: String, to peerID: String) {
|
||||
guard let meshService = meshService,
|
||||
let peerNickname = meshService.peerNickname(peerID: peerID) else {
|
||||
return
|
||||
}
|
||||
|
||||
let messageID = UUID().uuidString
|
||||
|
||||
// Create local message
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: meshService.myNickname,
|
||||
content: content,
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: peerNickname,
|
||||
senderPeerID: meshService.myPeerID,
|
||||
mentions: nil,
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
|
||||
// Add to chat
|
||||
if privateChats[peerID] == nil { privateChats[peerID] = [] }
|
||||
privateChats[peerID]?.append(message)
|
||||
// Enforce per-chat cap on local append
|
||||
if var arr = privateChats[peerID], arr.count > privateChatCap {
|
||||
let remove = arr.count - privateChatCap
|
||||
arr.removeFirst(remove)
|
||||
privateChats[peerID] = arr
|
||||
}
|
||||
|
||||
// Send via mesh service
|
||||
meshService.sendPrivateMessage(content, to: peerID, recipientNickname: peerNickname, messageID: messageID)
|
||||
}
|
||||
|
||||
/// Handle incoming private message
|
||||
func handleIncomingMessage(_ message: BitchatMessage) {
|
||||
guard let senderPeerID = message.senderPeerID else { return }
|
||||
|
||||
// Initialize chat if needed
|
||||
if privateChats[senderPeerID] == nil {
|
||||
privateChats[senderPeerID] = []
|
||||
}
|
||||
|
||||
// Deduplicate by ID: replace existing message if present, else append
|
||||
if let idx = privateChats[senderPeerID]?.firstIndex(where: { $0.id == message.id }) {
|
||||
privateChats[senderPeerID]?[idx] = message
|
||||
} else {
|
||||
privateChats[senderPeerID]?.append(message)
|
||||
}
|
||||
|
||||
// Sanitize chat to avoid duplicate IDs and sort by timestamp
|
||||
sanitizeChat(for: senderPeerID)
|
||||
// Enforce cap after sanitize
|
||||
if var arr = privateChats[senderPeerID], arr.count > privateChatCap {
|
||||
let remove = arr.count - privateChatCap
|
||||
arr.removeFirst(remove)
|
||||
privateChats[senderPeerID] = arr
|
||||
}
|
||||
|
||||
// Mark as unread if not in this chat
|
||||
if selectedPeer != senderPeerID {
|
||||
unreadMessages.insert(senderPeerID)
|
||||
|
||||
// Avoid notifying for messages already marked as read (dup/resubscribe cases)
|
||||
if !sentReadReceipts.contains(message.id) {
|
||||
NotificationService.shared.sendPrivateMessageNotification(
|
||||
from: message.sender,
|
||||
message: message.content,
|
||||
peerID: senderPeerID
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Send read receipt if viewing this chat
|
||||
sendReadReceipt(for: message)
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove duplicate messages by ID and keep chronological order
|
||||
func sanitizeChat(for peerID: String) {
|
||||
guard let arr = privateChats[peerID] else { return }
|
||||
var seen = Set<String>()
|
||||
if arr.count <= 1 {
|
||||
return
|
||||
}
|
||||
|
||||
var indexByID: [String: Int] = [:]
|
||||
indexByID.reserveCapacity(arr.count)
|
||||
var deduped: [BitchatMessage] = []
|
||||
deduped.reserveCapacity(arr.count)
|
||||
|
||||
for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
|
||||
if !seen.contains(msg.id) {
|
||||
seen.insert(msg.id)
|
||||
deduped.append(msg)
|
||||
if let existing = indexByID[msg.id] {
|
||||
deduped[existing] = msg
|
||||
} else {
|
||||
// Replace previous with the latest occurrence (which is later in sort)
|
||||
if let index = deduped.firstIndex(where: { $0.id == msg.id }) {
|
||||
deduped[index] = msg
|
||||
}
|
||||
indexByID[msg.id] = deduped.count
|
||||
deduped.append(msg)
|
||||
}
|
||||
}
|
||||
|
||||
privateChats[peerID] = deduped
|
||||
}
|
||||
|
||||
@@ -168,48 +96,6 @@ final class PrivateChatManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the selected peer if fingerprint matches (for reconnections)
|
||||
func updateSelectedPeer(peers: [String: String]) {
|
||||
guard let fingerprint = selectedPeerFingerprint else { return }
|
||||
|
||||
// Find peer with matching fingerprint
|
||||
for (peerID, _) in peers {
|
||||
if meshService?.getFingerprint(for: peerID) == fingerprint {
|
||||
selectedPeer = peerID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get chat messages for current context
|
||||
func getCurrentMessages() -> [BitchatMessage] {
|
||||
guard let peer = selectedPeer else { return [] }
|
||||
return privateChats[peer] ?? []
|
||||
}
|
||||
|
||||
/// Clear a private chat
|
||||
func clearChat(with peerID: String) {
|
||||
privateChats[peerID]?.removeAll()
|
||||
}
|
||||
|
||||
/// Handle delivery acknowledgment
|
||||
func handleDeliveryAck(messageID: String, from peerID: String) {
|
||||
guard privateChats[peerID] != nil else { return }
|
||||
|
||||
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
privateChats[peerID]?[index].deliveryStatus = .delivered(to: "recipient", at: Date())
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle read receipt
|
||||
func handleReadReceipt(messageID: String, from peerID: String) {
|
||||
guard privateChats[peerID] != nil else { return }
|
||||
|
||||
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
privateChats[peerID]?[index].deliveryStatus = .read(by: "recipient", at: Date())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func sendReadReceipt(for message: BitchatMessage) {
|
||||
@@ -223,13 +109,13 @@ final class PrivateChatManager: ObservableObject {
|
||||
// Create read receipt using the simplified method
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: message.id,
|
||||
readerID: meshService?.myPeerID ?? "",
|
||||
readerID: meshService?.myPeerID.id ?? "",
|
||||
readerNickname: meshService?.myNickname ?? ""
|
||||
)
|
||||
|
||||
// Route via MessageRouter to avoid handshakeRequired spam when session isn't established
|
||||
if let router = messageRouter {
|
||||
SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.prefix(8))… via router", category: .session)
|
||||
SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.id.prefix(8))… via router", category: .session)
|
||||
Task { @MainActor in
|
||||
router.sendReadReceipt(receipt, to: senderPeerID)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import Combine
|
||||
/// Abstract transport interface used by ChatViewModel and services.
|
||||
/// BLEService implements this protocol; a future Nostr transport can too.
|
||||
struct TransportPeerSnapshot: Equatable, Hashable {
|
||||
let id: String
|
||||
let peerID: PeerID
|
||||
let nickname: String
|
||||
let isConnected: Bool
|
||||
let noisePublicKey: Data?
|
||||
@@ -12,13 +12,17 @@ struct TransportPeerSnapshot: Equatable, Hashable {
|
||||
}
|
||||
|
||||
protocol Transport: AnyObject {
|
||||
// Peer events (preferred over publishers for UI)
|
||||
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
|
||||
// Event sink
|
||||
var delegate: BitchatDelegate? { get set }
|
||||
// Peer events (preferred over publishers for UI)
|
||||
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
|
||||
|
||||
// Peer snapshots (for non-UI services)
|
||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
|
||||
func currentPeerSnapshots() -> [TransportPeerSnapshot]
|
||||
|
||||
// Identity
|
||||
var myPeerID: String { get }
|
||||
var myPeerID: PeerID { get }
|
||||
var myNickname: String { get }
|
||||
func setNickname(_ nickname: String)
|
||||
|
||||
@@ -28,37 +32,33 @@ protocol Transport: AnyObject {
|
||||
func emergencyDisconnectAll()
|
||||
|
||||
// Connectivity and peers
|
||||
func isPeerConnected(_ peerID: String) -> Bool
|
||||
func isPeerReachable(_ peerID: String) -> Bool
|
||||
func peerNickname(peerID: String) -> String?
|
||||
func getPeerNicknames() -> [String: String]
|
||||
func isPeerConnected(_ peerID: PeerID) -> Bool
|
||||
func isPeerReachable(_ peerID: PeerID) -> Bool
|
||||
func peerNickname(peerID: PeerID) -> String?
|
||||
func getPeerNicknames() -> [PeerID: String]
|
||||
|
||||
// Protocol utilities
|
||||
func getFingerprint(for peerID: String) -> String?
|
||||
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState
|
||||
func triggerHandshake(with peerID: String)
|
||||
func getFingerprint(for peerID: PeerID) -> String?
|
||||
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState
|
||||
func triggerHandshake(with peerID: PeerID)
|
||||
func getNoiseService() -> NoiseEncryptionService
|
||||
|
||||
// Messaging
|
||||
func sendMessage(_ content: String, mentions: [String])
|
||||
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String)
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String)
|
||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool)
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
||||
func sendBroadcastAnnounce()
|
||||
func sendDeliveryAck(for messageID: String, to peerID: String)
|
||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID)
|
||||
|
||||
// QR verification (optional for transports)
|
||||
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
||||
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
||||
|
||||
// Peer snapshots (for non-UI services)
|
||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
|
||||
func currentPeerSnapshots() -> [TransportPeerSnapshot]
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
}
|
||||
|
||||
extension Transport {
|
||||
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||
}
|
||||
|
||||
protocol TransportPeerEventsDelegate: AnyObject {
|
||||
|
||||
@@ -41,12 +41,6 @@ enum TransportConfig {
|
||||
static let uiLateInsertThresholdGeo: TimeInterval = 0.0
|
||||
static let uiProcessedNostrEventsCap: Int = 2000
|
||||
static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60
|
||||
|
||||
// UI rate limiters (token buckets)
|
||||
static let uiSenderRateBucketCapacity: Double = 5
|
||||
static let uiSenderRateBucketRefillPerSec: Double = 1.0
|
||||
static let uiContentRateBucketCapacity: Double = 3
|
||||
static let uiContentRateBucketRefillPerSec: Double = 0.5
|
||||
|
||||
// UI sleeps/delays
|
||||
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
|
||||
|
||||
@@ -10,7 +10,6 @@ import BitLogger
|
||||
import Foundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
import CryptoKit
|
||||
|
||||
/// Single source of truth for peer state, combining mesh connectivity and favorites
|
||||
@MainActor
|
||||
@@ -19,14 +18,14 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
// MARK: - Published Properties
|
||||
|
||||
@Published private(set) var peers: [BitchatPeer] = []
|
||||
@Published private(set) var connectedPeerIDs: Set<String> = []
|
||||
@Published private(set) var connectedPeerIDs: Set<PeerID> = []
|
||||
@Published private(set) var favorites: [BitchatPeer] = []
|
||||
@Published private(set) var mutualFavorites: [BitchatPeer] = []
|
||||
|
||||
// MARK: - Private Properties
|
||||
|
||||
private var peerIndex: [String: BitchatPeer] = [:]
|
||||
private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint
|
||||
private var peerIndex: [PeerID: BitchatPeer] = [:]
|
||||
private var fingerprintCache: [PeerID: String] = [:]
|
||||
private let meshService: Transport
|
||||
private let identityManager: SecureIdentityStateManagerProtocol
|
||||
weak var messageRouter: MessageRouter?
|
||||
@@ -47,7 +46,17 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
updatePeers()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
deinit {
|
||||
// Clean up NotificationCenter observers
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
|
||||
// Clean up Combine subscriptions
|
||||
cancellables.removeAll()
|
||||
|
||||
SecureLogger.debug("UnifiedPeerService deinitialized", category: .session)
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupSubscriptions() {
|
||||
@@ -78,12 +87,12 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
let favorites = favoritesService.favorites
|
||||
|
||||
var enrichedPeers: [BitchatPeer] = []
|
||||
var connected: Set<String> = []
|
||||
var addedPeerIDs: Set<String> = []
|
||||
var connected: Set<PeerID> = []
|
||||
var addedPeerIDs: Set<PeerID> = []
|
||||
|
||||
// Phase 1: Add all mesh peers (connected and reachable)
|
||||
for peerInfo in meshPeers {
|
||||
let peerID = peerInfo.id
|
||||
let peerID = peerInfo.peerID
|
||||
guard peerID != meshService.myPeerID else { continue } // Never add self
|
||||
|
||||
let peer = buildPeerFromMesh(
|
||||
@@ -104,7 +113,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
|
||||
// Phase 2: Add offline favorites that we actively favorite
|
||||
for (favoriteKey, favorite) in favorites where favorite.isFavorite {
|
||||
let peerID = favoriteKey.hexEncodedString()
|
||||
let peerID = PeerID(hexData: favoriteKey)
|
||||
|
||||
// Skip if already added (connected peer)
|
||||
if addedPeerIDs.contains(peerID) { continue }
|
||||
@@ -138,10 +147,10 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
// Phase 4: Build subsets and indices
|
||||
var favoritesList: [BitchatPeer] = []
|
||||
var mutualsList: [BitchatPeer] = []
|
||||
var newIndex: [String: BitchatPeer] = [:]
|
||||
var newIndex: [PeerID: BitchatPeer] = [:]
|
||||
|
||||
for peer in enrichedPeers {
|
||||
newIndex[peer.id] = peer
|
||||
newIndex[peer.peerID] = peer
|
||||
|
||||
if peer.isFavorite {
|
||||
favoritesList.append(peer)
|
||||
@@ -185,7 +194,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached)
|
||||
|
||||
var peer = BitchatPeer(
|
||||
id: peerInfo.id,
|
||||
peerID: peerInfo.peerID,
|
||||
noisePublicKey: peerInfo.noisePublicKey ?? Data(),
|
||||
nickname: peerInfo.nickname,
|
||||
lastSeen: peerInfo.lastSeen,
|
||||
@@ -205,10 +214,10 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
|
||||
private func buildPeerFromFavorite(
|
||||
favorite: FavoritesPersistenceService.FavoriteRelationship,
|
||||
peerID: String
|
||||
peerID: PeerID
|
||||
) -> BitchatPeer {
|
||||
var peer = BitchatPeer(
|
||||
id: peerID,
|
||||
peerID: peerID,
|
||||
noisePublicKey: favorite.peerNoisePublicKey,
|
||||
nickname: favorite.peerNickname,
|
||||
lastSeen: favorite.lastUpdated,
|
||||
@@ -225,27 +234,22 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
// MARK: - Public Methods
|
||||
|
||||
/// Get peer by ID
|
||||
func getPeer(by id: String) -> BitchatPeer? {
|
||||
return peerIndex[id]
|
||||
func getPeer(by peerID: PeerID) -> BitchatPeer? {
|
||||
return peerIndex[peerID]
|
||||
}
|
||||
|
||||
/// Get peer ID for nickname
|
||||
func getPeerID(for nickname: String) -> String? {
|
||||
for peer in peers {
|
||||
if peer.displayName == nickname || peer.nickname == nickname {
|
||||
return peer.id
|
||||
return peer.peerID.id
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Check if peer is online
|
||||
func isOnline(_ peerID: String) -> Bool {
|
||||
return connectedPeerIDs.contains(peerID)
|
||||
}
|
||||
|
||||
/// Check if peer is blocked
|
||||
func isBlocked(_ peerID: String) -> Bool {
|
||||
func isBlocked(_ peerID: PeerID) -> Bool {
|
||||
// Get fingerprint
|
||||
guard let fingerprint = getFingerprint(for: peerID) else { return false }
|
||||
|
||||
@@ -258,8 +262,8 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
}
|
||||
|
||||
/// Toggle favorite status
|
||||
func toggleFavorite(_ peerID: String) {
|
||||
guard let peer = getPeer(by: peerID) else {
|
||||
func toggleFavorite(_ peerID: PeerID) {
|
||||
guard let peer = getPeer(by: peerID) else {
|
||||
SecureLogger.warning("⚠️ Cannot toggle favorite - peer not found: \(peerID)", category: .session)
|
||||
return
|
||||
}
|
||||
@@ -322,39 +326,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle blocked status
|
||||
func toggleBlocked(_ peerID: String) {
|
||||
guard let fingerprint = getFingerprint(for: peerID) else { return }
|
||||
|
||||
// Get or create social identity
|
||||
var identity = identityManager.getSocialIdentity(for: fingerprint)
|
||||
?? SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: nil,
|
||||
claimedNickname: getPeer(by: peerID)?.displayName ?? "Unknown",
|
||||
trustLevel: .unknown,
|
||||
isFavorite: false,
|
||||
isBlocked: false,
|
||||
notes: nil
|
||||
)
|
||||
|
||||
// Toggle blocked status
|
||||
identity.isBlocked = !identity.isBlocked
|
||||
|
||||
// Can't be both favorite and blocked
|
||||
if identity.isBlocked {
|
||||
identity.isFavorite = false
|
||||
// Also remove from favorites service
|
||||
if let peer = getPeer(by: peerID) {
|
||||
favoritesService.removeFavorite(peerNoisePublicKey: peer.noisePublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
identityManager.updateSocialIdentity(identity)
|
||||
}
|
||||
|
||||
/// Get fingerprint for peer ID
|
||||
func getFingerprint(for peerID: String) -> String? {
|
||||
func getFingerprint(for peerID: PeerID) -> String? {
|
||||
// Check cache first
|
||||
if let cached = fingerprintCache[peerID] {
|
||||
return cached
|
||||
@@ -379,23 +351,13 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
// MARK: - Compatibility Methods (for easy migration)
|
||||
|
||||
var allPeers: [BitchatPeer] { peers }
|
||||
var connectedPeers: [String] { Array(connectedPeerIDs) }
|
||||
var favoritePeers: Set<String> {
|
||||
Set(favorites.compactMap { getFingerprint(for: $0.id) })
|
||||
var connectedPeers: [PeerID] { Array(connectedPeerIDs) }
|
||||
var favoritePeers: Set<String> {
|
||||
Set(favorites.compactMap { getFingerprint(for: $0.peerID) })
|
||||
}
|
||||
var blockedUsers: Set<String> {
|
||||
Set(peers.compactMap { peer in
|
||||
isBlocked(peer.id) ? getFingerprint(for: peer.id) : nil
|
||||
isBlocked(peer.peerID) ? getFingerprint(for: peer.peerID) : nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Extensions
|
||||
|
||||
extension Data {
|
||||
func sha256Fingerprint() -> String {
|
||||
// Implementation matches existing fingerprint generation in NoiseEncryptionService
|
||||
let hash = SHA256.hash(data: self)
|
||||
return hash.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// QR verification scaffolding: schema, signing, and basic challenge/response helpers.
|
||||
final class VerificationService {
|
||||
@@ -95,7 +94,7 @@ final class VerificationService {
|
||||
nickname: payload.nickname,
|
||||
ts: payload.ts,
|
||||
nonceB64: payload.nonceB64,
|
||||
sigHex: sig.map { String(format: "%02x", $0) }.joined())
|
||||
sigHex: sig.hexEncodedString())
|
||||
let out = signed.toURLString()
|
||||
Cache.last = (nickname, npub, Date(), out)
|
||||
return out
|
||||
|
||||
@@ -4,7 +4,7 @@ import Foundation
|
||||
final class GossipSyncManager {
|
||||
protocol Delegate: AnyObject {
|
||||
func sendPacket(_ packet: BitchatPacket)
|
||||
func sendPacket(to peerID: String, packet: BitchatPacket)
|
||||
func sendPacket(to peerID: PeerID, packet: BitchatPacket)
|
||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
|
||||
}
|
||||
|
||||
@@ -12,9 +12,10 @@ final class GossipSyncManager {
|
||||
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
|
||||
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
||||
var gcsTargetFpr: Double = 0.01 // 1%
|
||||
var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages
|
||||
}
|
||||
|
||||
private let myPeerID: String
|
||||
private let myPeerID: PeerID
|
||||
private let config: Config
|
||||
weak var delegate: Delegate?
|
||||
|
||||
@@ -27,7 +28,7 @@ final class GossipSyncManager {
|
||||
private var periodicTimer: DispatchSourceTimer?
|
||||
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
||||
|
||||
init(myPeerID: String, config: Config = Config()) {
|
||||
init(myPeerID: PeerID, config: Config = Config()) {
|
||||
self.myPeerID = myPeerID
|
||||
self.config = config
|
||||
}
|
||||
@@ -36,7 +37,10 @@ final class GossipSyncManager {
|
||||
stop()
|
||||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||
timer.schedule(deadline: .now() + 30.0, repeating: 30.0, leeway: .seconds(1))
|
||||
timer.setEventHandler { [weak self] in self?.sendRequestSync() }
|
||||
timer.setEventHandler { [weak self] in
|
||||
self?.cleanupExpiredMessages()
|
||||
self?.sendRequestSync()
|
||||
}
|
||||
timer.resume()
|
||||
periodicTimer = timer
|
||||
}
|
||||
@@ -45,7 +49,7 @@ final class GossipSyncManager {
|
||||
periodicTimer?.cancel(); periodicTimer = nil
|
||||
}
|
||||
|
||||
func scheduleInitialSyncToPeer(_ peerID: String, delaySeconds: TimeInterval = 5.0) {
|
||||
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
|
||||
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
||||
self?.sendRequestSync(to: peerID)
|
||||
}
|
||||
@@ -57,6 +61,18 @@ final class GossipSyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to check if a packet is within the age threshold
|
||||
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
|
||||
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
let ageThresholdMs = UInt64(config.maxMessageAgeSeconds * 1000)
|
||||
|
||||
// If current time is less than threshold, accept all (handle clock issues gracefully)
|
||||
guard nowMs >= ageThresholdMs else { return true }
|
||||
|
||||
let cutoffMs = nowMs - ageThresholdMs
|
||||
return packet.timestamp >= cutoffMs
|
||||
}
|
||||
|
||||
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
||||
let mt = MessageType(rawValue: packet.type)
|
||||
let isBroadcastRecipient: Bool = {
|
||||
@@ -67,6 +83,9 @@ final class GossipSyncManager {
|
||||
let isAnnounce = (mt == .announce)
|
||||
guard isBroadcastMessage || isAnnounce else { return }
|
||||
|
||||
// Reject expired packets to prevent ghost peers and old messages
|
||||
guard isPacketFresh(packet) else { return }
|
||||
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
|
||||
if isBroadcastMessage {
|
||||
@@ -90,7 +109,7 @@ final class GossipSyncManager {
|
||||
let payload = buildGcsPayload()
|
||||
let pkt = BitchatPacket(
|
||||
type: MessageType.requestSync.rawValue,
|
||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||
senderID: Data(hexString: myPeerID.id) ?? Data(),
|
||||
recipientID: nil, // broadcast
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
@@ -101,10 +120,10 @@ final class GossipSyncManager {
|
||||
delegate?.sendPacket(signed)
|
||||
}
|
||||
|
||||
private func sendRequestSync(to peerID: String) {
|
||||
private func sendRequestSync(to peerID: PeerID) {
|
||||
let payload = buildGcsPayload()
|
||||
var recipient = Data()
|
||||
var temp = peerID
|
||||
var temp = peerID.id
|
||||
while temp.count >= 2 && recipient.count < 8 {
|
||||
let hexByte = String(temp.prefix(2))
|
||||
if let b = UInt8(hexByte, radix: 16) { recipient.append(b) }
|
||||
@@ -112,7 +131,7 @@ final class GossipSyncManager {
|
||||
}
|
||||
let pkt = BitchatPacket(
|
||||
type: MessageType.requestSync.rawValue,
|
||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||
senderID: Data(hexString: myPeerID.id) ?? Data(),
|
||||
recipientID: recipient,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
@@ -123,13 +142,13 @@ final class GossipSyncManager {
|
||||
delegate?.sendPacket(to: peerID, packet: signed)
|
||||
}
|
||||
|
||||
func handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
|
||||
func handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
|
||||
queue.async { [weak self] in
|
||||
self?._handleRequestSync(fromPeerID: fromPeerID, request: request)
|
||||
self?._handleRequestSync(from: peerID, request: request)
|
||||
}
|
||||
}
|
||||
|
||||
private func _handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
|
||||
private func _handleRequestSync(from peerID: PeerID, 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 {
|
||||
@@ -137,36 +156,46 @@ final class GossipSyncManager {
|
||||
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
||||
}
|
||||
|
||||
// 1) Announcements: send latest per peer if requester lacks them
|
||||
// 1) Announcements: send latest per peer if requester lacks them (and not expired)
|
||||
for (_, pair) in latestAnnouncementByPeer {
|
||||
let (idHex, pkt) = pair
|
||||
guard isPacketFresh(pkt) else { continue }
|
||||
let idBytes = Data(hexString: idHex) ?? Data()
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
delegate?.sendPacket(to: fromPeerID, packet: toSend)
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Broadcast messages: send all missing
|
||||
// 2) Broadcast messages: send all missing (and not expired)
|
||||
let toSendMsgs = messageOrder.compactMap { messages[$0] }
|
||||
for pkt in toSendMsgs {
|
||||
guard isPacketFresh(pkt) else { continue }
|
||||
let idBytes = PacketIdUtil.computeId(pkt)
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
delegate?.sendPacket(to: fromPeerID, packet: toSend)
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build REQUEST_SYNC payload using current candidates and GCS params
|
||||
private func buildGcsPayload() -> Data {
|
||||
// Collect candidates: latest announce per peer + broadcast messages
|
||||
// Collect candidates: latest announce per peer + broadcast messages (only fresh)
|
||||
var candidates: [BitchatPacket] = []
|
||||
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count)
|
||||
for (_, pair) in latestAnnouncementByPeer { candidates.append(pair.packet) }
|
||||
for id in messageOrder { if let p = messages[id] { candidates.append(p) } }
|
||||
for (_, pair) in latestAnnouncementByPeer {
|
||||
if isPacketFresh(pair.packet) {
|
||||
candidates.append(pair.packet)
|
||||
}
|
||||
}
|
||||
for id in messageOrder {
|
||||
if let p = messages[id], isPacketFresh(p) {
|
||||
candidates.append(p)
|
||||
}
|
||||
}
|
||||
// Sort by timestamp desc
|
||||
candidates.sort { $0.timestamp > $1.timestamp }
|
||||
|
||||
@@ -184,15 +213,32 @@ final class GossipSyncManager {
|
||||
return req.encode()
|
||||
}
|
||||
|
||||
// Periodic cleanup of expired messages and announcements
|
||||
private func cleanupExpiredMessages() {
|
||||
// Remove expired announcements
|
||||
latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pair in
|
||||
isPacketFresh(pair.packet)
|
||||
}
|
||||
|
||||
// Remove expired messages
|
||||
let expiredMessageIds = messages.compactMap { id, pkt in
|
||||
isPacketFresh(pkt) ? nil : id
|
||||
}
|
||||
for id in expiredMessageIds {
|
||||
messages.removeValue(forKey: id)
|
||||
messageOrder.removeAll { $0 == id }
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit removal hook for LEAVE/stale peer
|
||||
func removeAnnouncementForPeer(_ peerID: String) {
|
||||
func removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||
queue.async { [weak self] in
|
||||
self?._removeAnnouncementForPeer(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
private func _removeAnnouncementForPeer(_ peerID: String) {
|
||||
let normalizedPeerID = peerID.lowercased()
|
||||
private func _removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||
let normalizedPeerID = peerID.id.lowercased()
|
||||
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
|
||||
|
||||
// Remove messages from this peer
|
||||
|
||||
@@ -14,8 +14,4 @@ enum PacketIdUtil {
|
||||
let digest = hasher.finalize()
|
||||
return Data(digest.prefix(16))
|
||||
}
|
||||
|
||||
static func computeIdHex(_ packet: BitchatPacket) -> String {
|
||||
return computeId(packet).hexEncodedString()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// Data+SHA256.swift
|
||||
// bitchat
|
||||
//
|
||||
// Created by Islam on 26/09/2025.
|
||||
//
|
||||
|
||||
import struct Foundation.Data
|
||||
import struct CryptoKit.SHA256
|
||||
|
||||
extension Data {
|
||||
/// Returns the hex representation of SHA256 hash
|
||||
func sha256Fingerprint() -> String {
|
||||
// Implementation matches existing fingerprint generation in NoiseEncryptionService
|
||||
sha256Hash().hexEncodedString()
|
||||
}
|
||||
|
||||
/// Returns the SHA256 hash wrapped in Data
|
||||
func sha256Hash() -> Data {
|
||||
Data(SHA256.hash(data: self))
|
||||
}
|
||||
}
|
||||
@@ -20,15 +20,19 @@ private extension Font.TextStyle {
|
||||
return .caption2
|
||||
case ..<13.0:
|
||||
return .caption
|
||||
case ..<14.0:
|
||||
case ..<13.75:
|
||||
return .footnote
|
||||
case ..<16.5:
|
||||
case ..<15.5:
|
||||
return .subheadline
|
||||
case ..<17.5:
|
||||
return .callout
|
||||
case ..<19.5:
|
||||
return .body
|
||||
case ..<19.0:
|
||||
case ..<22.5:
|
||||
return .title3
|
||||
case ..<23.0:
|
||||
case ..<27.5:
|
||||
return .title2
|
||||
case ..<30.0:
|
||||
case ..<34.0:
|
||||
return .title
|
||||
default:
|
||||
return .largeTitle
|
||||
|
||||
@@ -11,51 +11,24 @@ struct InputValidator {
|
||||
// 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 maxReasonLength = 200
|
||||
static let maxPeerIDLength = 64
|
||||
static let hexPeerIDLength = 16 // 8 bytes = 16 hex chars
|
||||
}
|
||||
|
||||
// MARK: - Peer ID Validation
|
||||
|
||||
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
|
||||
static func validatePeerID(_ peerID: String) -> Bool {
|
||||
// Accept short routing IDs (exact 16-hex)
|
||||
if PeerIDResolver.isShortID(peerID) { return true }
|
||||
// If length equals short-hex length but isn't valid hex, reject
|
||||
if peerID.count == Limits.hexPeerIDLength { return false }
|
||||
// Accept full Noise key hex (exact 64-hex)
|
||||
if PeerIDResolver.isNoiseKeyHex(peerID) { return true }
|
||||
// If length equals full key length but isn't valid hex, reject
|
||||
if peerID.count == Limits.maxPeerIDLength { return false }
|
||||
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
|
||||
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
||||
return !peerID.isEmpty &&
|
||||
peerID.count < Limits.maxPeerIDLength &&
|
||||
peerID.rangeOfCharacter(from: validCharset.inverted) == nil
|
||||
}
|
||||
|
||||
// MARK: - String Content Validation
|
||||
|
||||
/// Validates and sanitizes user-provided strings (nicknames, messages)
|
||||
static func validateUserString(_ string: String, maxLength: Int, allowNewlines: Bool = false) -> String? {
|
||||
/// Validates and sanitizes user-provided strings used in UI
|
||||
static func validateUserString(_ string: String, maxLength: Int) -> String? {
|
||||
// Check empty
|
||||
guard !string.isEmpty else { return nil }
|
||||
|
||||
|
||||
// Trim whitespace
|
||||
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
|
||||
// Check length
|
||||
guard trimmed.count <= maxLength else { return nil }
|
||||
|
||||
// Remove control characters except allowed ones
|
||||
var allowedControlChars = CharacterSet()
|
||||
if allowNewlines {
|
||||
allowedControlChars.insert(charactersIn: "\n\r")
|
||||
}
|
||||
|
||||
let controlChars = CharacterSet.controlCharacters.subtracting(allowedControlChars)
|
||||
|
||||
// Remove control characters
|
||||
let controlChars = CharacterSet.controlCharacters
|
||||
let cleaned = trimmed.components(separatedBy: controlChars).joined()
|
||||
|
||||
// Ensure valid UTF-8 (should already be, but double-check)
|
||||
@@ -70,29 +43,14 @@ struct InputValidator {
|
||||
|
||||
/// Validates nickname
|
||||
static func validateNickname(_ nickname: String) -> String? {
|
||||
return validateUserString(nickname, maxLength: Limits.maxNicknameLength, allowNewlines: false)
|
||||
}
|
||||
|
||||
/// Validates message content
|
||||
static func validateMessageContent(_ content: String) -> String? {
|
||||
return validateUserString(content, maxLength: Limits.maxMessageLength, allowNewlines: true)
|
||||
}
|
||||
|
||||
/// Validates error/reason strings
|
||||
static func validateReasonString(_ reason: String) -> String? {
|
||||
return validateUserString(reason, maxLength: Limits.maxReasonLength, allowNewlines: false)
|
||||
return validateUserString(nickname, maxLength: Limits.maxNicknameLength)
|
||||
}
|
||||
|
||||
// MARK: - Protocol Field Validation
|
||||
|
||||
|
||||
// Note: Message type validation is performed closer to decoding using
|
||||
// MessageType/NoisePayloadType enums; keeping validator free of stale lists.
|
||||
|
||||
/// Validates hop count is reasonable
|
||||
static func validateHopCount(_ hopCount: UInt8) -> Bool {
|
||||
return hopCount <= 10 // Prevent excessive forwarding
|
||||
}
|
||||
|
||||
|
||||
/// Validates timestamp is reasonable (not too far in past or future)
|
||||
static func validateTimestamp(_ timestamp: Date) -> Bool {
|
||||
let now = Date()
|
||||
@@ -100,38 +58,5 @@ struct InputValidator {
|
||||
let oneHourFromNow = now.addingTimeInterval(3600)
|
||||
return timestamp >= oneHourAgo && timestamp <= oneHourFromNow
|
||||
}
|
||||
|
||||
/// Validates data size for different contexts
|
||||
static func validateDataSize(_ data: Data, maxSize: Int) -> Bool {
|
||||
return data.count > 0 && data.count <= maxSize
|
||||
}
|
||||
|
||||
// MARK: - Binary Data Validation
|
||||
|
||||
/// Validates UUID format
|
||||
static func validateUUID(_ uuid: String) -> Bool {
|
||||
// Remove dashes and validate hex
|
||||
let cleaned = uuid.replacingOccurrences(of: "-", with: "")
|
||||
return cleaned.count == 32 && cleaned.allSatisfy { $0.isHexDigit }
|
||||
}
|
||||
|
||||
/// Validates public key data
|
||||
static func validatePublicKey(_ keyData: Data) -> Bool {
|
||||
// Curve25519 public keys are 32 bytes
|
||||
return keyData.count == 32
|
||||
}
|
||||
|
||||
/// Validates signature data
|
||||
static func validateSignature(_ signature: Data) -> Bool {
|
||||
// Ed25519 signatures are 64 bytes
|
||||
return signature.count == 64
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Character Extensions
|
||||
|
||||
private extension Character {
|
||||
var isHexDigit: Bool {
|
||||
return "0123456789abcdefABCDEF".contains(self)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import Foundation
|
||||
struct PeerDisplayNameResolver {
|
||||
/// Computes display names with a `#xxxx` suffix for connected peers when nickname collisions occur.
|
||||
/// - Parameters:
|
||||
/// - peers: Array of tuples (id, nickname, isConnected).
|
||||
/// - peers: Array of tuples (peerID, nickname, isConnected).
|
||||
/// - selfNickname: The local user's current nickname, included in collision counts to suffix remotes matching it.
|
||||
/// - Returns: Map of peerID -> displayName.
|
||||
static func resolve(_ peers: [(id: String, nickname: String, isConnected: Bool)], selfNickname: String) -> [String: String] {
|
||||
static func resolve(_ peers: [(peerID: PeerID, nickname: String, isConnected: Bool)], selfNickname: String) -> [PeerID: String] {
|
||||
// Count collisions among connected peers and include our own nickname
|
||||
var counts: [String: Int] = [:]
|
||||
for p in peers where p.isConnected {
|
||||
@@ -15,13 +15,13 @@ struct PeerDisplayNameResolver {
|
||||
}
|
||||
counts[selfNickname, default: 0] += 1
|
||||
|
||||
var result: [String: String] = [:]
|
||||
var result: [PeerID: String] = [:]
|
||||
for p in peers {
|
||||
var name = p.nickname
|
||||
if p.isConnected, (counts[p.nickname] ?? 0) > 1 {
|
||||
name += "#" + String(p.id.prefix(4))
|
||||
name += "#" + String(p.peerID.id.prefix(4))
|
||||
}
|
||||
result[p.id] = name
|
||||
result[p.peerID] = name
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct PeerIDResolver {
|
||||
/// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed
|
||||
static func toShortID(_ id: String) -> String {
|
||||
if id.count == 64, let data = Data(hexString: id) {
|
||||
return PeerIDUtils.derivePeerID(fromPublicKey: data)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
static func isShortID(_ id: String) -> Bool {
|
||||
return id.count == 16 && Data(hexString: id) != nil
|
||||
}
|
||||
|
||||
static func isNoiseKeyHex(_ id: String) -> Bool {
|
||||
return id.count == 64 && Data(hexString: id) != nil
|
||||
}
|
||||
}
|
||||
|
||||
+417
-1242
File diff suppressed because it is too large
Load Diff
@@ -18,41 +18,77 @@ struct AppInfoView: View {
|
||||
|
||||
// MARK: - Constants
|
||||
private enum Strings {
|
||||
static let appName = "bitchat"
|
||||
static let tagline = "sidegroupchat"
|
||||
|
||||
static let appName: LocalizedStringKey = "app_info.app_name"
|
||||
static let tagline: LocalizedStringKey = "app_info.tagline"
|
||||
|
||||
enum Features {
|
||||
static let title = "FEATURES"
|
||||
static let offlineComm = ("wifi.slash", "offline communication", "works without internet using Bluetooth low energy")
|
||||
static let encryption = ("lock.shield", "end-to-end encryption", "private messages encrypted with noise protocol")
|
||||
static let extendedRange = ("antenna.radiowaves.left.and.right", "extended range", "messages relay through peers, going the distance")
|
||||
static let mentions = ("at", "mentions", "use @nickname to notify specific people")
|
||||
static let favorites = ("star.fill", "favorites", "get notified when your favorite people join")
|
||||
static let geohash = ("number", "local channels", "geohash channels to chat with people in nearby regions over decentralized anonymous relays")
|
||||
static let title: LocalizedStringKey = "app_info.features.title"
|
||||
static let offlineComm = AppInfoFeatureInfo(
|
||||
icon: "wifi.slash",
|
||||
title: "app_info.features.offline.title",
|
||||
description: "app_info.features.offline.description"
|
||||
)
|
||||
static let encryption = AppInfoFeatureInfo(
|
||||
icon: "lock.shield",
|
||||
title: "app_info.features.encryption.title",
|
||||
description: "app_info.features.encryption.description"
|
||||
)
|
||||
static let extendedRange = AppInfoFeatureInfo(
|
||||
icon: "antenna.radiowaves.left.and.right",
|
||||
title: "app_info.features.extended_range.title",
|
||||
description: "app_info.features.extended_range.description"
|
||||
)
|
||||
static let mentions = AppInfoFeatureInfo(
|
||||
icon: "at",
|
||||
title: "app_info.features.mentions.title",
|
||||
description: "app_info.features.mentions.description"
|
||||
)
|
||||
static let favorites = AppInfoFeatureInfo(
|
||||
icon: "star.fill",
|
||||
title: "app_info.features.favorites.title",
|
||||
description: "app_info.features.favorites.description"
|
||||
)
|
||||
static let geohash = AppInfoFeatureInfo(
|
||||
icon: "number",
|
||||
title: "app_info.features.geohash.title",
|
||||
description: "app_info.features.geohash.description"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
enum Privacy {
|
||||
static let title = "PRIVACY"
|
||||
static let noTracking = ("eye.slash", "no tracking", "no servers, accounts, or data collection")
|
||||
static let ephemeral = ("shuffle", "ephemeral identity", "new peer ID generated regularly")
|
||||
static let panic = ("hand.raised.fill", "panic mode", "triple-tap logo to instantly clear all data")
|
||||
static let title: LocalizedStringKey = "app_info.privacy.title"
|
||||
static let noTracking = AppInfoFeatureInfo(
|
||||
icon: "eye.slash",
|
||||
title: "app_info.privacy.no_tracking.title",
|
||||
description: "app_info.privacy.no_tracking.description"
|
||||
)
|
||||
static let ephemeral = AppInfoFeatureInfo(
|
||||
icon: "shuffle",
|
||||
title: "app_info.privacy.ephemeral.title",
|
||||
description: "app_info.privacy.ephemeral.description"
|
||||
)
|
||||
static let panic = AppInfoFeatureInfo(
|
||||
icon: "hand.raised.fill",
|
||||
title: "app_info.privacy.panic.title",
|
||||
description: "app_info.privacy.panic.description"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
enum HowToUse {
|
||||
static let title = "HOW TO USE"
|
||||
static let instructions = [
|
||||
"• set your nickname by tapping it",
|
||||
"• tap #mesh to change channels",
|
||||
"• tap people icon for sidebar",
|
||||
"• tap a peer's name to start a DM",
|
||||
"• triple-tap chat to clear",
|
||||
"• type / for commands"
|
||||
static let title: LocalizedStringKey = "app_info.how_to_use.title"
|
||||
static let instructions: [LocalizedStringKey] = [
|
||||
"app_info.how_to_use.set_nickname",
|
||||
"app_info.how_to_use.change_channels",
|
||||
"app_info.how_to_use.open_sidebar",
|
||||
"app_info.how_to_use.start_dm",
|
||||
"app_info.how_to_use.clear_chat",
|
||||
"app_info.how_to_use.commands"
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
enum Warning {
|
||||
static let title = "WARNING"
|
||||
static let message = "private message security has not yet been fully audited. do not use for critical situations until this warning disappears."
|
||||
static let title: LocalizedStringKey = "app_info.warning.title"
|
||||
static let message: LocalizedStringKey = "app_info.warning.message"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +98,7 @@ struct AppInfoView: View {
|
||||
// Custom header for macOS
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("DONE") {
|
||||
Button("app_info.done") {
|
||||
dismiss()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -93,7 +129,7 @@ struct AppInfoView: View {
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close")
|
||||
.accessibilityLabel("app_info.close")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,7 +157,7 @@ struct AppInfoView: View {
|
||||
SectionHeader(Strings.HowToUse.title)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(Strings.HowToUse.instructions, id: \.self) { instruction in
|
||||
ForEach(Array(Strings.HowToUse.instructions.enumerated()), id: \.offset) { _, instruction in
|
||||
Text(instruction)
|
||||
}
|
||||
}
|
||||
@@ -132,49 +168,31 @@ struct AppInfoView: View {
|
||||
// Features
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
SectionHeader(Strings.Features.title)
|
||||
|
||||
FeatureRow(icon: Strings.Features.offlineComm.0,
|
||||
title: Strings.Features.offlineComm.1,
|
||||
description: Strings.Features.offlineComm.2)
|
||||
|
||||
FeatureRow(icon: Strings.Features.encryption.0,
|
||||
title: Strings.Features.encryption.1,
|
||||
description: Strings.Features.encryption.2)
|
||||
|
||||
FeatureRow(icon: Strings.Features.extendedRange.0,
|
||||
title: Strings.Features.extendedRange.1,
|
||||
description: Strings.Features.extendedRange.2)
|
||||
|
||||
FeatureRow(icon: Strings.Features.favorites.0,
|
||||
title: Strings.Features.favorites.1,
|
||||
description: Strings.Features.favorites.2)
|
||||
|
||||
FeatureRow(icon: Strings.Features.geohash.0,
|
||||
title: Strings.Features.geohash.1,
|
||||
description: Strings.Features.geohash.2)
|
||||
|
||||
FeatureRow(icon: Strings.Features.mentions.0,
|
||||
title: Strings.Features.mentions.1,
|
||||
description: Strings.Features.mentions.2)
|
||||
|
||||
FeatureRow(info: Strings.Features.offlineComm)
|
||||
|
||||
FeatureRow(info: Strings.Features.encryption)
|
||||
|
||||
FeatureRow(info: Strings.Features.extendedRange)
|
||||
|
||||
FeatureRow(info: Strings.Features.favorites)
|
||||
|
||||
FeatureRow(info: Strings.Features.geohash)
|
||||
|
||||
FeatureRow(info: Strings.Features.mentions)
|
||||
}
|
||||
|
||||
|
||||
// Privacy
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
SectionHeader(Strings.Privacy.title)
|
||||
|
||||
FeatureRow(icon: Strings.Privacy.noTracking.0,
|
||||
title: Strings.Privacy.noTracking.1,
|
||||
description: Strings.Privacy.noTracking.2)
|
||||
|
||||
FeatureRow(icon: Strings.Privacy.ephemeral.0,
|
||||
title: Strings.Privacy.ephemeral.1,
|
||||
description: Strings.Privacy.ephemeral.2)
|
||||
|
||||
FeatureRow(icon: Strings.Privacy.panic.0,
|
||||
title: Strings.Privacy.panic.1,
|
||||
description: Strings.Privacy.panic.2)
|
||||
|
||||
FeatureRow(info: Strings.Privacy.noTracking)
|
||||
|
||||
FeatureRow(info: Strings.Privacy.ephemeral)
|
||||
|
||||
FeatureRow(info: Strings.Privacy.panic)
|
||||
}
|
||||
|
||||
|
||||
// Warning
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
SectionHeader(Strings.Warning.title)
|
||||
@@ -197,15 +215,21 @@ struct AppInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct AppInfoFeatureInfo {
|
||||
let icon: String
|
||||
let title: LocalizedStringKey
|
||||
let description: LocalizedStringKey
|
||||
}
|
||||
|
||||
struct SectionHeader: View {
|
||||
let title: String
|
||||
let title: LocalizedStringKey
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
private var textColor: Color {
|
||||
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||
}
|
||||
|
||||
init(_ title: String) {
|
||||
init(_ title: LocalizedStringKey) {
|
||||
self.title = title
|
||||
}
|
||||
|
||||
@@ -218,9 +242,7 @@ struct SectionHeader: View {
|
||||
}
|
||||
|
||||
struct FeatureRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let description: String
|
||||
let info: AppInfoFeatureInfo
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
private var textColor: Color {
|
||||
@@ -233,17 +255,17 @@ struct FeatureRow: View {
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
Image(systemName: info.icon)
|
||||
.font(.bitchatSystem(size: 20))
|
||||
.foregroundColor(textColor)
|
||||
.frame(width: 30)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
Text(info.title)
|
||||
.font(.bitchatSystem(size: 14, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Text(description)
|
||||
Text(info.description)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
//
|
||||
// DeliveryStatusView.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct DeliveryStatusView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let status: DeliveryStatus
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
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 enum Strings {
|
||||
static func delivered(to nickname: String) -> String {
|
||||
String(
|
||||
format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
}
|
||||
|
||||
static func read(by nickname: String) -> String {
|
||||
String(
|
||||
format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
}
|
||||
|
||||
static func failed(_ reason: String) -> String {
|
||||
String(
|
||||
format: String(localized: "content.delivery.failed", comment: "Tooltip for failed message delivery"),
|
||||
locale: .current,
|
||||
reason
|
||||
)
|
||||
}
|
||||
|
||||
static func deliveredToMembers(_ reached: Int, _ total: Int) -> String {
|
||||
String(
|
||||
format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"),
|
||||
locale: .current,
|
||||
reached,
|
||||
total
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
switch status {
|
||||
case .sending:
|
||||
Image(systemName: "circle")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
|
||||
case .sent:
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
|
||||
case .delivered(let nickname, _):
|
||||
HStack(spacing: -2) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
}
|
||||
.foregroundColor(textColor.opacity(0.8))
|
||||
.help(Strings.delivered(to: nickname))
|
||||
|
||||
case .read(let nickname, _):
|
||||
HStack(spacing: -2) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10, weight: .bold))
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10, weight: .bold))
|
||||
}
|
||||
.foregroundColor(Color(red: 0.0, green: 0.478, blue: 1.0)) // Bright blue
|
||||
.help(Strings.read(by: nickname))
|
||||
|
||||
case .failed(let reason):
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(Color.red.opacity(0.8))
|
||||
.help(Strings.failed(reason))
|
||||
|
||||
case .partiallyDelivered(let reached, let total):
|
||||
HStack(spacing: 1) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
Text(verbatim: "\(reached)/\(total)")
|
||||
.font(.bitchatSystem(size: 10, design: .monospaced))
|
||||
}
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
.help(Strings.deliveredToMembers(reached, total))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let statuses: [DeliveryStatus] = [
|
||||
.sending,
|
||||
.sent,
|
||||
.delivered(to: "John Doe", at: Date()),
|
||||
.read(by: "Jane Doe", at: Date()),
|
||||
.failed(reason: "Offline"),
|
||||
.partiallyDelivered(reached: 2, total: 5)
|
||||
]
|
||||
|
||||
List {
|
||||
ForEach(statuses, id: \.self) { status in
|
||||
HStack {
|
||||
Text(status.displayText)
|
||||
Spacer()
|
||||
DeliveryStatusView(status: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
.environment(\.colorScheme, .light)
|
||||
|
||||
List {
|
||||
ForEach(statuses, id: \.self) { status in
|
||||
HStack {
|
||||
Text(status.displayText)
|
||||
Spacer()
|
||||
DeliveryStatusView(status: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//
|
||||
// PaymentChipView.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PaymentChipView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
enum PaymentType {
|
||||
case cashu(String)
|
||||
case lightning(String)
|
||||
|
||||
var url: URL? {
|
||||
switch self {
|
||||
case .cashu(let link), .lightning(let link):
|
||||
return URL(string: link)
|
||||
}
|
||||
}
|
||||
|
||||
var emoji: String {
|
||||
switch self {
|
||||
case .cashu: "🥜"
|
||||
case .lightning: "⚡"
|
||||
}
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .cashu:
|
||||
String(localized: "content.payment.cashu", comment: "Label for Cashu payment chip")
|
||||
case .lightning:
|
||||
String(localized: "content.payment.lightning", comment: "Label for Lightning payment chip")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let paymentType: PaymentType
|
||||
|
||||
private var fgColor: Color {
|
||||
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||
}
|
||||
private var bgColor: Color {
|
||||
colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12)
|
||||
}
|
||||
private var border: Color { fgColor.opacity(0.25) }
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
#if os(iOS)
|
||||
if let url = paymentType.url { openURL(url) }
|
||||
#else
|
||||
if let url = paymentType.url { NSWorkspace.shared.open(url) }
|
||||
#endif
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Text(paymentType.emoji)
|
||||
Text(paymentType.label)
|
||||
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(bgColor)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(border, lineWidth: 1)
|
||||
)
|
||||
.foregroundColor(fgColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let cashuLink = "https://example.com/cashu"
|
||||
let lightningLink = "https://example.com/lightning"
|
||||
|
||||
List {
|
||||
HStack {
|
||||
PaymentChipView(paymentType: .cashu(cashuLink))
|
||||
PaymentChipView(paymentType: .lightning(lightningLink))
|
||||
}
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(EmptyView())
|
||||
}
|
||||
.environment(\.colorScheme, .light)
|
||||
|
||||
List {
|
||||
HStack {
|
||||
PaymentChipView(paymentType: .cashu(cashuLink))
|
||||
PaymentChipView(paymentType: .lightning(lightningLink))
|
||||
}
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(EmptyView())
|
||||
}
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//
|
||||
// TextMessageView.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct TextMessageView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
||||
@EnvironmentObject private var viewModel: ChatViewModel
|
||||
|
||||
let message: BitchatMessage
|
||||
@Binding var expandedMessageIDs: Set<String>
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Precompute heavy token scans once per row
|
||||
let cashuLinks = message.content.extractCashuLinks()
|
||||
let lightningLinks = message.content.extractLightningLinks()
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.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)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// Expand/Collapse for very long messages
|
||||
if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty {
|
||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||
let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more")
|
||||
Button(labelKey) {
|
||||
if isExpanded { expandedMessageIDs.remove(message.id) }
|
||||
else { expandedMessageIDs.insert(message.id) }
|
||||
}
|
||||
.font(.bitchatSystem(size: 11, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(Color.blue)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
// Render payment chips (Lightning / Cashu) with rounded background
|
||||
if !lightningLinks.isEmpty || !cashuLinks.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(lightningLinks, id: \.self) { link in
|
||||
PaymentChipView(paymentType: .lightning(link))
|
||||
}
|
||||
ForEach(cashuLinks, id: \.self) { link in
|
||||
PaymentChipView(paymentType: .cashu(link))
|
||||
}
|
||||
}
|
||||
.padding(.top, 6)
|
||||
.padding(.leading, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 14, iOS 17, *)
|
||||
#Preview {
|
||||
@Previewable @State var ids: Set<String> = []
|
||||
let keychain = PreviewKeychainManager()
|
||||
|
||||
Group {
|
||||
List {
|
||||
TextMessageView(message: .preview, expandedMessageIDs: $ids)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(EmptyView())
|
||||
}
|
||||
.environment(\.colorScheme, .light)
|
||||
|
||||
List {
|
||||
TextMessageView(message: .preview, expandedMessageIDs: $ids)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(EmptyView())
|
||||
}
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
.environmentObject(
|
||||
ChatViewModel(
|
||||
keychain: keychain,
|
||||
identityManager: SecureIdentityStateManager(keychain)
|
||||
)
|
||||
)
|
||||
}
|
||||
+263
-296
@@ -55,6 +55,8 @@ struct ContentView: View {
|
||||
@State private var notesGeohash: String? = nil
|
||||
@State private var sheetNotesCount: Int = 0
|
||||
@ScaledMetric(relativeTo: .body) private var headerHeight: CGFloat = 44
|
||||
@ScaledMetric(relativeTo: .subheadline) private var headerPeerIconSize: CGFloat = 11
|
||||
@ScaledMetric(relativeTo: .subheadline) private var headerPeerCountFontSize: CGFloat = 12
|
||||
// Timer-based refresh removed; use LocationChannelManager live updates instead
|
||||
// Window sizes for rendering (infinite scroll up)
|
||||
@State private var windowCountPublic: Int = 300
|
||||
@@ -127,6 +129,45 @@ struct ContentView: View {
|
||||
)
|
||||
}
|
||||
|
||||
// Right edge swipe zone for easier sidebar opening (iOS-native behavior)
|
||||
if !showSidebar {
|
||||
HStack {
|
||||
Spacer()
|
||||
Color.clear
|
||||
.frame(width: 20)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 5)
|
||||
.onChanged { value in
|
||||
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
||||
if translationWidth < 0 {
|
||||
let newOffset = max(translationWidth, -300)
|
||||
if abs(newOffset - sidebarDragOffset) > 2 {
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
sidebarDragOffset = newOffset
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onEnded { value in
|
||||
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
||||
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
|
||||
withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||
if translationWidth < -50 || velocity < -300 {
|
||||
showSidebar = true
|
||||
sidebarDragOffset = 0
|
||||
} else {
|
||||
sidebarDragOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.allowsHitTesting(true)
|
||||
}
|
||||
|
||||
// Sidebar overlay
|
||||
HStack(spacing: 0) {
|
||||
// Tap to dismiss area
|
||||
@@ -139,31 +180,19 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Only render sidebar content when it's visible or animating
|
||||
if showSidebar || sidebarDragOffset != 0 {
|
||||
sidebarView
|
||||
#if os(macOS)
|
||||
.frame(width: min(300, max(0, geometry.size.width.isNaN ? 300 : geometry.size.width) * 0.4))
|
||||
#else
|
||||
.frame(width: max(0, geometry.size.width.isNaN ? 300 : geometry.size.width) * 0.7)
|
||||
#endif
|
||||
.transition(.move(edge: .trailing))
|
||||
} else {
|
||||
// Empty placeholder when hidden
|
||||
Color.clear
|
||||
#if os(macOS)
|
||||
.frame(width: min(300, max(0, geometry.size.width.isNaN ? 300 : geometry.size.width) * 0.4))
|
||||
#else
|
||||
.frame(width: max(0, geometry.size.width.isNaN ? 300 : geometry.size.width) * 0.7)
|
||||
#endif
|
||||
}
|
||||
// Always render sidebar to avoid layout recalculation during drag
|
||||
sidebarView
|
||||
#if os(macOS)
|
||||
.frame(width: min(300, max(0, geometry.size.width.isNaN ? 300 : geometry.size.width) * 0.4))
|
||||
#else
|
||||
.frame(width: max(0, geometry.size.width.isNaN ? 300 : geometry.size.width) * 0.7)
|
||||
#endif
|
||||
}
|
||||
.offset(x: {
|
||||
let dragOffset = sidebarDragOffset.isNaN ? 0 : sidebarDragOffset
|
||||
let width = geometry.size.width.isNaN ? 0 : max(0, geometry.size.width)
|
||||
return showSidebar ? -dragOffset : width - dragOffset
|
||||
return showSidebar ? dragOffset : width + dragOffset
|
||||
}())
|
||||
.animation(.easeInOut(duration: TransportConfig.uiAnimationSidebarSeconds), value: showSidebar)
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
@@ -188,11 +217,11 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
selectedMessageSender.map { "@\($0)" } ?? "Actions",
|
||||
selectedMessageSender.map { "@\($0)" } ?? String(localized: "content.actions.title", comment: "Fallback title for the message action sheet"),
|
||||
isPresented: $showMessageActions,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("mention") {
|
||||
Button("content.actions.mention") {
|
||||
if let sender = selectedMessageSender {
|
||||
// Pre-fill the input with an @mention and focus the field
|
||||
messageText = "@\(sender) "
|
||||
@@ -200,7 +229,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Button("direct message") {
|
||||
Button("content.actions.direct_message") {
|
||||
if let peerID = selectedMessageSenderID {
|
||||
if peerID.hasPrefix("nostr:") {
|
||||
if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
|
||||
@@ -215,20 +244,20 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button("hug") {
|
||||
|
||||
Button("content.actions.hug") {
|
||||
if let sender = selectedMessageSender {
|
||||
viewModel.sendMessage("/hug @\(sender)")
|
||||
}
|
||||
}
|
||||
|
||||
Button("slap") {
|
||||
|
||||
Button("content.actions.slap") {
|
||||
if let sender = selectedMessageSender {
|
||||
viewModel.sendMessage("/slap @\(sender)")
|
||||
}
|
||||
}
|
||||
|
||||
Button("BLOCK", role: .destructive) {
|
||||
|
||||
Button("content.actions.block", role: .destructive) {
|
||||
// Prefer direct geohash block when we have a Nostr sender ID
|
||||
if let peerID = selectedMessageSenderID, peerID.hasPrefix("nostr:"),
|
||||
let full = viewModel.fullNostrHex(forSenderPeerID: peerID),
|
||||
@@ -238,18 +267,18 @@ struct ContentView: View {
|
||||
viewModel.sendMessage("/block \(sender)")
|
||||
}
|
||||
}
|
||||
|
||||
Button("cancel", role: .cancel) {}
|
||||
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
}
|
||||
.alert("Bluetooth Required", isPresented: $viewModel.showBluetoothAlert) {
|
||||
Button("Settings") {
|
||||
.alert("content.alert.bluetooth_required.title", isPresented: $viewModel.showBluetoothAlert) {
|
||||
Button("content.alert.bluetooth_required.settings") {
|
||||
#if os(iOS)
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
Button("OK", role: .cancel) {}
|
||||
Button("common.ok", role: .cancel) {}
|
||||
} message: {
|
||||
Text(viewModel.bluetoothAlertMessage)
|
||||
}
|
||||
@@ -306,77 +335,7 @@ struct ContentView: View {
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
// Regular messages with natural text wrapping
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// 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
|
||||
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
|
||||
if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty {
|
||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||
Button(isExpanded ? "show less" : "show more") {
|
||||
if isExpanded { expandedMessageIDs.remove(message.id) }
|
||||
else { expandedMessageIDs.insert(message.id) }
|
||||
}
|
||||
.font(.bitchatSystem(size: 11, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(Color.blue)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
// Render payment chips (Lightning / Cashu) with rounded background
|
||||
if !lightningLinks.isEmpty || !cashuTokens.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(Array(lightningLinks.prefix(3)).indices, id: \.self) { i in
|
||||
let link = lightningLinks[i]
|
||||
PaymentChipView(
|
||||
emoji: "⚡",
|
||||
label: "pay via lightning",
|
||||
colorScheme: colorScheme
|
||||
) {
|
||||
#if os(iOS)
|
||||
if let url = URL(string: link) { UIApplication.shared.open(url) }
|
||||
#else
|
||||
if let url = URL(string: link) { NSWorkspace.shared.open(url) }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
ForEach(Array(cashuTokens.prefix(3)).indices, id: \.self) { i in
|
||||
let token = cashuTokens[i]
|
||||
let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token
|
||||
let urlStr = "cashu:\(enc)"
|
||||
PaymentChipView(
|
||||
emoji: "🥜",
|
||||
label: "pay via cashu",
|
||||
colorScheme: colorScheme
|
||||
) {
|
||||
#if os(iOS)
|
||||
if let url = URL(string: urlStr) { UIApplication.shared.open(url) }
|
||||
#else
|
||||
if let url = URL(string: urlStr) { NSWorkspace.shared.open(url) }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 6)
|
||||
.padding(.leading, 2)
|
||||
}
|
||||
}
|
||||
TextMessageView(message: message, expandedMessageIDs: $expandedMessageIDs)
|
||||
}
|
||||
}
|
||||
.id(item.uiID)
|
||||
@@ -432,7 +391,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
Button("Copy message") {
|
||||
Button("content.message.copy") {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string = message.content
|
||||
#else
|
||||
@@ -462,7 +421,7 @@ struct ContentView: View {
|
||||
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
|
||||
} else {
|
||||
// Mesh sender: use current mesh nickname if available; otherwise fall back to last non-system message
|
||||
if let name = viewModel.meshService.peerNickname(peerID: peerID) {
|
||||
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: peerID)) {
|
||||
selectedMessageSender = name
|
||||
} else {
|
||||
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
|
||||
@@ -806,7 +765,7 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
TextField("type a message...", text: $messageText)
|
||||
TextField("content.input.message_placeholder", text: $messageText)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
@@ -833,18 +792,18 @@ struct ContentView: View {
|
||||
}()
|
||||
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")
|
||||
("/block", String(localized: "content.commands.block", comment: "Description for /block command")),
|
||||
("/clear", String(localized: "content.commands.clear", comment: "Description for /clear command")),
|
||||
("/hug", String(localized: "content.commands.hug", comment: "Description for /hug command")),
|
||||
("/m", String(localized: "content.commands.message", comment: "Description for /m command")),
|
||||
("/slap", String(localized: "content.commands.slap", comment: "Description for /slap command")),
|
||||
("/unblock", String(localized: "content.commands.unblock", comment: "Description for /unblock command")),
|
||||
("/w", String(localized: "content.commands.who", comment: "Description for /w command"))
|
||||
]
|
||||
// Only show favorites commands when not in geohash context
|
||||
if !(isGeoPublic || isGeoDM) {
|
||||
commandDescriptions.append(("/fav", "add to favorites"))
|
||||
commandDescriptions.append(("/unfav", "remove from favorites"))
|
||||
commandDescriptions.append(("/fav", String(localized: "content.commands.favorite", comment: "Description for /fav command")))
|
||||
commandDescriptions.append(("/unfav", String(localized: "content.commands.unfavorite", comment: "Description for /unfav command")))
|
||||
}
|
||||
|
||||
let input = newValue.lowercased()
|
||||
@@ -890,8 +849,14 @@ struct ContentView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.trailing, 12)
|
||||
.accessibilityLabel("Send message")
|
||||
.accessibilityHint(messageText.isEmpty ? "Enter a message to send" : "Double tap to send")
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.send_message", comment: "Accessibility label for the send message button")
|
||||
)
|
||||
.accessibilityHint(
|
||||
messageText.isEmpty
|
||||
? String(localized: "content.accessibility.send_hint_empty", comment: "Hint prompting the user to enter a message")
|
||||
: String(localized: "content.accessibility.send_hint_ready", comment: "Hint prompting the user to send the message")
|
||||
)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.background(backgroundColor.opacity(0.95))
|
||||
@@ -923,7 +888,7 @@ struct ContentView: View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Header - match main toolbar height
|
||||
HStack {
|
||||
Text("PEOPLE")
|
||||
Text("content.header.people")
|
||||
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
Spacer()
|
||||
@@ -934,7 +899,9 @@ struct ContentView: View {
|
||||
.font(.bitchatSystem(size: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Verification: show my QR or scan a friend")
|
||||
.help(
|
||||
String(localized: "content.help.verification", comment: "Help text for verification button")
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(height: headerHeight) // Match header height
|
||||
@@ -978,12 +945,55 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.id(viewModel.allPeers.map { "\($0.id)-\($0.isConnected)" }.joined())
|
||||
.id(viewModel.allPeers.map { "\($0.peerID)-\($0.isConnected)" }.joined())
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.background(backgroundColor)
|
||||
Spacer()
|
||||
}
|
||||
.background(backgroundColor)
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 10)
|
||||
.onChanged { value in
|
||||
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
||||
let translationHeight = value.translation.height.isNaN ? 0 : value.translation.height
|
||||
|
||||
// Only handle horizontal drags on the sidebar
|
||||
guard abs(translationWidth) > abs(translationHeight) * 1.5 else { return }
|
||||
|
||||
if translationWidth > 0 {
|
||||
let newOffset = min(translationWidth, 300)
|
||||
if abs(newOffset - sidebarDragOffset) > 2 {
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
sidebarDragOffset = newOffset
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onEnded { value in
|
||||
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
||||
let translationHeight = value.translation.height.isNaN ? 0 : value.translation.height
|
||||
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
|
||||
|
||||
// Only handle if drag is predominantly horizontal
|
||||
guard abs(translationWidth) > abs(translationHeight) * 1.5 else {
|
||||
sidebarDragOffset = 0
|
||||
return
|
||||
}
|
||||
|
||||
withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||
if translationWidth > 100 || (translationWidth > 50 && velocity > 500) {
|
||||
showSidebar = false
|
||||
sidebarDragOffset = 0
|
||||
} else {
|
||||
sidebarDragOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -999,29 +1009,56 @@ struct ContentView: View {
|
||||
}
|
||||
.background(backgroundColor)
|
||||
.foregroundColor(textColor)
|
||||
.gesture(
|
||||
DragGesture()
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 10)
|
||||
.onChanged { value in
|
||||
let translation = value.translation.width.isNaN ? 0 : value.translation.width
|
||||
if !showSidebar && translation < 0 {
|
||||
sidebarDragOffset = max(translation, -300)
|
||||
} else if showSidebar && translation > 0 {
|
||||
sidebarDragOffset = min(-300 + translation, 0)
|
||||
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
||||
let translationHeight = value.translation.height.isNaN ? 0 : value.translation.height
|
||||
|
||||
// Only handle if drag is predominantly horizontal (prevents interfering with scroll)
|
||||
guard abs(translationWidth) > abs(translationHeight) * 1.5 else { return }
|
||||
|
||||
if !showSidebar && translationWidth < 0 {
|
||||
let newOffset = max(translationWidth, -300)
|
||||
if abs(newOffset - sidebarDragOffset) > 2 {
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
sidebarDragOffset = newOffset
|
||||
}
|
||||
}
|
||||
} else if showSidebar && translationWidth > 0 {
|
||||
let newOffset = min(-300 + translationWidth, 0)
|
||||
if abs(newOffset - sidebarDragOffset) > 2 {
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
sidebarDragOffset = newOffset
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onEnded { value in
|
||||
let translation = value.translation.width.isNaN ? 0 : value.translation.width
|
||||
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
||||
let translationHeight = value.translation.height.isNaN ? 0 : value.translation.height
|
||||
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
|
||||
|
||||
// Only handle if drag is predominantly horizontal
|
||||
guard abs(translationWidth) > abs(translationHeight) * 1.5 else {
|
||||
sidebarDragOffset = 0
|
||||
return
|
||||
}
|
||||
|
||||
withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||
if !showSidebar {
|
||||
if translation < -100 || (translation < -50 && velocity < -500) {
|
||||
if translationWidth < -100 || (translationWidth < -50 && velocity < -500) {
|
||||
showSidebar = true
|
||||
sidebarDragOffset = 0
|
||||
} else {
|
||||
sidebarDragOffset = 0
|
||||
}
|
||||
} else {
|
||||
if translation > 100 || (translation > 50 && velocity > 500) {
|
||||
if translationWidth > 100 || (translationWidth > 50 && velocity > 500) {
|
||||
showSidebar = false
|
||||
sidebarDragOffset = 0
|
||||
} else {
|
||||
@@ -1051,19 +1088,6 @@ struct ContentView: View {
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
}
|
||||
|
||||
// Split a name into base and a '#abcd' suffix if present
|
||||
private func splitNameSuffix(_ name: String) -> (base: String, suffix: 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, "")
|
||||
}
|
||||
|
||||
// Compute channel-aware people count and color for toolbar (cross-platform)
|
||||
private func channelPeopleCountAndColor() -> (Int, Color) {
|
||||
@@ -1074,7 +1098,7 @@ struct ContentView: View {
|
||||
return (n, n > 0 ? standardGreen : Color.secondary)
|
||||
case .mesh:
|
||||
let counts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in
|
||||
guard peer.id != viewModel.meshService.myPeerID else { return }
|
||||
guard peer.peerID != viewModel.meshService.myPeerID else { return }
|
||||
if peer.isConnected { counts.mesh += 1; counts.others += 1 }
|
||||
else if peer.isReachable { counts.others += 1 }
|
||||
}
|
||||
@@ -1087,7 +1111,7 @@ struct ContentView: View {
|
||||
|
||||
private var mainHeaderView: some View {
|
||||
HStack(spacing: 0) {
|
||||
Text("bitchat/")
|
||||
Text(verbatim: "bitchat/")
|
||||
.font(.bitchatSystem(size: 18, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.onTapGesture(count: 3) {
|
||||
@@ -1100,11 +1124,11 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
HStack(spacing: 0) {
|
||||
Text("@")
|
||||
Text(verbatim: "@")
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
|
||||
TextField("nickname", text: $viewModel.nickname)
|
||||
TextField("content.input.nickname_placeholder", text: $viewModel.nickname)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.frame(maxWidth: 80)
|
||||
@@ -1149,7 +1173,9 @@ struct ContentView: View {
|
||||
.foregroundColor(Color.orange)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Open unread private chat")
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.open_unread_private_chat", comment: "Accessibility label for the unread private chat button")
|
||||
)
|
||||
}
|
||||
// Notes icon (mesh only and when location is authorized), to the left of #mesh
|
||||
if case .mesh = locationManager.selectedChannel, locationManager.permissionState == .authorized {
|
||||
@@ -1162,8 +1188,7 @@ struct ContentView: View {
|
||||
showLocationNotes = true
|
||||
}) {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
let currentCount = (notesCounter.count ?? 0)
|
||||
let hasNotes = (!notesCounter.initialLoadComplete ? max(currentCount, sheetNotesCount) : currentCount) > 0
|
||||
let hasNotes = (notesCounter.count ?? 0) > 0
|
||||
Image(systemName: "long.text.page.and.pencil")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.foregroundColor(hasNotes ? textColor : Color.gray)
|
||||
@@ -1172,7 +1197,9 @@ struct ContentView: View {
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Location notes for this place")
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.location_notes", comment: "Accessibility label for location notes button")
|
||||
)
|
||||
}
|
||||
|
||||
// Bookmark toggle (geochats): to the left of #geohash
|
||||
@@ -1182,7 +1209,13 @@ struct ContentView: View {
|
||||
.font(.bitchatSystem(size: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Toggle bookmark for #\(ch.geohash)")
|
||||
.accessibilityLabel(
|
||||
String(
|
||||
format: String(localized: "content.accessibility.toggle_bookmark", comment: "Accessibility label for toggling a geohash bookmark"),
|
||||
locale: .current,
|
||||
ch.geohash
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Location channels button '#'
|
||||
@@ -1207,7 +1240,9 @@ struct ContentView: View {
|
||||
.lineLimit(headerLineLimit)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.layoutPriority(2)
|
||||
.accessibilityLabel("location channels")
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.location_channels", comment: "Accessibility label for the location channels button")
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 4)
|
||||
@@ -1216,10 +1251,16 @@ struct ContentView: View {
|
||||
HStack(spacing: 4) {
|
||||
// People icon with count
|
||||
Image(systemName: "person.2.fill")
|
||||
.font(.bitchatSystem(size: 11))
|
||||
.accessibilityLabel("\(headerOtherPeersCount) people")
|
||||
.font(.system(size: headerPeerIconSize, weight: .regular))
|
||||
.accessibilityLabel(
|
||||
String(
|
||||
format: String(localized: "content.accessibility.people_count", comment: "Accessibility label announcing number of people in header"),
|
||||
locale: .current,
|
||||
headerOtherPeersCount
|
||||
)
|
||||
)
|
||||
Text("\(headerOtherPeersCount)")
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.font(.system(size: headerPeerCountFontSize, weight: .regular, design: .monospaced))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.foregroundColor(headerCountColor)
|
||||
@@ -1248,7 +1289,9 @@ struct ContentView: View {
|
||||
.onAppear { viewModel.isLocationChannelsSheetPresented = true }
|
||||
.onDisappear { viewModel.isLocationChannelsSheetPresented = false }
|
||||
}
|
||||
.sheet(isPresented: $showLocationNotes) {
|
||||
.sheet(isPresented: $showLocationNotes, onDismiss: {
|
||||
notesGeohash = nil
|
||||
}) {
|
||||
Group {
|
||||
if let gh = notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {
|
||||
LocationNotesView(geohash: gh, onNotesCountChanged: { cnt in sheetNotesCount = cnt })
|
||||
@@ -1256,7 +1299,7 @@ struct ContentView: View {
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
Text("notes")
|
||||
Text("content.notes.title")
|
||||
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||
Spacer()
|
||||
Button(action: { showLocationNotes = false }) {
|
||||
@@ -1266,15 +1309,15 @@ struct ContentView: View {
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close")
|
||||
.accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons"))
|
||||
}
|
||||
.frame(height: headerHeight)
|
||||
.padding(.horizontal, 12)
|
||||
.background(backgroundColor.opacity(0.95))
|
||||
Text("location unavailable")
|
||||
Text("content.notes.location_unavailable")
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
Button("enable location") {
|
||||
Button("content.location.enable") {
|
||||
LocationChannelManager.shared.enableLocationChannels()
|
||||
LocationChannelManager.shared.refreshChannels()
|
||||
}
|
||||
@@ -1332,10 +1375,10 @@ struct ContentView: View {
|
||||
LocationChannelManager.shared.refreshChannels()
|
||||
}
|
||||
}
|
||||
.alert("heads up", isPresented: $viewModel.showScreenshotPrivacyWarning) {
|
||||
Button("ok", role: .cancel) {}
|
||||
.alert("content.alert.screenshot.title", isPresented: $viewModel.showScreenshotPrivacyWarning) {
|
||||
Button("common.ok", role: .cancel) {}
|
||||
} message: {
|
||||
Text("screenshots of location channels will reveal your location. think before sharing publicly.")
|
||||
Text("content.alert.screenshot.message")
|
||||
}
|
||||
.background(backgroundColor.opacity(0.95))
|
||||
}
|
||||
@@ -1372,13 +1415,13 @@ struct ContentView: View {
|
||||
// Try mesh/unified peer display
|
||||
if let name = peer?.displayName { return name }
|
||||
// Try direct mesh nickname (connected-only)
|
||||
if let name = viewModel.meshService.peerNickname(peerID: headerPeerID) { return name }
|
||||
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: headerPeerID)) { return name }
|
||||
// Try favorite nickname by stable Noise key
|
||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data()),
|
||||
!fav.peerNickname.isEmpty { return fav.peerNickname }
|
||||
// Fallback: resolve from persisted social identity via fingerprint mapping
|
||||
if headerPeerID.count == 16 {
|
||||
let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
|
||||
let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(PeerID(str: headerPeerID))
|
||||
if let id = candidates.first,
|
||||
let social = viewModel.identityManager.getSocialIdentity(for: id.fingerprint) {
|
||||
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
||||
@@ -1391,7 +1434,7 @@ struct ContentView: View {
|
||||
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
||||
}
|
||||
}
|
||||
return "Unknown"
|
||||
return String(localized: "common.unknown", comment: "Fallback label for unknown peer")
|
||||
}()
|
||||
let isNostrAvailable: Bool = {
|
||||
guard let connectionState = peer?.connectionState else {
|
||||
@@ -1425,41 +1468,53 @@ struct ContentView: View {
|
||||
Image(systemName: "dot.radiowaves.left.and.right")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(textColor)
|
||||
.accessibilityLabel("Connected via mesh")
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")
|
||||
)
|
||||
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))
|
||||
.foregroundColor(textColor)
|
||||
.accessibilityLabel("Reachable via mesh")
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")
|
||||
)
|
||||
case .nostrAvailable:
|
||||
// Purple globe for Nostr
|
||||
Image(systemName: "globe")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(.purple)
|
||||
.accessibilityLabel("Available via Nostr")
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")
|
||||
)
|
||||
case .offline:
|
||||
// Should not happen for PM header, but handle gracefully
|
||||
EmptyView()
|
||||
}
|
||||
} else if viewModel.meshService.isPeerReachable(headerPeerID) {
|
||||
} else if viewModel.meshService.isPeerReachable(PeerID(str: headerPeerID)) {
|
||||
// Fallback: reachable via mesh but not in current peer list
|
||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(textColor)
|
||||
.accessibilityLabel("Reachable via mesh")
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")
|
||||
)
|
||||
} else if isNostrAvailable {
|
||||
// Fallback to Nostr if peer not in list but is mutual favorite
|
||||
Image(systemName: "globe")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(.purple)
|
||||
.accessibilityLabel("Available via Nostr")
|
||||
} else if viewModel.meshService.isPeerConnected(headerPeerID) || viewModel.connectedPeers.contains(headerPeerID) {
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")
|
||||
)
|
||||
} else if viewModel.meshService.isPeerConnected(PeerID(str: 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))
|
||||
.foregroundColor(textColor)
|
||||
.accessibilityLabel("Connected via mesh")
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")
|
||||
)
|
||||
}
|
||||
|
||||
Text("\(privatePeerNick)")
|
||||
@@ -1480,12 +1535,26 @@ struct ContentView: View {
|
||||
.foregroundColor(encryptionStatus == .noiseVerified ? textColor :
|
||||
encryptionStatus == .noiseSecured ? textColor :
|
||||
Color.red)
|
||||
.accessibilityLabel("Encryption status: \(encryptionStatus == .noiseVerified ? "verified" : encryptionStatus == .noiseSecured ? "secured" : "not encrypted")")
|
||||
.accessibilityLabel(
|
||||
String(
|
||||
format: String(localized: "content.accessibility.encryption_status", comment: "Accessibility label announcing encryption status"),
|
||||
locale: .current,
|
||||
encryptionStatus.accessibilityDescription
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Private chat with \(privatePeerNick)")
|
||||
.accessibilityHint("Tap to view encryption fingerprint")
|
||||
.accessibilityLabel(
|
||||
String(
|
||||
format: String(localized: "content.accessibility.private_chat_header", comment: "Accessibility label describing the private chat header"),
|
||||
locale: .current,
|
||||
privatePeerNick
|
||||
)
|
||||
)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint")
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -1504,7 +1573,9 @@ struct ContentView: View {
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Back to main chat")
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.back_to_main_chat", comment: "Accessibility label for returning to main chat")
|
||||
)
|
||||
|
||||
Spacer()
|
||||
|
||||
@@ -1518,8 +1589,14 @@ struct ContentView: View {
|
||||
.foregroundColor(viewModel.isFavorite(peerID: headerPeerID) ? Color.yellow : textColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(viewModel.isFavorite(peerID: privatePeerID) ? "Remove from favorites" : "Add to favorites")
|
||||
.accessibilityHint("Double tap to toggle favorite status")
|
||||
.accessibilityLabel(
|
||||
viewModel.isFavorite(peerID: privatePeerID)
|
||||
? String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite")
|
||||
: String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite")
|
||||
)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.toggle_favorite_hint", comment: "Accessibility hint for toggling favorite status")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1557,113 +1634,3 @@ extension ContentView {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Views
|
||||
|
||||
// Rounded payment chip button
|
||||
private struct PaymentChipView: View {
|
||||
let emoji: String
|
||||
let label: String
|
||||
let colorScheme: ColorScheme
|
||||
let action: () -> Void
|
||||
|
||||
private var fgColor: Color {
|
||||
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||
}
|
||||
private var bgColor: Color {
|
||||
colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12)
|
||||
}
|
||||
private var border: Color { fgColor.opacity(0.25) }
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 6) {
|
||||
Text(emoji)
|
||||
Text(label)
|
||||
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(bgColor)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(border, lineWidth: 1)
|
||||
)
|
||||
.foregroundColor(fgColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
// Delivery status indicator view
|
||||
struct DeliveryStatusView: View {
|
||||
let status: DeliveryStatus
|
||||
let colorScheme: ColorScheme
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
switch status {
|
||||
case .sending:
|
||||
Image(systemName: "circle")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
|
||||
case .sent:
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
|
||||
case .delivered(let nickname, _):
|
||||
HStack(spacing: -2) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
}
|
||||
.foregroundColor(textColor.opacity(0.8))
|
||||
.help("Delivered to \(nickname)")
|
||||
|
||||
case .read(let nickname, _):
|
||||
HStack(spacing: -2) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10, weight: .bold))
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(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))
|
||||
.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))
|
||||
Text("\(reached)/\(total)")
|
||||
.font(.bitchatSystem(size: 10, design: .monospaced))
|
||||
}
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
.help("Delivered to \(reached) of \(total) members")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,12 +21,35 @@ struct FingerprintView: View {
|
||||
private var backgroundColor: Color {
|
||||
colorScheme == .dark ? Color.black : Color.white
|
||||
}
|
||||
|
||||
private enum Strings {
|
||||
static let title: LocalizedStringKey = "fingerprint.title"
|
||||
static let theirFingerprint: LocalizedStringKey = "fingerprint.their_label"
|
||||
static let handshakePending: LocalizedStringKey = "fingerprint.handshake_pending"
|
||||
static let yourFingerprint: LocalizedStringKey = "fingerprint.your_label"
|
||||
static let copy: LocalizedStringKey = "common.copy"
|
||||
static let verifiedBadge: LocalizedStringKey = "fingerprint.badge.verified"
|
||||
static let notVerifiedBadge: LocalizedStringKey = "fingerprint.badge.not_verified"
|
||||
static let verifiedMessage: LocalizedStringKey = "fingerprint.message.verified"
|
||||
static func verifyHint(_ nickname: String) -> String {
|
||||
String(
|
||||
format: String(localized: "fingerprint.message.verify_hint", comment: "Instruction to compare fingerprints with a named peer"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
}
|
||||
static let markVerified: LocalizedStringKey = "fingerprint.action.mark_verified"
|
||||
static let removeVerification: LocalizedStringKey = "fingerprint.action.remove_verification"
|
||||
static func unknownPeer() -> String {
|
||||
String(localized: "common.unknown", comment: "Label for an unknown peer")
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
// Header
|
||||
HStack {
|
||||
Text("SECURITY VERIFICATION")
|
||||
Text(Strings.title)
|
||||
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
|
||||
@@ -49,7 +72,7 @@ struct FingerprintView: View {
|
||||
// Resolve a friendly name
|
||||
let peerNickname: String = {
|
||||
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
|
||||
if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name }
|
||||
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: statusPeerID)) { return name }
|
||||
if peerID.count == 64, let data = Data(hexString: peerID) {
|
||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
|
||||
let fp = data.sha256Fingerprint()
|
||||
@@ -58,7 +81,7 @@ struct FingerprintView: View {
|
||||
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
||||
}
|
||||
}
|
||||
return "Unknown"
|
||||
return Strings.unknownPeer()
|
||||
}()
|
||||
// Accurate encryption state based on short ID session
|
||||
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
||||
@@ -88,7 +111,7 @@ struct FingerprintView: View {
|
||||
|
||||
// Their fingerprint
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("THEIR FINGERPRINT:")
|
||||
Text(Strings.theirFingerprint)
|
||||
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(textColor.opacity(0.7))
|
||||
|
||||
@@ -104,7 +127,7 @@ struct FingerprintView: View {
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
.contextMenu {
|
||||
Button("Copy") {
|
||||
Button(Strings.copy) {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string = fingerprint
|
||||
#else
|
||||
@@ -114,16 +137,16 @@ struct FingerprintView: View {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("not available - handshake in progress")
|
||||
Text(Strings.handshakePending)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.foregroundColor(Color.orange)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// My fingerprint
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("YOUR FINGERPRINT:")
|
||||
Text(Strings.yourFingerprint)
|
||||
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(textColor.opacity(0.7))
|
||||
|
||||
@@ -139,7 +162,7 @@ struct FingerprintView: View {
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
.contextMenu {
|
||||
Button("Copy") {
|
||||
Button(Strings.copy) {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string = myFingerprint
|
||||
#else
|
||||
@@ -155,14 +178,18 @@ struct FingerprintView: View {
|
||||
let isVerified = encryptionStatus == .noiseVerified
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Text(isVerified ? "✓ VERIFIED" : "⚠️ NOT VERIFIED")
|
||||
Text(isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
|
||||
.font(.bitchatSystem(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.")
|
||||
Group {
|
||||
if isVerified {
|
||||
Text(Strings.verifiedMessage)
|
||||
} else {
|
||||
Text(Strings.verifyHint(peerNickname))
|
||||
}
|
||||
}
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(textColor.opacity(0.7))
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -175,7 +202,7 @@ struct FingerprintView: View {
|
||||
viewModel.verifyFingerprint(for: peerID)
|
||||
dismiss()
|
||||
}) {
|
||||
Text("MARK AS VERIFIED")
|
||||
Text(Strings.markVerified)
|
||||
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 20)
|
||||
@@ -189,7 +216,7 @@ struct FingerprintView: View {
|
||||
viewModel.unverifyFingerprint(for: peerID)
|
||||
dismiss()
|
||||
}) {
|
||||
Text("REMOVE VERIFICATION")
|
||||
Text(Strings.removeVerification)
|
||||
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
@@ -8,10 +8,18 @@ struct GeohashPeopleList: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@State private var orderedIDs: [String] = []
|
||||
|
||||
private enum Strings {
|
||||
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
|
||||
static let youSuffix: LocalizedStringKey = "geohash_people.you_suffix"
|
||||
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to users blocked in geohash channels")
|
||||
static let unblock: LocalizedStringKey = "geohash_people.action.unblock"
|
||||
static let block: LocalizedStringKey = "geohash_people.action.block"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if viewModel.visibleGeohashPeople().isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("nobody around...")
|
||||
Text(Strings.noneNearby)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.padding(.horizontal)
|
||||
@@ -53,7 +61,7 @@ struct GeohashPeopleList: View {
|
||||
let rowColor: Color = isMe ? .orange : assignedColor
|
||||
Image(systemName: icon).font(.bitchatSystem(size: 12)).foregroundColor(rowColor)
|
||||
|
||||
let (base, suffix) = splitSuffix(from: person.displayName)
|
||||
let (base, suffix) = person.displayName.splitSuffix()
|
||||
HStack(spacing: 0) {
|
||||
Text(base)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
@@ -66,7 +74,7 @@ struct GeohashPeopleList: View {
|
||||
.foregroundColor(suffixColor)
|
||||
}
|
||||
if isMe {
|
||||
Text(" (you)")
|
||||
Text(Strings.youSuffix)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.foregroundColor(rowColor)
|
||||
}
|
||||
@@ -76,7 +84,7 @@ struct GeohashPeopleList: View {
|
||||
Image(systemName: "nosign")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(.red)
|
||||
.help("Blocked in geochash")
|
||||
.help(Strings.blockedTooltip)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
@@ -97,9 +105,9 @@ struct GeohashPeopleList: View {
|
||||
} else {
|
||||
let blocked = viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id)
|
||||
if blocked {
|
||||
Button("Unblock") { viewModel.unblockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
|
||||
Button(Strings.unblock) { viewModel.unblockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
|
||||
} else {
|
||||
Button("Block") { viewModel.blockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
|
||||
Button(Strings.block) { viewModel.blockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,16 +126,3 @@ struct GeohashPeopleList: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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, "")
|
||||
}
|
||||
|
||||
@@ -17,16 +17,72 @@ struct LocationChannelsSheet: View {
|
||||
|
||||
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
|
||||
|
||||
private enum Strings {
|
||||
static let title: LocalizedStringKey = "location_channels.title"
|
||||
static let description: LocalizedStringKey = "location_channels.description"
|
||||
static let requestPermissions: LocalizedStringKey = "location_channels.action.request_permissions"
|
||||
static let permissionDenied: LocalizedStringKey = "location_channels.permission_denied"
|
||||
static let openSettings: LocalizedStringKey = "location_channels.action.open_settings"
|
||||
static let loadingNearby: LocalizedStringKey = "location_channels.loading_nearby"
|
||||
static let teleport: LocalizedStringKey = "location_channels.action.teleport"
|
||||
static let bookmarked: LocalizedStringKey = "location_channels.bookmarked_section_title"
|
||||
static let removeAccess: LocalizedStringKey = "location_channels.action.remove_access"
|
||||
static let torTitle: LocalizedStringKey = "location_channels.tor.title"
|
||||
static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle"
|
||||
static let toggleOn: LocalizedStringKey = "common.toggle.on"
|
||||
static let toggleOff: LocalizedStringKey = "common.toggle.off"
|
||||
|
||||
static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid")
|
||||
|
||||
static func meshTitle(_ count: Int) -> String {
|
||||
let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row")
|
||||
return rowTitle(label: label, count: count)
|
||||
}
|
||||
|
||||
static func levelTitle(for level: GeohashChannelLevel, count: Int) -> String {
|
||||
return rowTitle(label: level.displayName, count: count)
|
||||
}
|
||||
|
||||
static func bookmarkTitle(geohash: String, count: Int) -> String {
|
||||
return rowTitle(label: "#\(geohash)", count: count)
|
||||
}
|
||||
|
||||
static func subtitlePrefix(geohash: String, coverage: String) -> String {
|
||||
String(
|
||||
format: String(localized: "location_channels.subtitle_prefix", comment: "Subtitle prefix showing geohash and coverage"),
|
||||
locale: .current,
|
||||
geohash, coverage
|
||||
)
|
||||
}
|
||||
|
||||
static func subtitle(prefix: String, name: String?) -> String {
|
||||
guard let name, !name.isEmpty else { return prefix }
|
||||
return String(
|
||||
format: String(localized: "location_channels.subtitle_with_name", comment: "Subtitle combining prefix and resolved location name"),
|
||||
locale: .current,
|
||||
prefix, name
|
||||
)
|
||||
}
|
||||
|
||||
private static func rowTitle(label: String, count: Int) -> String {
|
||||
String(
|
||||
format: String(localized: "location_channels.row_title", comment: "List row title with participant count"),
|
||||
locale: .current,
|
||||
label, count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
Text("#location channels")
|
||||
Text(Strings.title)
|
||||
.font(.bitchatSystem(size: 18, design: .monospaced))
|
||||
Spacer()
|
||||
closeButton
|
||||
}
|
||||
Text("chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps. your IP address is hidden by routing all traffic over tor.")
|
||||
Text(Strings.description)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
@@ -34,7 +90,7 @@ struct LocationChannelsSheet: View {
|
||||
switch manager.permissionState {
|
||||
case LocationChannelManager.PermissionState.notDetermined:
|
||||
Button(action: { manager.enableLocationChannels() }) {
|
||||
Text("get location and my geohashes")
|
||||
Text(Strings.requestPermissions)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(standardGreen)
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -45,10 +101,10 @@ struct LocationChannelsSheet: View {
|
||||
.buttonStyle(.plain)
|
||||
case LocationChannelManager.PermissionState.denied, LocationChannelManager.PermissionState.restricted:
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("location permission denied. enable in settings to use location channels.")
|
||||
Text(Strings.permissionDenied)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
Button("open settings") { openSystemLocationSettings() }
|
||||
Button(Strings.openSettings) { openSystemLocationSettings() }
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
case LocationChannelManager.PermissionState.authorized:
|
||||
@@ -107,188 +163,214 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
|
||||
private var channelList: some View {
|
||||
List {
|
||||
// Mesh option first (no bookmark)
|
||||
channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth • \(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
|
||||
manager.select(ChannelID.mesh)
|
||||
isPresented = false
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
channelRow(title: Strings.meshTitle(meshCount()), subtitlePrefix: Strings.subtitlePrefix(geohash: "bluetooth", coverage: bluetoothRangeString()), isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
|
||||
manager.select(ChannelID.mesh)
|
||||
isPresented = false
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
|
||||
// Nearby options
|
||||
if !manager.availableChannels.isEmpty {
|
||||
ForEach(manager.availableChannels.filter { $0.level != .building }) { channel in
|
||||
let coverage = coverageString(forPrecision: channel.geohash.count)
|
||||
let nameBase = locationName(for: channel.level)
|
||||
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
|
||||
let subtitlePrefix = "#\(channel.geohash) • \(coverage)"
|
||||
let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0
|
||||
let nearby = manager.availableChannels.filter { $0.level != .building }
|
||||
if !nearby.isEmpty {
|
||||
ForEach(nearby) { channel in
|
||||
sectionDivider
|
||||
let coverage = coverageString(forPrecision: channel.geohash.count)
|
||||
let nameBase = locationName(for: channel.level)
|
||||
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
|
||||
let participantCount = viewModel.geohashParticipantCount(for: channel.geohash)
|
||||
let subtitlePrefix = Strings.subtitlePrefix(geohash: channel.geohash, coverage: coverage)
|
||||
let highlight = participantCount > 0
|
||||
channelRow(
|
||||
title: Strings.levelTitle(for: channel.level, count: participantCount),
|
||||
subtitlePrefix: subtitlePrefix,
|
||||
subtitleName: namePart,
|
||||
isSelected: isSelected(channel),
|
||||
titleBold: highlight,
|
||||
trailingAccessory: {
|
||||
Button(action: { bookmarks.toggle(channel.geohash) }) {
|
||||
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
) {
|
||||
manager.markTeleported(for: channel.geohash, false)
|
||||
manager.select(ChannelID.location(channel))
|
||||
isPresented = false
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
} else {
|
||||
sectionDivider
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text(Strings.loadingNearby)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
sectionDivider
|
||||
customTeleportSection
|
||||
.padding(.vertical, 8)
|
||||
|
||||
let bookmarkedList = bookmarks.bookmarks
|
||||
if !bookmarkedList.isEmpty {
|
||||
sectionDivider
|
||||
bookmarkedSection(bookmarkedList)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||||
sectionDivider
|
||||
torToggleSection
|
||||
.padding(.top, 12)
|
||||
Button(action: {
|
||||
openSystemLocationSettings()
|
||||
}) {
|
||||
Text(Strings.removeAccess)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.red.opacity(0.08))
|
||||
.cornerRadius(6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 6)
|
||||
.background(backgroundColor)
|
||||
}
|
||||
.background(backgroundColor)
|
||||
}
|
||||
|
||||
private var sectionDivider: some View {
|
||||
Rectangle()
|
||||
.fill(dividerColor)
|
||||
.frame(height: 1)
|
||||
}
|
||||
|
||||
private var dividerColor: Color {
|
||||
colorScheme == .dark ? Color.white.opacity(0.12) : Color.black.opacity(0.08)
|
||||
}
|
||||
|
||||
private var customTeleportSection: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 2) {
|
||||
Text(verbatim: "#")
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
TextField("geohash", text: $customGeohash)
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled(true)
|
||||
.keyboardType(.asciiCapable)
|
||||
#endif
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.onChange(of: customGeohash) { newValue in
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
let filtered = newValue
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: "#", with: "")
|
||||
.filter { allowed.contains($0) }
|
||||
if filtered.count > 12 {
|
||||
customGeohash = String(filtered.prefix(12))
|
||||
} else if filtered != newValue {
|
||||
customGeohash = filtered
|
||||
}
|
||||
}
|
||||
let normalized = customGeohash
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: "#", with: "")
|
||||
let isValid = validateGeohash(normalized)
|
||||
Button(action: {
|
||||
let gh = normalized
|
||||
guard isValid else { customError = Strings.invalidGeohash; return }
|
||||
let level = levelForLength(gh.count)
|
||||
let ch = GeohashChannel(level: level, geohash: gh)
|
||||
manager.markTeleported(for: ch.geohash, true)
|
||||
manager.select(ChannelID.location(ch))
|
||||
isPresented = false
|
||||
}) {
|
||||
HStack(spacing: 6) {
|
||||
Text(Strings.teleport)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
Image(systemName: "face.dashed")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 10)
|
||||
.background(Color.secondary.opacity(0.12))
|
||||
.cornerRadius(6)
|
||||
.opacity(isValid ? 1.0 : 0.4)
|
||||
.disabled(!isValid)
|
||||
}
|
||||
if let err = customError {
|
||||
Text(err)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func bookmarkedSection(_ entries: [String]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(Strings.bookmarked)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in
|
||||
let level = levelForLength(gh.count)
|
||||
let channel = GeohashChannel(level: level, geohash: gh)
|
||||
let coverage = coverageString(forPrecision: gh.count)
|
||||
let subtitle = Strings.subtitlePrefix(geohash: gh, coverage: coverage)
|
||||
let name = bookmarks.bookmarkNames[gh]
|
||||
let participantCount = viewModel.geohashParticipantCount(for: gh)
|
||||
channelRow(
|
||||
title: geohashTitleWithCount(for: channel),
|
||||
subtitlePrefix: subtitlePrefix,
|
||||
subtitleName: namePart,
|
||||
title: Strings.bookmarkTitle(geohash: gh, count: participantCount),
|
||||
subtitlePrefix: subtitle,
|
||||
subtitleName: name.map { formattedNamePrefix(for: level) + $0 },
|
||||
isSelected: isSelected(channel),
|
||||
titleBold: highlight,
|
||||
trailingAccessory: {
|
||||
Button(action: { bookmarks.toggle(channel.geohash) }) {
|
||||
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
||||
Button(action: { bookmarks.toggle(gh) }) {
|
||||
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
) {
|
||||
// Selecting a suggested nearby channel is not a teleport. Persist this.
|
||||
manager.markTeleported(for: channel.geohash, false)
|
||||
let inRegional = manager.availableChannels.contains { $0.geohash == gh }
|
||||
if !inRegional && !manager.availableChannels.isEmpty {
|
||||
manager.markTeleported(for: gh, true)
|
||||
} else {
|
||||
manager.markTeleported(for: gh, false)
|
||||
}
|
||||
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))
|
||||
}
|
||||
.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))
|
||||
.foregroundColor(.secondary)
|
||||
TextField("geohash", text: $customGeohash)
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled(true)
|
||||
.keyboardType(.asciiCapable)
|
||||
#endif
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.onChange(of: customGeohash) { newValue in
|
||||
// Allow only geohash base32 characters, strip '#', limit length
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
let filtered = newValue
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: "#", with: "")
|
||||
.filter { allowed.contains($0) }
|
||||
if filtered.count > 12 {
|
||||
customGeohash = String(filtered.prefix(12))
|
||||
} else if filtered != newValue {
|
||||
customGeohash = filtered
|
||||
}
|
||||
}
|
||||
let normalized = customGeohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().replacingOccurrences(of: "#", with: "")
|
||||
let isValid = validateGeohash(normalized)
|
||||
Button(action: {
|
||||
let gh = normalized
|
||||
guard isValid else { customError = "invalid geohash"; return }
|
||||
let level = levelForLength(gh.count)
|
||||
let ch = GeohashChannel(level: level, geohash: gh)
|
||||
// Mark this selection as a manual teleport
|
||||
manager.markTeleported(for: ch.geohash, true)
|
||||
manager.select(ChannelID.location(ch))
|
||||
isPresented = false
|
||||
}) {
|
||||
HStack(spacing: 6) {
|
||||
Text("teleport")
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
Image(systemName: "face.dashed")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.secondary.opacity(0.12))
|
||||
.cornerRadius(6)
|
||||
.opacity(isValid ? 1.0 : 0.4)
|
||||
.disabled(!isValid)
|
||||
}
|
||||
if let err = customError {
|
||||
Text(err)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
|
||||
.onAppear { bookmarks.resolveNameIfNeeded(for: gh) }
|
||||
|
||||
// Bookmarked geohashes
|
||||
if !bookmarks.bookmarks.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("bookmarked")
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
VStack(spacing: 6) {
|
||||
ForEach(bookmarks.bookmarks, id: \.self) { gh in
|
||||
let level = levelForLength(gh.count)
|
||||
let channel = GeohashChannel(level: level, geohash: gh)
|
||||
let coverage = coverageString(forPrecision: gh.count)
|
||||
let subtitle = "#\(gh) • \(coverage)"
|
||||
let name = bookmarks.bookmarkNames[gh]
|
||||
channelRow(
|
||||
title: geohashHashTitleWithCount(gh),
|
||||
subtitlePrefix: subtitle,
|
||||
subtitleName: name.map { formattedNamePrefix(for: level) + $0 },
|
||||
isSelected: isSelected(channel),
|
||||
trailingAccessory: {
|
||||
Button(action: { bookmarks.toggle(gh) }) {
|
||||
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
) {
|
||||
// For bookmarked selection, mark teleported based on regional membership
|
||||
let inRegional = manager.availableChannels.contains { $0.geohash == gh }
|
||||
if !inRegional && !manager.availableChannels.isEmpty {
|
||||
manager.markTeleported(for: gh, true)
|
||||
} else {
|
||||
manager.markTeleported(for: gh, false)
|
||||
}
|
||||
manager.select(ChannelID.location(channel))
|
||||
isPresented = false
|
||||
}
|
||||
.onAppear { bookmarks.resolveNameIfNeeded(for: gh) }
|
||||
}
|
||||
if index < entries.count - 1 {
|
||||
sectionDivider
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.secondary.opacity(0.12))
|
||||
.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))
|
||||
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.red.opacity(0.08))
|
||||
.cornerRadius(6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 10, trailing: 0))
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(backgroundColor)
|
||||
}
|
||||
|
||||
|
||||
private func isSelected(_ channel: GeohashChannel) -> Bool {
|
||||
if case .location(let ch) = manager.selectedChannel {
|
||||
return ch == channel
|
||||
@@ -328,12 +410,7 @@ struct LocationChannelsSheet: View {
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
let subtitleFull: String = {
|
||||
if let name = subtitleName, !name.isEmpty {
|
||||
return subtitlePrefix + " • " + name
|
||||
}
|
||||
return subtitlePrefix
|
||||
}()
|
||||
let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName)
|
||||
Text(subtitleFull)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
@@ -342,7 +419,7 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
Spacer()
|
||||
if isSelected {
|
||||
Text("✔︎")
|
||||
Text(verbatim: "✔︎")
|
||||
.font(.bitchatSystem(size: 16, design: .monospaced))
|
||||
.foregroundColor(standardGreen)
|
||||
}
|
||||
@@ -362,36 +439,15 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
|
||||
// MARK: - Helpers for counts
|
||||
private func meshTitleWithCount() -> String {
|
||||
// Count currently connected mesh peers (excluding self)
|
||||
let meshCount = meshCount()
|
||||
let noun = meshCount == 1 ? "person" : "people"
|
||||
return "mesh [\(meshCount) \(noun)]"
|
||||
}
|
||||
|
||||
private func meshCount() -> Int {
|
||||
// Count mesh-connected OR mesh-reachable peers (exclude self)
|
||||
let myID = viewModel.meshService.myPeerID
|
||||
return viewModel.allPeers.reduce(0) { acc, peer in
|
||||
if peer.id != myID && (peer.isConnected || peer.isReachable) { return acc + 1 }
|
||||
if peer.peerID != myID && (peer.isConnected || peer.isReachable) { return acc + 1 }
|
||||
return acc
|
||||
}
|
||||
}
|
||||
|
||||
private func geohashTitleWithCount(for channel: GeohashChannel) -> String {
|
||||
// Main list: keep level labels (block/neighborhood/city/province/region)
|
||||
let count = viewModel.geohashParticipantCount(for: channel.geohash)
|
||||
let noun = count == 1 ? "person" : "people"
|
||||
return "\(channel.level.displayName.lowercased()) [\(count) \(noun)]"
|
||||
}
|
||||
|
||||
private func geohashHashTitleWithCount(_ geohash: String) -> String {
|
||||
// Bookmarked list: show the #geohash as the main label
|
||||
let count = viewModel.geohashParticipantCount(for: geohash)
|
||||
let noun = count == 1 ? "person" : "people"
|
||||
return "#\(geohash) [\(count) \(noun)]"
|
||||
}
|
||||
|
||||
private func validateGeohash(_ s: String) -> Bool {
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
guard !s.isEmpty, s.count <= 12 else { return false }
|
||||
@@ -424,21 +480,19 @@ extension LocationChannelsSheet {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Toggle(isOn: torToggleBinding) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("tor routing")
|
||||
Text(Strings.torTitle)
|
||||
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(.primary)
|
||||
Text("hides your ip for location channels. recommended: on.")
|
||||
Text(Strings.torSubtitle)
|
||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.toggleStyle(IRCToggleStyle(accent: standardGreen))
|
||||
.toggleStyle(IRCToggleStyle(accent: standardGreen, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.secondary.opacity(0.12))
|
||||
.cornerRadius(8)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
private var standardGreen: Color {
|
||||
@@ -451,13 +505,15 @@ extension LocationChannelsSheet {
|
||||
|
||||
private struct IRCToggleStyle: ToggleStyle {
|
||||
let accent: Color
|
||||
let onLabel: LocalizedStringKey
|
||||
let offLabel: LocalizedStringKey
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
Button(action: { configuration.isOn.toggle() }) {
|
||||
HStack(spacing: 12) {
|
||||
configuration.label
|
||||
Spacer()
|
||||
Text(configuration.isOn ? "on" : "off")
|
||||
Text(configuration.isOn ? onLabel : offLabel)
|
||||
.textCase(.uppercase)
|
||||
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(configuration.isOn ? accent : .secondary)
|
||||
|
||||
@@ -23,6 +23,21 @@ struct LocationNotesView: View {
|
||||
private var accentGreen: Color { colorScheme == .dark ? .green : Color(red: 0, green: 0.5, blue: 0) }
|
||||
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
|
||||
|
||||
private enum Strings {
|
||||
static let closeAccessibility = String(localized: "common.close", comment: "Accessibility label for close buttons")
|
||||
static let description: LocalizedStringKey = "location_notes.description"
|
||||
static let loadingRecent: LocalizedStringKey = "location_notes.loading_recent"
|
||||
static let relaysPaused: LocalizedStringKey = "location_notes.relays_paused"
|
||||
static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby"
|
||||
static let retry: LocalizedStringKey = "location_notes.action.retry"
|
||||
static let relaysRetryHint: LocalizedStringKey = "location_notes.relays_retry_hint"
|
||||
static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes"
|
||||
static let emptyTitle: LocalizedStringKey = "location_notes.empty_title"
|
||||
static let emptySubtitle: LocalizedStringKey = "location_notes.empty_subtitle"
|
||||
static let dismissError: LocalizedStringKey = "location_notes.action.dismiss"
|
||||
static let addPlaceholder: LocalizedStringKey = "location_notes.placeholder"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
VStack(spacing: 0) {
|
||||
@@ -85,14 +100,14 @@ struct LocationNotesView: View {
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close")
|
||||
.accessibilityLabel(Strings.closeAccessibility)
|
||||
}
|
||||
|
||||
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")")
|
||||
Text(headerTitle(for: count))
|
||||
.font(.bitchatSystem(size: 18, design: .monospaced))
|
||||
Spacer()
|
||||
closeButton
|
||||
@@ -106,16 +121,12 @@ struct LocationNotesView: View {
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(accentGreen)
|
||||
}
|
||||
Text("add short permanent notes to this location for other visitors to find.")
|
||||
Text(Strings.description)
|
||||
.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")
|
||||
if manager.state == .noRelays {
|
||||
Text(Strings.relaysPaused)
|
||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
@@ -126,6 +137,14 @@ struct LocationNotesView: View {
|
||||
.background(backgroundColor)
|
||||
}
|
||||
|
||||
private func headerTitle(for count: Int) -> String {
|
||||
String(
|
||||
format: String(localized: "location_notes.header", comment: "Header displaying the geohash and localized note count"),
|
||||
locale: .current,
|
||||
geohash, count
|
||||
)
|
||||
}
|
||||
|
||||
private var notesContent: some View {
|
||||
LazyVStack(alignment: .leading, spacing: 12) {
|
||||
if manager.state == .noRelays {
|
||||
@@ -153,7 +172,7 @@ struct LocationNotesView: View {
|
||||
let ts = timestampText(for: note.createdAt)
|
||||
return VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text("@\(baseName)")
|
||||
Text(verbatim: "@\(baseName)")
|
||||
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||
if !ts.isEmpty {
|
||||
Text(ts)
|
||||
@@ -171,12 +190,12 @@ struct LocationNotesView: View {
|
||||
|
||||
private var noRelaysRow: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("no geo relays nearby")
|
||||
Text(Strings.noRelaysNearby)
|
||||
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||
Text("notes rely on geo relays. check connection and try again.")
|
||||
Text(Strings.relaysRetryHint)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
Button("retry") { manager.refresh() }
|
||||
Button(Strings.retry) { manager.refresh() }
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -186,7 +205,7 @@ struct LocationNotesView: View {
|
||||
private var loadingRow: some View {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
Text("loading notes…")
|
||||
Text(Strings.loadingNotes)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
@@ -196,9 +215,9 @@ struct LocationNotesView: View {
|
||||
|
||||
private var emptyRow: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("no notes yet")
|
||||
Text(Strings.emptyTitle)
|
||||
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||
Text("be the first to add one for this spot.")
|
||||
Text(Strings.emptySubtitle)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
@@ -214,7 +233,7 @@ struct LocationNotesView: View {
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
Spacer()
|
||||
}
|
||||
Button("dismiss") { manager.clearError() }
|
||||
Button(Strings.dismissError) { manager.clearError() }
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -223,7 +242,7 @@ struct LocationNotesView: View {
|
||||
|
||||
private var inputSection: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
TextField("add a note for this place", text: $draft, axis: .vertical)
|
||||
TextField(Strings.addPlaceholder, text: $draft, axis: .vertical)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.lineLimit(maxDraftLines, reservesSpace: true)
|
||||
|
||||
@@ -11,37 +11,43 @@ struct MeshPeerList: View {
|
||||
|
||||
@State private var orderedIDs: [String] = []
|
||||
|
||||
private enum Strings {
|
||||
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
|
||||
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to a blocked peer indicator")
|
||||
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let myPeerID = viewModel.meshService.myPeerID
|
||||
let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in
|
||||
let isMe = peer.peerID == myPeerID
|
||||
let hasUnread = viewModel.hasUnreadMessages(for: peer.peerID.id)
|
||||
let enc = viewModel.getEncryptionStatus(for: peer.peerID.id)
|
||||
return (peer, isMe, hasUnread, enc)
|
||||
}
|
||||
// Stable visual order without mutating state here
|
||||
let currentIDs = mapped.map { $0.peer.peerID.id }
|
||||
let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }
|
||||
let peers: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = displayIDs.compactMap { id in
|
||||
mapped.first(where: { $0.peer.peerID.id == id })
|
||||
}
|
||||
|
||||
if viewModel.allPeers.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("nobody around...")
|
||||
Text(Strings.noneNearby)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 12)
|
||||
}
|
||||
} else {
|
||||
let myPeerID = viewModel.meshService.myPeerID
|
||||
let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in
|
||||
let isMe = peer.id == myPeerID
|
||||
let hasUnread = viewModel.hasUnreadMessages(for: peer.id)
|
||||
let enc = viewModel.getEncryptionStatus(for: peer.id)
|
||||
return (peer, isMe, hasUnread, enc)
|
||||
}
|
||||
// Stable visual order without mutating state here
|
||||
let currentIDs = mapped.map { $0.peer.id }
|
||||
let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }
|
||||
let peers: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = displayIDs.compactMap { id in
|
||||
mapped.first(where: { $0.peer.id == id })
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(0..<peers.count, id: \.self) { idx in
|
||||
let item = peers[idx]
|
||||
let peer = item.peer
|
||||
let isMe = item.isMe
|
||||
HStack(spacing: 4) {
|
||||
let assigned = viewModel.colorForMeshPeer(id: peer.id, isDark: colorScheme == .dark)
|
||||
let assigned = viewModel.colorForMeshPeer(id: peer.peerID.id, isDark: colorScheme == .dark)
|
||||
let baseColor = isMe ? Color.orange : assigned
|
||||
if isMe {
|
||||
Image(systemName: "person.fill")
|
||||
@@ -70,7 +76,7 @@ struct MeshPeerList: View {
|
||||
}
|
||||
|
||||
let displayName = isMe ? viewModel.nickname : peer.nickname
|
||||
let (base, suffix) = splitSuffix(from: displayName)
|
||||
let (base, suffix) = displayName.splitSuffix()
|
||||
HStack(spacing: 0) {
|
||||
Text(base)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
@@ -83,11 +89,11 @@ struct MeshPeerList: View {
|
||||
}
|
||||
}
|
||||
|
||||
if !isMe, viewModel.isPeerBlocked(peer.id) {
|
||||
if !isMe, viewModel.isPeerBlocked(peer.peerID.id) {
|
||||
Image(systemName: "nosign")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(.red)
|
||||
.help("Blocked")
|
||||
.help(Strings.blockedTooltip)
|
||||
}
|
||||
|
||||
if !isMe {
|
||||
@@ -99,7 +105,7 @@ struct MeshPeerList: View {
|
||||
}
|
||||
} else {
|
||||
// Offline: prefer showing verified badge from persisted fingerprints
|
||||
if let fp = viewModel.getFingerprint(for: peer.id),
|
||||
if let fp = viewModel.getFingerprint(for: peer.peerID.id),
|
||||
viewModel.verifiedFingerprints.contains(fp) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
@@ -120,11 +126,11 @@ struct MeshPeerList: View {
|
||||
Image(systemName: "envelope.fill")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(.orange)
|
||||
.help("New messages")
|
||||
.help(Strings.newMessagesTooltip)
|
||||
}
|
||||
|
||||
if !isMe {
|
||||
Button(action: { onToggleFavorite(peer.id) }) {
|
||||
Button(action: { onToggleFavorite(peer.peerID.id) }) {
|
||||
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
|
||||
@@ -136,16 +142,16 @@ struct MeshPeerList: View {
|
||||
.padding(.vertical, 4)
|
||||
.padding(.top, idx == 0 ? 10 : 0)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { if !isMe { onTapPeer(peer.id) } }
|
||||
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.id) } }
|
||||
.onTapGesture { if !isMe { onTapPeer(peer.peerID.id) } }
|
||||
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID.id) } }
|
||||
}
|
||||
}
|
||||
// Seed and update order outside result builder
|
||||
.onAppear {
|
||||
let currentIDs = mapped.map { $0.peer.id }
|
||||
let currentIDs = mapped.map { $0.peer.peerID.id }
|
||||
orderedIDs = currentIDs
|
||||
}
|
||||
.onChange(of: mapped.map { $0.peer.id }) { ids in
|
||||
.onChange(of: mapped.map { $0.peer.peerID.id }) { ids in
|
||||
var newOrder = orderedIDs
|
||||
newOrder.removeAll { !ids.contains($0) }
|
||||
for id in ids where !newOrder.contains(id) { newOrder.append(id) }
|
||||
@@ -154,16 +160,3 @@ struct MeshPeerList: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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, "")
|
||||
}
|
||||
|
||||
@@ -37,15 +37,16 @@ extension String {
|
||||
}
|
||||
|
||||
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
|
||||
func extractCashuTokens(max: Int = 3) -> [String] {
|
||||
func extractCashuLinks(max: Int = 3) -> [String] {
|
||||
let regex = RegexCache.cashu
|
||||
let ns = self as NSString
|
||||
let range = NSRange(location: 0, length: ns.length)
|
||||
var found: [String] = []
|
||||
for m in regex.matches(in: self, options: [], range: range) {
|
||||
for m in regex.matches(in: self, range: range) {
|
||||
if m.numberOfRanges > 0 {
|
||||
let token = ns.substring(with: m.range(at: 0))
|
||||
found.append(token)
|
||||
let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token
|
||||
found.append("cashu:\(enc)")
|
||||
if found.count >= max { break }
|
||||
}
|
||||
}
|
||||
@@ -58,19 +59,19 @@ extension String {
|
||||
let ns = self as NSString
|
||||
let full = NSRange(location: 0, length: ns.length)
|
||||
// lightning: scheme
|
||||
for m in RegexCache.lightningScheme.matches(in: self, options: [], range: full) {
|
||||
for m in RegexCache.lightningScheme.matches(in: self, range: full) {
|
||||
let s = ns.substring(with: m.range(at: 0))
|
||||
results.append(s)
|
||||
if results.count >= max { return results }
|
||||
}
|
||||
// BOLT11
|
||||
for m in RegexCache.bolt11.matches(in: self, options: [], range: full) {
|
||||
for m in RegexCache.bolt11.matches(in: self, range: full) {
|
||||
let s = ns.substring(with: m.range(at: 0))
|
||||
results.append("lightning:\(s)")
|
||||
if results.count >= max { return results }
|
||||
}
|
||||
// LNURL bech32
|
||||
for m in RegexCache.lnurl.matches(in: self, options: [], range: full) {
|
||||
for m in RegexCache.lnurl.matches(in: self, range: full) {
|
||||
let s = ns.substring(with: m.range(at: 0))
|
||||
results.append("lightning:\(s)")
|
||||
if results.count >= max { return results }
|
||||
|
||||
@@ -13,14 +13,19 @@ struct MyQRView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
private var boxColor: Color { Color.gray.opacity(0.1) }
|
||||
|
||||
private enum Strings {
|
||||
static let title: LocalizedStringKey = "verification.my_qr.title"
|
||||
static let accessibilityLabel = String(localized: "verification.my_qr.accessibility_label", comment: "Accessibility label describing the verification QR code")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Text("scan to verify me")
|
||||
Text(Strings.title)
|
||||
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||
|
||||
VStack(spacing: 10) {
|
||||
QRCodeImage(data: qrString, size: 240)
|
||||
.accessibilityLabel("verification qr code")
|
||||
.accessibilityLabel(Strings.accessibilityLabel)
|
||||
|
||||
// Non-scrolling, fully visible URL (wraps across lines)
|
||||
Text(qrString)
|
||||
@@ -49,6 +54,10 @@ struct QRCodeImage: View {
|
||||
private let context = CIContext()
|
||||
private let filter = CIFilter.qrCodeGenerator()
|
||||
|
||||
private enum Strings {
|
||||
static let unavailable: LocalizedStringKey = "verification.my_qr.unavailable"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image = generateImage() {
|
||||
@@ -59,7 +68,7 @@ struct QRCodeImage: View {
|
||||
.stroke(Color.gray.opacity(0.5), lineWidth: 1)
|
||||
.frame(width: size, height: size)
|
||||
.overlay(
|
||||
Text("qr unavailable")
|
||||
Text(Strings.unavailable)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.gray)
|
||||
)
|
||||
@@ -100,17 +109,41 @@ struct ImageWrapper: View {
|
||||
struct QRScanView: View {
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
var isActive: Bool = true
|
||||
var onSuccess: (() -> Void)? = nil // Called when verification succeeds
|
||||
@State private var input = ""
|
||||
@State private var result: String = "" // not shown for iOS scanner
|
||||
@State private var lastValid: String = ""
|
||||
|
||||
private enum Strings {
|
||||
static let pastePrompt: LocalizedStringKey = "verification.scan.paste_prompt"
|
||||
static let validate: LocalizedStringKey = "verification.scan.validate"
|
||||
static func requested(_ nickname: String) -> String {
|
||||
String(
|
||||
format: String(localized: "verification.scan.status.requested", comment: "Status text when verification is requested for a nickname"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
}
|
||||
static let notFound = String(localized: "verification.scan.status.no_peer", comment: "Status when no matching peer is found for a verification request")
|
||||
static let invalid = String(localized: "verification.scan.status.invalid", comment: "Status when a scanned QR payload is invalid")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
#if os(iOS)
|
||||
CameraScannerView(isActive: isActive) { code in
|
||||
// Deduplicate: ignore if we just processed this exact QR code
|
||||
guard code != lastValid else { return }
|
||||
|
||||
if let qr = VerificationService.shared.verifyScannedQR(code) {
|
||||
let ok = viewModel.beginQRVerification(with: qr)
|
||||
if !ok { /* already pending; continue scanning */ }
|
||||
lastValid = code
|
||||
if ok {
|
||||
// Successfully initiated verification; remember this QR to prevent re-scanning
|
||||
lastValid = code
|
||||
// Close scanner and return to "My QR" view
|
||||
onSuccess?()
|
||||
}
|
||||
// If !ok, peer not found or already pending - don't set lastValid so user can retry
|
||||
} else {
|
||||
// ignore invalid reads; continue scanning
|
||||
}
|
||||
@@ -118,17 +151,30 @@ struct QRScanView: View {
|
||||
.frame(height: 260)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
#else
|
||||
Text("paste qr content to validate:")
|
||||
Text(Strings.pastePrompt)
|
||||
.font(.bitchatSystem(size: 14, weight: .medium, design: .monospaced))
|
||||
TextEditor(text: $input)
|
||||
.frame(height: 100)
|
||||
.border(Color.gray.opacity(0.4))
|
||||
Button("validate") {
|
||||
Button(Strings.validate) {
|
||||
// Deduplicate: ignore if we just processed this exact QR
|
||||
guard input != lastValid else {
|
||||
result = Strings.requested("") // Already processed
|
||||
return
|
||||
}
|
||||
|
||||
if let qr = VerificationService.shared.verifyScannedQR(input) {
|
||||
let ok = viewModel.beginQRVerification(with: qr)
|
||||
result = ok ? "verification requested for \(qr.nickname)" : "could not find matching peer"
|
||||
if ok {
|
||||
result = Strings.requested(qr.nickname)
|
||||
lastValid = input
|
||||
// Close scanner and return to "My QR" view
|
||||
onSuccess?()
|
||||
} else {
|
||||
result = Strings.notFound
|
||||
}
|
||||
} else {
|
||||
result = "invalid or expired qr payload"
|
||||
result = Strings.invalid
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
@@ -254,7 +300,7 @@ struct VerificationSheetView: View {
|
||||
VStack(spacing: 0) {
|
||||
// Top header (always at top)
|
||||
HStack {
|
||||
Text("VERIFY")
|
||||
Text("verification.sheet.title")
|
||||
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(accentColor)
|
||||
Spacer()
|
||||
@@ -278,18 +324,22 @@ struct VerificationSheetView: View {
|
||||
Group {
|
||||
if showingScanner {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("scan a friend's qr")
|
||||
Text("verification.scan.prompt_friend")
|
||||
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||
.frame(maxWidth: .infinity)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(accentColor)
|
||||
#if os(iOS)
|
||||
QRScanView(isActive: showingScanner)
|
||||
QRScanView(isActive: showingScanner, onSuccess: {
|
||||
showingScanner = false
|
||||
})
|
||||
.environmentObject(viewModel)
|
||||
.frame(height: 280)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
#else
|
||||
QRScanView()
|
||||
QRScanView(onSuccess: {
|
||||
showingScanner = false
|
||||
})
|
||||
.environmentObject(viewModel)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//
|
||||
// BitchatMessage+Preview.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension BitchatMessage {
|
||||
static var preview: BitchatMessage {
|
||||
BitchatMessage(
|
||||
id: UUID().uuidString,
|
||||
sender: "John Doe",
|
||||
content: "Hello",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: "Jane Doe",
|
||||
senderPeerID: nil,
|
||||
mentions: nil,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//
|
||||
// PreviewKeychainManager.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
final class PreviewKeychainManager: KeychainManagerProtocol {
|
||||
private var storage: [String: Data] = [:]
|
||||
init() {}
|
||||
|
||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||
storage[key] = keyData
|
||||
return true
|
||||
}
|
||||
|
||||
func getIdentityKey(forKey key: String) -> Data? {
|
||||
storage[key]
|
||||
}
|
||||
|
||||
func deleteIdentityKey(forKey key: String) -> Bool {
|
||||
storage.removeValue(forKey: key)
|
||||
return true
|
||||
}
|
||||
|
||||
func deleteAllKeychainData() -> Bool {
|
||||
storage.removeAll()
|
||||
return true
|
||||
}
|
||||
|
||||
func secureClear(_ data: inout Data) {}
|
||||
|
||||
func secureClear(_ string: inout String) {}
|
||||
|
||||
func verifyIdentityKeyExists() -> Bool {
|
||||
storage["identity_noiseStaticKey"] != nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
{
|
||||
"sourceLanguage": "en",
|
||||
"strings": {
|
||||
"share.fallback.shared_link_title": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "shared Link",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"ar": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "رابط مشترك",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "geteilter link",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "enlace compartido",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "lien partagé",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"he": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "קישור משותף",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "tautan dibagikan",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "link condiviso",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "共有リンク",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"ne": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "साझा गरिएको लिङ्क",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"pt-BR": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "link compartilhado",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"ru": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "поделился ссылкой",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"uk": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "спільне посилання",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"zh-Hans": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "分享的链接",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "공유된 링크",
|
||||
"comment": "Fallback title when saving a shared link"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"share.status.failed_to_encode": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "failed to encode link",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"ar": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "تعذر ترميز الرابط",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "link konnte nicht codiert werden",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "no se pudo codificar el enlace",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "échec de l'encodage du lien",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"he": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "לא ניתן לקודד את הקישור",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "gagal mengodekan tautan",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "impossibile codificare il link",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "リンクのエンコードに失敗しました",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"ne": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "लिङ्क सङ्केत गर्न सकेन",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"pt-BR": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "falha ao codificar link",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"ru": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "не удалось закодировать ссылку",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"uk": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "не вдалося закодувати посилання",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"zh-Hans": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "无法编码链接",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "링크를 인코딩하는 데 실패했습니다",
|
||||
"comment": "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"share.status.no_shareable_content": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "no shareable content",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"ar": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "لا محتوى قابلاً للمشاركة",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "kein teilbarer inhalt",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "sin contenido que se pueda compartir",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "aucun contenu partageable",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"he": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "אין תוכן שניתן לשתף",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "tidak ada konten yang bisa dibagikan",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "nessun contenuto condivisibile",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "共有可能なコンテンツがありません",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"ne": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "बाँड्न मिल्ने सामग्री छैन",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"pt-BR": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "nenhum conteúdo compartilhável",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"ru": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "нет подходящего контента",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"uk": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "нема відповідного контенту",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"zh-Hans": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "没有可分享的素材",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "공유할 수 있는 내용이 없습니다",
|
||||
"comment": "Shown when provided content cannot be shared"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"share.status.nothing_to_share": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "nothing to share",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"ar": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "لا شيء لمشاركته",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "nichts zum teilen",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "nada que compartir",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "rien à partager",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"he": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "אין מה לשתף",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "tidak ada yang bisa dibagikan",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "niente da condividere",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "共有できるものがありません",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"ne": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "बाँड्ने केही छैन",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"pt-BR": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "nada para compartilhar",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"ru": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "нечем поделиться",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"uk": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "нема чим ділитися",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"zh-Hans": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "没有可分享的内容",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "공유할 내용이 없습니다",
|
||||
"comment": "Shown when the share extension receives no content"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"share.status.shared_link": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ shared link to bitchat",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"ar": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ تم إرسال الرابط إلى bitchat",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ link zu bitchat geteilt",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ enlace compartido con bitchat",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ lien partagé vers bitchat",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"he": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ הקישור נשלח אל bitchat",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ tautan dikirim ke bitchat",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ link inviato a bitchat",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ bitchatにリンクを共有",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"ne": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ bitchat मा लिङ्क पठाइयो",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"pt-BR": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ link enviado para bitchat",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"ru": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ ссылка отправлена в bitchat",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"uk": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ посилання надіслано в bitchat",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"zh-Hans": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ 已将链接分享至 bitchat",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ bitchat으로 링크를 공유했습니다",
|
||||
"comment": "Confirmation after successfully sharing a link"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"share.status.shared_text": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ shared text to bitchat",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"ar": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ تم إرسال النص إلى bitchat",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ text zu bitchat geteilt",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ texto compartido con bitchat",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ texte partagé vers bitchat",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"he": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ הטקסט נשלח אל bitchat",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ teks dikirim ke bitchat",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ testo inviato a bitchat",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ bitchatにテキストを共有",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"ne": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ bitchat मा पाठ पठाइयो",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"pt-BR": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ texto enviado para bitchat",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"ru": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ текст отправлен в bitchat",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"uk": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ текст надіслано в bitchat",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"zh-Hans": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ 已将文本分享至 bitchat",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "✓ bitchat으로 텍스트를 공유했습니다",
|
||||
"comment": "Confirmation after successfully sharing text"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": "1.0"
|
||||
}
|
||||
@@ -14,6 +14,15 @@ import UniformTypeIdentifiers
|
||||
final class ShareViewController: UIViewController {
|
||||
// Bundle.main.bundleIdentifier would get the extension's bundleID
|
||||
private static let groupID = "group.chat.bitchat"
|
||||
|
||||
private enum Strings {
|
||||
static let nothingToShare = String(localized: "share.status.nothing_to_share", comment: "Shown when the share extension receives no content")
|
||||
static let noShareableContent = String(localized: "share.status.no_shareable_content", comment: "Shown when provided content cannot be shared")
|
||||
static let sharedLinkTitleFallback = String(localized: "share.fallback.shared_link_title", comment: "Fallback title when saving a shared link")
|
||||
static let sharedLinkConfirmation = String(localized: "share.status.shared_link", comment: "Confirmation after successfully sharing a link")
|
||||
static let sharedTextConfirmation = String(localized: "share.status.shared_text", comment: "Confirmation after successfully sharing text")
|
||||
static let failedToEncode = String(localized: "share.status.failed_to_encode", comment: "Shown when the share payload cannot be encoded")
|
||||
}
|
||||
|
||||
private let statusLabel: UILabel = {
|
||||
let l = UILabel()
|
||||
@@ -44,7 +53,7 @@ final class ShareViewController: UIViewController {
|
||||
private func processShare() {
|
||||
guard let ctx = self.extensionContext,
|
||||
let item = ctx.inputItems.first as? NSExtensionItem else {
|
||||
finishWithMessage("Nothing to share")
|
||||
finishWithMessage(Strings.nothingToShare)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -61,7 +70,7 @@ final class ShareViewController: UIViewController {
|
||||
if let title = item.attributedTitle?.string, !title.isEmpty {
|
||||
saveAndFinish(text: title)
|
||||
} else {
|
||||
finishWithMessage("No shareable content")
|
||||
finishWithMessage(Strings.noShareableContent)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -81,7 +90,7 @@ final class ShareViewController: UIViewController {
|
||||
self.saveAndFinish(text: t)
|
||||
}
|
||||
} else {
|
||||
self.finishWithMessage("No shareable content")
|
||||
self.finishWithMessage(Strings.noShareableContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,20 +145,20 @@ final class ShareViewController: UIViewController {
|
||||
private func saveAndFinish(url: URL, title: String?) {
|
||||
let payload: [String: String] = [
|
||||
"url": url.absoluteString,
|
||||
"title": title ?? url.host ?? "Shared Link"
|
||||
"title": title ?? url.host ?? Strings.sharedLinkTitleFallback
|
||||
]
|
||||
if let json = try? JSONSerialization.data(withJSONObject: payload),
|
||||
let s = String(data: json, encoding: .utf8) {
|
||||
saveToSharedDefaults(content: s, type: "url")
|
||||
finishWithMessage("✓ Shared link to bitchat")
|
||||
finishWithMessage(Strings.sharedLinkConfirmation)
|
||||
} else {
|
||||
finishWithMessage("Failed to encode link")
|
||||
finishWithMessage(Strings.failedToEncode)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveAndFinish(text: String) {
|
||||
saveToSharedDefaults(content: text, type: "text")
|
||||
finishWithMessage("✓ Shared text to bitchat")
|
||||
finishWithMessage(Strings.sharedTextConfirmation)
|
||||
}
|
||||
|
||||
private func saveToSharedDefaults(content: String, type: String) {
|
||||
|
||||
@@ -281,9 +281,12 @@ private final class MockBitchatDelegate: BitchatDelegate {
|
||||
messageHandler(message)
|
||||
}
|
||||
|
||||
func didConnectToPeer(_ peerID: String) {}
|
||||
func didDisconnectFromPeer(_ peerID: String) {}
|
||||
func didUpdatePeerList(_ peers: [String]) {}
|
||||
func didConnectToPeer(_ peerID: PeerID) {}
|
||||
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
||||
func didUpdatePeerList(_ peers: [PeerID]) {}
|
||||
func isFavorite(fingerprint: String) -> Bool { return false }
|
||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
||||
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {}
|
||||
}
|
||||
|
||||
@@ -6,115 +6,109 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import Testing
|
||||
import CryptoKit
|
||||
import struct Foundation.UUID
|
||||
@testable import bitchat
|
||||
|
||||
final class PrivateChatE2ETests: XCTestCase {
|
||||
// TODO: Remove once MockBLEService is refactored to fix race condition
|
||||
@Suite(.serialized)
|
||||
struct PrivateChatE2ETests {
|
||||
|
||||
var alice: MockBluetoothMeshService!
|
||||
var bob: MockBluetoothMeshService!
|
||||
var charlie: MockBluetoothMeshService!
|
||||
private let alice: MockBLEService
|
||||
private let bob: MockBLEService
|
||||
private let charlie: MockBLEService
|
||||
private let mockKeychain: MockKeychain
|
||||
|
||||
private var mockKeychain: MockKeychain!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
MockBLEService.resetTestBus()
|
||||
|
||||
// Create services
|
||||
alice = createMockService(peerID: TestConstants.testPeerID1, nickname: TestConstants.testNickname1)
|
||||
bob = createMockService(peerID: TestConstants.testPeerID2, nickname: TestConstants.testNickname2)
|
||||
charlie = createMockService(peerID: TestConstants.testPeerID3, nickname: TestConstants.testNickname3)
|
||||
init() {
|
||||
// Create services with unique peer IDs to avoid any collision
|
||||
alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1)
|
||||
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2)
|
||||
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3)
|
||||
mockKeychain = MockKeychain()
|
||||
|
||||
// Delivery tracking is now handled internally by BLEService
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
alice = nil
|
||||
bob = nil
|
||||
charlie = nil
|
||||
mockKeychain = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - Basic Private Messaging Tests
|
||||
|
||||
func testSimplePrivateMessage() {
|
||||
simulateConnection(alice, bob)
|
||||
|
||||
let expectation = XCTestExpectation(description: "Bob receives private message")
|
||||
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 &&
|
||||
message.isPrivate &&
|
||||
message.sender == TestConstants.testNickname1 {
|
||||
expectation.fulfill()
|
||||
|
||||
@Test func simplePrivateMessageShouldNotBeSentWithoutConnection() async {
|
||||
// Intentionally not connecting alice and bob to test
|
||||
|
||||
var bobReceivedMessage = false
|
||||
|
||||
await confirmation("Bob should not receive a private message", expectedCount: 0) { bobReceivesMessage in
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 &&
|
||||
message.isPrivate &&
|
||||
message.sender == TestConstants.testNickname1 {
|
||||
bobReceivedMessage = true
|
||||
bobReceivesMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Alice sends private message to Bob
|
||||
alice.sendPrivateMessage(
|
||||
TestConstants.testMessage1,
|
||||
to: TestConstants.testPeerID2,
|
||||
recipientNickname: TestConstants.testNickname2
|
||||
)
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.shortTimeout)
|
||||
}
|
||||
|
||||
func testPrivateMessageNotReceivedByOthers() {
|
||||
// Connect all three
|
||||
simulateConnection(alice, bob)
|
||||
simulateConnection(alice, charlie)
|
||||
|
||||
let bobExpectation = XCTestExpectation(description: "Bob receives private message")
|
||||
let charlieExpectation = XCTestExpectation(description: "Charlie should not receive")
|
||||
charlieExpectation.isInverted = true
|
||||
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 && message.isPrivate {
|
||||
bobExpectation.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
charlie.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
charlieExpectation.fulfill() // Should not happen
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay to ensure connections are registered before send
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
// Alice sends private message to Bob only
|
||||
self.alice.sendPrivateMessage(
|
||||
|
||||
// Alice sends private message to Bob
|
||||
alice.sendPrivateMessage(
|
||||
TestConstants.testMessage1,
|
||||
to: TestConstants.testPeerID2,
|
||||
to: bob.peerID,
|
||||
recipientNickname: TestConstants.testNickname2
|
||||
)
|
||||
|
||||
// Wait a bit to ensure message would have been delivered if it was going to be
|
||||
try? await Task.sleep(nanoseconds: UInt64(TestConstants.shortTimeout * 1_000_000_000))
|
||||
}
|
||||
|
||||
#expect(!bobReceivedMessage, "Bob should not have received the message")
|
||||
}
|
||||
|
||||
@Test func simplePrivateMessage() async {
|
||||
alice.simulateConnection(with: bob)
|
||||
|
||||
await confirmation("Bob receives private message") { bobReceivesMessage in
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 &&
|
||||
message.isPrivate &&
|
||||
message.sender == TestConstants.testNickname1 {
|
||||
bobReceivesMessage()
|
||||
}
|
||||
}
|
||||
|
||||
// Alice sends private message to Bob
|
||||
alice.sendPrivateMessage(
|
||||
TestConstants.testMessage1,
|
||||
to: bob.peerID,
|
||||
recipientNickname: TestConstants.testNickname2
|
||||
)
|
||||
}
|
||||
|
||||
wait(for: [bobExpectation, charlieExpectation], timeout: TestConstants.shortTimeout)
|
||||
}
|
||||
|
||||
// MARK: - Delivery Acknowledgment Tests
|
||||
|
||||
// NOTE: DeliveryTracker has been removed in BLEService.
|
||||
// Delivery tracking is now handled internally.
|
||||
|
||||
|
||||
// MARK: - Message Retry Tests
|
||||
|
||||
// NOTE: MessageRetryService has been removed in BLEService.
|
||||
// Retry logic is now handled internally.
|
||||
|
||||
|
||||
@Test func privateMessageNotReceivedByOthers() async {
|
||||
alice.simulateConnection(with: bob)
|
||||
alice.simulateConnection(with: charlie)
|
||||
|
||||
await confirmation("Bob receives private message") { bobReceivesMessage in
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 && message.isPrivate {
|
||||
bobReceivesMessage()
|
||||
}
|
||||
}
|
||||
|
||||
charlie.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
Issue.record("Charlie should not receive")
|
||||
}
|
||||
}
|
||||
|
||||
alice.sendPrivateMessage(
|
||||
TestConstants.testMessage1,
|
||||
to: bob.peerID,
|
||||
recipientNickname: TestConstants.testNickname2
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - End-to-End Encryption Tests
|
||||
|
||||
func testPrivateMessageEncryption() {
|
||||
simulateConnection(alice, bob)
|
||||
@Test func privateMessageEncryption() async {
|
||||
alice.simulateConnection(with: bob)
|
||||
|
||||
// Setup Noise sessions
|
||||
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
@@ -125,175 +119,144 @@ final class PrivateChatE2ETests: XCTestCase {
|
||||
|
||||
// Establish encrypted session
|
||||
do {
|
||||
let handshake1 = try aliceManager.initiateHandshake(with: TestConstants.testPeerID2)
|
||||
let handshake2 = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: handshake1)!
|
||||
let handshake3 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: handshake2)!
|
||||
_ = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: handshake3)
|
||||
let handshake1 = try aliceManager.initiateHandshake(with: bob.peerID)
|
||||
let handshake2 = try bobManager.handleIncomingHandshake(from: alice.peerID, message: handshake1)!
|
||||
let handshake3 = try aliceManager.handleIncomingHandshake(from: bob.peerID, message: handshake2)!
|
||||
_ = try bobManager.handleIncomingHandshake(from: alice.peerID, message: handshake3)
|
||||
} catch {
|
||||
XCTFail("Failed to establish Noise session: \(error)")
|
||||
Issue.record("Failed to establish Noise session: \(error)")
|
||||
}
|
||||
|
||||
let expectation = XCTestExpectation(description: "Encrypted message received")
|
||||
|
||||
// Setup packet handlers for encryption
|
||||
alice.packetDeliveryHandler = { packet in
|
||||
// Encrypt outgoing private messages
|
||||
if packet.type == 0x01,
|
||||
let message = BitchatMessage(packet.payload),
|
||||
message.isPrivate {
|
||||
do {
|
||||
let encrypted = try aliceManager.encrypt(packet.payload, for: TestConstants.testPeerID2)
|
||||
let encryptedPacket = BitchatPacket(
|
||||
type: 0x02, // Encrypted message type
|
||||
senderID: packet.senderID,
|
||||
recipientID: packet.recipientID,
|
||||
timestamp: packet.timestamp,
|
||||
payload: encrypted,
|
||||
signature: packet.signature,
|
||||
ttl: packet.ttl
|
||||
)
|
||||
self.bob.simulateIncomingPacket(encryptedPacket)
|
||||
} catch {
|
||||
XCTFail("Encryption failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bob.packetDeliveryHandler = { packet in
|
||||
// Decrypt incoming encrypted messages
|
||||
if packet.type == 0x02 {
|
||||
do {
|
||||
let decrypted = try bobManager.decrypt(packet.payload, from: TestConstants.testPeerID1)
|
||||
if let message = BitchatMessage(decrypted) {
|
||||
XCTAssertEqual(message.content, TestConstants.testMessage1)
|
||||
XCTAssertTrue(message.isPrivate)
|
||||
expectation.fulfill()
|
||||
await confirmation("Encrypted message received") { receiveEncryptedMessage in
|
||||
// Setup packet handlers for encryption
|
||||
alice.packetDeliveryHandler = { packet in
|
||||
// Encrypt outgoing private messages
|
||||
if packet.type == 0x01,
|
||||
let message = BitchatMessage(packet.payload),
|
||||
message.isPrivate {
|
||||
do {
|
||||
let encrypted = try aliceManager.encrypt(packet.payload, for: bob.peerID)
|
||||
let encryptedPacket = BitchatPacket(
|
||||
type: 0x02, // Encrypted message type
|
||||
senderID: packet.senderID,
|
||||
recipientID: packet.recipientID,
|
||||
timestamp: packet.timestamp,
|
||||
payload: encrypted,
|
||||
signature: packet.signature,
|
||||
ttl: packet.ttl
|
||||
)
|
||||
self.bob.simulateIncomingPacket(encryptedPacket)
|
||||
} catch {
|
||||
Issue.record("Encryption failed: \(error)")
|
||||
}
|
||||
} catch {
|
||||
XCTFail("Decryption failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send encrypted private message
|
||||
alice.sendPrivateMessage(
|
||||
TestConstants.testMessage1,
|
||||
to: TestConstants.testPeerID2,
|
||||
recipientNickname: TestConstants.testNickname2
|
||||
)
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
}
|
||||
|
||||
// MARK: - Multi-hop Private Message Tests
|
||||
|
||||
func testPrivateMessageRelay() {
|
||||
// Setup: Alice -> Bob -> Charlie
|
||||
simulateConnection(alice, bob)
|
||||
simulateConnection(bob, charlie)
|
||||
|
||||
let expectation = XCTestExpectation(description: "Private message relayed to Charlie")
|
||||
|
||||
// Bob relays private messages for Charlie
|
||||
bob.packetDeliveryHandler = { packet in
|
||||
if let recipientID = packet.recipientID,
|
||||
String(data: recipientID, encoding: .utf8) == TestConstants.testPeerID3 {
|
||||
// Relay to Charlie
|
||||
var relayPacket = packet
|
||||
relayPacket.ttl = packet.ttl - 1
|
||||
self.charlie.simulateIncomingPacket(relayPacket)
|
||||
}
|
||||
}
|
||||
|
||||
charlie.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 &&
|
||||
message.isPrivate &&
|
||||
message.recipientNickname == TestConstants.testNickname3 {
|
||||
expectation.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
// Alice sends private message to Charlie (through Bob)
|
||||
alice.sendPrivateMessage(
|
||||
TestConstants.testMessage1,
|
||||
to: TestConstants.testPeerID3,
|
||||
recipientNickname: TestConstants.testNickname3
|
||||
)
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
}
|
||||
|
||||
// MARK: - Performance Tests
|
||||
|
||||
func testPrivateMessageThroughput() {
|
||||
simulateConnection(alice, bob)
|
||||
|
||||
let messageCount = 100
|
||||
var receivedCount = 0
|
||||
let expectation = XCTestExpectation(description: "All private messages received")
|
||||
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.isPrivate && message.sender == TestConstants.testNickname1 {
|
||||
receivedCount += 1
|
||||
if receivedCount == messageCount {
|
||||
expectation.fulfill()
|
||||
|
||||
bob.packetDeliveryHandler = { packet in
|
||||
// Decrypt incoming encrypted messages
|
||||
if packet.type == 0x02 {
|
||||
do {
|
||||
let decrypted = try bobManager.decrypt(packet.payload, from: alice.peerID)
|
||||
if let message = BitchatMessage(decrypted) {
|
||||
#expect(message.content == TestConstants.testMessage1)
|
||||
#expect(message.isPrivate)
|
||||
receiveEncryptedMessage()
|
||||
}
|
||||
} catch {
|
||||
Issue.record("Decryption failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send many private messages
|
||||
for i in 0..<messageCount {
|
||||
|
||||
// Send encrypted private message
|
||||
alice.sendPrivateMessage(
|
||||
"Private message \(i)",
|
||||
TestConstants.testMessage1,
|
||||
to: TestConstants.testPeerID2,
|
||||
recipientNickname: TestConstants.testNickname2
|
||||
)
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.longTimeout)
|
||||
XCTAssertEqual(receivedCount, messageCount)
|
||||
}
|
||||
|
||||
func testLargePrivateMessage() {
|
||||
simulateConnection(alice, bob)
|
||||
// MARK: - Multi-hop Private Message Tests
|
||||
|
||||
@Test func privateMessageRelay() async {
|
||||
// Setup: Alice -> Bob -> Charlie
|
||||
alice.simulateConnection(with: bob)
|
||||
bob.simulateConnection(with: charlie)
|
||||
|
||||
let expectation = XCTestExpectation(description: "Large private message received")
|
||||
await confirmation("Private message relayed to Charlie") { charlieReceivesMessage in
|
||||
// Bob relays private messages for Charlie
|
||||
bob.packetDeliveryHandler = { packet in
|
||||
if let recipientID = packet.recipientID,
|
||||
String(data: recipientID, encoding: .utf8) == charlie.peerID {
|
||||
// Relay to Charlie
|
||||
var relayPacket = packet
|
||||
relayPacket.ttl = packet.ttl - 1
|
||||
self.charlie.simulateIncomingPacket(relayPacket)
|
||||
}
|
||||
}
|
||||
|
||||
charlie.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 &&
|
||||
message.isPrivate &&
|
||||
message.recipientNickname == TestConstants.testNickname3 {
|
||||
charlieReceivesMessage()
|
||||
}
|
||||
}
|
||||
|
||||
// Alice sends private message to Charlie (through Bob)
|
||||
alice.sendPrivateMessage(
|
||||
TestConstants.testMessage1,
|
||||
to: charlie.peerID,
|
||||
recipientNickname: TestConstants.testNickname3
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Performance Tests
|
||||
|
||||
@Test func privateMessageThroughput() async {
|
||||
alice.simulateConnection(with: bob)
|
||||
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testLongMessage && message.isPrivate {
|
||||
expectation.fulfill()
|
||||
let messageCount = 100
|
||||
var receivedCount = 0
|
||||
|
||||
await confirmation("All private messages received") { receivePrivateMessage in
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.isPrivate && message.sender == TestConstants.testNickname1 {
|
||||
receivedCount += 1
|
||||
if receivedCount == messageCount {
|
||||
receivePrivateMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send many private messages
|
||||
for i in 0..<messageCount {
|
||||
alice.sendPrivateMessage(
|
||||
"Private message \(i)",
|
||||
to: TestConstants.testPeerID2,
|
||||
recipientNickname: TestConstants.testNickname2
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
alice.sendPrivateMessage(
|
||||
TestConstants.testLongMessage,
|
||||
to: TestConstants.testPeerID2,
|
||||
recipientNickname: TestConstants.testNickname2
|
||||
)
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
}
|
||||
|
||||
// MARK: - Error Handling Tests
|
||||
|
||||
// NOTE: This test relied on MessageRetryService which has been removed
|
||||
|
||||
func testDuplicateAckPrevention() throws {
|
||||
throw XCTSkip("DeliveryTracker/ACK flow removed; test not applicable")
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
private func createMockService(peerID: String, nickname: String) -> MockBluetoothMeshService {
|
||||
let service = MockBluetoothMeshService()
|
||||
service.myPeerID = peerID
|
||||
service.mockNickname = nickname
|
||||
service._testRegister()
|
||||
return service
|
||||
}
|
||||
|
||||
private func simulateConnection(_ peer1: MockBluetoothMeshService, _ peer2: MockBluetoothMeshService) {
|
||||
peer1.simulateConnectedPeer(peer2.peerID)
|
||||
peer2.simulateConnectedPeer(peer1.peerID)
|
||||
|
||||
@Test func largePrivateMessage() async {
|
||||
alice.simulateConnection(with: bob)
|
||||
|
||||
await confirmation("Large private message received") { receiveLargeMessage in
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testLongMessage && message.isPrivate {
|
||||
receiveLargeMessage()
|
||||
}
|
||||
}
|
||||
|
||||
alice.sendPrivateMessage(
|
||||
TestConstants.testLongMessage,
|
||||
to: TestConstants.testPeerID2,
|
||||
recipientNickname: TestConstants.testNickname2
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,440 +6,389 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import Testing
|
||||
import struct Foundation.UUID
|
||||
@testable import bitchat
|
||||
|
||||
final class PublicChatE2ETests: XCTestCase {
|
||||
@Suite(.serialized)
|
||||
struct PublicChatE2ETests {
|
||||
|
||||
var alice: MockBluetoothMeshService!
|
||||
var bob: MockBluetoothMeshService!
|
||||
var charlie: MockBluetoothMeshService!
|
||||
var david: MockBluetoothMeshService!
|
||||
private let alice: MockBLEService
|
||||
private let bob: MockBLEService
|
||||
private let charlie: MockBLEService
|
||||
private let david: MockBLEService
|
||||
|
||||
var receivedMessages: [String: [BitchatMessage]] = [:]
|
||||
private var receivedMessages: [String: [BitchatMessage]] = [:]
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
MockBLEService.resetTestBus()
|
||||
|
||||
// Create mock services
|
||||
alice = createMockService(peerID: TestConstants.testPeerID1, nickname: TestConstants.testNickname1)
|
||||
bob = createMockService(peerID: TestConstants.testPeerID2, nickname: TestConstants.testNickname2)
|
||||
charlie = createMockService(peerID: TestConstants.testPeerID3, nickname: TestConstants.testNickname3)
|
||||
david = createMockService(peerID: TestConstants.testPeerID4, nickname: TestConstants.testNickname4)
|
||||
|
||||
// Clear received messages
|
||||
receivedMessages.removeAll()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
alice = nil
|
||||
bob = nil
|
||||
charlie = nil
|
||||
david = nil
|
||||
super.tearDown()
|
||||
init() {
|
||||
// Create mock services with unique peer IDs to avoid any collision
|
||||
alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1)
|
||||
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2)
|
||||
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3)
|
||||
david = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname4)
|
||||
}
|
||||
|
||||
// MARK: - Basic Broadcasting Tests
|
||||
|
||||
func testSimplePublicMessage() {
|
||||
// Connect Alice and Bob
|
||||
simulateConnection(alice, bob)
|
||||
@Test func simplePublicMessage() async {
|
||||
alice.simulateConnection(with: bob)
|
||||
|
||||
let expectation = XCTestExpectation(description: "Bob receives message")
|
||||
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 && message.sender == TestConstants.testNickname1 {
|
||||
expectation.fulfill()
|
||||
await confirmation("Bob receives message") { bobReceivesMessage in
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 && message.sender == TestConstants.testNickname1 {
|
||||
bobReceivesMessage()
|
||||
}
|
||||
}
|
||||
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
}
|
||||
|
||||
// Alice sends public message
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.shortTimeout)
|
||||
}
|
||||
|
||||
func testMultiRecipientBroadcast() {
|
||||
// Connect Alice to Bob and Charlie
|
||||
simulateConnection(alice, bob)
|
||||
simulateConnection(alice, charlie)
|
||||
@Test func multiRecipientBroadcast() async {
|
||||
alice.simulateConnection(with: bob)
|
||||
alice.simulateConnection(with: charlie)
|
||||
|
||||
let bobExpectation = XCTestExpectation(description: "Bob receives message")
|
||||
let charlieExpectation = XCTestExpectation(description: "Charlie receives message")
|
||||
var bobReceivedMessage = false
|
||||
var charlieReceivedMessage = false
|
||||
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
bobExpectation.fulfill()
|
||||
await confirmation("Both recieve message", expectedCount: 2) { receiveMessage in
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
if !bobReceivedMessage {
|
||||
bobReceivedMessage = true
|
||||
receiveMessage()
|
||||
} else {
|
||||
Issue.record("Bob received more than once")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
charlie.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
charlieExpectation.fulfill()
|
||||
|
||||
charlie.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
if !charlieReceivedMessage {
|
||||
charlieReceivedMessage = true
|
||||
receiveMessage()
|
||||
} else {
|
||||
Issue.record("Charlie received more than once")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
}
|
||||
|
||||
// Alice broadcasts
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
|
||||
wait(for: [bobExpectation, charlieExpectation], timeout: TestConstants.shortTimeout)
|
||||
}
|
||||
|
||||
// MARK: - Message Routing and Relay Tests
|
||||
|
||||
func testMessageRelayChain() {
|
||||
@Test func messageRelayChain() async {
|
||||
// Linear topology: Alice -> Bob -> Charlie
|
||||
simulateConnection(alice, bob)
|
||||
simulateConnection(bob, charlie)
|
||||
|
||||
let expectation = XCTestExpectation(description: "Charlie receives relayed message")
|
||||
|
||||
// Set up relay in Bob
|
||||
bob.packetDeliveryHandler = { packet in
|
||||
// Bob should relay to Charlie
|
||||
if let message = BitchatMessage(packet.payload),
|
||||
message.sender == TestConstants.testNickname1 {
|
||||
|
||||
// Create relay message
|
||||
let relayMessage = BitchatMessage(
|
||||
id: message.id,
|
||||
sender: message.sender,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
isRelay: true,
|
||||
originalSender: message.sender,
|
||||
isPrivate: message.isPrivate,
|
||||
recipientNickname: message.recipientNickname,
|
||||
senderPeerID: message.senderPeerID,
|
||||
mentions: message.mentions
|
||||
)
|
||||
|
||||
if let relayPayload = relayMessage.toBinaryPayload() {
|
||||
let relayPacket = BitchatPacket(
|
||||
type: packet.type,
|
||||
senderID: packet.senderID,
|
||||
recipientID: packet.recipientID,
|
||||
timestamp: packet.timestamp,
|
||||
payload: relayPayload,
|
||||
signature: packet.signature,
|
||||
ttl: packet.ttl - 1
|
||||
alice.simulateConnection(with: bob)
|
||||
bob.simulateConnection(with: charlie)
|
||||
|
||||
await confirmation("Charlie receives relayed message") { charlieReceivesMessage in
|
||||
// Set up relay in Bob
|
||||
bob.packetDeliveryHandler = { packet in
|
||||
// Bob should relay to Charlie
|
||||
if let message = BitchatMessage(packet.payload),
|
||||
message.sender == TestConstants.testNickname1 {
|
||||
|
||||
// Create relay message
|
||||
let relayMessage = BitchatMessage(
|
||||
id: message.id,
|
||||
sender: message.sender,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
isRelay: true,
|
||||
originalSender: message.sender,
|
||||
isPrivate: message.isPrivate,
|
||||
recipientNickname: message.recipientNickname,
|
||||
senderPeerID: message.senderPeerID,
|
||||
mentions: message.mentions
|
||||
)
|
||||
|
||||
// Simulate relay to Charlie
|
||||
self.charlie.simulateIncomingPacket(relayPacket)
|
||||
|
||||
if let relayPayload = relayMessage.toBinaryPayload() {
|
||||
let relayPacket = BitchatPacket(
|
||||
type: packet.type,
|
||||
senderID: packet.senderID,
|
||||
recipientID: packet.recipientID,
|
||||
timestamp: packet.timestamp,
|
||||
payload: relayPayload,
|
||||
signature: packet.signature,
|
||||
ttl: packet.ttl - 1
|
||||
)
|
||||
|
||||
// Simulate relay to Charlie
|
||||
self.charlie.simulateIncomingPacket(relayPacket)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
charlie.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 &&
|
||||
message.originalSender == TestConstants.testNickname1 &&
|
||||
message.isRelay {
|
||||
expectation.fulfill()
|
||||
|
||||
charlie.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 &&
|
||||
message.originalSender == TestConstants.testNickname1 &&
|
||||
message.isRelay {
|
||||
charlieReceivesMessage()
|
||||
}
|
||||
}
|
||||
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
}
|
||||
|
||||
// Alice sends message
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
}
|
||||
|
||||
func testMultiHopRelay() {
|
||||
@Test func multiHopRelay() async {
|
||||
// Topology: Alice -> Bob -> Charlie -> David
|
||||
simulateConnection(alice, bob)
|
||||
simulateConnection(bob, charlie)
|
||||
simulateConnection(charlie, david)
|
||||
alice.simulateConnection(with: bob)
|
||||
bob.simulateConnection(with: charlie)
|
||||
charlie.simulateConnection(with: david)
|
||||
|
||||
let expectation = XCTestExpectation(description: "David receives multi-hop message")
|
||||
|
||||
// Set up relay chain
|
||||
setupRelayHandler(bob, nextHops: [charlie])
|
||||
setupRelayHandler(charlie, nextHops: [david])
|
||||
// Allow handlers to install
|
||||
let sendDelay = DispatchTime.now() + 0.05
|
||||
|
||||
david.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 &&
|
||||
message.originalSender == TestConstants.testNickname1 &&
|
||||
message.isRelay {
|
||||
expectation.fulfill()
|
||||
await confirmation("David receives multi-hop message") { davidReceivesMessage in
|
||||
// Set up relay chain
|
||||
setupRelayHandler(bob, nextHops: [charlie])
|
||||
setupRelayHandler(charlie, nextHops: [david])
|
||||
|
||||
david.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 &&
|
||||
message.originalSender == TestConstants.testNickname1 &&
|
||||
message.isRelay {
|
||||
davidReceivesMessage()
|
||||
}
|
||||
}
|
||||
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
}
|
||||
|
||||
// Alice sends message
|
||||
DispatchQueue.main.asyncAfter(deadline: sendDelay) {
|
||||
self.alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
}
|
||||
|
||||
// MARK: - TTL (Time To Live) Tests
|
||||
|
||||
func testTTLDecrement() {
|
||||
@Test func ttlDecrement() async {
|
||||
// Create a chain longer than TTL
|
||||
let nodes = [alice!, bob!, charlie!, david!]
|
||||
let nodes = [alice, bob, charlie, david]
|
||||
|
||||
// Connect in chain
|
||||
for i in 0..<nodes.count-1 {
|
||||
simulateConnection(nodes[i], nodes[i+1])
|
||||
nodes[i].simulateConnection(with: nodes[i+1])
|
||||
if i > 0 && i < nodes.count-1 {
|
||||
setupRelayHandler(nodes[i], nextHops: [nodes[i+1]])
|
||||
}
|
||||
}
|
||||
|
||||
let expectation = XCTestExpectation(description: "Message dropped due to TTL")
|
||||
expectation.isInverted = true // Should NOT be fulfilled
|
||||
|
||||
david.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
expectation.fulfill() // This should not happen
|
||||
await confirmation("Message dropped due to TTL", expectedCount: 0) { receiveMessage in
|
||||
david.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
receiveMessage() // This should not happen
|
||||
}
|
||||
}
|
||||
|
||||
// Inject at Bob with TTL=2 so Charlie sees it (TTL->1) and does not relay to David
|
||||
let msg = TestHelpers.createTestMessage(
|
||||
content: TestConstants.testMessage1,
|
||||
sender: TestConstants.testNickname1,
|
||||
senderPeerID: alice.peerID
|
||||
)
|
||||
|
||||
if let payload = msg.toBinaryPayload() {
|
||||
let pkt = TestHelpers.createTestPacket(senderID: alice.peerID, payload: payload, ttl: 2)
|
||||
bob.simulateIncomingPacket(pkt)
|
||||
}
|
||||
}
|
||||
|
||||
// Inject at Bob with TTL=2 so Charlie sees it (TTL->1) and does not relay to David
|
||||
let msg = TestHelpers.createTestMessage(content: TestConstants.testMessage1, sender: TestConstants.testNickname1, senderPeerID: alice.peerID)
|
||||
if let payload = msg.toBinaryPayload() {
|
||||
let pkt = TestHelpers.createTestPacket(senderID: alice.peerID, payload: payload, ttl: 2)
|
||||
bob.simulateIncomingPacket(pkt)
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.shortTimeout)
|
||||
}
|
||||
|
||||
func testZeroTTLNotRelayed() {
|
||||
simulateConnection(alice, bob)
|
||||
simulateConnection(bob, charlie)
|
||||
@Test func zeroTTLNotRelayed() async {
|
||||
alice.simulateConnection(with: bob)
|
||||
bob.simulateConnection(with: charlie)
|
||||
|
||||
let expectation = XCTestExpectation(description: "Zero TTL message not relayed")
|
||||
expectation.isInverted = true
|
||||
|
||||
charlie.messageDeliveryHandler = { message in
|
||||
if message.content == "Zero TTL message" {
|
||||
expectation.fulfill() // Should not happen
|
||||
await confirmation("Zero TTL message not relayed", expectedCount: 0) { receiveMessage in
|
||||
charlie.messageDeliveryHandler = { message in
|
||||
if message.content == "Zero TTL message" {
|
||||
receiveMessage() // Should not happen
|
||||
}
|
||||
}
|
||||
|
||||
// Create packet with TTL=0
|
||||
let message = TestHelpers.createTestMessage(content: "Zero TTL message")
|
||||
if let payload = message.toBinaryPayload() {
|
||||
let packet = TestHelpers.createTestPacket(payload: payload, ttl: 0)
|
||||
alice.simulateIncomingPacket(packet)
|
||||
}
|
||||
}
|
||||
|
||||
// Create packet with TTL=0
|
||||
let message = TestHelpers.createTestMessage(content: "Zero TTL message")
|
||||
if let payload = message.toBinaryPayload() {
|
||||
let packet = TestHelpers.createTestPacket(payload: payload, ttl: 0)
|
||||
alice.simulateIncomingPacket(packet)
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.shortTimeout)
|
||||
}
|
||||
|
||||
// MARK: - Duplicate Detection Tests
|
||||
|
||||
func testDuplicateMessagePrevention() {
|
||||
simulateConnection(alice, bob)
|
||||
@Test func duplicateMessagePrevention() async {
|
||||
alice.simulateConnection(with: bob)
|
||||
|
||||
var messageCount = 0
|
||||
let expectation = XCTestExpectation(description: "Only one message received")
|
||||
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
messageCount += 1
|
||||
if messageCount == 1 {
|
||||
// Send duplicate after small delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
self.alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil, messageID: message.id)
|
||||
await confirmation("Only one message received") { receiveMessage in
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
receiveMessage()
|
||||
messageCount += 1
|
||||
if messageCount == 1 {
|
||||
// Send duplicate after small delay
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil, messageID: message.id)
|
||||
} else {
|
||||
Issue.record("Duplicate message was not filtered")
|
||||
}
|
||||
} else {
|
||||
XCTFail("Duplicate message was not filtered")
|
||||
}
|
||||
}
|
||||
|
||||
// Send original message
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func duplicateContentAsNewMessageNotPrevented() async {
|
||||
alice.simulateConnection(with: bob)
|
||||
|
||||
// Send original message
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
var messageCount = 0
|
||||
|
||||
// Wait to ensure duplicate would have been received
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
XCTAssertEqual(messageCount, 1)
|
||||
expectation.fulfill()
|
||||
await confirmation("Only one message received", expectedCount: 2) { receiveMessage in
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
receiveMessage()
|
||||
messageCount += 1
|
||||
if messageCount == 1 {
|
||||
// Send the same content as a new message
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send original message
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
}
|
||||
|
||||
// MARK: - Mention Tests
|
||||
|
||||
func testMessageWithMentions() {
|
||||
simulateConnection(alice, bob)
|
||||
simulateConnection(alice, charlie)
|
||||
|
||||
let expectation = XCTestExpectation(description: "Mentioned users receive notification")
|
||||
@Test func messageWithMentions() async {
|
||||
alice.simulateConnection(with: bob)
|
||||
alice.simulateConnection(with: charlie)
|
||||
|
||||
var mentionedUsers: Set<String> = []
|
||||
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if let mentions = message.mentions, mentions.contains(TestConstants.testNickname2) {
|
||||
mentionedUsers.insert(TestConstants.testNickname2)
|
||||
await confirmation("Mentioned users receive notification", expectedCount: 2) { receiveMention in
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.mentions?.contains(TestConstants.testNickname2) == true {
|
||||
mentionedUsers.insert(TestConstants.testNickname2)
|
||||
receiveMention()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
charlie.messageDeliveryHandler = { message in
|
||||
if let mentions = message.mentions, mentions.contains(TestConstants.testNickname3) {
|
||||
mentionedUsers.insert(TestConstants.testNickname3)
|
||||
|
||||
charlie.messageDeliveryHandler = { message in
|
||||
if message.mentions?.contains(TestConstants.testNickname3) == true {
|
||||
mentionedUsers.insert(TestConstants.testNickname3)
|
||||
receiveMention()
|
||||
}
|
||||
}
|
||||
|
||||
// Alice mentions Bob and Charlie
|
||||
alice.sendMessage(
|
||||
"Hey @\(TestConstants.testNickname2) and @\(TestConstants.testNickname3)!",
|
||||
mentions: [TestConstants.testNickname2, TestConstants.testNickname3],
|
||||
to: nil
|
||||
)
|
||||
}
|
||||
|
||||
// Alice mentions Bob and Charlie
|
||||
alice.sendMessage(
|
||||
"Hey @\(TestConstants.testNickname2) and @\(TestConstants.testNickname3)!",
|
||||
mentions: [TestConstants.testNickname2, TestConstants.testNickname3],
|
||||
to: nil
|
||||
)
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
XCTAssertEqual(mentionedUsers, [TestConstants.testNickname2, TestConstants.testNickname3])
|
||||
expectation.fulfill()
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
#expect(mentionedUsers == [TestConstants.testNickname2, TestConstants.testNickname3])
|
||||
}
|
||||
|
||||
// MARK: - Network Topology Tests
|
||||
|
||||
func testMeshTopologyBroadcast() {
|
||||
@Test func meshTopologyBroadcast() async {
|
||||
// Create mesh: Everyone connected to everyone
|
||||
let nodes = [alice!, bob!, charlie!, david!]
|
||||
let nodes = [alice, bob, charlie, david]
|
||||
for i in 0..<nodes.count {
|
||||
for j in i+1..<nodes.count {
|
||||
simulateConnection(nodes[i], nodes[j])
|
||||
nodes[i].simulateConnection(with: nodes[j])
|
||||
}
|
||||
}
|
||||
|
||||
var receivedCount = 0
|
||||
let expectation = XCTestExpectation(description: "All nodes receive message")
|
||||
|
||||
for (index, node) in nodes.enumerated() where index > 0 {
|
||||
node.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
receivedCount += 1
|
||||
if receivedCount == 3 { // Bob, Charlie, David
|
||||
expectation.fulfill()
|
||||
await confirmation("All nodes receive message", expectedCount: 3) { receiveMessage in
|
||||
for (index, node) in nodes.enumerated() where index > 0 {
|
||||
node.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
receiveMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
}
|
||||
|
||||
// Alice broadcasts
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
}
|
||||
|
||||
func testPartialMeshRelay() {
|
||||
@Test func partialMeshRelay() async {
|
||||
// Partial mesh: Alice -> Bob, Bob -> Charlie, Charlie -> David, David -> Alice
|
||||
simulateConnection(alice, bob)
|
||||
simulateConnection(bob, charlie)
|
||||
simulateConnection(charlie, david)
|
||||
simulateConnection(david, alice)
|
||||
alice.simulateConnection(with: bob)
|
||||
bob.simulateConnection(with: charlie)
|
||||
charlie.simulateConnection(with: david)
|
||||
david.simulateConnection(with: alice)
|
||||
|
||||
// Setup relay handlers
|
||||
setupRelayHandler(bob, nextHops: [charlie])
|
||||
setupRelayHandler(charlie, nextHops: [david])
|
||||
setupRelayHandler(david, nextHops: [alice])
|
||||
let sendDelay2 = DispatchTime.now() + 0.05
|
||||
|
||||
var receivedCount = 0
|
||||
let expectation = XCTestExpectation(description: "Message reaches all nodes once")
|
||||
|
||||
let checkCompletion = {
|
||||
if receivedCount == 3 { // Bob, Charlie, David should receive
|
||||
expectation.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
for node in [bob!, charlie!, david!] {
|
||||
node.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
receivedCount += 1
|
||||
checkCompletion()
|
||||
await confirmation("Message reaches all nodes once", expectedCount: 3) { receiveMessage in
|
||||
for node in [bob, charlie, david] {
|
||||
node.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
receiveMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
}
|
||||
|
||||
// Alice broadcasts
|
||||
DispatchQueue.main.asyncAfter(deadline: sendDelay2) {
|
||||
self.alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
}
|
||||
|
||||
// MARK: - Performance and Stress Tests
|
||||
|
||||
func testHighVolumeMessaging() {
|
||||
simulateConnection(alice, bob)
|
||||
@Test func highVolumeMessaging() async {
|
||||
alice.simulateConnection(with: bob)
|
||||
|
||||
let messageCount = 100
|
||||
var receivedCount = 0
|
||||
let expectation = XCTestExpectation(description: "All messages received")
|
||||
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.sender == TestConstants.testNickname1 {
|
||||
receivedCount += 1
|
||||
if receivedCount == messageCount {
|
||||
expectation.fulfill()
|
||||
await confirmation("All messages received", expectedCount: messageCount) { receiveMessage in
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.sender == TestConstants.testNickname1 {
|
||||
receiveMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send many messages rapidly
|
||||
for i in 0..<messageCount {
|
||||
alice.sendMessage("Message \(i)", mentions: [], to: nil)
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.longTimeout)
|
||||
XCTAssertEqual(receivedCount, messageCount)
|
||||
}
|
||||
|
||||
func testLargeMessageBroadcast() {
|
||||
simulateConnection(alice, bob)
|
||||
|
||||
let expectation = XCTestExpectation(description: "Large message received")
|
||||
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testLongMessage {
|
||||
expectation.fulfill()
|
||||
|
||||
// Send many messages rapidly
|
||||
for i in 0..<messageCount {
|
||||
alice.sendMessage("Message \(i)", mentions: [], to: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func largeMessageBroadcast() async {
|
||||
alice.simulateConnection(with: bob)
|
||||
|
||||
// Send large message
|
||||
alice.sendMessage(TestConstants.testLongMessage, mentions: [], to: nil)
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
await confirmation("Large message received") { receiveLargeMessage in
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testLongMessage {
|
||||
receiveLargeMessage()
|
||||
}
|
||||
}
|
||||
|
||||
alice.sendMessage(TestConstants.testLongMessage, mentions: [], to: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
private func createMockService(peerID: String, nickname: String) -> MockBluetoothMeshService {
|
||||
let service = MockBluetoothMeshService()
|
||||
service.myPeerID = peerID
|
||||
service.mockNickname = nickname
|
||||
service._testRegister()
|
||||
return service
|
||||
}
|
||||
|
||||
private func simulateConnection(_ peer1: MockBluetoothMeshService, _ peer2: MockBluetoothMeshService) {
|
||||
peer1.simulateConnectedPeer(peer2.peerID)
|
||||
peer2.simulateConnectedPeer(peer1.peerID)
|
||||
}
|
||||
|
||||
private func setupRelayHandler(_ node: MockBluetoothMeshService, nextHops: [MockBluetoothMeshService]) {
|
||||
|
||||
private func setupRelayHandler(_ node: MockBLEService, nextHops: [MockBLEService]) {
|
||||
node.packetDeliveryHandler = { packet in
|
||||
// Check if should relay
|
||||
guard packet.ttl > 1 else { return }
|
||||
|
||||
|
||||
if let message = BitchatMessage(packet.payload) {
|
||||
// Don't relay own messages
|
||||
guard message.senderPeerID != node.peerID else { return }
|
||||
guard message.senderPeerID?.id != node.peerID else { return }
|
||||
|
||||
// Create relay message
|
||||
let relayMessage = BitchatMessage(
|
||||
@@ -458,7 +407,7 @@ final class PublicChatE2ETests: XCTestCase {
|
||||
if let relayPayload = relayMessage.toBinaryPayload() {
|
||||
let relayPacket = BitchatPacket(
|
||||
type: packet.type,
|
||||
senderID: node.peerID.data(using: .utf8)!,
|
||||
senderID: node.peerID.id.data(using: .utf8)!,
|
||||
recipientID: packet.recipientID,
|
||||
timestamp: packet.timestamp,
|
||||
payload: relayPayload,
|
||||
|
||||
@@ -3,14 +3,14 @@ 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))
|
||||
}
|
||||
// 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))
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -6,48 +6,149 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import Testing
|
||||
import Foundation
|
||||
import CoreBluetooth
|
||||
@testable import bitchat
|
||||
|
||||
final class FragmentationTests: XCTestCase {
|
||||
struct FragmentationTests {
|
||||
|
||||
private var mockKeychain: MockKeychain!
|
||||
private var mockIdentityManager: MockIdentityManager!
|
||||
private let mockKeychain: MockKeychain
|
||||
private let mockIdentityManager: MockIdentityManager
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
init() {
|
||||
mockKeychain = MockKeychain()
|
||||
mockIdentityManager = MockIdentityManager(mockKeychain)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
mockKeychain = nil
|
||||
mockIdentityManager = nil
|
||||
super.tearDown()
|
||||
@Test("Reassembly from fragments delivers a public message")
|
||||
func reassemblyFromFragmentsDeliversPublicMessage() async throws {
|
||||
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
|
||||
let capture = CaptureDelegate()
|
||||
ble.delegate = capture
|
||||
|
||||
// Construct a big packet (3KB) from a remote sender (not our own ID)
|
||||
let remoteShortID: PeerID = "1122334455667788"
|
||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000)
|
||||
|
||||
// Use a small fragment size to ensure multiple pieces
|
||||
let fragments = fragmentPacket(original, fragmentSize: 400)
|
||||
|
||||
// Shuffle fragments to simulate out-of-order arrival
|
||||
let shuffled = fragments.shuffled()
|
||||
|
||||
// Inject fragments spaced out to avoid concurrent mutation inside BLEService
|
||||
for (i, fragment) in shuffled.enumerated() {
|
||||
let delay = UInt64(5 * i) * 1_000_000 // nanoseconds
|
||||
Task {
|
||||
try await Task.sleep(nanoseconds: delay)
|
||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||
}
|
||||
}
|
||||
|
||||
// Allow async processing
|
||||
try await Task.sleep(nanoseconds: 500_000_000) // 0.5s
|
||||
|
||||
#expect(capture.publicMessages.count == 1)
|
||||
#expect(capture.publicMessages.first?.content.count == 3_000)
|
||||
}
|
||||
|
||||
@Test("Duplicate fragment does not break reassembly")
|
||||
func duplicateFragmentDoesNotBreakReassembly() async throws {
|
||||
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
|
||||
let capture = CaptureDelegate()
|
||||
ble.delegate = capture
|
||||
|
||||
let remoteShortID: PeerID = "A1B2C3D4E5F60708"
|
||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048)
|
||||
var frags = fragmentPacket(original, fragmentSize: 300)
|
||||
|
||||
// Duplicate one fragment
|
||||
if let dup = frags.first {
|
||||
frags.insert(dup, at: 1)
|
||||
}
|
||||
|
||||
for (i, fragment) in frags.enumerated() {
|
||||
let delay = UInt64(5 * i) * 1_000_000 // nanoseconds
|
||||
Task {
|
||||
try await Task.sleep(nanoseconds: delay)
|
||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||
}
|
||||
}
|
||||
|
||||
// Allow async processing
|
||||
try await Task.sleep(nanoseconds: 500_000_000) // 0.5s
|
||||
|
||||
#expect(capture.publicMessages.count == 1)
|
||||
#expect(capture.publicMessages.first?.content.count == 2048)
|
||||
}
|
||||
|
||||
@Test("Invalid fragment header is ignored")
|
||||
func invalidFragmentHeaderIsIgnored() async throws {
|
||||
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
|
||||
let capture = CaptureDelegate()
|
||||
ble.delegate = capture
|
||||
|
||||
let remoteShortID: PeerID = "0011223344556677"
|
||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 1000)
|
||||
let fragments = fragmentPacket(original, fragmentSize: 250)
|
||||
|
||||
// Corrupt one fragment: make payload too short (header incomplete)
|
||||
var corrupted = fragments
|
||||
if !corrupted.isEmpty {
|
||||
var p = corrupted[0]
|
||||
p = BitchatPacket(
|
||||
type: p.type,
|
||||
senderID: p.senderID,
|
||||
recipientID: p.recipientID,
|
||||
timestamp: p.timestamp,
|
||||
payload: Data([0x00, 0x01, 0x02]), // invalid header
|
||||
signature: nil,
|
||||
ttl: p.ttl
|
||||
)
|
||||
corrupted[0] = p
|
||||
}
|
||||
|
||||
for (i, fragment) in corrupted.enumerated() {
|
||||
let delay = UInt64(5 * i) * 1_000_000 // nanoseconds
|
||||
Task {
|
||||
try await Task.sleep(nanoseconds: delay)
|
||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||
}
|
||||
}
|
||||
|
||||
// Allow async processing
|
||||
try await Task.sleep(nanoseconds: 500_000_000) // 0.5s
|
||||
|
||||
// Should not deliver since one fragment is invalid and reassembly can't complete
|
||||
#expect(capture.publicMessages.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
extension FragmentationTests {
|
||||
private final class CaptureDelegate: BitchatDelegate {
|
||||
var publicMessages: [(peerID: String, nickname: String, content: String)] = []
|
||||
var publicMessages: [(peerID: PeerID, nickname: String, content: String)] = []
|
||||
func didReceiveMessage(_ message: BitchatMessage) {}
|
||||
func didConnectToPeer(_ peerID: String) {}
|
||||
func didDisconnectFromPeer(_ peerID: String) {}
|
||||
func didUpdatePeerList(_ peers: [String]) {}
|
||||
func didConnectToPeer(_ peerID: PeerID) {}
|
||||
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
||||
func didUpdatePeerList(_ peers: [PeerID]) {}
|
||||
func isFavorite(fingerprint: String) -> Bool { false }
|
||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
||||
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {
|
||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
||||
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
|
||||
publicMessages.append((peerID, nickname, content))
|
||||
}
|
||||
func didReceiveRegionalPublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {}
|
||||
func didReceiveRegionalPublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {}
|
||||
}
|
||||
|
||||
// Helper: build a large message packet (unencrypted public message)
|
||||
private func makeLargePublicPacket(senderShortHex: String, size: Int) -> BitchatPacket {
|
||||
private func makeLargePublicPacket(senderShortHex: PeerID, size: Int) -> BitchatPacket {
|
||||
let content = String(repeating: "A", count: size)
|
||||
let payload = Data(content.utf8)
|
||||
let pkt = BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: Data(hexString: senderShortHex) ?? Data(),
|
||||
senderID: Data(hexString: senderShortHex.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
@@ -88,101 +189,4 @@ final class FragmentationTests: XCTestCase {
|
||||
}
|
||||
return packets
|
||||
}
|
||||
|
||||
func test_reassembly_from_fragments_delivers_public_message() {
|
||||
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
|
||||
let capture = CaptureDelegate()
|
||||
ble.delegate = capture
|
||||
|
||||
// Construct a big packet (3KB) from a remote sender (not our own ID)
|
||||
let remoteShortID = "1122334455667788"
|
||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000)
|
||||
// Use a small fragment size to ensure multiple pieces
|
||||
let fragments = fragmentPacket(original, fragmentSize: 400)
|
||||
|
||||
// Shuffle fragments to simulate out-of-order arrival
|
||||
let shuffled = fragments.shuffled()
|
||||
|
||||
// Inject fragments spaced out to avoid concurrent mutation inside BLEService
|
||||
for (i, f) in shuffled.enumerated() {
|
||||
let delay = DispatchTime.now() + .milliseconds(5 * i)
|
||||
DispatchQueue.global().asyncAfter(deadline: delay) {
|
||||
ble._test_handlePacket(f, fromPeerID: remoteShortID)
|
||||
}
|
||||
}
|
||||
|
||||
// Allow async processing
|
||||
let exp = expectation(description: "reassembled")
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { exp.fulfill() }
|
||||
wait(for: [exp], timeout: 2.0)
|
||||
|
||||
XCTAssertEqual(capture.publicMessages.count, 1)
|
||||
XCTAssertEqual(capture.publicMessages.first?.content.count, 3_000)
|
||||
}
|
||||
|
||||
func test_duplicate_fragment_does_not_break_reassembly() {
|
||||
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
|
||||
let capture = CaptureDelegate()
|
||||
ble.delegate = capture
|
||||
|
||||
let remoteShortID = "A1B2C3D4E5F60708"
|
||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048)
|
||||
var frags = fragmentPacket(original, fragmentSize: 300)
|
||||
// Duplicate one fragment
|
||||
if let dup = frags.first { frags.insert(dup, at: 1) }
|
||||
|
||||
for (i, f) in frags.enumerated() {
|
||||
let delay = DispatchTime.now() + .milliseconds(5 * i)
|
||||
DispatchQueue.global().asyncAfter(deadline: delay) {
|
||||
ble._test_handlePacket(f, fromPeerID: remoteShortID)
|
||||
}
|
||||
}
|
||||
|
||||
let exp = expectation(description: "reassembled2")
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { exp.fulfill() }
|
||||
wait(for: [exp], timeout: 2.0)
|
||||
|
||||
XCTAssertEqual(capture.publicMessages.count, 1)
|
||||
XCTAssertEqual(capture.publicMessages.first?.content.count, 2048)
|
||||
}
|
||||
|
||||
func test_invalid_fragment_header_is_ignored() {
|
||||
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
|
||||
let capture = CaptureDelegate()
|
||||
ble.delegate = capture
|
||||
|
||||
let remoteShortID = "0011223344556677"
|
||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 1000)
|
||||
let fragments = fragmentPacket(original, fragmentSize: 250)
|
||||
|
||||
// Corrupt one fragment: make payload too short (header incomplete)
|
||||
var corrupted = fragments
|
||||
if !corrupted.isEmpty {
|
||||
var p = corrupted[0]
|
||||
p = BitchatPacket(
|
||||
type: p.type,
|
||||
senderID: p.senderID,
|
||||
recipientID: p.recipientID,
|
||||
timestamp: p.timestamp,
|
||||
payload: Data([0x00, 0x01, 0x02]), // invalid header
|
||||
signature: nil,
|
||||
ttl: p.ttl
|
||||
)
|
||||
corrupted[0] = p
|
||||
}
|
||||
|
||||
for (i, f) in corrupted.enumerated() {
|
||||
let delay = DispatchTime.now() + .milliseconds(5 * i)
|
||||
DispatchQueue.global().asyncAfter(deadline: delay) {
|
||||
ble._test_handlePacket(f, fromPeerID: remoteShortID)
|
||||
}
|
||||
}
|
||||
|
||||
let exp = expectation(description: "no reassembly")
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { exp.fulfill() }
|
||||
wait(for: [exp], timeout: 2.0)
|
||||
|
||||
// Should not deliver since one fragment is invalid and reassembly can't complete
|
||||
XCTAssertEqual(capture.publicMessages.count, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,23 +3,25 @@ import XCTest
|
||||
|
||||
final class GeohashBookmarksStoreTests: XCTestCase {
|
||||
let storeKey = "locationChannel.bookmarks"
|
||||
var storage: UserDefaults!
|
||||
var store: GeohashBookmarksStore!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
// Clear persisted state before each test
|
||||
UserDefaults.standard.removeObject(forKey: storeKey)
|
||||
GeohashBookmarksStore.shared._resetForTesting()
|
||||
// Unique instance for each test to avoid race condition
|
||||
storage = UserDefaults(suiteName: UUID().uuidString)
|
||||
store = GeohashBookmarksStore(storage: storage!)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
// Clean after each test
|
||||
UserDefaults.standard.removeObject(forKey: storeKey)
|
||||
GeohashBookmarksStore.shared._resetForTesting()
|
||||
storage.removeObject(forKey: storeKey)
|
||||
store._resetForTesting()
|
||||
store = nil
|
||||
storage = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testToggleAndNormalize() {
|
||||
let store = GeohashBookmarksStore.shared
|
||||
// Start clean
|
||||
XCTAssertTrue(store.bookmarks.isEmpty)
|
||||
|
||||
@@ -35,12 +37,11 @@ final class GeohashBookmarksStoreTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testPersistenceWritten() throws {
|
||||
let store = GeohashBookmarksStore.shared
|
||||
store.toggle("ezs42")
|
||||
store.toggle("u4pruy")
|
||||
|
||||
// Verify persisted JSON contains both (order not enforced here)
|
||||
guard let data = UserDefaults.standard.data(forKey: storeKey) else {
|
||||
guard let data = storage.data(forKey: storeKey) else {
|
||||
XCTFail("No persisted data found")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ private final class RecordingDelegate: GossipSyncManager.Delegate {
|
||||
onSend?()
|
||||
}
|
||||
|
||||
func sendPacket(to peerID: String, packet: BitchatPacket) {
|
||||
func sendPacket(to peerID: PeerID, packet: BitchatPacket) {
|
||||
sendPacket(packet)
|
||||
}
|
||||
|
||||
|
||||
@@ -587,11 +587,10 @@ final class IntegrationTests: XCTestCase {
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
private func createNode(_ name: String, peerID: String) {
|
||||
private func createNode(_ name: String, peerID: PeerID) {
|
||||
let node = MockBluetoothMeshService()
|
||||
node.myPeerID = peerID
|
||||
node.mockNickname = name
|
||||
node._testRegister()
|
||||
nodes[name] = node
|
||||
|
||||
// Create Noise manager
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
{
|
||||
"app": [
|
||||
"app_info.app_name",
|
||||
"app_info.close",
|
||||
"app_info.done",
|
||||
"app_info.features.encryption.title",
|
||||
"app_info.features.offline.title",
|
||||
"app_info.warning.message",
|
||||
"common.cancel",
|
||||
"common.close",
|
||||
"common.copy",
|
||||
"common.ok",
|
||||
"content.accessibility.people_count",
|
||||
"content.accessibility.send_message",
|
||||
"content.actions.title",
|
||||
"content.alert.bluetooth_required.permission",
|
||||
"content.alert.bluetooth_required.settings",
|
||||
"content.alert.bluetooth_required.title",
|
||||
"content.delivery.delivered_to",
|
||||
"content.input.message_placeholder",
|
||||
"fingerprint.action.mark_verified",
|
||||
"fingerprint.badge.not_verified",
|
||||
"fingerprint.badge.verified",
|
||||
"fingerprint.title",
|
||||
"location_channels.action.open_settings",
|
||||
"location_channels.action.request_permissions",
|
||||
"location_channels.title",
|
||||
"location_notes.header",
|
||||
"system.tor.started"
|
||||
],
|
||||
"shareExtension": [
|
||||
"share.fallback.shared_link_title",
|
||||
"share.status.failed_to_encode",
|
||||
"share.status.no_shareable_content",
|
||||
"share.status.nothing_to_share",
|
||||
"share.status.shared_link",
|
||||
"share.status.shared_text"
|
||||
],
|
||||
"expectedValues": {
|
||||
"ar": {
|
||||
"common.ok": "موافق",
|
||||
"content.input.message_placeholder": "اكتب رسالة...",
|
||||
"location_channels.title": "#قنوات الموقع",
|
||||
"system.tor.started": "tor يعمل. كل الدردشة تمر عبر tor للخصوصية.",
|
||||
"share.status.shared_text": "✓ تم إرسال النص إلى bitchat",
|
||||
"share.status.shared_link": "✓ تم إرسال الرابط إلى bitchat",
|
||||
"share.status.failed_to_encode": "تعذر ترميز الرابط",
|
||||
"common.cancel": "إلغاء",
|
||||
"common.close": "إغلاق",
|
||||
"common.copy": "نسخ",
|
||||
"app_info.close": "إغلاق",
|
||||
"app_info.done": "تم",
|
||||
"content.alert.bluetooth_required.permission": "تحتاج bitchat إلى إذن bluetooth للاتصال بالأجهزة القريبة. فعّل الوصول في الإعدادات.",
|
||||
"content.alert.bluetooth_required.settings": "الإعدادات",
|
||||
"app_info.app_name": "bitchat",
|
||||
"app_info.features.encryption.title": "تشفير طرف لطرف",
|
||||
"app_info.features.offline.title": "تواصل بدون اتصال",
|
||||
"fingerprint.badge.verified": "✓ مُتحقق",
|
||||
"fingerprint.badge.not_verified": "⚠️ غير مُتحقق",
|
||||
"fingerprint.action.mark_verified": "وضع علامة تم التحقق",
|
||||
"location_channels.action.open_settings": "فتح الإعدادات",
|
||||
"content.actions.title": "إجراءات",
|
||||
"content.accessibility.send_message": "إرسال رسالة",
|
||||
"share.status.nothing_to_share": "لا شيء لمشاركته",
|
||||
"share.status.no_shareable_content": "لا محتوى قابلاً للمشاركة",
|
||||
"share.fallback.shared_link_title": "رابط مشترك"
|
||||
},
|
||||
"de": {
|
||||
"common.ok": "OK",
|
||||
"content.input.message_placeholder": "nachricht eingeben...",
|
||||
"location_channels.title": "#standort-kanäle",
|
||||
"system.tor.started": "tor läuft. der gesamte chat wird über tor geleitet.",
|
||||
"share.status.shared_text": "✓ text zu bitchat geteilt",
|
||||
"share.status.shared_link": "✓ link zu bitchat geteilt",
|
||||
"share.status.failed_to_encode": "link konnte nicht codiert werden",
|
||||
"common.cancel": "abbrechen",
|
||||
"common.close": "schließen",
|
||||
"common.copy": "kopieren",
|
||||
"app_info.close": "schließen",
|
||||
"app_info.done": "FERTIG",
|
||||
"content.alert.bluetooth_required.permission": "bitchat benötigt bluetooth-berechtigung, um sich mit geräten in der nähe zu verbinden. erlaube den zugriff in den einstellungen.",
|
||||
"content.alert.bluetooth_required.settings": "einstellungen",
|
||||
"app_info.app_name": "bitchat",
|
||||
"app_info.features.encryption.title": "end-to-end-verschlüsselung",
|
||||
"app_info.features.offline.title": "offline-kommunikation",
|
||||
"fingerprint.badge.verified": "✓ VERIFIZIERT",
|
||||
"fingerprint.badge.not_verified": "⚠️ NICHT VERIFIZIERT",
|
||||
"fingerprint.action.mark_verified": "als verifiziert markieren",
|
||||
"location_channels.action.open_settings": "einstellungen öffnen",
|
||||
"content.actions.title": "aktionen",
|
||||
"content.accessibility.send_message": "nachricht senden",
|
||||
"share.status.nothing_to_share": "nichts zum teilen",
|
||||
"share.status.no_shareable_content": "kein teilbarer inhalt",
|
||||
"share.fallback.shared_link_title": "geteilter link"
|
||||
},
|
||||
"es": {
|
||||
"common.ok": "aceptar",
|
||||
"content.input.message_placeholder": "escribe un mensaje...",
|
||||
"location_channels.title": "#canales de ubicación",
|
||||
"system.tor.started": "tor se inició. Todo el chat se enruta por Tor para privacidad.",
|
||||
"share.status.shared_text": "✓ texto compartido con bitchat",
|
||||
"share.status.shared_link": "✓ enlace compartido con bitchat",
|
||||
"share.status.failed_to_encode": "no se pudo codificar el enlace",
|
||||
"common.cancel": "cancelar",
|
||||
"common.close": "cerrar",
|
||||
"common.copy": "copiar",
|
||||
"app_info.close": "cerrar",
|
||||
"app_info.done": "LISTO",
|
||||
"content.alert.bluetooth_required.permission": "bitChat necesita permiso de Bluetooth para conectarse con dispositivos cercanos. Habilita el acceso en Ajustes.",
|
||||
"content.alert.bluetooth_required.settings": "ajustes",
|
||||
"app_info.app_name": "bitchat",
|
||||
"app_info.features.encryption.title": "cifrado de extremo a extremo",
|
||||
"app_info.features.offline.title": "comunicación sin conexión",
|
||||
"fingerprint.badge.verified": "✓ VERIFICADO",
|
||||
"fingerprint.badge.not_verified": "⚠️ NO VERIFICADO",
|
||||
"fingerprint.action.mark_verified": "marcar como verificado",
|
||||
"location_channels.action.open_settings": "abrir ajustes",
|
||||
"content.actions.title": "acciones",
|
||||
"content.accessibility.send_message": "enviar mensaje",
|
||||
"share.status.nothing_to_share": "nada que compartir",
|
||||
"share.status.no_shareable_content": "sin contenido que se pueda compartir",
|
||||
"share.fallback.shared_link_title": "enlace compartido"
|
||||
},
|
||||
"fr": {
|
||||
"common.ok": "OK",
|
||||
"content.input.message_placeholder": "écris un message...",
|
||||
"location_channels.title": "#canaux localisation",
|
||||
"system.tor.started": "tor a démarré. tout le chat passe par tor pour la confidentialité.",
|
||||
"share.status.shared_text": "✓ texte partagé vers bitchat",
|
||||
"share.status.shared_link": "✓ lien partagé vers bitchat",
|
||||
"share.status.failed_to_encode": "échec de l'encodage du lien",
|
||||
"common.cancel": "annuler",
|
||||
"common.close": "fermer",
|
||||
"common.copy": "copier",
|
||||
"app_info.close": "fermer",
|
||||
"app_info.done": "TERMINÉ",
|
||||
"content.alert.bluetooth_required.permission": "bitchat a besoin de l'autorisation bluetooth pour se connecter aux appareils proches. active l'accès dans réglages.",
|
||||
"content.alert.bluetooth_required.settings": "réglages",
|
||||
"app_info.app_name": "bitchat",
|
||||
"app_info.features.encryption.title": "chiffrement de bout en bout",
|
||||
"app_info.features.offline.title": "communication hors ligne",
|
||||
"fingerprint.badge.verified": "✓ VÉRIFIÉ",
|
||||
"fingerprint.badge.not_verified": "⚠️ NON VÉRIFIÉ",
|
||||
"fingerprint.action.mark_verified": "marquer comme vérifié",
|
||||
"location_channels.action.open_settings": "ouvrir réglages",
|
||||
"content.actions.title": "actions",
|
||||
"content.accessibility.send_message": "envoyer le message",
|
||||
"share.status.nothing_to_share": "rien à partager",
|
||||
"share.status.no_shareable_content": "aucun contenu partageable",
|
||||
"share.fallback.shared_link_title": "lien partagé"
|
||||
},
|
||||
"he": {
|
||||
"common.ok": "OK",
|
||||
"content.input.message_placeholder": "כתוב הודעה...",
|
||||
"location_channels.title": "#ערוצי מיקום",
|
||||
"system.tor.started": "tor פעיל. כל הצ'אט עובר דרך tor לפרטיות.",
|
||||
"share.status.shared_text": "✓ הטקסט נשלח אל bitchat",
|
||||
"share.status.shared_link": "✓ הקישור נשלח אל bitchat",
|
||||
"share.status.failed_to_encode": "לא ניתן לקודד את הקישור",
|
||||
"common.cancel": "ביטול",
|
||||
"common.close": "סגור",
|
||||
"common.copy": "העתק",
|
||||
"app_info.close": "סגור",
|
||||
"app_info.done": "בוצע",
|
||||
"content.alert.bluetooth_required.permission": "bitchat צריכה הרשאת bluetooth כדי להתחבר למכשירים קרובים. אפשר גישה בהגדרות.",
|
||||
"content.alert.bluetooth_required.settings": "הגדרות",
|
||||
"app_info.app_name": "bitchat",
|
||||
"app_info.features.encryption.title": "הצפנה מקצה לקצה",
|
||||
"app_info.features.offline.title": "תקשורת לא מקוונת",
|
||||
"fingerprint.badge.verified": "✓ מאומת",
|
||||
"fingerprint.badge.not_verified": "⚠️ לא מאומת",
|
||||
"fingerprint.action.mark_verified": "סמן כמאומת",
|
||||
"location_channels.action.open_settings": "פתח הגדרות",
|
||||
"content.actions.title": "פעולות",
|
||||
"content.accessibility.send_message": "שלח הודעה",
|
||||
"share.status.nothing_to_share": "אין מה לשתף",
|
||||
"share.status.no_shareable_content": "אין תוכן שניתן לשתף",
|
||||
"share.fallback.shared_link_title": "קישור משותף"
|
||||
},
|
||||
"id": {
|
||||
"common.ok": "OK",
|
||||
"content.input.message_placeholder": "ketik pesan...",
|
||||
"location_channels.title": "#kanal lokasi",
|
||||
"system.tor.started": "tor berjalan. seluruh chat dirutekan lewat tor demi privasi.",
|
||||
"share.status.shared_text": "✓ teks dikirim ke bitchat",
|
||||
"share.status.shared_link": "✓ tautan dikirim ke bitchat",
|
||||
"share.status.failed_to_encode": "gagal mengodekan tautan",
|
||||
"common.cancel": "batal",
|
||||
"common.close": "tutup",
|
||||
"common.copy": "salin",
|
||||
"app_info.close": "tutup",
|
||||
"app_info.done": "SELESAI",
|
||||
"content.alert.bluetooth_required.permission": "bitchat memerlukan izin bluetooth untuk terhubung dengan perangkat dekat. aktifkan akses di pengaturan.",
|
||||
"content.alert.bluetooth_required.settings": "pengaturan",
|
||||
"app_info.app_name": "bitchat",
|
||||
"app_info.features.encryption.title": "enkripsi ujung ke ujung",
|
||||
"app_info.features.offline.title": "komunikasi offline",
|
||||
"fingerprint.badge.verified": "✓ TERVERIFIKASI",
|
||||
"fingerprint.badge.not_verified": "⚠️ BELUM TERVERIFIKASI",
|
||||
"fingerprint.action.mark_verified": "tandai sebagai terverifikasi",
|
||||
"location_channels.action.open_settings": "buka pengaturan",
|
||||
"content.actions.title": "aksi",
|
||||
"content.accessibility.send_message": "kirim pesan",
|
||||
"share.status.nothing_to_share": "tidak ada yang bisa dibagikan",
|
||||
"share.status.no_shareable_content": "tidak ada konten yang bisa dibagikan",
|
||||
"share.fallback.shared_link_title": "tautan dibagikan"
|
||||
},
|
||||
"it": {
|
||||
"common.ok": "OK",
|
||||
"content.input.message_placeholder": "scrivi un messaggio...",
|
||||
"location_channels.title": "#canali posizione",
|
||||
"system.tor.started": "tor è avviato. tutta la chat passa da tor per la privacy.",
|
||||
"share.status.shared_text": "✓ testo inviato a bitchat",
|
||||
"share.status.shared_link": "✓ link inviato a bitchat",
|
||||
"share.status.failed_to_encode": "impossibile codificare il link",
|
||||
"common.cancel": "annulla",
|
||||
"common.close": "chiudi",
|
||||
"common.copy": "copia",
|
||||
"app_info.close": "chiudi",
|
||||
"app_info.done": "FATTO",
|
||||
"content.alert.bluetooth_required.permission": "bitchat richiede l'autorizzazione bluetooth per collegarsi ai dispositivi vicini. abilita l'accesso nelle impostazioni.",
|
||||
"content.alert.bluetooth_required.settings": "impostazioni",
|
||||
"app_info.app_name": "bitchat",
|
||||
"app_info.features.encryption.title": "crittografia end-to-end",
|
||||
"app_info.features.offline.title": "comunicazione offline",
|
||||
"fingerprint.badge.verified": "✓ VERIFICATO",
|
||||
"fingerprint.badge.not_verified": "⚠️ NON VERIFICATO",
|
||||
"fingerprint.action.mark_verified": "segna come verificato",
|
||||
"location_channels.action.open_settings": "apri impostazioni",
|
||||
"content.actions.title": "azioni",
|
||||
"content.accessibility.send_message": "invia messaggio",
|
||||
"share.status.nothing_to_share": "niente da condividere",
|
||||
"share.status.no_shareable_content": "nessun contenuto condivisibile",
|
||||
"share.fallback.shared_link_title": "link condiviso"
|
||||
},
|
||||
"ja": {
|
||||
"common.ok": "OK",
|
||||
"content.input.message_placeholder": "メッセージを入力...",
|
||||
"location_channels.title": "#ロケーションチャンネル",
|
||||
"system.tor.started": "torを起動しました。全チャットをtor経由で配信します。",
|
||||
"share.status.shared_text": "✓ bitchatにテキストを共有",
|
||||
"share.status.shared_link": "✓ bitchatにリンクを共有",
|
||||
"share.status.failed_to_encode": "リンクのエンコードに失敗しました",
|
||||
"common.cancel": "キャンセル",
|
||||
"common.close": "閉じる",
|
||||
"common.copy": "コピー",
|
||||
"app_info.close": "閉じる",
|
||||
"app_info.done": "完了",
|
||||
"content.alert.bluetooth_required.permission": "bitchatは近くのデバイスと接続するためbluetooth権限が必要です。設定でアクセスを有効にしてください。",
|
||||
"content.alert.bluetooth_required.settings": "設定",
|
||||
"app_info.app_name": "bitchat",
|
||||
"app_info.features.encryption.title": "エンドツーエンド暗号",
|
||||
"app_info.features.offline.title": "オフライン通信",
|
||||
"fingerprint.badge.verified": "✓ 検証済み",
|
||||
"fingerprint.badge.not_verified": "⚠️ 未検証",
|
||||
"fingerprint.action.mark_verified": "検証済みにする",
|
||||
"location_channels.action.open_settings": "設定を開く",
|
||||
"content.actions.title": "アクション",
|
||||
"content.accessibility.send_message": "メッセージ送信",
|
||||
"share.status.nothing_to_share": "共有できるものがありません",
|
||||
"share.status.no_shareable_content": "共有可能なコンテンツがありません",
|
||||
"share.fallback.shared_link_title": "共有リンク"
|
||||
},
|
||||
"ko": {
|
||||
"common.ok": "확인",
|
||||
"content.input.message_placeholder": "메시지를 입력하세요...",
|
||||
"location_channels.title": "#위치 채널",
|
||||
"system.tor.started": "tor가 시작되었습니다. IP 보호를 위해 모든 대화를 tor를 통해 라우팅합니다.",
|
||||
"share.status.shared_text": "✓ bitchat으로 텍스트를 공유했습니다",
|
||||
"share.status.shared_link": "✓ bitchat으로 링크를 공유했습니다",
|
||||
"share.status.failed_to_encode": "링크를 인코딩하는 데 실패했습니다",
|
||||
"common.cancel": "취소",
|
||||
"common.close": "닫기",
|
||||
"common.copy": "복사",
|
||||
"app_info.close": "닫기",
|
||||
"app_info.done": "확인",
|
||||
"content.alert.bluetooth_required.permission": "bitchat은 주변 기기와 연결하기 위해 bluetooth 권한이 필요합니다. 설정에서 bluetooth 접근을 활성화해주세요.",
|
||||
"content.alert.bluetooth_required.settings": "설정",
|
||||
"app_info.app_name": "bitchat",
|
||||
"app_info.features.encryption.title": "종단간 암호화",
|
||||
"app_info.features.offline.title": "오프라인 통신",
|
||||
"fingerprint.badge.verified": "✓ 인증됨",
|
||||
"fingerprint.badge.not_verified": "⚠️ 인증되지 않음",
|
||||
"fingerprint.action.mark_verified": "인증됨으로 표시",
|
||||
"location_channels.action.open_settings": "설정 열기",
|
||||
"content.actions.title": "작업",
|
||||
"content.accessibility.send_message": "메시지 보내기",
|
||||
"share.status.nothing_to_share": "공유할 내용이 없습니다",
|
||||
"share.status.no_shareable_content": "공유할 수 있는 내용이 없습니다",
|
||||
"share.fallback.shared_link_title": "공유된 링크"
|
||||
},
|
||||
"ne": {
|
||||
"common.ok": "ठिक",
|
||||
"content.input.message_placeholder": "सन्देश टाइप गर...",
|
||||
"location_channels.title": "#स्थान च्यानल",
|
||||
"system.tor.started": "tor सुरु भयो। गोपनीयताका लागि पूरा च्याट tor मार्फत जान्छ।",
|
||||
"share.status.shared_text": "✓ bitchat मा पाठ पठाइयो",
|
||||
"share.status.shared_link": "✓ bitchat मा लिङ्क पठाइयो",
|
||||
"share.status.failed_to_encode": "लिङ्क सङ्केत गर्न सकेन",
|
||||
"common.cancel": "रद्द",
|
||||
"common.close": "बन्द",
|
||||
"common.copy": "प्रतिलिपि",
|
||||
"app_info.close": "बन्द",
|
||||
"app_info.done": "सम्पन्न",
|
||||
"content.alert.bluetooth_required.permission": "bitchat लाई नजिकका उपकरणसँग जडान हुन bluetooth अनुमति चाहिन्छ। सेटिङमा पहुँच सक्षम गर।",
|
||||
"content.alert.bluetooth_required.settings": "सेटिङ",
|
||||
"app_info.app_name": "bitchat",
|
||||
"app_info.features.encryption.title": "एन्ड-टु-एन्ड सङ्केत",
|
||||
"app_info.features.offline.title": "अफलाइन सञ्चार",
|
||||
"fingerprint.badge.verified": "✓ प्रमाणित",
|
||||
"fingerprint.badge.not_verified": "⚠️ प्रमाणित छैन",
|
||||
"fingerprint.action.mark_verified": "प्रमाणित चिन्ह लगाउ",
|
||||
"location_channels.action.open_settings": "सेटिङ खोल",
|
||||
"content.actions.title": "कार्य",
|
||||
"content.accessibility.send_message": "सन्देश पठाउ",
|
||||
"share.status.nothing_to_share": "बाँड्ने केही छैन",
|
||||
"share.status.no_shareable_content": "बाँड्न मिल्ने सामग्री छैन",
|
||||
"share.fallback.shared_link_title": "साझा गरिएको लिङ्क"
|
||||
},
|
||||
"pt-BR": {
|
||||
"common.ok": "OK",
|
||||
"content.input.message_placeholder": "digite uma mensagem...",
|
||||
"location_channels.title": "#canais de localização",
|
||||
"system.tor.started": "tor iniciou. todo o chat é roteado por tor para privacidade.",
|
||||
"share.status.shared_text": "✓ texto enviado para bitchat",
|
||||
"share.status.shared_link": "✓ link enviado para bitchat",
|
||||
"share.status.failed_to_encode": "falha ao codificar link",
|
||||
"common.cancel": "cancelar",
|
||||
"common.close": "fechar",
|
||||
"common.copy": "copiar",
|
||||
"app_info.close": "fechar",
|
||||
"app_info.done": "CONCLUÍDO",
|
||||
"content.alert.bluetooth_required.permission": "bitchat precisa de permissão de bluetooth para conectar com dispositivos próximos. habilite o acesso em ajustes.",
|
||||
"content.alert.bluetooth_required.settings": "ajustes",
|
||||
"app_info.app_name": "bitchat",
|
||||
"app_info.features.encryption.title": "criptografia ponto a ponto",
|
||||
"app_info.features.offline.title": "comunicação offline",
|
||||
"fingerprint.badge.verified": "✓ VERIFICADO",
|
||||
"fingerprint.badge.not_verified": "⚠️ NÃO VERIFICADO",
|
||||
"fingerprint.action.mark_verified": "marcar como verificado",
|
||||
"location_channels.action.open_settings": "abrir ajustes",
|
||||
"content.actions.title": "ações",
|
||||
"content.accessibility.send_message": "enviar mensagem",
|
||||
"share.status.nothing_to_share": "nada para compartilhar",
|
||||
"share.status.no_shareable_content": "nenhum conteúdo compartilhável",
|
||||
"share.fallback.shared_link_title": "link compartilhado"
|
||||
},
|
||||
"ru": {
|
||||
"common.ok": "OK",
|
||||
"content.input.message_placeholder": "напиши сообщение...",
|
||||
"location_channels.title": "#каналы локации",
|
||||
"system.tor.started": "tor запущен. весь чат идёт через tor для приватности.",
|
||||
"share.status.shared_text": "✓ текст отправлен в bitchat",
|
||||
"share.status.shared_link": "✓ ссылка отправлена в bitchat",
|
||||
"share.status.failed_to_encode": "не удалось закодировать ссылку",
|
||||
"common.cancel": "отмена",
|
||||
"common.close": "закрыть",
|
||||
"common.copy": "копировать",
|
||||
"app_info.close": "закрыть",
|
||||
"app_info.done": "ГОТОВО",
|
||||
"content.alert.bluetooth_required.permission": "bitchat нужен доступ к bluetooth, чтобы соединяться с ближайшими устройствами. включи разрешение в настройках.",
|
||||
"content.alert.bluetooth_required.settings": "настройки",
|
||||
"app_info.app_name": "bitchat",
|
||||
"app_info.features.encryption.title": "сквозное шифрование",
|
||||
"app_info.features.offline.title": "офлайн-связь",
|
||||
"fingerprint.badge.verified": "✓ ПРОВЕРЕНО",
|
||||
"fingerprint.badge.not_verified": "⚠️ НЕ ПРОВЕРЕНО",
|
||||
"fingerprint.action.mark_verified": "пометить как проверено",
|
||||
"location_channels.action.open_settings": "открыть настройки",
|
||||
"content.actions.title": "действия",
|
||||
"content.accessibility.send_message": "отправить сообщение",
|
||||
"share.status.nothing_to_share": "нечем поделиться",
|
||||
"share.status.no_shareable_content": "нет подходящего контента",
|
||||
"share.fallback.shared_link_title": "поделился ссылкой"
|
||||
},
|
||||
"uk": {
|
||||
"common.ok": "OK",
|
||||
"content.input.message_placeholder": "напиши повідомлення...",
|
||||
"location_channels.title": "#канали локації",
|
||||
"system.tor.started": "tor запущено. увесь чат іде через tor для приватності.",
|
||||
"share.status.shared_text": "✓ текст надіслано в bitchat",
|
||||
"share.status.shared_link": "✓ посилання надіслано в bitchat",
|
||||
"share.status.failed_to_encode": "не вдалося закодувати посилання",
|
||||
"common.cancel": "скасувати",
|
||||
"common.close": "закрити",
|
||||
"common.copy": "скопіювати",
|
||||
"app_info.close": "закрити",
|
||||
"app_info.done": "ГОТОВО",
|
||||
"content.alert.bluetooth_required.permission": "bitchat потребує дозволу bluetooth для з'єднання з пристроями поруч. ввімкни доступ у налаштуваннях.",
|
||||
"content.alert.bluetooth_required.settings": "налаштування",
|
||||
"app_info.app_name": "bitchat",
|
||||
"app_info.features.encryption.title": "скрізьове шифрування",
|
||||
"app_info.features.offline.title": "офлайн-зв'язок",
|
||||
"fingerprint.badge.verified": "✓ ПЕРЕВІРЕНО",
|
||||
"fingerprint.badge.not_verified": "⚠️ НЕ ПЕРЕВІРЕНО",
|
||||
"fingerprint.action.mark_verified": "позначити як перевірено",
|
||||
"location_channels.action.open_settings": "відкрити налаштування",
|
||||
"content.actions.title": "дії",
|
||||
"content.accessibility.send_message": "надіслати повідомлення",
|
||||
"share.status.nothing_to_share": "нема чим ділитися",
|
||||
"share.status.no_shareable_content": "нема відповідного контенту",
|
||||
"share.fallback.shared_link_title": "спільне посилання"
|
||||
},
|
||||
"zh-Hans": {
|
||||
"common.ok": "确定",
|
||||
"content.input.message_placeholder": "输入消息...",
|
||||
"location_channels.title": "#位置频道",
|
||||
"system.tor.started": "tor 已启动。所有聊天通过 tor 路由以保护 IP。",
|
||||
"share.status.shared_text": "✓ 已将文本分享至 bitchat",
|
||||
"share.status.shared_link": "✓ 已将链接分享至 bitchat",
|
||||
"share.status.failed_to_encode": "无法编码链接",
|
||||
"common.cancel": "取消",
|
||||
"common.close": "关闭",
|
||||
"common.copy": "复制",
|
||||
"app_info.close": "关闭",
|
||||
"app_info.done": "完成",
|
||||
"content.alert.bluetooth_required.permission": "bitchat 需要 bluetooth 权限以连接附近设备。请在设置中启用访问。",
|
||||
"content.alert.bluetooth_required.settings": "设置",
|
||||
"app_info.app_name": "bitchat",
|
||||
"app_info.features.encryption.title": "端到端加密",
|
||||
"app_info.features.offline.title": "离线通信",
|
||||
"fingerprint.badge.verified": "✓ 已验证",
|
||||
"fingerprint.badge.not_verified": "⚠️ 未验证",
|
||||
"fingerprint.action.mark_verified": "标记为已验证",
|
||||
"location_channels.action.open_settings": "打开设置",
|
||||
"content.actions.title": "操作",
|
||||
"content.accessibility.send_message": "发送消息",
|
||||
"share.status.nothing_to_share": "没有可分享的内容",
|
||||
"share.status.no_shareable_content": "没有可分享的素材",
|
||||
"share.fallback.shared_link_title": "分享的链接"
|
||||
}
|
||||
},
|
||||
"testLocales": [
|
||||
"en",
|
||||
"ar",
|
||||
"de",
|
||||
"es",
|
||||
"fr",
|
||||
"he",
|
||||
"id",
|
||||
"it",
|
||||
"ja",
|
||||
"ko",
|
||||
"ne",
|
||||
"pt-BR",
|
||||
"ru",
|
||||
"uk",
|
||||
"zh-Hans"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
//import Foundation
|
||||
//import XCTest
|
||||
//@testable import bitchat
|
||||
//
|
||||
//private let localizationTestsDirectoryURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
|
||||
//private let testsRootURL = localizationTestsDirectoryURL.deletingLastPathComponent()
|
||||
//private let repoRootURL = testsRootURL.deletingLastPathComponent()
|
||||
//
|
||||
//final class LocalizationCatalogTests: XCTestCase {
|
||||
// // Ensures every app locale includes exactly the same keys as Base.
|
||||
// func testAppCatalogLocaleParity() throws {
|
||||
// let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||
// assertLocaleParity(context: context, catalogName: "App")
|
||||
// }
|
||||
//
|
||||
// // Verifies format placeholders stay consistent across app locales.
|
||||
// func testAppCatalogPlaceholderConsistency() throws {
|
||||
// let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||
// assertPlaceholderConsistency(context: context, catalogName: "App")
|
||||
// }
|
||||
//
|
||||
// // Guards a core set of app strings from going empty per locale.
|
||||
// func testAppPrimaryKeysNonEmpty() throws {
|
||||
// let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||
// let primaryKeys = try loadPrimaryKeys().app
|
||||
// assertPrimaryKeysPresent(context: context, keys: primaryKeys, catalogName: "App")
|
||||
// }
|
||||
//
|
||||
// // Ensures every share extension locale matches Base key coverage.
|
||||
// func testShareExtensionCatalogLocaleParity() throws {
|
||||
// let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||
// assertLocaleParity(context: context, catalogName: "ShareExtension")
|
||||
// }
|
||||
//
|
||||
// // Verifies share extension placeholders align across locales.
|
||||
// func testShareExtensionCatalogPlaceholderConsistency() throws {
|
||||
// let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||
// assertPlaceholderConsistency(context: context, catalogName: "ShareExtension")
|
||||
// }
|
||||
//
|
||||
// // Confirms critical share extension strings remain non-empty per locale.
|
||||
// func testShareExtensionPrimaryKeysNonEmpty() throws {
|
||||
// let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||
// let primaryKeys = try loadPrimaryKeys().shareExtension
|
||||
// assertPrimaryKeysPresent(context: context, keys: primaryKeys, catalogName: "ShareExtension")
|
||||
// }
|
||||
//
|
||||
// // Validates that configured locales contain expected string values.
|
||||
// func testLocalizationExpectedValues() throws {
|
||||
// let appContext = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||
// let shareContext = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||
// let config = try loadPrimaryKeys()
|
||||
//
|
||||
// guard let testLocales = config.testLocales else {
|
||||
// // If no testLocales specified, skip this test
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// guard let expectedValues = config.expectedValues else {
|
||||
// XCTFail("No expectedValues configured in PrimaryLocalizationKeys.json")
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// // Loop through each locale to test
|
||||
// for locale in testLocales {
|
||||
// guard let localeExpectedValues = expectedValues[locale] else {
|
||||
// XCTFail("No expected values configured for locale '\(locale)' in PrimaryLocalizationKeys.json")
|
||||
// continue
|
||||
// }
|
||||
//
|
||||
// // Test each expected key/value pair for this locale
|
||||
// for (key, expectedValue) in localeExpectedValues {
|
||||
// if config.app.contains(key) {
|
||||
// assertLocaleStringValue(context: appContext, locale: locale, key: key, expectedValue: expectedValue, catalogName: "App")
|
||||
// } else if config.shareExtension.contains(key) {
|
||||
// assertLocaleStringValue(context: shareContext, locale: locale, key: key, expectedValue: expectedValue, catalogName: "ShareExtension")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Ensures configured test locales are present and complete.
|
||||
// func testConfiguredLocalesCompleteness() throws {
|
||||
// let appContext = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||
// let shareContext = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||
// let config = try loadPrimaryKeys()
|
||||
//
|
||||
// guard let testLocales = config.testLocales else {
|
||||
// // If no testLocales specified, skip this test
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// let baseLocale = appContext.baseLocale
|
||||
// let baseAppKeys = appContext.keysByLocale[baseLocale] ?? Set()
|
||||
// let baseShareKeys = shareContext.keysByLocale[baseLocale] ?? Set()
|
||||
//
|
||||
// for locale in testLocales {
|
||||
// // Skip base locale comparison with itself
|
||||
// if locale == baseLocale { continue }
|
||||
//
|
||||
// // Verify locale is present in both catalogs
|
||||
// XCTAssertTrue(appContext.locales.contains(locale), "Locale '\(locale)' missing from app catalog")
|
||||
// XCTAssertTrue(shareContext.locales.contains(locale), "Locale '\(locale)' missing from share extension catalog")
|
||||
//
|
||||
// // Verify locale has same number of keys as base locale
|
||||
// let appLocaleKeys = appContext.keysByLocale[locale] ?? Set()
|
||||
// XCTAssertEqual(appLocaleKeys.count, baseAppKeys.count, "Locale '\(locale)' app catalog missing keys compared to \(baseLocale)")
|
||||
//
|
||||
// let shareLocaleKeys = shareContext.keysByLocale[locale] ?? Set()
|
||||
// XCTAssertEqual(shareLocaleKeys.count, baseShareKeys.count, "Locale '\(locale)' share extension catalog missing keys compared to \(baseLocale)")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // MARK: - Assertions
|
||||
//
|
||||
// private func assertLocaleParity(context: CatalogContext, catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
|
||||
// let baseLocale = context.baseLocale
|
||||
// guard let baseKeys = context.keysByLocale[baseLocale] else {
|
||||
// return XCTFail("Missing base locale \(baseLocale) in \(catalogName) catalog", file: file, line: line)
|
||||
// }
|
||||
//
|
||||
// for (locale, keys) in context.keysByLocale.sorted(by: { $0.key < $1.key }) {
|
||||
// XCTAssertEqual(keys, baseKeys, "Locale \(locale) has key mismatch in \(catalogName) catalog", file: file, line: line)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private func assertPlaceholderConsistency(context: CatalogContext, catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
|
||||
// let baseLocale = context.baseLocale
|
||||
// guard let baseSignatures = context.placeholderSignature[baseLocale] else {
|
||||
// return XCTFail("Missing base placeholder signature for \(catalogName)", file: file, line: line)
|
||||
// }
|
||||
//
|
||||
// for (locale, localeSignatures) in context.placeholderSignature.sorted(by: { $0.key < $1.key }) {
|
||||
// guard locale != baseLocale else { continue }
|
||||
// for key in baseSignatures.keys.sorted() {
|
||||
// guard let baseMap = baseSignatures[key] else {
|
||||
// continue
|
||||
// }
|
||||
// guard let localeMap = localeSignatures[key] else {
|
||||
// return XCTFail("Key \(key) missing for locale \(locale) in \(catalogName) catalog", file: file, line: line)
|
||||
// }
|
||||
// for path in baseMap.keys.sorted() {
|
||||
// let expected = normalizedPlaceholders(baseMap[path, default: []])
|
||||
// let actual = normalizedPlaceholders(localeMap[path, default: []])
|
||||
// XCTAssertEqual(actual, expected, "Placeholder mismatch for key \(key) at \(path) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||
// }
|
||||
// for (localePath, localeTokens) in localeMap {
|
||||
// guard baseMap[localePath] == nil else { continue }
|
||||
// guard let fallback = fallbackPath(for: localePath, baseMap: baseMap) else {
|
||||
// XCTFail("Unexpected variation \(localePath) for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||
// continue
|
||||
// }
|
||||
// let expected = normalizedPlaceholders(baseMap[fallback, default: []])
|
||||
// let actual = normalizedPlaceholders(localeTokens)
|
||||
// XCTAssertEqual(actual, expected, "Placeholder mismatch for key \(key) at \(localePath) (fallback \(fallback)) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private func assertPrimaryKeysPresent(context: CatalogContext, keys: [String], catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
|
||||
// for key in keys {
|
||||
// guard let entry = context.catalog.strings[key] else {
|
||||
// XCTFail("Missing primary key \(key) in \(catalogName) catalog", file: file, line: line)
|
||||
// continue
|
||||
// }
|
||||
// for locale in context.locales.sorted() {
|
||||
// guard let localization = entry.localizations[locale], let unit = localization.stringUnit else {
|
||||
// XCTFail("Missing localization for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||
// continue
|
||||
// }
|
||||
// let segments = gatherSegments(from: unit)
|
||||
// XCTAssertFalse(segments.isEmpty, "No content for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||
// for segment in segments {
|
||||
// let trimmed = segment.value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
// XCTAssertFalse(trimmed.isEmpty, "Empty translation for key \(key) at \(segment.path) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private func assertLocaleStringValue(context: CatalogContext, locale: String, key: String, expectedValue: String, catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
|
||||
// guard let entry = context.catalog.strings[key] else {
|
||||
// XCTFail("Missing key \(key) in \(catalogName) catalog", file: file, line: line)
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// guard let localization = entry.localizations[locale], let unit = localization.stringUnit else {
|
||||
// XCTFail("Missing \(locale) localization for key \(key) in \(catalogName) catalog", file: file, line: line)
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// // For simple strings (non-pluralized)
|
||||
// if let actualValue = unit.value {
|
||||
// XCTAssertEqual(actualValue, expectedValue, "\(locale) translation mismatch for key \(key) in \(catalogName) catalog. Expected: '\(expectedValue)', Actual: '\(actualValue)'", file: file, line: line)
|
||||
// } else {
|
||||
// XCTFail("Key \(key) has no value in \(locale) localization for \(catalogName) catalog", file: file, line: line)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // MARK: - Loading
|
||||
//
|
||||
// private func loadContext(relativePath: String) throws -> CatalogContext {
|
||||
// let catalog = try loadCatalog(relativePath: relativePath)
|
||||
// let locales = catalog.locales
|
||||
// let baseLocale = catalog.sourceLanguage
|
||||
// var keysByLocale: [String: Set<String>] = [:]
|
||||
// var placeholderSignature: [String: [String: [String: [String]]]] = [:]
|
||||
//
|
||||
// for locale in locales {
|
||||
// var localeKeys: Set<String> = []
|
||||
// var localePlaceholders: [String: [String: [String]]] = [:]
|
||||
// for (key, entry) in catalog.strings {
|
||||
// guard let localization = entry.localizations[locale], let unit = localization.stringUnit else {
|
||||
// continue
|
||||
// }
|
||||
// localeKeys.insert(key)
|
||||
// let segments = gatherSegments(from: unit)
|
||||
// var pathMap: [String: [String]] = [:]
|
||||
// for segment in segments {
|
||||
// pathMap[segment.path] = placeholders(in: segment.value)
|
||||
// }
|
||||
// localePlaceholders[key] = pathMap
|
||||
// }
|
||||
// keysByLocale[locale] = localeKeys
|
||||
// placeholderSignature[locale] = localePlaceholders
|
||||
// }
|
||||
//
|
||||
// return CatalogContext(catalog: catalog, locales: locales, baseLocale: baseLocale, keysByLocale: keysByLocale, placeholderSignature: placeholderSignature)
|
||||
// }
|
||||
//
|
||||
// private func loadCatalog(relativePath: String) throws -> StringCatalog {
|
||||
// let url = repoRootURL.appendingPathComponent(relativePath)
|
||||
// let data = try Data(contentsOf: url)
|
||||
// return try JSONDecoder().decode(StringCatalog.self, from: data)
|
||||
// }
|
||||
//
|
||||
// private func loadPrimaryKeys() throws -> PrimaryKeyConfig {
|
||||
// let url = localizationTestsDirectoryURL.appendingPathComponent("PrimaryLocalizationKeys.json")
|
||||
// let data = try Data(contentsOf: url)
|
||||
// return try JSONDecoder().decode(PrimaryKeyConfig.self, from: data)
|
||||
// }
|
||||
//
|
||||
// // MARK: - Regression Tests
|
||||
//
|
||||
// /// Helper method to access the app bundle for localization testing.
|
||||
// /// This ensures tests use the actual app's Localizable.xcstrings instead of
|
||||
// /// the test bundle, preventing false positives where localization keys are
|
||||
// /// returned instead of translated strings.
|
||||
// private func appBundle() -> Bundle {
|
||||
// Bundle(for: ChatViewModel.self)
|
||||
// }
|
||||
//
|
||||
// /// Tests to prevent regression of the pluralization format string issue
|
||||
// /// These tests ensure that String(localized:) properly handles
|
||||
// /// pluralized format strings with %#@variable@ syntax
|
||||
// func testPluralizedFormatStringsDoNotCrash() {
|
||||
// // These are the exact calls that were causing runtime errors
|
||||
//
|
||||
// // Test 1: content.accessibility.people_count with Int argument
|
||||
// XCTAssertNoThrow({
|
||||
// let result = String(
|
||||
// localized: "content.accessibility.people_count",
|
||||
// bundle: appBundle(),
|
||||
// comment: "Accessibility label announcing number of people in header"
|
||||
// )
|
||||
// XCTAssertFalse(result.isEmpty, "Should return formatted string")
|
||||
// // Verify we're getting actual localized content, not just the key
|
||||
// XCTAssertNotEqual(result, "content.accessibility.people_count", "Should return localized string, not key")
|
||||
// }(), "People count localization should not throw")
|
||||
//
|
||||
// // Test 2: location_notes.header with String and Int arguments
|
||||
// XCTAssertNoThrow({
|
||||
// let result = String(
|
||||
// localized: "location_notes.header",
|
||||
// bundle: appBundle(),
|
||||
// comment: "Header displaying the geohash and localized note count"
|
||||
// )
|
||||
// XCTAssertFalse(result.isEmpty, "Should return formatted string")
|
||||
// // Verify we're getting actual localized content, not just the key
|
||||
// XCTAssertNotEqual(result, "location_notes.header", "Should return localized string, not key")
|
||||
// }(), "Location notes header localization should not throw")
|
||||
// }
|
||||
//
|
||||
// func testFormatStringArgumentMatching() {
|
||||
// // Verify that the format strings properly handle their expected arguments
|
||||
//
|
||||
// // People count expects: %#@people@ format with Int
|
||||
// let peopleResult = String(
|
||||
// localized: "content.accessibility.people_count",
|
||||
// bundle: appBundle(),
|
||||
// comment: "Test"
|
||||
// )
|
||||
//
|
||||
// // Should not show format specifiers in the base string
|
||||
// XCTAssertFalse(peopleResult.isEmpty, "Should return non-empty string")
|
||||
//
|
||||
// // Location notes expects: #%@ • %#@note_count@ format with String, Int
|
||||
// let notesResult = String(
|
||||
// localized: "location_notes.header",
|
||||
// bundle: appBundle(),
|
||||
// comment: "Test"
|
||||
// )
|
||||
//
|
||||
// XCTAssertFalse(notesResult.isEmpty, "Should return non-empty string")
|
||||
// }
|
||||
//
|
||||
// func testPluralizedStringsWithArguments() {
|
||||
// // Test the pluralized strings with actual format arguments
|
||||
//
|
||||
// // Test people count with different values
|
||||
// let testCases = [0, 1, 2, 5]
|
||||
//
|
||||
// for count in testCases {
|
||||
// let result = String(
|
||||
// format: String(localized: "content.accessibility.people_count", bundle: appBundle(), comment: "Test"),
|
||||
// locale: .current,
|
||||
// count
|
||||
// )
|
||||
//
|
||||
// // Just verify it doesn't crash and returns a reasonable string
|
||||
// XCTAssertFalse(result.isEmpty,
|
||||
// "People count should return non-empty string for count \(count)")
|
||||
// XCTAssertTrue(result.contains("\(count)"),
|
||||
// "Result should contain the count number for count \(count)")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func testLocationNotesHeaderWithArguments() {
|
||||
// let geohash = "abc123"
|
||||
// let testCases = [0, 1, 2, 10]
|
||||
//
|
||||
// for count in testCases {
|
||||
// let result = String(
|
||||
// format: String(localized: "location_notes.header", bundle: appBundle(), comment: "Test"),
|
||||
// locale: .current,
|
||||
// geohash, count
|
||||
// )
|
||||
//
|
||||
// // Verify basic structure is maintained
|
||||
// XCTAssertTrue(result.contains(geohash),
|
||||
// "Result should contain geohash for count \(count)")
|
||||
// XCTAssertTrue(result.contains("\(count)"),
|
||||
// "Result should contain count for count \(count)")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func testLocationChannelsRowTitleWithArguments() {
|
||||
// let label = "test-channel"
|
||||
// let testCases = [0, 1, 2, 5]
|
||||
//
|
||||
// for count in testCases {
|
||||
// let result = String(
|
||||
// format: String(localized: "location_channels.row_title", bundle: appBundle(), comment: "Test"),
|
||||
// locale: .current,
|
||||
// label, count
|
||||
// )
|
||||
//
|
||||
// // Verify basic structure is maintained
|
||||
// XCTAssertTrue(result.contains(label),
|
||||
// "Result should contain label for count \(count)")
|
||||
// XCTAssertTrue(result.contains("\(count)"),
|
||||
// "Result should contain count for count \(count)")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func testNoArgumentsLocalization() {
|
||||
// // Test that strings without arguments still work
|
||||
// let result = String(
|
||||
// localized: "common.ok",
|
||||
// bundle: appBundle(),
|
||||
// comment: "OK button text"
|
||||
// )
|
||||
//
|
||||
// XCTAssertFalse(result.isEmpty, "Simple localization should work")
|
||||
// }
|
||||
//
|
||||
// func testLocalizationEdgeCases() {
|
||||
// // Test edge cases that might cause issues
|
||||
//
|
||||
// // Large numbers
|
||||
// let largeNumberResult = String(
|
||||
// format: String(localized: "content.accessibility.people_count", bundle: appBundle(), comment: "Test"),
|
||||
// locale: .current,
|
||||
// 1000000
|
||||
// )
|
||||
// XCTAssertTrue(largeNumberResult.contains("1000000"), "Should handle large numbers")
|
||||
//
|
||||
// // Zero
|
||||
// let zeroResult = String(
|
||||
// format: String(localized: "content.accessibility.people_count", bundle: appBundle(), comment: "Test"),
|
||||
// locale: .current,
|
||||
// 0
|
||||
// )
|
||||
// XCTAssertTrue(zeroResult.contains("0"), "Should handle zero")
|
||||
//
|
||||
// // Empty geohash (edge case)
|
||||
// let emptyGeohashResult = String(
|
||||
// format: String(localized: "location_notes.header", bundle: appBundle(), comment: "Test"),
|
||||
// locale: .current,
|
||||
// "", 1
|
||||
// )
|
||||
// XCTAssertFalse(emptyGeohashResult.isEmpty, "Should handle empty geohash")
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//// MARK: - Helpers
|
||||
//
|
||||
//private struct CatalogContext {
|
||||
// let catalog: StringCatalog
|
||||
// let locales: [String]
|
||||
// let baseLocale: String
|
||||
// let keysByLocale: [String: Set<String>]
|
||||
// let placeholderSignature: [String: [String: [String: [String]]]]
|
||||
//}
|
||||
//
|
||||
//private struct StringCatalog: Decodable {
|
||||
// let sourceLanguage: String
|
||||
// let strings: [String: CatalogEntry]
|
||||
//
|
||||
// var locales: [String] {
|
||||
// var localeSet: Set<String> = []
|
||||
// for entry in strings.values {
|
||||
// localeSet.formUnion(entry.localizations.keys)
|
||||
// }
|
||||
// return localeSet.sorted()
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//private struct CatalogEntry: Decodable {
|
||||
// let localizations: [String: CatalogLocalization]
|
||||
//}
|
||||
//
|
||||
//private struct CatalogLocalization: Decodable {
|
||||
// let stringUnit: CatalogStringUnit?
|
||||
//}
|
||||
//
|
||||
//private struct CatalogStringUnit: Decodable {
|
||||
// let state: String
|
||||
// let value: String?
|
||||
// let variations: CatalogVariations?
|
||||
// let comment: String?
|
||||
//}
|
||||
//
|
||||
//private struct CatalogVariations: Decodable {
|
||||
// let plural: [String: [String: CatalogVariationValue]]?
|
||||
//}
|
||||
//
|
||||
//private struct CatalogVariationValue: Decodable {
|
||||
// let stringUnit: CatalogStringUnit?
|
||||
//}
|
||||
//
|
||||
//private struct Segment {
|
||||
// let components: [String]
|
||||
// let value: String
|
||||
//
|
||||
// var path: String {
|
||||
// components.isEmpty ? "base" : components.joined(separator: ".")
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//private func gatherSegments(from unit: CatalogStringUnit, prefix: [String] = []) -> [Segment] {
|
||||
// var segments: [Segment] = []
|
||||
// if let value = unit.value {
|
||||
// segments.append(Segment(components: prefix, value: value))
|
||||
// } else if prefix.isEmpty {
|
||||
// segments.append(Segment(components: [], value: ""))
|
||||
// }
|
||||
// if let plural = unit.variations?.plural {
|
||||
// for (variable, categories) in plural.sorted(by: { $0.key < $1.key }) {
|
||||
// for (category, variation) in categories.sorted(by: { $0.key < $1.key }) {
|
||||
// if let nested = variation.stringUnit {
|
||||
// var nextPrefix = prefix
|
||||
// nextPrefix.append("plural")
|
||||
// nextPrefix.append(variable)
|
||||
// nextPrefix.append(category)
|
||||
// segments.append(contentsOf: gatherSegments(from: nested, prefix: nextPrefix))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// return segments
|
||||
//}
|
||||
//
|
||||
//private func normalizedPlaceholders(_ tokens: [String]) -> [String] {
|
||||
// tokens.sorted()
|
||||
//}
|
||||
//
|
||||
//private func fallbackPath(for localePath: String, baseMap: [String: [String]]) -> String? {
|
||||
// let parts = localePath.split(separator: ".")
|
||||
// guard parts.count == 3, parts.first == "plural" else {
|
||||
// return nil
|
||||
// }
|
||||
// let variable = parts[1]
|
||||
// let otherKey = "plural.\(variable).other"
|
||||
// if baseMap[otherKey] != nil {
|
||||
// return otherKey
|
||||
// }
|
||||
// let oneKey = "plural.\(variable).one"
|
||||
// if baseMap[oneKey] != nil {
|
||||
// return oneKey
|
||||
// }
|
||||
// return nil
|
||||
//}
|
||||
//
|
||||
//private let placeholderRegex: NSRegularExpression = {
|
||||
// let pattern = "%(?:\\d+\\$)?#@[A-Za-z0-9_]+@|%(?:\\d+\\$)?[#0\\- +'\"]*(?:\\d+|\\*)?(?:\\.\\d+)?(?:hh|h|ll|l|z|t|L)?[a-zA-Z@]"
|
||||
// return try! NSRegularExpression(pattern: pattern, options: [])
|
||||
//}()
|
||||
//
|
||||
//private func placeholders(in string: String) -> [String] {
|
||||
// let range = NSRange(location: 0, length: (string as NSString).length)
|
||||
// let matches = placeholderRegex.matches(in: string, options: [], range: range)
|
||||
// var tokens: [String] = []
|
||||
// for match in matches {
|
||||
// if let range = Range(match.range, in: string) {
|
||||
// let token = String(string[range])
|
||||
// if token == "%%" { continue }
|
||||
// tokens.append(token)
|
||||
// }
|
||||
// }
|
||||
// return tokens
|
||||
//}
|
||||
//
|
||||
//private struct PrimaryKeyConfig: Decodable {
|
||||
// let app: [String]
|
||||
// let shareExtension: [String]
|
||||
// let expectedValues: [String: [String: String]]?
|
||||
// let testLocales: [String]?
|
||||
//}
|
||||
@@ -3,45 +3,49 @@ import XCTest
|
||||
|
||||
@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 testSubscribeWithoutRelaysSetsNoRelaysState() {
|
||||
// var subscribeCalled = false
|
||||
// let deps = LocationNotesDependencies(
|
||||
// relayLookup: { _, _ in [] },
|
||||
// subscribe: { _, _, _, _, _ in
|
||||
// subscribeCalled = true
|
||||
// },
|
||||
// unsubscribe: { _ in },
|
||||
// sendEvent: { _, _ in },
|
||||
// deriveIdentity: { _ in fatalError("should not derive identity") },
|
||||
// now: { Date() }
|
||||
// )
|
||||
//
|
||||
// let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
||||
//
|
||||
// XCTAssertFalse(subscribeCalled)
|
||||
// XCTAssertEqual(manager.state, .noRelays)
|
||||
// XCTAssertTrue(manager.initialLoadComplete)
|
||||
// XCTAssertEqual(manager.errorMessage, String(localized: "location_notes.error.no_relays"))
|
||||
// // Make sure we're getting an actual translated value and not the localization key
|
||||
// XCTAssertNotEqual(manager.errorMessage, "location_notes.error.no_relays")
|
||||
// }
|
||||
//
|
||||
// func testSendWhenNoRelaysSurfacesError() {
|
||||
// var sendCalled = false
|
||||
// let deps = LocationNotesDependencies(
|
||||
// relayLookup: { _, _ in [] },
|
||||
// subscribe: { _, _, _, _, _ in },
|
||||
// unsubscribe: { _ in },
|
||||
// sendEvent: { _, _ in sendCalled = true },
|
||||
// deriveIdentity: { _ in throw TestError.shouldNotDerive },
|
||||
// now: { Date() }
|
||||
// )
|
||||
//
|
||||
// let manager = LocationNotesManager(geohash: "zzzzzzzz", dependencies: deps)
|
||||
// manager.send(content: "hello", nickname: "tester")
|
||||
//
|
||||
// XCTAssertFalse(sendCalled)
|
||||
// XCTAssertEqual(manager.state, .noRelays)
|
||||
// XCTAssertEqual(manager.errorMessage, String(localized: "location_notes.error.no_relays"))
|
||||
// // Make sure we're getting an actual translated value and not the localization key
|
||||
// XCTAssertNotEqual(manager.errorMessage, "location_notes.error.no_relays")
|
||||
// }
|
||||
|
||||
func testSubscribeUsesGeoRelaysAndAppendsNotes() {
|
||||
var relaysCaptured: [String] = []
|
||||
@@ -62,7 +66,7 @@ final class LocationNotesManagerTests: XCTestCase {
|
||||
now: { Date() }
|
||||
)
|
||||
|
||||
let manager = LocationNotesManager(geohash: "abcd1234", dependencies: deps)
|
||||
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
||||
XCTAssertEqual(relaysCaptured, ["wss://relay.one"])
|
||||
XCTAssertEqual(manager.state, .loading)
|
||||
|
||||
@@ -70,7 +74,7 @@ final class LocationNotesManagerTests: XCTestCase {
|
||||
pubkey: "pub",
|
||||
createdAt: Date(),
|
||||
kind: .textNote,
|
||||
tags: [["g", "abcd1234"]],
|
||||
tags: [["g", "u4pruydq"]],
|
||||
content: "hi"
|
||||
)
|
||||
event.id = "event1"
|
||||
@@ -98,7 +102,7 @@ final class LocationNotesCounterTests: XCTestCase {
|
||||
)
|
||||
|
||||
let counter = LocationNotesCounter(testDependencies: deps)
|
||||
counter.subscribe(geohash: "abcdefgh")
|
||||
counter.subscribe(geohash: "u4pruydq")
|
||||
|
||||
XCTAssertFalse(subscribeCalled)
|
||||
XCTAssertFalse(counter.relayAvailable)
|
||||
@@ -122,13 +126,13 @@ final class LocationNotesCounterTests: XCTestCase {
|
||||
)
|
||||
|
||||
let counter = LocationNotesCounter(testDependencies: deps)
|
||||
counter.subscribe(geohash: "abcdefgh")
|
||||
counter.subscribe(geohash: "u4pruydq")
|
||||
|
||||
var first = NostrEvent(
|
||||
pubkey: "pub",
|
||||
createdAt: Date(),
|
||||
kind: .textNote,
|
||||
tags: [["g", "abcdefgh"]],
|
||||
tags: [["g", "u4pruydq"]],
|
||||
content: "a"
|
||||
)
|
||||
first.id = "eventA"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user