mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 01:45:18 +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
|
run: swift build
|
||||||
|
|
||||||
- name: Run Tests
|
- 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
|
CLAUDE.md
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
|
||||||
## User settings
|
|
||||||
xcuserdata/
|
|
||||||
|
|
||||||
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
||||||
*.xcscmblueprint
|
*.xcscmblueprint
|
||||||
*.xccheckout
|
*.xccheckout
|
||||||
@@ -57,7 +54,8 @@ iOSInjectionProject/
|
|||||||
|
|
||||||
## Xcode project
|
## Xcode project
|
||||||
*.xcodeproj/project.xcworkspace/
|
*.xcodeproj/project.xcworkspace/
|
||||||
*.xcodeproj/xcshareddata/
|
## Xcode User settings
|
||||||
|
xcuserdata/
|
||||||
|
|
||||||
## Python
|
## Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
MARKETING_VERSION = 1.4.2
|
MARKETING_VERSION = 1.4.4
|
||||||
CURRENT_PROJECT_VERSION = 1
|
CURRENT_PROJECT_VERSION = 1
|
||||||
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||||
|
|||||||
+10
-15
@@ -4,8 +4,9 @@ import PackageDescription
|
|||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "bitchat",
|
name: "bitchat",
|
||||||
|
defaultLocalization: "en",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v16),
|
.iOS(.v17),
|
||||||
.macOS(.v13)
|
.macOS(.v13)
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
@@ -15,6 +16,7 @@ let package = Package(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
dependencies:[
|
dependencies:[
|
||||||
|
.package(path: "localPackages/Tor"),
|
||||||
.package(path: "localPackages/BitLogger"),
|
.package(path: "localPackages/BitLogger"),
|
||||||
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
||||||
],
|
],
|
||||||
@@ -24,8 +26,7 @@ let package = Package(
|
|||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "P256K", package: "swift-secp256k1"),
|
.product(name: "P256K", package: "swift-secp256k1"),
|
||||||
.product(name: "BitLogger", package: "BitLogger"),
|
.product(name: "BitLogger", package: "BitLogger"),
|
||||||
.target(name: "TorC"),
|
.product(name: "Tor", package: "Tor")
|
||||||
.target(name: "tor-nolzma")
|
|
||||||
],
|
],
|
||||||
path: "bitchat",
|
path: "bitchat",
|
||||||
exclude: [
|
exclude: [
|
||||||
@@ -33,21 +34,12 @@ let package = Package(
|
|||||||
"Assets.xcassets",
|
"Assets.xcassets",
|
||||||
"bitchat.entitlements",
|
"bitchat.entitlements",
|
||||||
"bitchat-macOS.entitlements",
|
"bitchat-macOS.entitlements",
|
||||||
"LaunchScreen.storyboard",
|
"LaunchScreen.storyboard"
|
||||||
"Services/Tor/C/"
|
|
||||||
],
|
],
|
||||||
linkerSettings: [
|
resources: [
|
||||||
.linkedLibrary("z")
|
.process("Localizable.xcstrings")
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
.target(
|
|
||||||
name: "TorC",
|
|
||||||
path: "bitchat/Services/Tor/C"
|
|
||||||
),
|
|
||||||
.binaryTarget(
|
|
||||||
name: "tor-nolzma",
|
|
||||||
path: "Frameworks/tor-nolzma.xcframework"
|
|
||||||
),
|
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "bitchatTests",
|
name: "bitchatTests",
|
||||||
dependencies: ["bitchat"],
|
dependencies: ["bitchat"],
|
||||||
@@ -55,6 +47,9 @@ let package = Package(
|
|||||||
exclude: [
|
exclude: [
|
||||||
"Info.plist",
|
"Info.plist",
|
||||||
"README.md"
|
"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.
|
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.
|
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.
|
**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`)
|
### 6.2. Application Message Format (`BitchatMessage`)
|
||||||
|
|
||||||
For packets of type `message`, the payload is a binary-serialized `BitchatMessage` containing the chat content.
|
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. |
|
| 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. |
|
| 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
|
## 7. Message Routing and Propagation
|
||||||
|
|||||||
Generated
+44
-21
@@ -7,15 +7,14 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* 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, ); }; };
|
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };
|
3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };
|
||||||
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
|
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
|
||||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
|
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
|
||||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* 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 */; };
|
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 */; };
|
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
@@ -58,11 +57,10 @@
|
|||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
0481A35C2E6DA18600FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; };
|
|
||||||
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; };
|
61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = bitchatShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
96D0D41CA19EE5A772AA8434 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
96D0D41CA19EE5A772AA8434 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
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; };
|
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>"; };
|
E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = relays/online_relays_gps.csv; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
@@ -87,7 +85,6 @@
|
|||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
Info.plist,
|
Info.plist,
|
||||||
LaunchScreen.storyboard,
|
LaunchScreen.storyboard,
|
||||||
Services/Tor/C/include/.gitkeep,
|
|
||||||
);
|
);
|
||||||
target = 0576A29205865664C0937536 /* bitchat_macOS */;
|
target = 0576A29205865664C0937536 /* bitchat_macOS */;
|
||||||
};
|
};
|
||||||
@@ -136,32 +133,22 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
files = (
|
files = (
|
||||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
|
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
|
||||||
048A88812E76FD18000FBCDD /* libz.tbd in Frameworks */,
|
|
||||||
0481A3A02E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */,
|
|
||||||
3EE336D150427F736F32B56C /* P256K in Frameworks */,
|
3EE336D150427F736F32B56C /* P256K in Frameworks */,
|
||||||
|
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
|
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
files = (
|
files = (
|
||||||
|
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */,
|
||||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
||||||
0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */,
|
|
||||||
0481A3A12E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */,
|
|
||||||
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
||||||
|
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
0481A3542E6D877600FC845E /* Frameworks */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */,
|
|
||||||
0481A35C2E6DA18600FC845E /* libz.tbd */,
|
|
||||||
);
|
|
||||||
path = Frameworks;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
18198ED912AAF495D8AF7763 = {
|
18198ED912AAF495D8AF7763 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -170,8 +157,8 @@
|
|||||||
A6E32D212E762EAB0032EA8A /* bitchatShareExtension */,
|
A6E32D212E762EAB0032EA8A /* bitchatShareExtension */,
|
||||||
A6E32D412E762EAE0032EA8A /* bitchatTests */,
|
A6E32D412E762EAE0032EA8A /* bitchatTests */,
|
||||||
A6E367C92E76469E0032EA8A /* Configs */,
|
A6E367C92E76469E0032EA8A /* Configs */,
|
||||||
0481A3542E6D877600FC845E /* Frameworks */,
|
|
||||||
9F37F9F2C353B58AC809E93B /* Products */,
|
9F37F9F2C353B58AC809E93B /* Products */,
|
||||||
|
A6F183FB2E948783006A9046 /* Frameworks */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -187,6 +174,14 @@
|
|||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
A6F183FB2E948783006A9046 /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -207,6 +202,7 @@
|
|||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
B1D9136AA0083366353BFA2F /* P256K */,
|
B1D9136AA0083366353BFA2F /* P256K */,
|
||||||
A6E3E5712E7703760032EA8A /* BitLogger */,
|
A6E3E5712E7703760032EA8A /* BitLogger */,
|
||||||
|
A6E3EA802E7706A80032EA8A /* Tor */,
|
||||||
);
|
);
|
||||||
productName = bitchat_macOS;
|
productName = bitchat_macOS;
|
||||||
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
||||||
@@ -284,6 +280,7 @@
|
|||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
4EB6BA1B8464F1EA38F4E286 /* P256K */,
|
4EB6BA1B8464F1EA38F4E286 /* P256K */,
|
||||||
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
||||||
|
A6E3EA7E2E7706720032EA8A /* Tor */,
|
||||||
);
|
);
|
||||||
productName = bitchat_iOS;
|
productName = bitchat_iOS;
|
||||||
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
||||||
@@ -304,12 +301,26 @@
|
|||||||
knownRegions = (
|
knownRegions = (
|
||||||
Base,
|
Base,
|
||||||
en,
|
en,
|
||||||
|
es,
|
||||||
|
ar,
|
||||||
|
de,
|
||||||
|
fr,
|
||||||
|
he,
|
||||||
|
id,
|
||||||
|
it,
|
||||||
|
ja,
|
||||||
|
ne,
|
||||||
|
"pt-BR",
|
||||||
|
ru,
|
||||||
|
uk,
|
||||||
|
"zh-Hans",
|
||||||
);
|
);
|
||||||
mainGroup = 18198ED912AAF495D8AF7763;
|
mainGroup = 18198ED912AAF495D8AF7763;
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
||||||
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
||||||
|
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 90;
|
preferredProjectObjectVersion = 90;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@@ -867,6 +878,10 @@
|
|||||||
isa = XCLocalSwiftPackageReference;
|
isa = XCLocalSwiftPackageReference;
|
||||||
relativePath = localPackages/BitLogger;
|
relativePath = localPackages/BitLogger;
|
||||||
};
|
};
|
||||||
|
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = localPackages/Tor;
|
||||||
|
};
|
||||||
/* End XCLocalSwiftPackageReference section */
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
@@ -894,6 +909,14 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = BitLogger;
|
productName = BitLogger;
|
||||||
};
|
};
|
||||||
|
A6E3EA7E2E7706720032EA8A /* Tor */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = Tor;
|
||||||
|
};
|
||||||
|
A6E3EA802E7706A80032EA8A /* Tor */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = Tor;
|
||||||
|
};
|
||||||
B1D9136AA0083366353BFA2F /* P256K */ = {
|
B1D9136AA0083366353BFA2F /* P256K */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
|
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>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Tor
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ import Foundation
|
|||||||
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
|
/// 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.
|
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
|
||||||
struct EphemeralIdentity {
|
struct EphemeralIdentity {
|
||||||
let peerID: String // 8 random bytes
|
let peerID: PeerID // 8 random bytes
|
||||||
let sessionStart: Date
|
let sessionStart: Date
|
||||||
var handshakeState: HandshakeState
|
var handshakeState: HandshakeState
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ protocol SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
// MARK: Cryptographic Identities
|
// MARK: Cryptographic Identities
|
||||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?)
|
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?)
|
||||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity]
|
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity]
|
||||||
func updateSocialIdentity(_ identity: SocialIdentity)
|
func updateSocialIdentity(_ identity: SocialIdentity)
|
||||||
|
|
||||||
// MARK: Favorites Management
|
// MARK: Favorites Management
|
||||||
@@ -121,12 +121,12 @@ protocol SecureIdentityStateManagerProtocol {
|
|||||||
func getBlockedNostrPubkeys() -> Set<String>
|
func getBlockedNostrPubkeys() -> Set<String>
|
||||||
|
|
||||||
// MARK: Ephemeral Session Management
|
// MARK: Ephemeral Session Management
|
||||||
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState)
|
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState)
|
||||||
func updateHandshakeState(peerID: String, state: HandshakeState)
|
func updateHandshakeState(peerID: PeerID, state: HandshakeState)
|
||||||
|
|
||||||
// MARK: Cleanup
|
// MARK: Cleanup
|
||||||
func clearAllIdentityData()
|
func clearAllIdentityData()
|
||||||
func removeEphemeralSession(peerID: String)
|
func removeEphemeralSession(peerID: PeerID)
|
||||||
|
|
||||||
// MARK: Verification
|
// MARK: Verification
|
||||||
func setVerified(fingerprint: String, verified: Bool)
|
func setVerified(fingerprint: String, verified: Bool)
|
||||||
@@ -143,7 +143,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
private let encryptionKeyName = "identityCacheEncryptionKey"
|
private let encryptionKeyName = "identityCacheEncryptionKey"
|
||||||
|
|
||||||
// In-memory state
|
// In-memory state
|
||||||
private var ephemeralSessions: [String: EphemeralIdentity] = [:]
|
private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
|
||||||
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
||||||
private var cache: IdentityCache = IdentityCache()
|
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
|
/// 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 {
|
queue.sync {
|
||||||
// Defensive: ensure hex and correct length
|
// Defensive: ensure hex and correct length
|
||||||
guard peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] }
|
guard peerID.isShort else { return [] }
|
||||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
|
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,7 +455,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
// MARK: - Ephemeral Session Management
|
// MARK: - Ephemeral Session Management
|
||||||
|
|
||||||
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState = .none) {
|
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
||||||
peerID: peerID,
|
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) {
|
queue.async(flags: .barrier) {
|
||||||
self.ephemeralSessions[peerID]?.handshakeState = state
|
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) {
|
queue.async(flags: .barrier) {
|
||||||
self.ephemeralSessions.removeValue(forKey: peerID)
|
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 originalSender: String?
|
||||||
let isPrivate: Bool
|
let isPrivate: Bool
|
||||||
let recipientNickname: String?
|
let recipientNickname: String?
|
||||||
let senderPeerID: String?
|
let senderPeerID: PeerID?
|
||||||
let mentions: [String]? // Array of mentioned nicknames
|
let mentions: [String]? // Array of mentioned nicknames
|
||||||
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
||||||
|
|
||||||
@@ -42,7 +42,19 @@ final class BitchatMessage: Codable {
|
|||||||
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
|
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.id = id ?? UUID().uuidString
|
||||||
self.sender = sender
|
self.sender = sender
|
||||||
self.content = content
|
self.content = content
|
||||||
@@ -151,7 +163,7 @@ extension BitchatMessage {
|
|||||||
data.append(recipData.prefix(255))
|
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(UInt8(min(peerData.count, 255)))
|
||||||
data.append(peerData.prefix(255))
|
data.append(peerData.prefix(255))
|
||||||
}
|
}
|
||||||
@@ -264,11 +276,11 @@ extension BitchatMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var senderPeerID: String?
|
var senderPeerID: PeerID?
|
||||||
if hasSenderPeerID && offset < dataCopy.count {
|
if hasSenderPeerID && offset < dataCopy.count {
|
||||||
let length = Int(dataCopy[offset]); offset += 1
|
let length = Int(dataCopy[offset]); offset += 1
|
||||||
if offset + length <= dataCopy.count {
|
if offset + length <= dataCopy.count {
|
||||||
senderPeerID = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
|
senderPeerID = PeerID(data: dataCopy[offset..<offset+length])
|
||||||
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 {
|
extension Array where Element == BitchatMessage {
|
||||||
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
|
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
|
||||||
func cleanedAndDeduped() -> [Element] {
|
func cleanedAndDeduped() -> [Element] {
|
||||||
|
|||||||
@@ -34,12 +34,12 @@ struct BitchatPacket: Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convenience initializer for new binary format
|
// 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.version = 1
|
||||||
self.type = type
|
self.type = type
|
||||||
// Convert hex string peer ID to binary data (8 bytes)
|
// Convert hex string peer ID to binary data (8 bytes)
|
||||||
var senderData = Data()
|
var senderData = Data()
|
||||||
var tempID = senderID
|
var tempID = senderID.id
|
||||||
while tempID.count >= 2 {
|
while tempID.count >= 2 {
|
||||||
let hexByte = String(tempID.prefix(2))
|
let hexByte = String(tempID.prefix(2))
|
||||||
if let byte = UInt8(hexByte, radix: 16) {
|
if let byte = UInt8(hexByte, radix: 16) {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import Foundation
|
|||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
|
|
||||||
/// Represents a peer in the BitChat network with all associated metadata
|
/// Represents a peer in the BitChat network with all associated metadata
|
||||||
struct BitchatPeer: Identifiable, Equatable {
|
struct BitchatPeer: Equatable {
|
||||||
let id: String // Hex-encoded peer ID
|
let peerID: PeerID // Hex-encoded peer ID
|
||||||
let noisePublicKey: Data
|
let noisePublicKey: Data
|
||||||
let nickname: String
|
let nickname: String
|
||||||
let lastSeen: Date
|
let lastSeen: Date
|
||||||
@@ -51,7 +51,7 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
|
|
||||||
// Display helpers
|
// Display helpers
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
nickname.isEmpty ? String(id.prefix(8)) : nickname
|
nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname
|
||||||
}
|
}
|
||||||
|
|
||||||
var statusIcon: String {
|
var statusIcon: String {
|
||||||
@@ -73,14 +73,14 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
|
|
||||||
// Initialize from mesh service data
|
// Initialize from mesh service data
|
||||||
init(
|
init(
|
||||||
id: String,
|
peerID: PeerID,
|
||||||
noisePublicKey: Data,
|
noisePublicKey: Data,
|
||||||
nickname: String,
|
nickname: String,
|
||||||
lastSeen: Date = Date(),
|
lastSeen: Date = Date(),
|
||||||
isConnected: Bool = false,
|
isConnected: Bool = false,
|
||||||
isReachable: Bool = false
|
isReachable: Bool = false
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.peerID = peerID
|
||||||
self.noisePublicKey = noisePublicKey
|
self.noisePublicKey = noisePublicKey
|
||||||
self.nickname = nickname
|
self.nickname = nickname
|
||||||
self.lastSeen = lastSeen
|
self.lastSeen = lastSeen
|
||||||
@@ -93,8 +93,6 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func == (lhs: BitchatPeer, rhs: BitchatPeer) -> Bool {
|
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 }
|
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
||||||
let readerID = readerIDData.hexEncodedString()
|
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),
|
guard let timestamp = dataCopy.readDate(at: &offset),
|
||||||
InputValidator.validateTimestamp(timestamp),
|
InputValidator.validateTimestamp(timestamp),
|
||||||
|
|||||||
@@ -397,7 +397,7 @@ final class NoiseSymmetricState {
|
|||||||
if nameData.count <= 32 {
|
if nameData.count <= 32 {
|
||||||
self.hash = nameData + Data(repeating: 0, count: 32 - nameData.count)
|
self.hash = nameData + Data(repeating: 0, count: 32 - nameData.count)
|
||||||
} else {
|
} else {
|
||||||
self.hash = Data(SHA256.hash(data: nameData))
|
self.hash = nameData.sha256Hash()
|
||||||
}
|
}
|
||||||
self.chainingKey = self.hash
|
self.chainingKey = self.hash
|
||||||
}
|
}
|
||||||
@@ -410,7 +410,7 @@ final class NoiseSymmetricState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mixHash(_ data: Data) {
|
func mixHash(_ data: Data) {
|
||||||
hash = Data(SHA256.hash(data: hash + data))
|
hash = (hash + data).sha256Hash()
|
||||||
}
|
}
|
||||||
|
|
||||||
func mixKeyAndHash(_ inputKeyMaterial: Data) {
|
func mixKeyAndHash(_ inputKeyMaterial: Data) {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
// MARK: - Security Constants
|
// MARK: - Security Constants
|
||||||
|
|
||||||
@@ -53,11 +52,6 @@ struct NoiseSecurityValidator {
|
|||||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||||
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
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
|
// MARK: - Enhanced Noise Session with Security
|
||||||
@@ -137,8 +131,8 @@ final class SecureNoiseSession: NoiseSession {
|
|||||||
// MARK: - Rate Limiter
|
// MARK: - Rate Limiter
|
||||||
|
|
||||||
final class NoiseRateLimiter {
|
final class NoiseRateLimiter {
|
||||||
private var handshakeTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
private var handshakeTimestamps: [PeerID: [Date]] = [:]
|
||||||
private var messageTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
private var messageTimestamps: [PeerID: [Date]] = [:]
|
||||||
|
|
||||||
// Global rate limiting
|
// Global rate limiting
|
||||||
private var globalHandshakeTimestamps: [Date] = []
|
private var globalHandshakeTimestamps: [Date] = []
|
||||||
@@ -146,7 +140,7 @@ final class NoiseRateLimiter {
|
|||||||
|
|
||||||
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
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) {
|
return queue.sync(flags: .barrier) {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let oneMinuteAgo = now.addingTimeInterval(-60)
|
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) {
|
return queue.sync(flags: .barrier) {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let oneSecondAgo = now.addingTimeInterval(-1)
|
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) {
|
queue.async(flags: .barrier) {
|
||||||
self.handshakeTimestamps.removeValue(forKey: peerID)
|
self.handshakeTimestamps.removeValue(forKey: peerID)
|
||||||
self.messageTimestamps.removeValue(forKey: peerID)
|
self.messageTimestamps.removeValue(forKey: peerID)
|
||||||
|
|||||||
@@ -10,32 +10,8 @@ import BitLogger
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
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 {
|
class NoiseSession {
|
||||||
let peerID: String
|
let peerID: PeerID
|
||||||
let role: NoiseRole
|
let role: NoiseRole
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
private var state: NoiseSessionState = .uninitialized
|
private var state: NoiseSessionState = .uninitialized
|
||||||
@@ -55,7 +31,7 @@ class NoiseSession {
|
|||||||
private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent)
|
private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent)
|
||||||
|
|
||||||
init(
|
init(
|
||||||
peerID: String,
|
peerID: PeerID,
|
||||||
role: NoiseRole,
|
role: NoiseRole,
|
||||||
keychain: KeychainManagerProtocol,
|
keychain: KeychainManagerProtocol,
|
||||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
||||||
@@ -141,7 +117,7 @@ class NoiseSession {
|
|||||||
handshakeState = nil // Clear handshake state
|
handshakeState = nil // Clear handshake state
|
||||||
|
|
||||||
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established")
|
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
|
return nil
|
||||||
} else {
|
} else {
|
||||||
@@ -167,7 +143,7 @@ class NoiseSession {
|
|||||||
handshakeState = nil // Clear handshake state
|
handshakeState = nil // Clear handshake state
|
||||||
|
|
||||||
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established")
|
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
|
return response
|
||||||
@@ -252,249 +228,8 @@ class NoiseSession {
|
|||||||
handshakeHash = nil
|
handshakeHash = nil
|
||||||
|
|
||||||
if wasEstablished {
|
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 BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Tor
|
||||||
|
|
||||||
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
if let maybeData = Data(hexString: recipientPeerID) {
|
if let maybeData = Data(hexString: recipientPeerID) {
|
||||||
if maybeData.count == 32 {
|
if maybeData.count == 32 {
|
||||||
// Treat as Noise static public key; derive peerID from fingerprint
|
// 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 {
|
} else if maybeData.count == 8 {
|
||||||
// Already an 8-byte peer ID
|
// Already an 8-byte peer ID
|
||||||
return recipientPeerID
|
return recipientPeerID
|
||||||
|
|||||||
@@ -226,10 +226,7 @@ struct NostrIdentityBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// As a final fallback, hash the seed+msg and try again
|
// As a final fallback, hash the seed+msg and try again
|
||||||
var combined = Data()
|
let fallback = (seed + msg).sha256Hash()
|
||||||
combined.append(seed)
|
|
||||||
combined.append(msg)
|
|
||||||
let fallback = Data(CryptoKit.SHA256.hash(data: combined))
|
|
||||||
return try NostrIdentity(privateKeyData: fallback)
|
return try NostrIdentity(privateKeyData: fallback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -521,10 +521,7 @@ struct NostrEvent: Codable {
|
|||||||
] as [Any]
|
] as [Any]
|
||||||
|
|
||||||
let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
||||||
let hash = CryptoKit.SHA256.hash(data: data)
|
return (data.sha256Fingerprint(), data.sha256Hash())
|
||||||
let hashData = Data(hash)
|
|
||||||
let hashHex = hash.compactMap { String(format: "%02x", $0) }.joined()
|
|
||||||
return (hashHex, hashData)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func jsonString() throws -> String {
|
func jsonString() throws -> String {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import BitLogger
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
import Combine
|
import Combine
|
||||||
|
import Tor
|
||||||
|
|
||||||
/// Manages WebSocket connections to Nostr relays
|
/// Manages WebSocket connections to Nostr relays
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -108,7 +109,20 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.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
|
/// Connect to all configured relays
|
||||||
func connect() {
|
func connect() {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ extension Data {
|
|||||||
offset += 16
|
offset += 16
|
||||||
|
|
||||||
// Convert 16 bytes to UUID string format
|
// 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
|
// Insert hyphens at proper positions: 8-4-4-4-12
|
||||||
var result = ""
|
var result = ""
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import CoreBluetooth
|
||||||
|
|
||||||
// MARK: - Message Types
|
// MARK: - Message Types
|
||||||
|
|
||||||
@@ -131,7 +132,7 @@ enum LazyHandshakeState {
|
|||||||
// MARK: - Delivery Status
|
// MARK: - Delivery Status
|
||||||
|
|
||||||
// Delivery status for messages
|
// Delivery status for messages
|
||||||
enum DeliveryStatus: Codable, Equatable {
|
enum DeliveryStatus: Codable, Equatable, Hashable {
|
||||||
case sending
|
case sending
|
||||||
case sent // Left our device
|
case sent // Left our device
|
||||||
case delivered(to: String, at: Date) // Confirmed by recipient
|
case delivered(to: String, at: Date) // Confirmed by recipient
|
||||||
@@ -161,18 +162,21 @@ enum DeliveryStatus: Codable, Equatable {
|
|||||||
|
|
||||||
protocol BitchatDelegate: AnyObject {
|
protocol BitchatDelegate: AnyObject {
|
||||||
func didReceiveMessage(_ message: BitchatMessage)
|
func didReceiveMessage(_ message: BitchatMessage)
|
||||||
func didConnectToPeer(_ peerID: String)
|
func didConnectToPeer(_ peerID: PeerID)
|
||||||
func didDisconnectFromPeer(_ peerID: String)
|
func didDisconnectFromPeer(_ peerID: PeerID)
|
||||||
func didUpdatePeerList(_ peers: [String])
|
func didUpdatePeerList(_ peers: [PeerID])
|
||||||
|
|
||||||
// Optional method to check if a fingerprint belongs to a favorite peer
|
// Optional method to check if a fingerprint belongs to a favorite peer
|
||||||
func isFavorite(fingerprint: String) -> Bool
|
func isFavorite(fingerprint: String) -> Bool
|
||||||
|
|
||||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
|
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
|
||||||
|
|
||||||
// Low-level events for better separation of concerns
|
// Low-level events for better separation of concerns
|
||||||
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date)
|
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, 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
|
// Provide default implementation to make it effectively optional
|
||||||
@@ -185,11 +189,11 @@ extension BitchatDelegate {
|
|||||||
// Default empty implementation
|
// 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
|
// 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
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ enum Geohash {
|
|||||||
return map
|
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.
|
/// Encodes the provided coordinates into a geohash string.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - latitude: Latitude in degrees (-90...90)
|
/// - latitude: Latitude in degrees (-90...90)
|
||||||
|
|||||||
@@ -23,15 +23,21 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
|||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .building: return "Building"
|
case .building:
|
||||||
case .block: return "Block"
|
return String(localized: "location_levels.building", comment: "Name for building-level location channel")
|
||||||
case .neighborhood: return "Neighborhood"
|
case .block:
|
||||||
case .city: return "City"
|
return String(localized: "location_levels.block", comment: "Name for block-level location channel")
|
||||||
case .province: return "Province"
|
case .neighborhood:
|
||||||
case .region: return "Region"
|
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
|
// Backward-compatible Codable for renamed cases
|
||||||
extension GeohashChannelLevel {
|
extension GeohashChannelLevel {
|
||||||
init(from decoder: Decoder) throws {
|
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 {
|
if chatViewModel?.selectedPrivateChatPeer != nil {
|
||||||
// In private chat
|
// 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) *"
|
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
|
||||||
meshService?.sendPrivateMessage(personalMessage, to: targetPeerID,
|
meshService?.sendPrivateMessage(personalMessage, to: PeerID(str: targetPeerID),
|
||||||
recipientNickname: peerNickname,
|
recipientNickname: peerNickname,
|
||||||
messageID: UUID().uuidString)
|
messageID: UUID().uuidString)
|
||||||
// Also add a local system message so the sender sees a natural-language confirmation
|
// Also add a local system message so the sender sees a natural-language confirmation
|
||||||
let pastAction: String = {
|
let pastAction: String = {
|
||||||
@@ -214,7 +214,7 @@ final class CommandProcessor {
|
|||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
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) {
|
if identityManager.isBlocked(fingerprint: fingerprint) {
|
||||||
return .success(message: "\(nickname) is already blocked")
|
return .success(message: "\(nickname) is already blocked")
|
||||||
}
|
}
|
||||||
@@ -258,7 +258,7 @@ final class CommandProcessor {
|
|||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
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) {
|
if !identityManager.isBlocked(fingerprint: fingerprint) {
|
||||||
return .success(message: "\(nickname) is not blocked")
|
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)
|
.assign(to: &$mutualFavorites)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
// Clean up Combine subscriptions
|
||||||
|
cancellables.removeAll()
|
||||||
|
SecureLogger.debug("FavoritesPersistenceService deinitialized", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
/// Add or update a favorite
|
/// Add or update a favorite
|
||||||
func addFavorite(
|
func addFavorite(
|
||||||
peerNoisePublicKey: Data,
|
peerNoisePublicKey: Data,
|
||||||
@@ -179,125 +185,15 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
/// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey)
|
/// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey)
|
||||||
/// Falls back to scanning favorites and matching on derived peer ID.
|
/// 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)
|
// Quick sanity: peerID should be 16 hex chars (8 bytes)
|
||||||
guard peerID.count == 16 else { return nil }
|
guard peerID.isShort else { return nil }
|
||||||
for (pubkey, rel) in favorites {
|
for (pubkey, rel) in favorites where PeerID(publicKey: pubkey) == peerID {
|
||||||
let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey)
|
return rel
|
||||||
if derived == peerID { return rel }
|
|
||||||
}
|
}
|
||||||
return nil
|
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
|
/// Clear all favorites - used for panic mode
|
||||||
func clearAllFavorites() {
|
func clearAllFavorites() {
|
||||||
SecureLogger.warning("🧹 Clearing all favorites (panic mode)", category: .session)
|
SecureLogger.warning("🧹 Clearing all favorites (panic mode)", category: .session)
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
#if os(iOS) || os(macOS)
|
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
#endif
|
|
||||||
|
|
||||||
/// Stores a user-maintained list of bookmarked geohash channels.
|
/// Stores a user-maintained list of bookmarked geohash channels.
|
||||||
/// - Persistence: UserDefaults (JSON string array)
|
/// - Persistence: UserDefaults (JSON string array)
|
||||||
@@ -16,15 +15,22 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
private let storeKey = "locationChannel.bookmarks"
|
private let storeKey = "locationChannel.bookmarks"
|
||||||
private let namesStoreKey = "locationChannel.bookmarkNames"
|
private let namesStoreKey = "locationChannel.bookmarkNames"
|
||||||
private var membership: Set<String> = []
|
private var membership: Set<String> = []
|
||||||
#if os(iOS) || os(macOS)
|
|
||||||
private let geocoder = CLGeocoder()
|
private let geocoder = CLGeocoder()
|
||||||
private var resolving: Set<String> = []
|
private var resolving: Set<String> = []
|
||||||
#endif
|
|
||||||
|
private let storage: UserDefaults
|
||||||
|
|
||||||
private init() {
|
init(storage: UserDefaults = .standard) {
|
||||||
|
self.storage = storage
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
// Cancel any pending geocoding operations
|
||||||
|
geocoder.cancelGeocode()
|
||||||
|
SecureLogger.debug("GeohashBookmarksStore deinitialized", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Public API
|
// MARK: - Public API
|
||||||
func isBookmarked(_ geohash: String) -> Bool {
|
func isBookmarked(_ geohash: String) -> Bool {
|
||||||
return membership.contains(Self.normalize(geohash))
|
return membership.contains(Self.normalize(geohash))
|
||||||
@@ -64,7 +70,7 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Persistence
|
// MARK: - Persistence
|
||||||
private func load() {
|
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) {
|
if let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||||
// Sanitize, normalize, dedupe while preserving order (first occurrence wins)
|
// Sanitize, normalize, dedupe while preserving order (first occurrence wins)
|
||||||
var seen = Set<String>()
|
var seen = Set<String>()
|
||||||
@@ -81,7 +87,7 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
membership = seen
|
membership = seen
|
||||||
}
|
}
|
||||||
// Load any saved names
|
// 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) {
|
let dict = try? JSONDecoder().decode([String: String].self, from: namesData) {
|
||||||
bookmarkNames = dict
|
bookmarkNames = dict
|
||||||
}
|
}
|
||||||
@@ -89,13 +95,13 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
|
|
||||||
private func persist() {
|
private func persist() {
|
||||||
if let data = try? JSONEncoder().encode(bookmarks) {
|
if let data = try? JSONEncoder().encode(bookmarks) {
|
||||||
UserDefaults.standard.set(data, forKey: storeKey)
|
storage.set(data, forKey: storeKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func persistNames() {
|
private func persistNames() {
|
||||||
if let data = try? JSONEncoder().encode(bookmarkNames) {
|
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)
|
let gh = Self.normalize(geohash)
|
||||||
guard !gh.isEmpty else { return }
|
guard !gh.isEmpty else { return }
|
||||||
if bookmarkNames[gh] != nil { return }
|
if bookmarkNames[gh] != nil { return }
|
||||||
#if os(iOS) || os(macOS)
|
|
||||||
if resolving.contains(gh) { return }
|
if resolving.contains(gh) { return }
|
||||||
resolving.insert(gh)
|
resolving.insert(gh)
|
||||||
// For very coarse geohashes, sample multiple points to capture multiple admin areas
|
// 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]) {
|
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
|
||||||
var uniqueAdmins = OrderedSet<String>()
|
var uniqueAdmins = OrderedSet<String>()
|
||||||
var idx = 0
|
var idx = 0
|
||||||
@@ -212,7 +215,6 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
|
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
/// Testing-only reset helper
|
/// 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
|
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) {
|
func subscribe(geohash gh: String) {
|
||||||
let norm = gh.lowercased()
|
let norm = gh.lowercased()
|
||||||
if geohash == norm, subscriptionID != nil { return }
|
if geohash == norm, subscriptionID != nil { return }
|
||||||
|
// 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
|
// Unsubscribe previous without clearing count to avoid flicker
|
||||||
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
||||||
subscriptionID = nil
|
subscriptionID = nil
|
||||||
@@ -74,7 +85,7 @@ final class LocationNotesCounter: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
subscriptionID = subID
|
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
|
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||||
|
|||||||
@@ -74,23 +74,58 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
@Published private(set) var state: State = .loading
|
@Published private(set) var state: State = .loading
|
||||||
@Published private(set) var errorMessage: String?
|
@Published private(set) var errorMessage: String?
|
||||||
private var subscriptionID: String?
|
private var subscriptionID: String?
|
||||||
|
private var noteIDs = Set<String>() // O(1) duplicate detection
|
||||||
private let dependencies: LocationNotesDependencies
|
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) {
|
init(geohash: String, dependencies: LocationNotesDependencies = .live) {
|
||||||
self.geohash = geohash.lowercased()
|
let norm = geohash.lowercased()
|
||||||
|
self.geohash = norm
|
||||||
self.dependencies = dependencies
|
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()
|
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) {
|
func setGeohash(_ newGeohash: String) {
|
||||||
let norm = newGeohash.lowercased()
|
let norm = newGeohash.lowercased()
|
||||||
guard norm != geohash else { return }
|
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 {
|
if let sub = subscriptionID {
|
||||||
dependencies.unsubscribe(sub)
|
dependencies.unsubscribe(sub)
|
||||||
subscriptionID = nil
|
subscriptionID = nil
|
||||||
}
|
}
|
||||||
|
// Set loading state before clearing to prevent empty state flicker
|
||||||
|
state = .loading
|
||||||
|
initialLoadComplete = false
|
||||||
|
errorMessage = nil
|
||||||
geohash = norm
|
geohash = norm
|
||||||
notes.removeAll()
|
notes.removeAll()
|
||||||
|
noteIDs.removeAll()
|
||||||
subscribe()
|
subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +134,12 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
dependencies.unsubscribe(sub)
|
dependencies.unsubscribe(sub)
|
||||||
subscriptionID = nil
|
subscriptionID = nil
|
||||||
}
|
}
|
||||||
|
// Set loading state before clearing to prevent empty state flicker
|
||||||
|
state = .loading
|
||||||
|
initialLoadComplete = false
|
||||||
|
errorMessage = nil
|
||||||
notes.removeAll()
|
notes.removeAll()
|
||||||
|
noteIDs.removeAll()
|
||||||
subscribe()
|
subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +160,7 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
subscriptionID = nil
|
subscriptionID = nil
|
||||||
initialLoadComplete = true
|
initialLoadComplete = true
|
||||||
state = .noRelays
|
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)
|
SecureLogger.warning("LocationNotesManager: no geo relays for geohash=\(geohash)", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -135,12 +175,14 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||||
// Ensure matching tag
|
// Ensure matching tag
|
||||||
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) else { return }
|
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 nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
|
||||||
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
|
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
|
||||||
self.notes.append(note)
|
self.notes.append(note)
|
||||||
self.notes.sort { $0.createdAt > $1.createdAt }
|
self.notes.sort { $0.createdAt > $1.createdAt }
|
||||||
|
self.enforceMemoryCap()
|
||||||
self.state = .ready
|
self.state = .ready
|
||||||
}, { [weak self] in
|
}, { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
@@ -158,7 +200,7 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||||
guard !relays.isEmpty else {
|
guard !relays.isEmpty else {
|
||||||
state = .noRelays
|
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)
|
SecureLogger.warning("LocationNotesManager: send blocked, no geo relays for geohash=\(geohash)", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -176,15 +218,26 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
id: event.id,
|
id: event.id,
|
||||||
pubkey: id.publicKeyHex,
|
pubkey: id.publicKeyHex,
|
||||||
content: trimmed,
|
content: trimmed,
|
||||||
createdAt: dependencies.now(),
|
createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
|
||||||
nickname: nickname
|
nickname: nickname
|
||||||
)
|
)
|
||||||
|
self.noteIDs.insert(event.id)
|
||||||
self.notes.insert(echo, at: 0)
|
self.notes.insert(echo, at: 0)
|
||||||
|
self.enforceMemoryCap()
|
||||||
self.state = .ready
|
self.state = .ready
|
||||||
self.errorMessage = nil
|
self.errorMessage = nil
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session)
|
SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session)
|
||||||
errorMessage = "Failed to send note. \(error.localizedDescription)"
|
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 {
|
final class MessageRouter {
|
||||||
private let mesh: Transport
|
private let mesh: Transport
|
||||||
private let nostr: NostrTransport
|
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) {
|
init(mesh: Transport, nostr: NostrTransport) {
|
||||||
self.mesh = mesh
|
self.mesh = mesh
|
||||||
@@ -21,7 +21,7 @@ final class MessageRouter {
|
|||||||
) { [weak self] note in
|
) { [weak self] note in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
if let data = note.userInfo?["peerPublicKey"] as? Data {
|
if let data = note.userInfo?["peerPublicKey"] as? Data {
|
||||||
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: data)
|
let peerID = PeerID(publicKey: data)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.flushOutbox(for: peerID)
|
self.flushOutbox(for: peerID)
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ final class MessageRouter {
|
|||||||
// Handle key updates
|
// Handle key updates
|
||||||
if let newKey = note.userInfo?["peerPublicKey"] as? Data,
|
if let newKey = note.userInfo?["peerPublicKey"] as? Data,
|
||||||
let _ = note.userInfo?["isKeyUpdate"] as? Bool {
|
let _ = note.userInfo?["isKeyUpdate"] as? Bool {
|
||||||
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: newKey)
|
let peerID = PeerID(publicKey: newKey)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.flushOutbox(for: peerID)
|
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)
|
let reachableMesh = mesh.isPeerReachable(peerID)
|
||||||
if reachableMesh {
|
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
|
// BLEService will initiate a handshake if needed and queue the message
|
||||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
} else if canSendViaNostr(peerID: peerID) {
|
} 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)
|
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
} else {
|
} else {
|
||||||
// Queue for later (when mesh connects or Nostr mapping appears)
|
// Queue for later (when mesh connects or Nostr mapping appears)
|
||||||
if outbox[peerID] == nil { outbox[peerID] = [] }
|
if outbox[peerID] == nil { outbox[peerID] = [] }
|
||||||
outbox[peerID]?.append((content, recipientNickname, messageID))
|
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
|
// Prefer mesh for reachable peers; BLE will queue if handshake is needed
|
||||||
if mesh.isPeerReachable(peerID) {
|
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)
|
mesh.sendReadReceipt(receipt, to: peerID)
|
||||||
} else {
|
} 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)
|
nostr.sendReadReceipt(receipt, to: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendDeliveryAck(_ messageID: String, to peerID: String) {
|
func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
|
||||||
if mesh.isPeerReachable(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)
|
mesh.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
} else {
|
} else {
|
||||||
nostr.sendDeliveryAck(for: messageID, to: peerID)
|
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
|
// Route via mesh when connected; else use Nostr
|
||||||
if mesh.isPeerConnected(peerID) {
|
if mesh.isPeerConnected(peerID) {
|
||||||
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||||
@@ -84,16 +84,16 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Outbox Management
|
// MARK: - Outbox Management
|
||||||
private func canSendViaNostr(peerID: String) -> Bool {
|
private func canSendViaNostr(peerID: PeerID) -> Bool {
|
||||||
// Two forms are supported:
|
// Two forms are supported:
|
||||||
// - 64-hex Noise public key (32 bytes)
|
// - 64-hex Noise public key (32 bytes)
|
||||||
// - 16-hex short peer ID (derived from Noise pubkey)
|
// - 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),
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||||
fav.peerNostrPublicKey != nil {
|
fav.peerNostrPublicKey != nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
} else if peerID.count == 16 {
|
} else if peerID.isShort {
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||||
fav.peerNostrPublicKey != nil {
|
fav.peerNostrPublicKey != nil {
|
||||||
return true
|
return true
|
||||||
@@ -102,17 +102,17 @@ final class MessageRouter {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func flushOutbox(for peerID: String) {
|
func flushOutbox(for peerID: PeerID) {
|
||||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
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)] = []
|
var remaining: [(content: String, nickname: String, messageID: String)] = []
|
||||||
// Prefer mesh if connected; else try Nostr if mapping exists
|
// Prefer mesh if connected; else try Nostr if mapping exists
|
||||||
for (content, nickname, messageID) in queued {
|
for (content, nickname, messageID) in queued {
|
||||||
if mesh.isPeerReachable(peerID) {
|
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)
|
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||||
} else if canSendViaNostr(peerID: peerID) {
|
} 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)
|
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||||
} else {
|
} else {
|
||||||
// Keep unsent items queued
|
// Keep unsent items queued
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import Combine
|
import Combine
|
||||||
|
import Tor
|
||||||
|
|
||||||
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
|
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
|
||||||
/// Policy: permit start when either location permissions are authorized OR
|
/// Policy: permit start when either location permissions are authorized OR
|
||||||
@@ -19,6 +20,12 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
// Clean up Combine subscriptions
|
||||||
|
cancellables.removeAll()
|
||||||
|
SecureLogger.debug("NetworkActivationService deinitialized", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
guard !started else { return }
|
guard !started else { return }
|
||||||
started = true
|
started = true
|
||||||
|
|||||||
@@ -115,15 +115,30 @@ enum EncryptionStatus: Equatable {
|
|||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .none:
|
case .none:
|
||||||
return "Encryption failed"
|
return String(localized: "encryption.status.failed", comment: "Status text when encryption failed")
|
||||||
case .noHandshake:
|
case .noHandshake:
|
||||||
return "Not encrypted"
|
return String(localized: "encryption.status.not_encrypted", comment: "Status text when no encryption handshake happened")
|
||||||
case .noiseHandshaking:
|
case .noiseHandshaking:
|
||||||
return "Establishing encryption..."
|
return String(localized: "encryption.status.establishing", comment: "Status text when encryption is being established")
|
||||||
case .noiseSecured:
|
case .noiseSecured:
|
||||||
return "Encrypted"
|
return String(localized: "encryption.status.secured", comment: "Status text when encryption is secured but not verified")
|
||||||
case .noiseVerified:
|
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
|
private let sessionManager: NoiseSessionManager
|
||||||
|
|
||||||
// Peer fingerprints (SHA256 hash of static public key)
|
// Peer fingerprints (SHA256 hash of static public key)
|
||||||
private var peerFingerprints: [String: String] = [:] // peerID -> fingerprint
|
private var peerFingerprints: [PeerID: String] = [:]
|
||||||
private var fingerprintToPeerID: [String: String] = [:] // fingerprint -> peerID
|
private var fingerprintToPeerID: [String: PeerID] = [:]
|
||||||
|
|
||||||
// Thread safety
|
// Thread safety
|
||||||
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
|
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
|
||||||
@@ -163,7 +178,7 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication
|
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
|
// Add a handler for peer authentication
|
||||||
func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) {
|
func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) {
|
||||||
@@ -257,12 +272,11 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
/// Get our identity fingerprint
|
/// Get our identity fingerprint
|
||||||
func getIdentityFingerprint() -> String {
|
func getIdentityFingerprint() -> String {
|
||||||
let hash = SHA256.hash(data: staticIdentityPublicKey.rawRepresentation)
|
staticIdentityPublicKey.rawRepresentation.sha256Fingerprint()
|
||||||
return hash.map { String(format: "%02x", $0) }.joined()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get peer's public key data
|
/// Get peer's public key data
|
||||||
func getPeerPublicKeyData(_ peerID: String) -> Data? {
|
func getPeerPublicKeyData(_ peerID: PeerID) -> Data? {
|
||||||
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
|
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,11 +404,11 @@ final class NoiseEncryptionService {
|
|||||||
// MARK: - Handshake Management
|
// MARK: - Handshake Management
|
||||||
|
|
||||||
/// Initiate a Noise handshake with a peer
|
/// Initiate a Noise handshake with a peer
|
||||||
func initiateHandshake(with peerID: String) throws -> Data {
|
func initiateHandshake(with peerID: PeerID) throws -> Data {
|
||||||
|
|
||||||
// Validate peer ID
|
// Validate peer ID
|
||||||
guard NoiseSecurityValidator.validatePeerID(peerID) else {
|
guard peerID.isValid else {
|
||||||
SecureLogger.warning(.authenticationFailed(peerID: peerID))
|
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||||
throw NoiseSecurityError.invalidPeerID
|
throw NoiseSecurityError.invalidPeerID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,7 +418,7 @@ final class NoiseEncryptionService {
|
|||||||
throw NoiseSecurityError.rateLimitExceeded
|
throw NoiseSecurityError.rateLimitExceeded
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.info(.handshakeStarted(peerID: peerID))
|
SecureLogger.info(.handshakeStarted(peerID: peerID.id))
|
||||||
|
|
||||||
// Return raw handshake data without wrapper
|
// Return raw handshake data without wrapper
|
||||||
// The Noise protocol handles its own message format
|
// The Noise protocol handles its own message format
|
||||||
@@ -413,17 +427,17 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Process an incoming handshake message
|
/// 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
|
// Validate peer ID
|
||||||
guard NoiseSecurityValidator.validatePeerID(peerID) else {
|
guard peerID.isValid else {
|
||||||
SecureLogger.warning(.authenticationFailed(peerID: peerID))
|
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||||
throw NoiseSecurityError.invalidPeerID
|
throw NoiseSecurityError.invalidPeerID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate message size
|
// Validate message size
|
||||||
guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else {
|
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
|
throw NoiseSecurityError.messageTooLarge
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,19 +457,19 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if we have an established session with a peer
|
/// 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
|
return sessionManager.getSession(for: peerID)?.isEstablished() ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if we have a session (established or handshaking) with a peer
|
/// 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
|
return sessionManager.getSession(for: peerID) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Encryption/Decryption
|
// MARK: - Encryption/Decryption
|
||||||
|
|
||||||
/// Encrypt data for a specific peer
|
/// 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
|
// Validate message size
|
||||||
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
||||||
throw NoiseSecurityError.messageTooLarge
|
throw NoiseSecurityError.messageTooLarge
|
||||||
@@ -477,7 +491,7 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt data from a specific peer
|
/// 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
|
// Validate message size
|
||||||
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
||||||
throw NoiseSecurityError.messageTooLarge
|
throw NoiseSecurityError.messageTooLarge
|
||||||
@@ -499,32 +513,11 @@ final class NoiseEncryptionService {
|
|||||||
// MARK: - Peer Management
|
// MARK: - Peer Management
|
||||||
|
|
||||||
/// Get fingerprint for a peer
|
/// Get fingerprint for a peer
|
||||||
func getPeerFingerprint(_ peerID: String) -> String? {
|
func getPeerFingerprint(_ peerID: PeerID) -> String? {
|
||||||
return serviceQueue.sync {
|
return serviceQueue.sync {
|
||||||
return peerFingerprints[peerID]
|
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() {
|
func clearEphemeralStateForPanic() {
|
||||||
sessionManager.removeAllSessions()
|
sessionManager.removeAllSessions()
|
||||||
@@ -537,9 +530,9 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
private func handleSessionEstablished(peerID: String, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
|
private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
|
||||||
// Calculate fingerprint
|
// Calculate fingerprint
|
||||||
let fingerprint = calculateFingerprint(for: remoteStaticKey)
|
let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint()
|
||||||
|
|
||||||
// Store fingerprint mapping
|
// Store fingerprint mapping
|
||||||
serviceQueue.sync(flags: .barrier) {
|
serviceQueue.sync(flags: .barrier) {
|
||||||
@@ -548,20 +541,15 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log security event
|
// Log security event
|
||||||
SecureLogger.info(.handshakeCompleted(peerID: peerID))
|
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||||
|
|
||||||
// Notify all handlers about authentication
|
// Notify all handlers about authentication
|
||||||
serviceQueue.async { [weak self] in
|
serviceQueue.async { [weak self] in
|
||||||
self?.onPeerAuthenticatedHandlers.forEach { handler 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
|
// MARK: - Session Maintenance
|
||||||
|
|
||||||
|
|||||||
@@ -4,46 +4,49 @@ import Combine
|
|||||||
|
|
||||||
// Minimal Nostr transport conforming to Transport for offline sending
|
// Minimal Nostr transport conforming to Transport for offline sending
|
||||||
final class NostrTransport: Transport {
|
final class NostrTransport: Transport {
|
||||||
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
|
// Provide BLE short peer ID for BitChat embedding
|
||||||
var senderPeerID: String = ""
|
var senderPeerID = PeerID(str: "")
|
||||||
|
|
||||||
// Throttle READ receipts to avoid relay rate limits
|
// Throttle READ receipts to avoid relay rate limits
|
||||||
private struct QueuedRead {
|
private struct QueuedRead {
|
||||||
let receipt: ReadReceipt
|
let receipt: ReadReceipt
|
||||||
let peerID: String
|
let peerID: PeerID
|
||||||
}
|
}
|
||||||
private var readQueue: [QueuedRead] = []
|
private var readQueue: [QueuedRead] = []
|
||||||
private var isSendingReadAcks = false
|
private var isSendingReadAcks = false
|
||||||
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
|
|
||||||
var myPeerID: String { senderPeerID }
|
|
||||||
var myNickname: String { "" }
|
|
||||||
func setNickname(_ nickname: String) { /* not used for Nostr */ }
|
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol) {
|
init(keychain: KeychainManagerProtocol) {
|
||||||
self.keychain = keychain
|
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 startServices() { /* no-op */ }
|
||||||
func stopServices() { /* no-op */ }
|
func stopServices() { /* no-op */ }
|
||||||
func emergencyDisconnectAll() { /* no-op */ }
|
func emergencyDisconnectAll() { /* no-op */ }
|
||||||
|
|
||||||
func isPeerConnected(_ peerID: String) -> Bool { false }
|
func isPeerConnected(_ peerID: PeerID) -> Bool { false }
|
||||||
func isPeerReachable(_ peerID: String) -> Bool { false }
|
func isPeerReachable(_ peerID: PeerID) -> Bool { false }
|
||||||
func peerNickname(peerID: String) -> String? { nil }
|
func peerNickname(peerID: PeerID) -> String? { nil }
|
||||||
func getPeerNicknames() -> [String : String] { [:] }
|
func getPeerNicknames() -> [PeerID : String] { [:] }
|
||||||
|
|
||||||
func getFingerprint(for peerID: String) -> String? { nil }
|
func getFingerprint(for peerID: PeerID) -> String? { nil }
|
||||||
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState { .none }
|
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
|
||||||
func triggerHandshake(with peerID: String) { /* no-op */ }
|
func triggerHandshake(with peerID: PeerID) { /* no-op */ }
|
||||||
|
|
||||||
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
|
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
|
||||||
private static var cachedNoiseService: NoiseEncryptionService?
|
private static var cachedNoiseService: NoiseEncryptionService?
|
||||||
@@ -59,11 +62,11 @@ final class NostrTransport: Transport {
|
|||||||
// Public broadcast not supported over Nostr here
|
// Public broadcast not supported over Nostr here
|
||||||
func sendMessage(_ content: String, mentions: [String]) { /* no-op */ }
|
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
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() 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)
|
// Convert recipient npub -> hex (x-only)
|
||||||
let recipientHex: String
|
let recipientHex: String
|
||||||
do {
|
do {
|
||||||
@@ -77,7 +80,7 @@ final class NostrTransport: Transport {
|
|||||||
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
||||||
return
|
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)
|
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||||
return
|
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
|
// Enqueue and process with throttling to avoid relay rate limits
|
||||||
readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
|
readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
|
||||||
processReadQueueIfNeeded()
|
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() {
|
private func processReadQueueIfNeeded() {
|
||||||
guard !isSendingReadAcks else { return }
|
guard !isSendingReadAcks else { return }
|
||||||
guard !readQueue.isEmpty else { return }
|
guard !readQueue.isEmpty else { return }
|
||||||
@@ -117,7 +221,7 @@ final class NostrTransport: Transport {
|
|||||||
guard hrp == "npub" else { scheduleNextReadAck(); return }
|
guard hrp == "npub" else { scheduleNextReadAck(); return }
|
||||||
recipientHex = data.hexEncodedString()
|
recipientHex = data.hexEncodedString()
|
||||||
} catch { scheduleNextReadAck(); return }
|
} 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)
|
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||||
scheduleNextReadAck(); return
|
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
|
@MainActor
|
||||||
private func resolveRecipientNpub(for peerID: String) -> String? {
|
private func resolveRecipientNpub(for peerID: PeerID) -> String? {
|
||||||
if let noiseKey = Data(hexString: peerID),
|
if let noiseKey = Data(hexString: peerID.id),
|
||||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||||
let npub = fav.peerNostrPublicKey {
|
let npub = fav.peerNostrPublicKey {
|
||||||
return npub
|
return npub
|
||||||
}
|
}
|
||||||
if peerID.count == 16,
|
if peerID.id.count == 16,
|
||||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||||
let npub = fav.peerNostrPublicKey {
|
let npub = fav.peerNostrPublicKey {
|
||||||
return npub
|
return npub
|
||||||
}
|
}
|
||||||
return nil
|
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)
|
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
|
// Geohash public chat notification with deep link to a specific geohash
|
||||||
func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) {
|
func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) {
|
||||||
let title = "\(titlePrefix)\(geohash)"
|
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
|
self.meshService = meshService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
SecureLogger.debug("PrivateChatManager deinitialized", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
// Cap for messages stored per private chat
|
// Cap for messages stored per private chat
|
||||||
private let privateChatCap = TransportConfig.privateChatCap
|
private let privateChatCap = TransportConfig.privateChatCap
|
||||||
|
|
||||||
@@ -35,7 +39,7 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
selectedPeer = peerID
|
selectedPeer = peerID
|
||||||
|
|
||||||
// Store fingerprint for persistence across reconnections
|
// Store fingerprint for persistence across reconnections
|
||||||
if let fingerprint = meshService?.getFingerprint(for: peerID) {
|
if let fingerprint = meshService?.getFingerprint(for: PeerID(str: peerID)) {
|
||||||
selectedPeerFingerprint = fingerprint
|
selectedPeerFingerprint = fingerprint
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,104 +57,28 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
selectedPeer = nil
|
selectedPeer = nil
|
||||||
selectedPeerFingerprint = 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
|
/// Remove duplicate messages by ID and keep chronological order
|
||||||
func sanitizeChat(for peerID: String) {
|
func sanitizeChat(for peerID: String) {
|
||||||
guard let arr = privateChats[peerID] else { return }
|
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] = []
|
var deduped: [BitchatMessage] = []
|
||||||
|
deduped.reserveCapacity(arr.count)
|
||||||
|
|
||||||
for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
|
for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
|
||||||
if !seen.contains(msg.id) {
|
if let existing = indexByID[msg.id] {
|
||||||
seen.insert(msg.id)
|
deduped[existing] = msg
|
||||||
deduped.append(msg)
|
|
||||||
} else {
|
} else {
|
||||||
// Replace previous with the latest occurrence (which is later in sort)
|
indexByID[msg.id] = deduped.count
|
||||||
if let index = deduped.firstIndex(where: { $0.id == msg.id }) {
|
deduped.append(msg)
|
||||||
deduped[index] = msg
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
privateChats[peerID] = deduped
|
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
|
// MARK: - Private Methods
|
||||||
|
|
||||||
private func sendReadReceipt(for message: BitchatMessage) {
|
private func sendReadReceipt(for message: BitchatMessage) {
|
||||||
@@ -223,13 +109,13 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
// Create read receipt using the simplified method
|
// Create read receipt using the simplified method
|
||||||
let receipt = ReadReceipt(
|
let receipt = ReadReceipt(
|
||||||
originalMessageID: message.id,
|
originalMessageID: message.id,
|
||||||
readerID: meshService?.myPeerID ?? "",
|
readerID: meshService?.myPeerID.id ?? "",
|
||||||
readerNickname: meshService?.myNickname ?? ""
|
readerNickname: meshService?.myNickname ?? ""
|
||||||
)
|
)
|
||||||
|
|
||||||
// Route via MessageRouter to avoid handshakeRequired spam when session isn't established
|
// Route via MessageRouter to avoid handshakeRequired spam when session isn't established
|
||||||
if let router = messageRouter {
|
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
|
Task { @MainActor in
|
||||||
router.sendReadReceipt(receipt, to: senderPeerID)
|
router.sendReadReceipt(receipt, to: senderPeerID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Combine
|
|||||||
/// Abstract transport interface used by ChatViewModel and services.
|
/// Abstract transport interface used by ChatViewModel and services.
|
||||||
/// BLEService implements this protocol; a future Nostr transport can too.
|
/// BLEService implements this protocol; a future Nostr transport can too.
|
||||||
struct TransportPeerSnapshot: Equatable, Hashable {
|
struct TransportPeerSnapshot: Equatable, Hashable {
|
||||||
let id: String
|
let peerID: PeerID
|
||||||
let nickname: String
|
let nickname: String
|
||||||
let isConnected: Bool
|
let isConnected: Bool
|
||||||
let noisePublicKey: Data?
|
let noisePublicKey: Data?
|
||||||
@@ -12,13 +12,17 @@ struct TransportPeerSnapshot: Equatable, Hashable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protocol Transport: AnyObject {
|
protocol Transport: AnyObject {
|
||||||
// Peer events (preferred over publishers for UI)
|
|
||||||
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
|
|
||||||
// Event sink
|
// Event sink
|
||||||
var delegate: BitchatDelegate? { get set }
|
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
|
// Identity
|
||||||
var myPeerID: String { get }
|
var myPeerID: PeerID { get }
|
||||||
var myNickname: String { get }
|
var myNickname: String { get }
|
||||||
func setNickname(_ nickname: String)
|
func setNickname(_ nickname: String)
|
||||||
|
|
||||||
@@ -28,37 +32,33 @@ protocol Transport: AnyObject {
|
|||||||
func emergencyDisconnectAll()
|
func emergencyDisconnectAll()
|
||||||
|
|
||||||
// Connectivity and peers
|
// Connectivity and peers
|
||||||
func isPeerConnected(_ peerID: String) -> Bool
|
func isPeerConnected(_ peerID: PeerID) -> Bool
|
||||||
func isPeerReachable(_ peerID: String) -> Bool
|
func isPeerReachable(_ peerID: PeerID) -> Bool
|
||||||
func peerNickname(peerID: String) -> String?
|
func peerNickname(peerID: PeerID) -> String?
|
||||||
func getPeerNicknames() -> [String: String]
|
func getPeerNicknames() -> [PeerID: String]
|
||||||
|
|
||||||
// Protocol utilities
|
// Protocol utilities
|
||||||
func getFingerprint(for peerID: String) -> String?
|
func getFingerprint(for peerID: PeerID) -> String?
|
||||||
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState
|
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState
|
||||||
func triggerHandshake(with peerID: String)
|
func triggerHandshake(with peerID: PeerID)
|
||||||
func getNoiseService() -> NoiseEncryptionService
|
func getNoiseService() -> NoiseEncryptionService
|
||||||
|
|
||||||
// Messaging
|
// Messaging
|
||||||
func sendMessage(_ content: String, mentions: [String])
|
func sendMessage(_ content: String, mentions: [String])
|
||||||
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String)
|
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String)
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
||||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool)
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
||||||
func sendBroadcastAnnounce()
|
func sendBroadcastAnnounce()
|
||||||
func sendDeliveryAck(for messageID: String, to peerID: String)
|
func sendDeliveryAck(for messageID: String, to peerID: PeerID)
|
||||||
|
|
||||||
// QR verification (optional for transports)
|
// QR verification (optional for transports)
|
||||||
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||||
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||||
|
|
||||||
// Peer snapshots (for non-UI services)
|
|
||||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
|
|
||||||
func currentPeerSnapshots() -> [TransportPeerSnapshot]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Transport {
|
extension Transport {
|
||||||
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||||
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
|
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol TransportPeerEventsDelegate: AnyObject {
|
protocol TransportPeerEventsDelegate: AnyObject {
|
||||||
|
|||||||
@@ -41,12 +41,6 @@ enum TransportConfig {
|
|||||||
static let uiLateInsertThresholdGeo: TimeInterval = 0.0
|
static let uiLateInsertThresholdGeo: TimeInterval = 0.0
|
||||||
static let uiProcessedNostrEventsCap: Int = 2000
|
static let uiProcessedNostrEventsCap: Int = 2000
|
||||||
static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60
|
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
|
// UI sleeps/delays
|
||||||
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
|
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import BitLogger
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
/// Single source of truth for peer state, combining mesh connectivity and favorites
|
/// Single source of truth for peer state, combining mesh connectivity and favorites
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -19,14 +18,14 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
// MARK: - Published Properties
|
// MARK: - Published Properties
|
||||||
|
|
||||||
@Published private(set) var peers: [BitchatPeer] = []
|
@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 favorites: [BitchatPeer] = []
|
||||||
@Published private(set) var mutualFavorites: [BitchatPeer] = []
|
@Published private(set) var mutualFavorites: [BitchatPeer] = []
|
||||||
|
|
||||||
// MARK: - Private Properties
|
// MARK: - Private Properties
|
||||||
|
|
||||||
private var peerIndex: [String: BitchatPeer] = [:]
|
private var peerIndex: [PeerID: BitchatPeer] = [:]
|
||||||
private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint
|
private var fingerprintCache: [PeerID: String] = [:]
|
||||||
private let meshService: Transport
|
private let meshService: Transport
|
||||||
private let identityManager: SecureIdentityStateManagerProtocol
|
private let identityManager: SecureIdentityStateManagerProtocol
|
||||||
weak var messageRouter: MessageRouter?
|
weak var messageRouter: MessageRouter?
|
||||||
@@ -47,7 +46,17 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
updatePeers()
|
updatePeers()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
// Clean up NotificationCenter observers
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
|
||||||
|
// Clean up Combine subscriptions
|
||||||
|
cancellables.removeAll()
|
||||||
|
|
||||||
|
SecureLogger.debug("UnifiedPeerService deinitialized", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Setup
|
// MARK: - Setup
|
||||||
|
|
||||||
private func setupSubscriptions() {
|
private func setupSubscriptions() {
|
||||||
@@ -78,12 +87,12 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
let favorites = favoritesService.favorites
|
let favorites = favoritesService.favorites
|
||||||
|
|
||||||
var enrichedPeers: [BitchatPeer] = []
|
var enrichedPeers: [BitchatPeer] = []
|
||||||
var connected: Set<String> = []
|
var connected: Set<PeerID> = []
|
||||||
var addedPeerIDs: Set<String> = []
|
var addedPeerIDs: Set<PeerID> = []
|
||||||
|
|
||||||
// Phase 1: Add all mesh peers (connected and reachable)
|
// Phase 1: Add all mesh peers (connected and reachable)
|
||||||
for peerInfo in meshPeers {
|
for peerInfo in meshPeers {
|
||||||
let peerID = peerInfo.id
|
let peerID = peerInfo.peerID
|
||||||
guard peerID != meshService.myPeerID else { continue } // Never add self
|
guard peerID != meshService.myPeerID else { continue } // Never add self
|
||||||
|
|
||||||
let peer = buildPeerFromMesh(
|
let peer = buildPeerFromMesh(
|
||||||
@@ -104,7 +113,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
// Phase 2: Add offline favorites that we actively favorite
|
// Phase 2: Add offline favorites that we actively favorite
|
||||||
for (favoriteKey, favorite) in favorites where favorite.isFavorite {
|
for (favoriteKey, favorite) in favorites where favorite.isFavorite {
|
||||||
let peerID = favoriteKey.hexEncodedString()
|
let peerID = PeerID(hexData: favoriteKey)
|
||||||
|
|
||||||
// Skip if already added (connected peer)
|
// Skip if already added (connected peer)
|
||||||
if addedPeerIDs.contains(peerID) { continue }
|
if addedPeerIDs.contains(peerID) { continue }
|
||||||
@@ -138,10 +147,10 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
// Phase 4: Build subsets and indices
|
// Phase 4: Build subsets and indices
|
||||||
var favoritesList: [BitchatPeer] = []
|
var favoritesList: [BitchatPeer] = []
|
||||||
var mutualsList: [BitchatPeer] = []
|
var mutualsList: [BitchatPeer] = []
|
||||||
var newIndex: [String: BitchatPeer] = [:]
|
var newIndex: [PeerID: BitchatPeer] = [:]
|
||||||
|
|
||||||
for peer in enrichedPeers {
|
for peer in enrichedPeers {
|
||||||
newIndex[peer.id] = peer
|
newIndex[peer.peerID] = peer
|
||||||
|
|
||||||
if peer.isFavorite {
|
if peer.isFavorite {
|
||||||
favoritesList.append(peer)
|
favoritesList.append(peer)
|
||||||
@@ -185,7 +194,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached)
|
let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached)
|
||||||
|
|
||||||
var peer = BitchatPeer(
|
var peer = BitchatPeer(
|
||||||
id: peerInfo.id,
|
peerID: peerInfo.peerID,
|
||||||
noisePublicKey: peerInfo.noisePublicKey ?? Data(),
|
noisePublicKey: peerInfo.noisePublicKey ?? Data(),
|
||||||
nickname: peerInfo.nickname,
|
nickname: peerInfo.nickname,
|
||||||
lastSeen: peerInfo.lastSeen,
|
lastSeen: peerInfo.lastSeen,
|
||||||
@@ -205,10 +214,10 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
private func buildPeerFromFavorite(
|
private func buildPeerFromFavorite(
|
||||||
favorite: FavoritesPersistenceService.FavoriteRelationship,
|
favorite: FavoritesPersistenceService.FavoriteRelationship,
|
||||||
peerID: String
|
peerID: PeerID
|
||||||
) -> BitchatPeer {
|
) -> BitchatPeer {
|
||||||
var peer = BitchatPeer(
|
var peer = BitchatPeer(
|
||||||
id: peerID,
|
peerID: peerID,
|
||||||
noisePublicKey: favorite.peerNoisePublicKey,
|
noisePublicKey: favorite.peerNoisePublicKey,
|
||||||
nickname: favorite.peerNickname,
|
nickname: favorite.peerNickname,
|
||||||
lastSeen: favorite.lastUpdated,
|
lastSeen: favorite.lastUpdated,
|
||||||
@@ -225,27 +234,22 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
// MARK: - Public Methods
|
// MARK: - Public Methods
|
||||||
|
|
||||||
/// Get peer by ID
|
/// Get peer by ID
|
||||||
func getPeer(by id: String) -> BitchatPeer? {
|
func getPeer(by peerID: PeerID) -> BitchatPeer? {
|
||||||
return peerIndex[id]
|
return peerIndex[peerID]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get peer ID for nickname
|
/// Get peer ID for nickname
|
||||||
func getPeerID(for nickname: String) -> String? {
|
func getPeerID(for nickname: String) -> String? {
|
||||||
for peer in peers {
|
for peer in peers {
|
||||||
if peer.displayName == nickname || peer.nickname == nickname {
|
if peer.displayName == nickname || peer.nickname == nickname {
|
||||||
return peer.id
|
return peer.peerID.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if peer is online
|
|
||||||
func isOnline(_ peerID: String) -> Bool {
|
|
||||||
return connectedPeerIDs.contains(peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if peer is blocked
|
/// Check if peer is blocked
|
||||||
func isBlocked(_ peerID: String) -> Bool {
|
func isBlocked(_ peerID: PeerID) -> Bool {
|
||||||
// Get fingerprint
|
// Get fingerprint
|
||||||
guard let fingerprint = getFingerprint(for: peerID) else { return false }
|
guard let fingerprint = getFingerprint(for: peerID) else { return false }
|
||||||
|
|
||||||
@@ -258,8 +262,8 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle favorite status
|
/// Toggle favorite status
|
||||||
func toggleFavorite(_ peerID: String) {
|
func toggleFavorite(_ peerID: PeerID) {
|
||||||
guard let peer = getPeer(by: peerID) else {
|
guard let peer = getPeer(by: peerID) else {
|
||||||
SecureLogger.warning("⚠️ Cannot toggle favorite - peer not found: \(peerID)", category: .session)
|
SecureLogger.warning("⚠️ Cannot toggle favorite - peer not found: \(peerID)", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -322,39 +326,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle blocked status
|
func getFingerprint(for peerID: PeerID) -> String? {
|
||||||
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? {
|
|
||||||
// Check cache first
|
// Check cache first
|
||||||
if let cached = fingerprintCache[peerID] {
|
if let cached = fingerprintCache[peerID] {
|
||||||
return cached
|
return cached
|
||||||
@@ -379,23 +351,13 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
// MARK: - Compatibility Methods (for easy migration)
|
// MARK: - Compatibility Methods (for easy migration)
|
||||||
|
|
||||||
var allPeers: [BitchatPeer] { peers }
|
var allPeers: [BitchatPeer] { peers }
|
||||||
var connectedPeers: [String] { Array(connectedPeerIDs) }
|
var connectedPeers: [PeerID] { Array(connectedPeerIDs) }
|
||||||
var favoritePeers: Set<String> {
|
var favoritePeers: Set<String> {
|
||||||
Set(favorites.compactMap { getFingerprint(for: $0.id) })
|
Set(favorites.compactMap { getFingerprint(for: $0.peerID) })
|
||||||
}
|
}
|
||||||
var blockedUsers: Set<String> {
|
var blockedUsers: Set<String> {
|
||||||
Set(peers.compactMap { peer in
|
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 Foundation
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
/// QR verification scaffolding: schema, signing, and basic challenge/response helpers.
|
/// QR verification scaffolding: schema, signing, and basic challenge/response helpers.
|
||||||
final class VerificationService {
|
final class VerificationService {
|
||||||
@@ -95,7 +94,7 @@ final class VerificationService {
|
|||||||
nickname: payload.nickname,
|
nickname: payload.nickname,
|
||||||
ts: payload.ts,
|
ts: payload.ts,
|
||||||
nonceB64: payload.nonceB64,
|
nonceB64: payload.nonceB64,
|
||||||
sigHex: sig.map { String(format: "%02x", $0) }.joined())
|
sigHex: sig.hexEncodedString())
|
||||||
let out = signed.toURLString()
|
let out = signed.toURLString()
|
||||||
Cache.last = (nickname, npub, Date(), out)
|
Cache.last = (nickname, npub, Date(), out)
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Foundation
|
|||||||
final class GossipSyncManager {
|
final class GossipSyncManager {
|
||||||
protocol Delegate: AnyObject {
|
protocol Delegate: AnyObject {
|
||||||
func sendPacket(_ packet: BitchatPacket)
|
func sendPacket(_ packet: BitchatPacket)
|
||||||
func sendPacket(to peerID: String, packet: BitchatPacket)
|
func sendPacket(to peerID: PeerID, packet: BitchatPacket)
|
||||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> 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 seenCapacity: Int = 1000 // max packets per sync (cap across types)
|
||||||
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
||||||
var gcsTargetFpr: Double = 0.01 // 1%
|
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
|
private let config: Config
|
||||||
weak var delegate: Delegate?
|
weak var delegate: Delegate?
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ final class GossipSyncManager {
|
|||||||
private var periodicTimer: DispatchSourceTimer?
|
private var periodicTimer: DispatchSourceTimer?
|
||||||
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
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.myPeerID = myPeerID
|
||||||
self.config = config
|
self.config = config
|
||||||
}
|
}
|
||||||
@@ -36,7 +37,10 @@ final class GossipSyncManager {
|
|||||||
stop()
|
stop()
|
||||||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||||
timer.schedule(deadline: .now() + 30.0, repeating: 30.0, leeway: .seconds(1))
|
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()
|
timer.resume()
|
||||||
periodicTimer = timer
|
periodicTimer = timer
|
||||||
}
|
}
|
||||||
@@ -45,7 +49,7 @@ final class GossipSyncManager {
|
|||||||
periodicTimer?.cancel(); periodicTimer = nil
|
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
|
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
||||||
self?.sendRequestSync(to: peerID)
|
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) {
|
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
||||||
let mt = MessageType(rawValue: packet.type)
|
let mt = MessageType(rawValue: packet.type)
|
||||||
let isBroadcastRecipient: Bool = {
|
let isBroadcastRecipient: Bool = {
|
||||||
@@ -67,6 +83,9 @@ final class GossipSyncManager {
|
|||||||
let isAnnounce = (mt == .announce)
|
let isAnnounce = (mt == .announce)
|
||||||
guard isBroadcastMessage || isAnnounce else { return }
|
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()
|
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||||
|
|
||||||
if isBroadcastMessage {
|
if isBroadcastMessage {
|
||||||
@@ -90,7 +109,7 @@ final class GossipSyncManager {
|
|||||||
let payload = buildGcsPayload()
|
let payload = buildGcsPayload()
|
||||||
let pkt = BitchatPacket(
|
let pkt = BitchatPacket(
|
||||||
type: MessageType.requestSync.rawValue,
|
type: MessageType.requestSync.rawValue,
|
||||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
senderID: Data(hexString: myPeerID.id) ?? Data(),
|
||||||
recipientID: nil, // broadcast
|
recipientID: nil, // broadcast
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -101,10 +120,10 @@ final class GossipSyncManager {
|
|||||||
delegate?.sendPacket(signed)
|
delegate?.sendPacket(signed)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendRequestSync(to peerID: String) {
|
private func sendRequestSync(to peerID: PeerID) {
|
||||||
let payload = buildGcsPayload()
|
let payload = buildGcsPayload()
|
||||||
var recipient = Data()
|
var recipient = Data()
|
||||||
var temp = peerID
|
var temp = peerID.id
|
||||||
while temp.count >= 2 && recipient.count < 8 {
|
while temp.count >= 2 && recipient.count < 8 {
|
||||||
let hexByte = String(temp.prefix(2))
|
let hexByte = String(temp.prefix(2))
|
||||||
if let b = UInt8(hexByte, radix: 16) { recipient.append(b) }
|
if let b = UInt8(hexByte, radix: 16) { recipient.append(b) }
|
||||||
@@ -112,7 +131,7 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
let pkt = BitchatPacket(
|
let pkt = BitchatPacket(
|
||||||
type: MessageType.requestSync.rawValue,
|
type: MessageType.requestSync.rawValue,
|
||||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
senderID: Data(hexString: myPeerID.id) ?? Data(),
|
||||||
recipientID: recipient,
|
recipientID: recipient,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -123,13 +142,13 @@ final class GossipSyncManager {
|
|||||||
delegate?.sendPacket(to: peerID, packet: signed)
|
delegate?.sendPacket(to: peerID, packet: signed)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
|
func handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
|
||||||
queue.async { [weak self] in
|
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
|
// Decode GCS into sorted set and prepare membership checker
|
||||||
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
||||||
func mightContain(_ id: Data) -> Bool {
|
func mightContain(_ id: Data) -> Bool {
|
||||||
@@ -137,36 +156,46 @@ final class GossipSyncManager {
|
|||||||
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
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 {
|
for (_, pair) in latestAnnouncementByPeer {
|
||||||
let (idHex, pkt) = pair
|
let (idHex, pkt) = pair
|
||||||
|
guard isPacketFresh(pkt) else { continue }
|
||||||
let idBytes = Data(hexString: idHex) ?? Data()
|
let idBytes = Data(hexString: idHex) ?? Data()
|
||||||
if !mightContain(idBytes) {
|
if !mightContain(idBytes) {
|
||||||
var toSend = pkt
|
var toSend = pkt
|
||||||
toSend.ttl = 0
|
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] }
|
let toSendMsgs = messageOrder.compactMap { messages[$0] }
|
||||||
for pkt in toSendMsgs {
|
for pkt in toSendMsgs {
|
||||||
|
guard isPacketFresh(pkt) else { continue }
|
||||||
let idBytes = PacketIdUtil.computeId(pkt)
|
let idBytes = PacketIdUtil.computeId(pkt)
|
||||||
if !mightContain(idBytes) {
|
if !mightContain(idBytes) {
|
||||||
var toSend = pkt
|
var toSend = pkt
|
||||||
toSend.ttl = 0
|
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
|
// Build REQUEST_SYNC payload using current candidates and GCS params
|
||||||
private func buildGcsPayload() -> Data {
|
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] = []
|
var candidates: [BitchatPacket] = []
|
||||||
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count)
|
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count)
|
||||||
for (_, pair) in latestAnnouncementByPeer { candidates.append(pair.packet) }
|
for (_, pair) in latestAnnouncementByPeer {
|
||||||
for id in messageOrder { if let p = messages[id] { candidates.append(p) } }
|
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
|
// Sort by timestamp desc
|
||||||
candidates.sort { $0.timestamp > $1.timestamp }
|
candidates.sort { $0.timestamp > $1.timestamp }
|
||||||
|
|
||||||
@@ -184,15 +213,32 @@ final class GossipSyncManager {
|
|||||||
return req.encode()
|
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
|
// Explicit removal hook for LEAVE/stale peer
|
||||||
func removeAnnouncementForPeer(_ peerID: String) {
|
func removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||||
queue.async { [weak self] in
|
queue.async { [weak self] in
|
||||||
self?._removeAnnouncementForPeer(peerID)
|
self?._removeAnnouncementForPeer(peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func _removeAnnouncementForPeer(_ peerID: String) {
|
private func _removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||||
let normalizedPeerID = peerID.lowercased()
|
let normalizedPeerID = peerID.id.lowercased()
|
||||||
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
|
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
|
||||||
|
|
||||||
// Remove messages from this peer
|
// Remove messages from this peer
|
||||||
|
|||||||
@@ -14,8 +14,4 @@ enum PacketIdUtil {
|
|||||||
let digest = hasher.finalize()
|
let digest = hasher.finalize()
|
||||||
return Data(digest.prefix(16))
|
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
|
return .caption2
|
||||||
case ..<13.0:
|
case ..<13.0:
|
||||||
return .caption
|
return .caption
|
||||||
case ..<14.0:
|
case ..<13.75:
|
||||||
return .footnote
|
return .footnote
|
||||||
case ..<16.5:
|
case ..<15.5:
|
||||||
|
return .subheadline
|
||||||
|
case ..<17.5:
|
||||||
|
return .callout
|
||||||
|
case ..<19.5:
|
||||||
return .body
|
return .body
|
||||||
case ..<19.0:
|
case ..<22.5:
|
||||||
return .title3
|
return .title3
|
||||||
case ..<23.0:
|
case ..<27.5:
|
||||||
return .title2
|
return .title2
|
||||||
case ..<30.0:
|
case ..<34.0:
|
||||||
return .title
|
return .title
|
||||||
default:
|
default:
|
||||||
return .largeTitle
|
return .largeTitle
|
||||||
|
|||||||
@@ -11,51 +11,24 @@ struct InputValidator {
|
|||||||
// BinaryProtocol caps payload length at UInt16.max (65_535). Leave headroom
|
// BinaryProtocol caps payload length at UInt16.max (65_535). Leave headroom
|
||||||
// for headers/padding by limiting user content to 60_000 bytes.
|
// for headers/padding by limiting user content to 60_000 bytes.
|
||||||
static let maxMessageLength = 60_000
|
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
|
// MARK: - String Content Validation
|
||||||
|
|
||||||
/// Validates and sanitizes user-provided strings (nicknames, messages)
|
/// Validates and sanitizes user-provided strings used in UI
|
||||||
static func validateUserString(_ string: String, maxLength: Int, allowNewlines: Bool = false) -> String? {
|
static func validateUserString(_ string: String, maxLength: Int) -> String? {
|
||||||
// Check empty
|
// Check empty
|
||||||
guard !string.isEmpty else { return nil }
|
guard !string.isEmpty else { return nil }
|
||||||
|
|
||||||
// Trim whitespace
|
// Trim whitespace
|
||||||
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return nil }
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
|
||||||
// Check length
|
// Check length
|
||||||
guard trimmed.count <= maxLength else { return nil }
|
guard trimmed.count <= maxLength else { return nil }
|
||||||
|
|
||||||
// Remove control characters except allowed ones
|
// Remove control characters
|
||||||
var allowedControlChars = CharacterSet()
|
let controlChars = CharacterSet.controlCharacters
|
||||||
if allowNewlines {
|
|
||||||
allowedControlChars.insert(charactersIn: "\n\r")
|
|
||||||
}
|
|
||||||
|
|
||||||
let controlChars = CharacterSet.controlCharacters.subtracting(allowedControlChars)
|
|
||||||
let cleaned = trimmed.components(separatedBy: controlChars).joined()
|
let cleaned = trimmed.components(separatedBy: controlChars).joined()
|
||||||
|
|
||||||
// Ensure valid UTF-8 (should already be, but double-check)
|
// Ensure valid UTF-8 (should already be, but double-check)
|
||||||
@@ -70,29 +43,14 @@ struct InputValidator {
|
|||||||
|
|
||||||
/// Validates nickname
|
/// Validates nickname
|
||||||
static func validateNickname(_ nickname: String) -> String? {
|
static func validateNickname(_ nickname: String) -> String? {
|
||||||
return validateUserString(nickname, maxLength: Limits.maxNicknameLength, allowNewlines: false)
|
return validateUserString(nickname, maxLength: Limits.maxNicknameLength)
|
||||||
}
|
|
||||||
|
|
||||||
/// 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Protocol Field Validation
|
// MARK: - Protocol Field Validation
|
||||||
|
|
||||||
// Note: Message type validation is performed closer to decoding using
|
// Note: Message type validation is performed closer to decoding using
|
||||||
// MessageType/NoisePayloadType enums; keeping validator free of stale lists.
|
// 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)
|
/// Validates timestamp is reasonable (not too far in past or future)
|
||||||
static func validateTimestamp(_ timestamp: Date) -> Bool {
|
static func validateTimestamp(_ timestamp: Date) -> Bool {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
@@ -100,38 +58,5 @@ struct InputValidator {
|
|||||||
let oneHourFromNow = now.addingTimeInterval(3600)
|
let oneHourFromNow = now.addingTimeInterval(3600)
|
||||||
return timestamp >= oneHourAgo && timestamp <= oneHourFromNow
|
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 {
|
struct PeerDisplayNameResolver {
|
||||||
/// Computes display names with a `#xxxx` suffix for connected peers when nickname collisions occur.
|
/// Computes display names with a `#xxxx` suffix for connected peers when nickname collisions occur.
|
||||||
/// - Parameters:
|
/// - 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.
|
/// - selfNickname: The local user's current nickname, included in collision counts to suffix remotes matching it.
|
||||||
/// - Returns: Map of peerID -> displayName.
|
/// - 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
|
// Count collisions among connected peers and include our own nickname
|
||||||
var counts: [String: Int] = [:]
|
var counts: [String: Int] = [:]
|
||||||
for p in peers where p.isConnected {
|
for p in peers where p.isConnected {
|
||||||
@@ -15,13 +15,13 @@ struct PeerDisplayNameResolver {
|
|||||||
}
|
}
|
||||||
counts[selfNickname, default: 0] += 1
|
counts[selfNickname, default: 0] += 1
|
||||||
|
|
||||||
var result: [String: String] = [:]
|
var result: [PeerID: String] = [:]
|
||||||
for p in peers {
|
for p in peers {
|
||||||
var name = p.nickname
|
var name = p.nickname
|
||||||
if p.isConnected, (counts[p.nickname] ?? 0) > 1 {
|
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
|
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
|
// MARK: - Constants
|
||||||
private enum Strings {
|
private enum Strings {
|
||||||
static let appName = "bitchat"
|
static let appName: LocalizedStringKey = "app_info.app_name"
|
||||||
static let tagline = "sidegroupchat"
|
static let tagline: LocalizedStringKey = "app_info.tagline"
|
||||||
|
|
||||||
enum Features {
|
enum Features {
|
||||||
static let title = "FEATURES"
|
static let title: LocalizedStringKey = "app_info.features.title"
|
||||||
static let offlineComm = ("wifi.slash", "offline communication", "works without internet using Bluetooth low energy")
|
static let offlineComm = AppInfoFeatureInfo(
|
||||||
static let encryption = ("lock.shield", "end-to-end encryption", "private messages encrypted with noise protocol")
|
icon: "wifi.slash",
|
||||||
static let extendedRange = ("antenna.radiowaves.left.and.right", "extended range", "messages relay through peers, going the distance")
|
title: "app_info.features.offline.title",
|
||||||
static let mentions = ("at", "mentions", "use @nickname to notify specific people")
|
description: "app_info.features.offline.description"
|
||||||
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 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 {
|
enum Privacy {
|
||||||
static let title = "PRIVACY"
|
static let title: LocalizedStringKey = "app_info.privacy.title"
|
||||||
static let noTracking = ("eye.slash", "no tracking", "no servers, accounts, or data collection")
|
static let noTracking = AppInfoFeatureInfo(
|
||||||
static let ephemeral = ("shuffle", "ephemeral identity", "new peer ID generated regularly")
|
icon: "eye.slash",
|
||||||
static let panic = ("hand.raised.fill", "panic mode", "triple-tap logo to instantly clear all data")
|
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 {
|
enum HowToUse {
|
||||||
static let title = "HOW TO USE"
|
static let title: LocalizedStringKey = "app_info.how_to_use.title"
|
||||||
static let instructions = [
|
static let instructions: [LocalizedStringKey] = [
|
||||||
"• set your nickname by tapping it",
|
"app_info.how_to_use.set_nickname",
|
||||||
"• tap #mesh to change channels",
|
"app_info.how_to_use.change_channels",
|
||||||
"• tap people icon for sidebar",
|
"app_info.how_to_use.open_sidebar",
|
||||||
"• tap a peer's name to start a DM",
|
"app_info.how_to_use.start_dm",
|
||||||
"• triple-tap chat to clear",
|
"app_info.how_to_use.clear_chat",
|
||||||
"• type / for commands"
|
"app_info.how_to_use.commands"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Warning {
|
enum Warning {
|
||||||
static let title = "WARNING"
|
static let title: LocalizedStringKey = "app_info.warning.title"
|
||||||
static let message = "private message security has not yet been fully audited. do not use for critical situations until this warning disappears."
|
static let message: LocalizedStringKey = "app_info.warning.message"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +98,7 @@ struct AppInfoView: View {
|
|||||||
// Custom header for macOS
|
// Custom header for macOS
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("DONE") {
|
Button("app_info.done") {
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -93,7 +129,7 @@ struct AppInfoView: View {
|
|||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel("Close")
|
.accessibilityLabel("app_info.close")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,7 +157,7 @@ struct AppInfoView: View {
|
|||||||
SectionHeader(Strings.HowToUse.title)
|
SectionHeader(Strings.HowToUse.title)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
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)
|
Text(instruction)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,49 +168,31 @@ struct AppInfoView: View {
|
|||||||
// Features
|
// Features
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
SectionHeader(Strings.Features.title)
|
SectionHeader(Strings.Features.title)
|
||||||
|
|
||||||
FeatureRow(icon: Strings.Features.offlineComm.0,
|
FeatureRow(info: Strings.Features.offlineComm)
|
||||||
title: Strings.Features.offlineComm.1,
|
|
||||||
description: Strings.Features.offlineComm.2)
|
FeatureRow(info: Strings.Features.encryption)
|
||||||
|
|
||||||
FeatureRow(icon: Strings.Features.encryption.0,
|
FeatureRow(info: Strings.Features.extendedRange)
|
||||||
title: Strings.Features.encryption.1,
|
|
||||||
description: Strings.Features.encryption.2)
|
FeatureRow(info: Strings.Features.favorites)
|
||||||
|
|
||||||
FeatureRow(icon: Strings.Features.extendedRange.0,
|
FeatureRow(info: Strings.Features.geohash)
|
||||||
title: Strings.Features.extendedRange.1,
|
|
||||||
description: Strings.Features.extendedRange.2)
|
FeatureRow(info: Strings.Features.mentions)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Privacy
|
// Privacy
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
SectionHeader(Strings.Privacy.title)
|
SectionHeader(Strings.Privacy.title)
|
||||||
|
|
||||||
FeatureRow(icon: Strings.Privacy.noTracking.0,
|
FeatureRow(info: Strings.Privacy.noTracking)
|
||||||
title: Strings.Privacy.noTracking.1,
|
|
||||||
description: Strings.Privacy.noTracking.2)
|
FeatureRow(info: Strings.Privacy.ephemeral)
|
||||||
|
|
||||||
FeatureRow(icon: Strings.Privacy.ephemeral.0,
|
FeatureRow(info: Strings.Privacy.panic)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warning
|
// Warning
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
SectionHeader(Strings.Warning.title)
|
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 {
|
struct SectionHeader: View {
|
||||||
let title: String
|
let title: LocalizedStringKey
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
private var textColor: Color {
|
private var textColor: Color {
|
||||||
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
init(_ title: String) {
|
init(_ title: LocalizedStringKey) {
|
||||||
self.title = title
|
self.title = title
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,9 +242,7 @@ struct SectionHeader: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct FeatureRow: View {
|
struct FeatureRow: View {
|
||||||
let icon: String
|
let info: AppInfoFeatureInfo
|
||||||
let title: String
|
|
||||||
let description: String
|
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
private var textColor: Color {
|
private var textColor: Color {
|
||||||
@@ -233,17 +255,17 @@ struct FeatureRow: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
Image(systemName: icon)
|
Image(systemName: info.icon)
|
||||||
.font(.bitchatSystem(size: 20))
|
.font(.bitchatSystem(size: 20))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.frame(width: 30)
|
.frame(width: 30)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(title)
|
Text(info.title)
|
||||||
.font(.bitchatSystem(size: 14, weight: .semibold, design: .monospaced))
|
.font(.bitchatSystem(size: 14, weight: .semibold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
Text(description)
|
Text(info.description)
|
||||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.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 notesGeohash: String? = nil
|
||||||
@State private var sheetNotesCount: Int = 0
|
@State private var sheetNotesCount: Int = 0
|
||||||
@ScaledMetric(relativeTo: .body) private var headerHeight: CGFloat = 44
|
@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
|
// Timer-based refresh removed; use LocationChannelManager live updates instead
|
||||||
// Window sizes for rendering (infinite scroll up)
|
// Window sizes for rendering (infinite scroll up)
|
||||||
@State private var windowCountPublic: Int = 300
|
@State private var windowCountPublic: Int = 300
|
||||||
@@ -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
|
// Sidebar overlay
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
// Tap to dismiss area
|
// Tap to dismiss area
|
||||||
@@ -139,31 +180,19 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only render sidebar content when it's visible or animating
|
// Always render sidebar to avoid layout recalculation during drag
|
||||||
if showSidebar || sidebarDragOffset != 0 {
|
sidebarView
|
||||||
sidebarView
|
#if os(macOS)
|
||||||
#if os(macOS)
|
.frame(width: min(300, max(0, geometry.size.width.isNaN ? 300 : geometry.size.width) * 0.4))
|
||||||
.frame(width: min(300, max(0, geometry.size.width.isNaN ? 300 : geometry.size.width) * 0.4))
|
#else
|
||||||
#else
|
.frame(width: max(0, geometry.size.width.isNaN ? 300 : geometry.size.width) * 0.7)
|
||||||
.frame(width: max(0, geometry.size.width.isNaN ? 300 : geometry.size.width) * 0.7)
|
#endif
|
||||||
#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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.offset(x: {
|
.offset(x: {
|
||||||
let dragOffset = sidebarDragOffset.isNaN ? 0 : sidebarDragOffset
|
let dragOffset = sidebarDragOffset.isNaN ? 0 : sidebarDragOffset
|
||||||
let width = geometry.size.width.isNaN ? 0 : max(0, geometry.size.width)
|
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)
|
#if os(macOS)
|
||||||
@@ -188,11 +217,11 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
selectedMessageSender.map { "@\($0)" } ?? "Actions",
|
selectedMessageSender.map { "@\($0)" } ?? String(localized: "content.actions.title", comment: "Fallback title for the message action sheet"),
|
||||||
isPresented: $showMessageActions,
|
isPresented: $showMessageActions,
|
||||||
titleVisibility: .visible
|
titleVisibility: .visible
|
||||||
) {
|
) {
|
||||||
Button("mention") {
|
Button("content.actions.mention") {
|
||||||
if let sender = selectedMessageSender {
|
if let sender = selectedMessageSender {
|
||||||
// Pre-fill the input with an @mention and focus the field
|
// Pre-fill the input with an @mention and focus the field
|
||||||
messageText = "@\(sender) "
|
messageText = "@\(sender) "
|
||||||
@@ -200,7 +229,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("direct message") {
|
Button("content.actions.direct_message") {
|
||||||
if let peerID = selectedMessageSenderID {
|
if let peerID = selectedMessageSenderID {
|
||||||
if peerID.hasPrefix("nostr:") {
|
if peerID.hasPrefix("nostr:") {
|
||||||
if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
|
if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
|
||||||
@@ -215,20 +244,20 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("hug") {
|
Button("content.actions.hug") {
|
||||||
if let sender = selectedMessageSender {
|
if let sender = selectedMessageSender {
|
||||||
viewModel.sendMessage("/hug @\(sender)")
|
viewModel.sendMessage("/hug @\(sender)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("slap") {
|
Button("content.actions.slap") {
|
||||||
if let sender = selectedMessageSender {
|
if let sender = selectedMessageSender {
|
||||||
viewModel.sendMessage("/slap @\(sender)")
|
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
|
// Prefer direct geohash block when we have a Nostr sender ID
|
||||||
if let peerID = selectedMessageSenderID, peerID.hasPrefix("nostr:"),
|
if let peerID = selectedMessageSenderID, peerID.hasPrefix("nostr:"),
|
||||||
let full = viewModel.fullNostrHex(forSenderPeerID: peerID),
|
let full = viewModel.fullNostrHex(forSenderPeerID: peerID),
|
||||||
@@ -238,18 +267,18 @@ struct ContentView: View {
|
|||||||
viewModel.sendMessage("/block \(sender)")
|
viewModel.sendMessage("/block \(sender)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("cancel", role: .cancel) {}
|
Button("common.cancel", role: .cancel) {}
|
||||||
}
|
}
|
||||||
.alert("Bluetooth Required", isPresented: $viewModel.showBluetoothAlert) {
|
.alert("content.alert.bluetooth_required.title", isPresented: $viewModel.showBluetoothAlert) {
|
||||||
Button("Settings") {
|
Button("content.alert.bluetooth_required.settings") {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||||
UIApplication.shared.open(url)
|
UIApplication.shared.open(url)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
Button("OK", role: .cancel) {}
|
Button("common.ok", role: .cancel) {}
|
||||||
} message: {
|
} message: {
|
||||||
Text(viewModel.bluetoothAlertMessage)
|
Text(viewModel.bluetoothAlertMessage)
|
||||||
}
|
}
|
||||||
@@ -306,77 +335,7 @@ struct ContentView: View {
|
|||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
} else {
|
} else {
|
||||||
// Regular messages with natural text wrapping
|
TextMessageView(message: message, expandedMessageIDs: $expandedMessageIDs)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.id(item.uiID)
|
.id(item.uiID)
|
||||||
@@ -432,7 +391,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
Button("Copy message") {
|
Button("content.message.copy") {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
UIPasteboard.general.string = message.content
|
UIPasteboard.general.string = message.content
|
||||||
#else
|
#else
|
||||||
@@ -462,7 +421,7 @@ struct ContentView: View {
|
|||||||
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
|
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
|
||||||
} else {
|
} else {
|
||||||
// Mesh sender: use current mesh nickname if available; otherwise fall back to last non-system message
|
// 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
|
selectedMessageSender = name
|
||||||
} else {
|
} else {
|
||||||
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
|
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
|
||||||
@@ -806,7 +765,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
HStack(alignment: .center, spacing: 4) {
|
HStack(alignment: .center, spacing: 4) {
|
||||||
TextField("type a message...", text: $messageText)
|
TextField("content.input.message_placeholder", text: $messageText)
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
@@ -833,18 +792,18 @@ struct ContentView: View {
|
|||||||
}()
|
}()
|
||||||
let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
|
let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
|
||||||
var commandDescriptions = [
|
var commandDescriptions = [
|
||||||
("/block", "block or list blocked peers"),
|
("/block", String(localized: "content.commands.block", comment: "Description for /block command")),
|
||||||
("/clear", "clear chat messages"),
|
("/clear", String(localized: "content.commands.clear", comment: "Description for /clear command")),
|
||||||
("/hug", "send someone a warm hug"),
|
("/hug", String(localized: "content.commands.hug", comment: "Description for /hug command")),
|
||||||
("/m", "send private message"),
|
("/m", String(localized: "content.commands.message", comment: "Description for /m command")),
|
||||||
("/slap", "slap someone with a trout"),
|
("/slap", String(localized: "content.commands.slap", comment: "Description for /slap command")),
|
||||||
("/unblock", "unblock a peer"),
|
("/unblock", String(localized: "content.commands.unblock", comment: "Description for /unblock command")),
|
||||||
("/w", "see who's online")
|
("/w", String(localized: "content.commands.who", comment: "Description for /w command"))
|
||||||
]
|
]
|
||||||
// Only show favorites commands when not in geohash context
|
// Only show favorites commands when not in geohash context
|
||||||
if !(isGeoPublic || isGeoDM) {
|
if !(isGeoPublic || isGeoDM) {
|
||||||
commandDescriptions.append(("/fav", "add to favorites"))
|
commandDescriptions.append(("/fav", String(localized: "content.commands.favorite", comment: "Description for /fav command")))
|
||||||
commandDescriptions.append(("/unfav", "remove from favorites"))
|
commandDescriptions.append(("/unfav", String(localized: "content.commands.unfavorite", comment: "Description for /unfav command")))
|
||||||
}
|
}
|
||||||
|
|
||||||
let input = newValue.lowercased()
|
let input = newValue.lowercased()
|
||||||
@@ -890,8 +849,14 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.trailing, 12)
|
.padding(.trailing, 12)
|
||||||
.accessibilityLabel("Send message")
|
.accessibilityLabel(
|
||||||
.accessibilityHint(messageText.isEmpty ? "Enter a message to send" : "Double tap to send")
|
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)
|
.padding(.vertical, 8)
|
||||||
.background(backgroundColor.opacity(0.95))
|
.background(backgroundColor.opacity(0.95))
|
||||||
@@ -923,7 +888,7 @@ struct ContentView: View {
|
|||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
// Header - match main toolbar height
|
// Header - match main toolbar height
|
||||||
HStack {
|
HStack {
|
||||||
Text("PEOPLE")
|
Text("content.header.people")
|
||||||
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -934,7 +899,9 @@ struct ContentView: View {
|
|||||||
.font(.bitchatSystem(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.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
|
.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()
|
Spacer()
|
||||||
}
|
}
|
||||||
.background(backgroundColor)
|
.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)
|
.background(backgroundColor)
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.gesture(
|
.simultaneousGesture(
|
||||||
DragGesture()
|
DragGesture(minimumDistance: 10)
|
||||||
.onChanged { value in
|
.onChanged { value in
|
||||||
let translation = value.translation.width.isNaN ? 0 : value.translation.width
|
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
||||||
if !showSidebar && translation < 0 {
|
let translationHeight = value.translation.height.isNaN ? 0 : value.translation.height
|
||||||
sidebarDragOffset = max(translation, -300)
|
|
||||||
} else if showSidebar && translation > 0 {
|
// Only handle if drag is predominantly horizontal (prevents interfering with scroll)
|
||||||
sidebarDragOffset = min(-300 + translation, 0)
|
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
|
.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
|
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)) {
|
withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||||
if !showSidebar {
|
if !showSidebar {
|
||||||
if translation < -100 || (translation < -50 && velocity < -500) {
|
if translationWidth < -100 || (translationWidth < -50 && velocity < -500) {
|
||||||
showSidebar = true
|
showSidebar = true
|
||||||
sidebarDragOffset = 0
|
sidebarDragOffset = 0
|
||||||
} else {
|
} else {
|
||||||
sidebarDragOffset = 0
|
sidebarDragOffset = 0
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if translation > 100 || (translation > 50 && velocity > 500) {
|
if translationWidth > 100 || (translationWidth > 50 && velocity > 500) {
|
||||||
showSidebar = false
|
showSidebar = false
|
||||||
sidebarDragOffset = 0
|
sidebarDragOffset = 0
|
||||||
} else {
|
} else {
|
||||||
@@ -1051,19 +1088,6 @@ struct ContentView: View {
|
|||||||
.foregroundColor(textColor)
|
.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)
|
// Compute channel-aware people count and color for toolbar (cross-platform)
|
||||||
private func channelPeopleCountAndColor() -> (Int, Color) {
|
private func channelPeopleCountAndColor() -> (Int, Color) {
|
||||||
@@ -1074,7 +1098,7 @@ struct ContentView: View {
|
|||||||
return (n, n > 0 ? standardGreen : Color.secondary)
|
return (n, n > 0 ? standardGreen : Color.secondary)
|
||||||
case .mesh:
|
case .mesh:
|
||||||
let counts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in
|
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 }
|
if peer.isConnected { counts.mesh += 1; counts.others += 1 }
|
||||||
else if peer.isReachable { counts.others += 1 }
|
else if peer.isReachable { counts.others += 1 }
|
||||||
}
|
}
|
||||||
@@ -1087,7 +1111,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
private var mainHeaderView: some View {
|
private var mainHeaderView: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Text("bitchat/")
|
Text(verbatim: "bitchat/")
|
||||||
.font(.bitchatSystem(size: 18, weight: .medium, design: .monospaced))
|
.font(.bitchatSystem(size: 18, weight: .medium, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.onTapGesture(count: 3) {
|
.onTapGesture(count: 3) {
|
||||||
@@ -1100,11 +1124,11 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Text("@")
|
Text(verbatim: "@")
|
||||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
|
|
||||||
TextField("nickname", text: $viewModel.nickname)
|
TextField("content.input.nickname_placeholder", text: $viewModel.nickname)
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.frame(maxWidth: 80)
|
.frame(maxWidth: 80)
|
||||||
@@ -1149,7 +1173,9 @@ struct ContentView: View {
|
|||||||
.foregroundColor(Color.orange)
|
.foregroundColor(Color.orange)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.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
|
// Notes icon (mesh only and when location is authorized), to the left of #mesh
|
||||||
if case .mesh = locationManager.selectedChannel, locationManager.permissionState == .authorized {
|
if case .mesh = locationManager.selectedChannel, locationManager.permissionState == .authorized {
|
||||||
@@ -1162,8 +1188,7 @@ struct ContentView: View {
|
|||||||
showLocationNotes = true
|
showLocationNotes = true
|
||||||
}) {
|
}) {
|
||||||
HStack(alignment: .center, spacing: 4) {
|
HStack(alignment: .center, spacing: 4) {
|
||||||
let currentCount = (notesCounter.count ?? 0)
|
let hasNotes = (notesCounter.count ?? 0) > 0
|
||||||
let hasNotes = (!notesCounter.initialLoadComplete ? max(currentCount, sheetNotesCount) : currentCount) > 0
|
|
||||||
Image(systemName: "long.text.page.and.pencil")
|
Image(systemName: "long.text.page.and.pencil")
|
||||||
.font(.bitchatSystem(size: 12))
|
.font(.bitchatSystem(size: 12))
|
||||||
.foregroundColor(hasNotes ? textColor : Color.gray)
|
.foregroundColor(hasNotes ? textColor : Color.gray)
|
||||||
@@ -1172,7 +1197,9 @@ struct ContentView: View {
|
|||||||
.fixedSize(horizontal: true, vertical: false)
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.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
|
// Bookmark toggle (geochats): to the left of #geohash
|
||||||
@@ -1182,7 +1209,13 @@ struct ContentView: View {
|
|||||||
.font(.bitchatSystem(size: 12))
|
.font(.bitchatSystem(size: 12))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.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 '#'
|
// Location channels button '#'
|
||||||
@@ -1207,7 +1240,9 @@ struct ContentView: View {
|
|||||||
.lineLimit(headerLineLimit)
|
.lineLimit(headerLineLimit)
|
||||||
.fixedSize(horizontal: true, vertical: false)
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
.layoutPriority(2)
|
.layoutPriority(2)
|
||||||
.accessibilityLabel("location channels")
|
.accessibilityLabel(
|
||||||
|
String(localized: "content.accessibility.location_channels", comment: "Accessibility label for the location channels button")
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.leading, 4)
|
.padding(.leading, 4)
|
||||||
@@ -1216,10 +1251,16 @@ struct ContentView: View {
|
|||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
// People icon with count
|
// People icon with count
|
||||||
Image(systemName: "person.2.fill")
|
Image(systemName: "person.2.fill")
|
||||||
.font(.bitchatSystem(size: 11))
|
.font(.system(size: headerPeerIconSize, weight: .regular))
|
||||||
.accessibilityLabel("\(headerOtherPeersCount) people")
|
.accessibilityLabel(
|
||||||
|
String(
|
||||||
|
format: String(localized: "content.accessibility.people_count", comment: "Accessibility label announcing number of people in header"),
|
||||||
|
locale: .current,
|
||||||
|
headerOtherPeersCount
|
||||||
|
)
|
||||||
|
)
|
||||||
Text("\(headerOtherPeersCount)")
|
Text("\(headerOtherPeersCount)")
|
||||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
.font(.system(size: headerPeerCountFontSize, weight: .regular, design: .monospaced))
|
||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
.foregroundColor(headerCountColor)
|
.foregroundColor(headerCountColor)
|
||||||
@@ -1248,7 +1289,9 @@ struct ContentView: View {
|
|||||||
.onAppear { viewModel.isLocationChannelsSheetPresented = true }
|
.onAppear { viewModel.isLocationChannelsSheetPresented = true }
|
||||||
.onDisappear { viewModel.isLocationChannelsSheetPresented = false }
|
.onDisappear { viewModel.isLocationChannelsSheetPresented = false }
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showLocationNotes) {
|
.sheet(isPresented: $showLocationNotes, onDismiss: {
|
||||||
|
notesGeohash = nil
|
||||||
|
}) {
|
||||||
Group {
|
Group {
|
||||||
if let gh = notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {
|
if let gh = notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {
|
||||||
LocationNotesView(geohash: gh, onNotesCountChanged: { cnt in sheetNotesCount = cnt })
|
LocationNotesView(geohash: gh, onNotesCountChanged: { cnt in sheetNotesCount = cnt })
|
||||||
@@ -1256,7 +1299,7 @@ struct ContentView: View {
|
|||||||
} else {
|
} else {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("notes")
|
Text("content.notes.title")
|
||||||
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: { showLocationNotes = false }) {
|
Button(action: { showLocationNotes = false }) {
|
||||||
@@ -1266,15 +1309,15 @@ struct ContentView: View {
|
|||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel("Close")
|
.accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons"))
|
||||||
}
|
}
|
||||||
.frame(height: headerHeight)
|
.frame(height: headerHeight)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.background(backgroundColor.opacity(0.95))
|
.background(backgroundColor.opacity(0.95))
|
||||||
Text("location unavailable")
|
Text("content.notes.location_unavailable")
|
||||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
Button("enable location") {
|
Button("content.location.enable") {
|
||||||
LocationChannelManager.shared.enableLocationChannels()
|
LocationChannelManager.shared.enableLocationChannels()
|
||||||
LocationChannelManager.shared.refreshChannels()
|
LocationChannelManager.shared.refreshChannels()
|
||||||
}
|
}
|
||||||
@@ -1332,10 +1375,10 @@ struct ContentView: View {
|
|||||||
LocationChannelManager.shared.refreshChannels()
|
LocationChannelManager.shared.refreshChannels()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alert("heads up", isPresented: $viewModel.showScreenshotPrivacyWarning) {
|
.alert("content.alert.screenshot.title", isPresented: $viewModel.showScreenshotPrivacyWarning) {
|
||||||
Button("ok", role: .cancel) {}
|
Button("common.ok", role: .cancel) {}
|
||||||
} message: {
|
} message: {
|
||||||
Text("screenshots of location channels will reveal your location. think before sharing publicly.")
|
Text("content.alert.screenshot.message")
|
||||||
}
|
}
|
||||||
.background(backgroundColor.opacity(0.95))
|
.background(backgroundColor.opacity(0.95))
|
||||||
}
|
}
|
||||||
@@ -1372,13 +1415,13 @@ struct ContentView: View {
|
|||||||
// Try mesh/unified peer display
|
// Try mesh/unified peer display
|
||||||
if let name = peer?.displayName { return name }
|
if let name = peer?.displayName { return name }
|
||||||
// Try direct mesh nickname (connected-only)
|
// 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
|
// Try favorite nickname by stable Noise key
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data()),
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data()),
|
||||||
!fav.peerNickname.isEmpty { return fav.peerNickname }
|
!fav.peerNickname.isEmpty { return fav.peerNickname }
|
||||||
// Fallback: resolve from persisted social identity via fingerprint mapping
|
// Fallback: resolve from persisted social identity via fingerprint mapping
|
||||||
if headerPeerID.count == 16 {
|
if headerPeerID.count == 16 {
|
||||||
let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
|
let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(PeerID(str: headerPeerID))
|
||||||
if let id = candidates.first,
|
if let id = candidates.first,
|
||||||
let social = viewModel.identityManager.getSocialIdentity(for: id.fingerprint) {
|
let social = viewModel.identityManager.getSocialIdentity(for: id.fingerprint) {
|
||||||
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
||||||
@@ -1391,7 +1434,7 @@ struct ContentView: View {
|
|||||||
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "Unknown"
|
return String(localized: "common.unknown", comment: "Fallback label for unknown peer")
|
||||||
}()
|
}()
|
||||||
let isNostrAvailable: Bool = {
|
let isNostrAvailable: Bool = {
|
||||||
guard let connectionState = peer?.connectionState else {
|
guard let connectionState = peer?.connectionState else {
|
||||||
@@ -1425,41 +1468,53 @@ struct ContentView: View {
|
|||||||
Image(systemName: "dot.radiowaves.left.and.right")
|
Image(systemName: "dot.radiowaves.left.and.right")
|
||||||
.font(.bitchatSystem(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.accessibilityLabel("Connected via mesh")
|
.accessibilityLabel(
|
||||||
|
String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")
|
||||||
|
)
|
||||||
case .meshReachable:
|
case .meshReachable:
|
||||||
// point.3 filled icon for reachable via mesh (not directly connected)
|
// point.3 filled icon for reachable via mesh (not directly connected)
|
||||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||||
.font(.bitchatSystem(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.accessibilityLabel("Reachable via mesh")
|
.accessibilityLabel(
|
||||||
|
String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")
|
||||||
|
)
|
||||||
case .nostrAvailable:
|
case .nostrAvailable:
|
||||||
// Purple globe for Nostr
|
// Purple globe for Nostr
|
||||||
Image(systemName: "globe")
|
Image(systemName: "globe")
|
||||||
.font(.bitchatSystem(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(.purple)
|
.foregroundColor(.purple)
|
||||||
.accessibilityLabel("Available via Nostr")
|
.accessibilityLabel(
|
||||||
|
String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")
|
||||||
|
)
|
||||||
case .offline:
|
case .offline:
|
||||||
// Should not happen for PM header, but handle gracefully
|
// Should not happen for PM header, but handle gracefully
|
||||||
EmptyView()
|
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
|
// Fallback: reachable via mesh but not in current peer list
|
||||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||||
.font(.bitchatSystem(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.accessibilityLabel("Reachable via mesh")
|
.accessibilityLabel(
|
||||||
|
String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")
|
||||||
|
)
|
||||||
} else if isNostrAvailable {
|
} else if isNostrAvailable {
|
||||||
// Fallback to Nostr if peer not in list but is mutual favorite
|
// Fallback to Nostr if peer not in list but is mutual favorite
|
||||||
Image(systemName: "globe")
|
Image(systemName: "globe")
|
||||||
.font(.bitchatSystem(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(.purple)
|
.foregroundColor(.purple)
|
||||||
.accessibilityLabel("Available via Nostr")
|
.accessibilityLabel(
|
||||||
} else if viewModel.meshService.isPeerConnected(headerPeerID) || viewModel.connectedPeers.contains(headerPeerID) {
|
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
|
// Fallback: if peer lookup is missing but mesh reports connected, show radio
|
||||||
Image(systemName: "dot.radiowaves.left.and.right")
|
Image(systemName: "dot.radiowaves.left.and.right")
|
||||||
.font(.bitchatSystem(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.accessibilityLabel("Connected via mesh")
|
.accessibilityLabel(
|
||||||
|
String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("\(privatePeerNick)")
|
Text("\(privatePeerNick)")
|
||||||
@@ -1480,12 +1535,26 @@ struct ContentView: View {
|
|||||||
.foregroundColor(encryptionStatus == .noiseVerified ? textColor :
|
.foregroundColor(encryptionStatus == .noiseVerified ? textColor :
|
||||||
encryptionStatus == .noiseSecured ? textColor :
|
encryptionStatus == .noiseSecured ? textColor :
|
||||||
Color.red)
|
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)")
|
.accessibilityLabel(
|
||||||
.accessibilityHint("Tap to view encryption fingerprint")
|
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)
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
@@ -1504,7 +1573,9 @@ struct ContentView: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.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()
|
Spacer()
|
||||||
|
|
||||||
@@ -1518,8 +1589,14 @@ struct ContentView: View {
|
|||||||
.foregroundColor(viewModel.isFavorite(peerID: headerPeerID) ? Color.yellow : textColor)
|
.foregroundColor(viewModel.isFavorite(peerID: headerPeerID) ? Color.yellow : textColor)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(viewModel.isFavorite(peerID: privatePeerID) ? "Remove from favorites" : "Add to favorites")
|
.accessibilityLabel(
|
||||||
.accessibilityHint("Double tap to toggle favorite status")
|
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 {
|
private var backgroundColor: Color {
|
||||||
colorScheme == .dark ? Color.black : Color.white
|
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 {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
// Header
|
// Header
|
||||||
HStack {
|
HStack {
|
||||||
Text("SECURITY VERIFICATION")
|
Text(Strings.title)
|
||||||
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
@@ -49,7 +72,7 @@ struct FingerprintView: View {
|
|||||||
// Resolve a friendly name
|
// Resolve a friendly name
|
||||||
let peerNickname: String = {
|
let peerNickname: String = {
|
||||||
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
|
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 peerID.count == 64, let data = Data(hexString: peerID) {
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
|
||||||
let fp = data.sha256Fingerprint()
|
let fp = data.sha256Fingerprint()
|
||||||
@@ -58,7 +81,7 @@ struct FingerprintView: View {
|
|||||||
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "Unknown"
|
return Strings.unknownPeer()
|
||||||
}()
|
}()
|
||||||
// Accurate encryption state based on short ID session
|
// Accurate encryption state based on short ID session
|
||||||
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
||||||
@@ -88,7 +111,7 @@ struct FingerprintView: View {
|
|||||||
|
|
||||||
// Their fingerprint
|
// Their fingerprint
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("THEIR FINGERPRINT:")
|
Text(Strings.theirFingerprint)
|
||||||
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor.opacity(0.7))
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
|
|
||||||
@@ -104,7 +127,7 @@ struct FingerprintView: View {
|
|||||||
.background(Color.gray.opacity(0.1))
|
.background(Color.gray.opacity(0.1))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
Button("Copy") {
|
Button(Strings.copy) {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
UIPasteboard.general.string = fingerprint
|
UIPasteboard.general.string = fingerprint
|
||||||
#else
|
#else
|
||||||
@@ -114,16 +137,16 @@ struct FingerprintView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("not available - handshake in progress")
|
Text(Strings.handshakePending)
|
||||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(Color.orange)
|
.foregroundColor(Color.orange)
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// My fingerprint
|
// My fingerprint
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("YOUR FINGERPRINT:")
|
Text(Strings.yourFingerprint)
|
||||||
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor.opacity(0.7))
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
|
|
||||||
@@ -139,7 +162,7 @@ struct FingerprintView: View {
|
|||||||
.background(Color.gray.opacity(0.1))
|
.background(Color.gray.opacity(0.1))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
Button("Copy") {
|
Button(Strings.copy) {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
UIPasteboard.general.string = myFingerprint
|
UIPasteboard.general.string = myFingerprint
|
||||||
#else
|
#else
|
||||||
@@ -155,14 +178,18 @@ struct FingerprintView: View {
|
|||||||
let isVerified = encryptionStatus == .noiseVerified
|
let isVerified = encryptionStatus == .noiseVerified
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Text(isVerified ? "✓ VERIFIED" : "⚠️ NOT VERIFIED")
|
Text(isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
|
||||||
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(isVerified ? Color.green : Color.orange)
|
.foregroundColor(isVerified ? Color.green : Color.orange)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
Text(isVerified ?
|
Group {
|
||||||
"you have verified this person's identity." :
|
if isVerified {
|
||||||
"compare these fingerprints with \(peerNickname) using a secure channel.")
|
Text(Strings.verifiedMessage)
|
||||||
|
} else {
|
||||||
|
Text(Strings.verifyHint(peerNickname))
|
||||||
|
}
|
||||||
|
}
|
||||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(textColor.opacity(0.7))
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -175,7 +202,7 @@ struct FingerprintView: View {
|
|||||||
viewModel.verifyFingerprint(for: peerID)
|
viewModel.verifyFingerprint(for: peerID)
|
||||||
dismiss()
|
dismiss()
|
||||||
}) {
|
}) {
|
||||||
Text("MARK AS VERIFIED")
|
Text(Strings.markVerified)
|
||||||
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
@@ -189,7 +216,7 @@ struct FingerprintView: View {
|
|||||||
viewModel.unverifyFingerprint(for: peerID)
|
viewModel.unverifyFingerprint(for: peerID)
|
||||||
dismiss()
|
dismiss()
|
||||||
}) {
|
}) {
|
||||||
Text("REMOVE VERIFICATION")
|
Text(Strings.removeVerification)
|
||||||
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
|
|||||||
@@ -8,10 +8,18 @@ struct GeohashPeopleList: View {
|
|||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@State private var orderedIDs: [String] = []
|
@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 {
|
var body: some View {
|
||||||
if viewModel.visibleGeohashPeople().isEmpty {
|
if viewModel.visibleGeohashPeople().isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
Text("nobody around...")
|
Text(Strings.noneNearby)
|
||||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
@@ -53,7 +61,7 @@ struct GeohashPeopleList: View {
|
|||||||
let rowColor: Color = isMe ? .orange : assignedColor
|
let rowColor: Color = isMe ? .orange : assignedColor
|
||||||
Image(systemName: icon).font(.bitchatSystem(size: 12)).foregroundColor(rowColor)
|
Image(systemName: icon).font(.bitchatSystem(size: 12)).foregroundColor(rowColor)
|
||||||
|
|
||||||
let (base, suffix) = splitSuffix(from: person.displayName)
|
let (base, suffix) = person.displayName.splitSuffix()
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Text(base)
|
Text(base)
|
||||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
@@ -66,7 +74,7 @@ struct GeohashPeopleList: View {
|
|||||||
.foregroundColor(suffixColor)
|
.foregroundColor(suffixColor)
|
||||||
}
|
}
|
||||||
if isMe {
|
if isMe {
|
||||||
Text(" (you)")
|
Text(Strings.youSuffix)
|
||||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(rowColor)
|
.foregroundColor(rowColor)
|
||||||
}
|
}
|
||||||
@@ -76,7 +84,7 @@ struct GeohashPeopleList: View {
|
|||||||
Image(systemName: "nosign")
|
Image(systemName: "nosign")
|
||||||
.font(.bitchatSystem(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.help("Blocked in geochash")
|
.help(Strings.blockedTooltip)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -97,9 +105,9 @@ struct GeohashPeopleList: View {
|
|||||||
} else {
|
} else {
|
||||||
let blocked = viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id)
|
let blocked = viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id)
|
||||||
if blocked {
|
if blocked {
|
||||||
Button("Unblock") { viewModel.unblockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
|
Button(Strings.unblock) { viewModel.unblockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
|
||||||
} else {
|
} 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 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 {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Text("#location channels")
|
Text(Strings.title)
|
||||||
.font(.bitchatSystem(size: 18, design: .monospaced))
|
.font(.bitchatSystem(size: 18, design: .monospaced))
|
||||||
Spacer()
|
Spacer()
|
||||||
closeButton
|
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))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
@@ -34,7 +90,7 @@ struct LocationChannelsSheet: View {
|
|||||||
switch manager.permissionState {
|
switch manager.permissionState {
|
||||||
case LocationChannelManager.PermissionState.notDetermined:
|
case LocationChannelManager.PermissionState.notDetermined:
|
||||||
Button(action: { manager.enableLocationChannels() }) {
|
Button(action: { manager.enableLocationChannels() }) {
|
||||||
Text("get location and my geohashes")
|
Text(Strings.requestPermissions)
|
||||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(standardGreen)
|
.foregroundColor(standardGreen)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@@ -45,10 +101,10 @@ struct LocationChannelsSheet: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
case LocationChannelManager.PermissionState.denied, LocationChannelManager.PermissionState.restricted:
|
case LocationChannelManager.PermissionState.denied, LocationChannelManager.PermissionState.restricted:
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("location permission denied. enable in settings to use location channels.")
|
Text(Strings.permissionDenied)
|
||||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Button("open settings") { openSystemLocationSettings() }
|
Button(Strings.openSettings) { openSystemLocationSettings() }
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
case LocationChannelManager.PermissionState.authorized:
|
case LocationChannelManager.PermissionState.authorized:
|
||||||
@@ -107,188 +163,214 @@ struct LocationChannelsSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var channelList: some View {
|
private var channelList: some View {
|
||||||
List {
|
ScrollView {
|
||||||
// Mesh option first (no bookmark)
|
LazyVStack(spacing: 0) {
|
||||||
channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth • \(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
|
channelRow(title: Strings.meshTitle(meshCount()), subtitlePrefix: Strings.subtitlePrefix(geohash: "bluetooth", coverage: bluetoothRangeString()), isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
|
||||||
manager.select(ChannelID.mesh)
|
manager.select(ChannelID.mesh)
|
||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
|
.padding(.vertical, 6)
|
||||||
|
|
||||||
// Nearby options
|
let nearby = manager.availableChannels.filter { $0.level != .building }
|
||||||
if !manager.availableChannels.isEmpty {
|
if !nearby.isEmpty {
|
||||||
ForEach(manager.availableChannels.filter { $0.level != .building }) { channel in
|
ForEach(nearby) { channel in
|
||||||
let coverage = coverageString(forPrecision: channel.geohash.count)
|
sectionDivider
|
||||||
let nameBase = locationName(for: channel.level)
|
let coverage = coverageString(forPrecision: channel.geohash.count)
|
||||||
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
|
let nameBase = locationName(for: channel.level)
|
||||||
let subtitlePrefix = "#\(channel.geohash) • \(coverage)"
|
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
|
||||||
let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 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(
|
channelRow(
|
||||||
title: geohashTitleWithCount(for: channel),
|
title: Strings.bookmarkTitle(geohash: gh, count: participantCount),
|
||||||
subtitlePrefix: subtitlePrefix,
|
subtitlePrefix: subtitle,
|
||||||
subtitleName: namePart,
|
subtitleName: name.map { formattedNamePrefix(for: level) + $0 },
|
||||||
isSelected: isSelected(channel),
|
isSelected: isSelected(channel),
|
||||||
titleBold: highlight,
|
|
||||||
trailingAccessory: {
|
trailingAccessory: {
|
||||||
Button(action: { bookmarks.toggle(channel.geohash) }) {
|
Button(action: { bookmarks.toggle(gh) }) {
|
||||||
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
|
||||||
.font(.bitchatSystem(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.leading, 8)
|
.padding(.leading, 8)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
// Selecting a suggested nearby channel is not a teleport. Persist this.
|
let inRegional = manager.availableChannels.contains { $0.geohash == gh }
|
||||||
manager.markTeleported(for: channel.geohash, false)
|
if !inRegional && !manager.availableChannels.isEmpty {
|
||||||
|
manager.markTeleported(for: gh, true)
|
||||||
|
} else {
|
||||||
|
manager.markTeleported(for: gh, false)
|
||||||
|
}
|
||||||
manager.select(ChannelID.location(channel))
|
manager.select(ChannelID.location(channel))
|
||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
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)
|
.padding(.vertical, 6)
|
||||||
.background(Color.secondary.opacity(0.12))
|
.onAppear { bookmarks.resolveNameIfNeeded(for: gh) }
|
||||||
.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))
|
|
||||||
|
|
||||||
// Bookmarked geohashes
|
if index < entries.count - 1 {
|
||||||
if !bookmarks.bookmarks.isEmpty {
|
sectionDivider
|
||||||
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) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.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 {
|
private func isSelected(_ channel: GeohashChannel) -> Bool {
|
||||||
if case .location(let ch) = manager.selectedChannel {
|
if case .location(let ch) = manager.selectedChannel {
|
||||||
return ch == channel
|
return ch == channel
|
||||||
@@ -328,12 +410,7 @@ struct LocationChannelsSheet: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let subtitleFull: String = {
|
let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName)
|
||||||
if let name = subtitleName, !name.isEmpty {
|
|
||||||
return subtitlePrefix + " • " + name
|
|
||||||
}
|
|
||||||
return subtitlePrefix
|
|
||||||
}()
|
|
||||||
Text(subtitleFull)
|
Text(subtitleFull)
|
||||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
@@ -342,7 +419,7 @@ struct LocationChannelsSheet: View {
|
|||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if isSelected {
|
if isSelected {
|
||||||
Text("✔︎")
|
Text(verbatim: "✔︎")
|
||||||
.font(.bitchatSystem(size: 16, design: .monospaced))
|
.font(.bitchatSystem(size: 16, design: .monospaced))
|
||||||
.foregroundColor(standardGreen)
|
.foregroundColor(standardGreen)
|
||||||
}
|
}
|
||||||
@@ -362,36 +439,15 @@ struct LocationChannelsSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers for counts
|
// 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 {
|
private func meshCount() -> Int {
|
||||||
// Count mesh-connected OR mesh-reachable peers (exclude self)
|
// Count mesh-connected OR mesh-reachable peers (exclude self)
|
||||||
let myID = viewModel.meshService.myPeerID
|
let myID = viewModel.meshService.myPeerID
|
||||||
return viewModel.allPeers.reduce(0) { acc, peer in
|
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
|
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 {
|
private func validateGeohash(_ s: String) -> Bool {
|
||||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
guard !s.isEmpty, s.count <= 12 else { return false }
|
guard !s.isEmpty, s.count <= 12 else { return false }
|
||||||
@@ -424,21 +480,19 @@ extension LocationChannelsSheet {
|
|||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Toggle(isOn: torToggleBinding) {
|
Toggle(isOn: torToggleBinding) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("tor routing")
|
Text(Strings.torTitle)
|
||||||
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
Text("hides your ip for location channels. recommended: on.")
|
Text(Strings.torSubtitle)
|
||||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.toggleStyle(IRCToggleStyle(accent: standardGreen))
|
.toggleStyle(IRCToggleStyle(accent: standardGreen, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.background(Color.secondary.opacity(0.12))
|
.background(Color.secondary.opacity(0.12))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var standardGreen: Color {
|
private var standardGreen: Color {
|
||||||
@@ -451,13 +505,15 @@ extension LocationChannelsSheet {
|
|||||||
|
|
||||||
private struct IRCToggleStyle: ToggleStyle {
|
private struct IRCToggleStyle: ToggleStyle {
|
||||||
let accent: Color
|
let accent: Color
|
||||||
|
let onLabel: LocalizedStringKey
|
||||||
|
let offLabel: LocalizedStringKey
|
||||||
|
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
Button(action: { configuration.isOn.toggle() }) {
|
Button(action: { configuration.isOn.toggle() }) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
configuration.label
|
configuration.label
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(configuration.isOn ? "on" : "off")
|
Text(configuration.isOn ? onLabel : offLabel)
|
||||||
.textCase(.uppercase)
|
.textCase(.uppercase)
|
||||||
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||||
.foregroundColor(configuration.isOn ? accent : .secondary)
|
.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 accentGreen: Color { colorScheme == .dark ? .green : Color(red: 0, green: 0.5, blue: 0) }
|
||||||
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
|
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 {
|
var body: some View {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -85,14 +100,14 @@ struct LocationNotesView: View {
|
|||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel("Close")
|
.accessibilityLabel(Strings.closeAccessibility)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var headerSection: some View {
|
private var headerSection: some View {
|
||||||
let count = manager.notes.count
|
let count = manager.notes.count
|
||||||
return VStack(alignment: .leading, spacing: 8) {
|
return VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Text("#\(geohash) • \(count) \(count == 1 ? "note" : "notes")")
|
Text(headerTitle(for: count))
|
||||||
.font(.bitchatSystem(size: 18, design: .monospaced))
|
.font(.bitchatSystem(size: 18, design: .monospaced))
|
||||||
Spacer()
|
Spacer()
|
||||||
closeButton
|
closeButton
|
||||||
@@ -106,16 +121,12 @@ struct LocationNotesView: View {
|
|||||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(accentGreen)
|
.foregroundColor(accentGreen)
|
||||||
}
|
}
|
||||||
Text("add short permanent notes to this location for other visitors to find.")
|
Text(Strings.description)
|
||||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
if manager.state == .loading && !manager.initialLoadComplete {
|
if manager.state == .noRelays {
|
||||||
Text("loading recent notes…")
|
Text(Strings.relaysPaused)
|
||||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
} else if manager.state == .noRelays {
|
|
||||||
Text("geo relays unavailable; notes paused")
|
|
||||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
@@ -126,6 +137,14 @@ struct LocationNotesView: View {
|
|||||||
.background(backgroundColor)
|
.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 {
|
private var notesContent: some View {
|
||||||
LazyVStack(alignment: .leading, spacing: 12) {
|
LazyVStack(alignment: .leading, spacing: 12) {
|
||||||
if manager.state == .noRelays {
|
if manager.state == .noRelays {
|
||||||
@@ -153,7 +172,7 @@ struct LocationNotesView: View {
|
|||||||
let ts = timestampText(for: note.createdAt)
|
let ts = timestampText(for: note.createdAt)
|
||||||
return VStack(alignment: .leading, spacing: 2) {
|
return VStack(alignment: .leading, spacing: 2) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Text("@\(baseName)")
|
Text(verbatim: "@\(baseName)")
|
||||||
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||||
if !ts.isEmpty {
|
if !ts.isEmpty {
|
||||||
Text(ts)
|
Text(ts)
|
||||||
@@ -171,12 +190,12 @@ struct LocationNotesView: View {
|
|||||||
|
|
||||||
private var noRelaysRow: some View {
|
private var noRelaysRow: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("no geo relays nearby")
|
Text(Strings.noRelaysNearby)
|
||||||
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
.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))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Button("retry") { manager.refresh() }
|
Button(Strings.retry) { manager.refresh() }
|
||||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
@@ -186,7 +205,7 @@ struct LocationNotesView: View {
|
|||||||
private var loadingRow: some View {
|
private var loadingRow: some View {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
Text("loading notes…")
|
Text(Strings.loadingNotes)
|
||||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -196,9 +215,9 @@ struct LocationNotesView: View {
|
|||||||
|
|
||||||
private var emptyRow: some View {
|
private var emptyRow: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("no notes yet")
|
Text(Strings.emptyTitle)
|
||||||
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
.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))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
@@ -214,7 +233,7 @@ struct LocationNotesView: View {
|
|||||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
Button("dismiss") { manager.clearError() }
|
Button(Strings.dismissError) { manager.clearError() }
|
||||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
@@ -223,7 +242,7 @@ struct LocationNotesView: View {
|
|||||||
|
|
||||||
private var inputSection: some View {
|
private var inputSection: some View {
|
||||||
HStack(alignment: .top, spacing: 10) {
|
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)
|
.textFieldStyle(.plain)
|
||||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.lineLimit(maxDraftLines, reservesSpace: true)
|
.lineLimit(maxDraftLines, reservesSpace: true)
|
||||||
|
|||||||
@@ -11,37 +11,43 @@ struct MeshPeerList: View {
|
|||||||
|
|
||||||
@State private var orderedIDs: [String] = []
|
@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 {
|
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 {
|
if viewModel.allPeers.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
Text("nobody around...")
|
Text(Strings.noneNearby)
|
||||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
}
|
}
|
||||||
} else {
|
} 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) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
ForEach(0..<peers.count, id: \.self) { idx in
|
ForEach(0..<peers.count, id: \.self) { idx in
|
||||||
let item = peers[idx]
|
let item = peers[idx]
|
||||||
let peer = item.peer
|
let peer = item.peer
|
||||||
let isMe = item.isMe
|
let isMe = item.isMe
|
||||||
HStack(spacing: 4) {
|
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
|
let baseColor = isMe ? Color.orange : assigned
|
||||||
if isMe {
|
if isMe {
|
||||||
Image(systemName: "person.fill")
|
Image(systemName: "person.fill")
|
||||||
@@ -70,7 +76,7 @@ struct MeshPeerList: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let displayName = isMe ? viewModel.nickname : peer.nickname
|
let displayName = isMe ? viewModel.nickname : peer.nickname
|
||||||
let (base, suffix) = splitSuffix(from: displayName)
|
let (base, suffix) = displayName.splitSuffix()
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Text(base)
|
Text(base)
|
||||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
.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")
|
Image(systemName: "nosign")
|
||||||
.font(.bitchatSystem(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.help("Blocked")
|
.help(Strings.blockedTooltip)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isMe {
|
if !isMe {
|
||||||
@@ -99,7 +105,7 @@ struct MeshPeerList: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Offline: prefer showing verified badge from persisted fingerprints
|
// 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) {
|
viewModel.verifiedFingerprints.contains(fp) {
|
||||||
Image(systemName: "checkmark.seal.fill")
|
Image(systemName: "checkmark.seal.fill")
|
||||||
.font(.bitchatSystem(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
@@ -120,11 +126,11 @@ struct MeshPeerList: View {
|
|||||||
Image(systemName: "envelope.fill")
|
Image(systemName: "envelope.fill")
|
||||||
.font(.bitchatSystem(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(.orange)
|
||||||
.help("New messages")
|
.help(Strings.newMessagesTooltip)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isMe {
|
if !isMe {
|
||||||
Button(action: { onToggleFavorite(peer.id) }) {
|
Button(action: { onToggleFavorite(peer.peerID.id) }) {
|
||||||
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
||||||
.font(.bitchatSystem(size: 12))
|
.font(.bitchatSystem(size: 12))
|
||||||
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
|
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
|
||||||
@@ -136,16 +142,16 @@ struct MeshPeerList: View {
|
|||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.padding(.top, idx == 0 ? 10 : 0)
|
.padding(.top, idx == 0 ? 10 : 0)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { if !isMe { onTapPeer(peer.id) } }
|
.onTapGesture { if !isMe { onTapPeer(peer.peerID.id) } }
|
||||||
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.id) } }
|
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID.id) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Seed and update order outside result builder
|
// Seed and update order outside result builder
|
||||||
.onAppear {
|
.onAppear {
|
||||||
let currentIDs = mapped.map { $0.peer.id }
|
let currentIDs = mapped.map { $0.peer.peerID.id }
|
||||||
orderedIDs = currentIDs
|
orderedIDs = currentIDs
|
||||||
}
|
}
|
||||||
.onChange(of: mapped.map { $0.peer.id }) { ids in
|
.onChange(of: mapped.map { $0.peer.peerID.id }) { ids in
|
||||||
var newOrder = orderedIDs
|
var newOrder = orderedIDs
|
||||||
newOrder.removeAll { !ids.contains($0) }
|
newOrder.removeAll { !ids.contains($0) }
|
||||||
for id in ids where !newOrder.contains(id) { newOrder.append(id) }
|
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.
|
// 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 regex = RegexCache.cashu
|
||||||
let ns = self as NSString
|
let ns = self as NSString
|
||||||
let range = NSRange(location: 0, length: ns.length)
|
let range = NSRange(location: 0, length: ns.length)
|
||||||
var found: [String] = []
|
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 {
|
if m.numberOfRanges > 0 {
|
||||||
let token = ns.substring(with: m.range(at: 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 }
|
if found.count >= max { break }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,19 +59,19 @@ extension String {
|
|||||||
let ns = self as NSString
|
let ns = self as NSString
|
||||||
let full = NSRange(location: 0, length: ns.length)
|
let full = NSRange(location: 0, length: ns.length)
|
||||||
// lightning: scheme
|
// 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))
|
let s = ns.substring(with: m.range(at: 0))
|
||||||
results.append(s)
|
results.append(s)
|
||||||
if results.count >= max { return results }
|
if results.count >= max { return results }
|
||||||
}
|
}
|
||||||
// BOLT11
|
// 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))
|
let s = ns.substring(with: m.range(at: 0))
|
||||||
results.append("lightning:\(s)")
|
results.append("lightning:\(s)")
|
||||||
if results.count >= max { return results }
|
if results.count >= max { return results }
|
||||||
}
|
}
|
||||||
// LNURL bech32
|
// 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))
|
let s = ns.substring(with: m.range(at: 0))
|
||||||
results.append("lightning:\(s)")
|
results.append("lightning:\(s)")
|
||||||
if results.count >= max { return results }
|
if results.count >= max { return results }
|
||||||
|
|||||||
@@ -13,14 +13,19 @@ struct MyQRView: View {
|
|||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
private var boxColor: Color { Color.gray.opacity(0.1) }
|
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 {
|
var body: some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Text("scan to verify me")
|
Text(Strings.title)
|
||||||
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||||
|
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
QRCodeImage(data: qrString, size: 240)
|
QRCodeImage(data: qrString, size: 240)
|
||||||
.accessibilityLabel("verification qr code")
|
.accessibilityLabel(Strings.accessibilityLabel)
|
||||||
|
|
||||||
// Non-scrolling, fully visible URL (wraps across lines)
|
// Non-scrolling, fully visible URL (wraps across lines)
|
||||||
Text(qrString)
|
Text(qrString)
|
||||||
@@ -49,6 +54,10 @@ struct QRCodeImage: View {
|
|||||||
private let context = CIContext()
|
private let context = CIContext()
|
||||||
private let filter = CIFilter.qrCodeGenerator()
|
private let filter = CIFilter.qrCodeGenerator()
|
||||||
|
|
||||||
|
private enum Strings {
|
||||||
|
static let unavailable: LocalizedStringKey = "verification.my_qr.unavailable"
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if let image = generateImage() {
|
if let image = generateImage() {
|
||||||
@@ -59,7 +68,7 @@ struct QRCodeImage: View {
|
|||||||
.stroke(Color.gray.opacity(0.5), lineWidth: 1)
|
.stroke(Color.gray.opacity(0.5), lineWidth: 1)
|
||||||
.frame(width: size, height: size)
|
.frame(width: size, height: size)
|
||||||
.overlay(
|
.overlay(
|
||||||
Text("qr unavailable")
|
Text(Strings.unavailable)
|
||||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.gray)
|
.foregroundColor(.gray)
|
||||||
)
|
)
|
||||||
@@ -100,17 +109,41 @@ struct ImageWrapper: View {
|
|||||||
struct QRScanView: View {
|
struct QRScanView: View {
|
||||||
@EnvironmentObject var viewModel: ChatViewModel
|
@EnvironmentObject var viewModel: ChatViewModel
|
||||||
var isActive: Bool = true
|
var isActive: Bool = true
|
||||||
|
var onSuccess: (() -> Void)? = nil // Called when verification succeeds
|
||||||
@State private var input = ""
|
@State private var input = ""
|
||||||
@State private var result: String = "" // not shown for iOS scanner
|
@State private var result: String = "" // not shown for iOS scanner
|
||||||
@State private var lastValid: String = ""
|
@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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
CameraScannerView(isActive: isActive) { code in
|
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) {
|
if let qr = VerificationService.shared.verifyScannedQR(code) {
|
||||||
let ok = viewModel.beginQRVerification(with: qr)
|
let ok = viewModel.beginQRVerification(with: qr)
|
||||||
if !ok { /* already pending; continue scanning */ }
|
if ok {
|
||||||
lastValid = code
|
// 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 {
|
} else {
|
||||||
// ignore invalid reads; continue scanning
|
// ignore invalid reads; continue scanning
|
||||||
}
|
}
|
||||||
@@ -118,17 +151,30 @@ struct QRScanView: View {
|
|||||||
.frame(height: 260)
|
.frame(height: 260)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
#else
|
#else
|
||||||
Text("paste qr content to validate:")
|
Text(Strings.pastePrompt)
|
||||||
.font(.bitchatSystem(size: 14, weight: .medium, design: .monospaced))
|
.font(.bitchatSystem(size: 14, weight: .medium, design: .monospaced))
|
||||||
TextEditor(text: $input)
|
TextEditor(text: $input)
|
||||||
.frame(height: 100)
|
.frame(height: 100)
|
||||||
.border(Color.gray.opacity(0.4))
|
.border(Color.gray.opacity(0.4))
|
||||||
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) {
|
if let qr = VerificationService.shared.verifyScannedQR(input) {
|
||||||
let ok = viewModel.beginQRVerification(with: qr)
|
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 {
|
} else {
|
||||||
result = "invalid or expired qr payload"
|
result = Strings.invalid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
@@ -254,7 +300,7 @@ struct VerificationSheetView: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Top header (always at top)
|
// Top header (always at top)
|
||||||
HStack {
|
HStack {
|
||||||
Text("VERIFY")
|
Text("verification.sheet.title")
|
||||||
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(accentColor)
|
.foregroundColor(accentColor)
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -278,18 +324,22 @@ struct VerificationSheetView: View {
|
|||||||
Group {
|
Group {
|
||||||
if showingScanner {
|
if showingScanner {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Text("scan a friend's qr")
|
Text("verification.scan.prompt_friend")
|
||||||
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundColor(accentColor)
|
.foregroundColor(accentColor)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
QRScanView(isActive: showingScanner)
|
QRScanView(isActive: showingScanner, onSuccess: {
|
||||||
|
showingScanner = false
|
||||||
|
})
|
||||||
.environmentObject(viewModel)
|
.environmentObject(viewModel)
|
||||||
.frame(height: 280)
|
.frame(height: 280)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
#else
|
#else
|
||||||
QRScanView()
|
QRScanView(onSuccess: {
|
||||||
|
showingScanner = false
|
||||||
|
})
|
||||||
.environmentObject(viewModel)
|
.environmentObject(viewModel)
|
||||||
#endif
|
#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 {
|
final class ShareViewController: UIViewController {
|
||||||
// Bundle.main.bundleIdentifier would get the extension's bundleID
|
// Bundle.main.bundleIdentifier would get the extension's bundleID
|
||||||
private static let groupID = "group.chat.bitchat"
|
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 = {
|
private let statusLabel: UILabel = {
|
||||||
let l = UILabel()
|
let l = UILabel()
|
||||||
@@ -44,7 +53,7 @@ final class ShareViewController: UIViewController {
|
|||||||
private func processShare() {
|
private func processShare() {
|
||||||
guard let ctx = self.extensionContext,
|
guard let ctx = self.extensionContext,
|
||||||
let item = ctx.inputItems.first as? NSExtensionItem else {
|
let item = ctx.inputItems.first as? NSExtensionItem else {
|
||||||
finishWithMessage("Nothing to share")
|
finishWithMessage(Strings.nothingToShare)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +70,7 @@ final class ShareViewController: UIViewController {
|
|||||||
if let title = item.attributedTitle?.string, !title.isEmpty {
|
if let title = item.attributedTitle?.string, !title.isEmpty {
|
||||||
saveAndFinish(text: title)
|
saveAndFinish(text: title)
|
||||||
} else {
|
} else {
|
||||||
finishWithMessage("No shareable content")
|
finishWithMessage(Strings.noShareableContent)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -81,7 +90,7 @@ final class ShareViewController: UIViewController {
|
|||||||
self.saveAndFinish(text: t)
|
self.saveAndFinish(text: t)
|
||||||
}
|
}
|
||||||
} else {
|
} 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?) {
|
private func saveAndFinish(url: URL, title: String?) {
|
||||||
let payload: [String: String] = [
|
let payload: [String: String] = [
|
||||||
"url": url.absoluteString,
|
"url": url.absoluteString,
|
||||||
"title": title ?? url.host ?? "Shared Link"
|
"title": title ?? url.host ?? Strings.sharedLinkTitleFallback
|
||||||
]
|
]
|
||||||
if let json = try? JSONSerialization.data(withJSONObject: payload),
|
if let json = try? JSONSerialization.data(withJSONObject: payload),
|
||||||
let s = String(data: json, encoding: .utf8) {
|
let s = String(data: json, encoding: .utf8) {
|
||||||
saveToSharedDefaults(content: s, type: "url")
|
saveToSharedDefaults(content: s, type: "url")
|
||||||
finishWithMessage("✓ Shared link to bitchat")
|
finishWithMessage(Strings.sharedLinkConfirmation)
|
||||||
} else {
|
} else {
|
||||||
finishWithMessage("Failed to encode link")
|
finishWithMessage(Strings.failedToEncode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveAndFinish(text: String) {
|
private func saveAndFinish(text: String) {
|
||||||
saveToSharedDefaults(content: text, type: "text")
|
saveToSharedDefaults(content: text, type: "text")
|
||||||
finishWithMessage("✓ Shared text to bitchat")
|
finishWithMessage(Strings.sharedTextConfirmation)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveToSharedDefaults(content: String, type: String) {
|
private func saveToSharedDefaults(content: String, type: String) {
|
||||||
|
|||||||
@@ -281,9 +281,12 @@ private final class MockBitchatDelegate: BitchatDelegate {
|
|||||||
messageHandler(message)
|
messageHandler(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func didConnectToPeer(_ peerID: String) {}
|
func didConnectToPeer(_ peerID: PeerID) {}
|
||||||
func didDisconnectFromPeer(_ peerID: String) {}
|
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
||||||
func didUpdatePeerList(_ peers: [String]) {}
|
func didUpdatePeerList(_ peers: [PeerID]) {}
|
||||||
func isFavorite(fingerprint: String) -> Bool { return false }
|
func isFavorite(fingerprint: String) -> Bool { return false }
|
||||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
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>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import XCTest
|
import Testing
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
import struct Foundation.UUID
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class PrivateChatE2ETests: XCTestCase {
|
// TODO: Remove once MockBLEService is refactored to fix race condition
|
||||||
|
@Suite(.serialized)
|
||||||
|
struct PrivateChatE2ETests {
|
||||||
|
|
||||||
var alice: MockBluetoothMeshService!
|
private let alice: MockBLEService
|
||||||
var bob: MockBluetoothMeshService!
|
private let bob: MockBLEService
|
||||||
var charlie: MockBluetoothMeshService!
|
private let charlie: MockBLEService
|
||||||
|
private let mockKeychain: MockKeychain
|
||||||
|
|
||||||
private var mockKeychain: MockKeychain!
|
init() {
|
||||||
|
// Create services with unique peer IDs to avoid any collision
|
||||||
override func setUp() {
|
alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1)
|
||||||
super.setUp()
|
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2)
|
||||||
MockBLEService.resetTestBus()
|
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3)
|
||||||
|
|
||||||
// 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)
|
|
||||||
mockKeychain = MockKeychain()
|
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
|
// MARK: - Basic Private Messaging Tests
|
||||||
|
|
||||||
func testSimplePrivateMessage() {
|
@Test func simplePrivateMessageShouldNotBeSentWithoutConnection() async {
|
||||||
simulateConnection(alice, bob)
|
// Intentionally not connecting alice and bob to test
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Bob receives private message")
|
var bobReceivedMessage = false
|
||||||
|
|
||||||
bob.messageDeliveryHandler = { message in
|
await confirmation("Bob should not receive a private message", expectedCount: 0) { bobReceivesMessage in
|
||||||
if message.content == TestConstants.testMessage1 &&
|
bob.messageDeliveryHandler = { message in
|
||||||
message.isPrivate &&
|
if message.content == TestConstants.testMessage1 &&
|
||||||
message.sender == TestConstants.testNickname1 {
|
message.isPrivate &&
|
||||||
expectation.fulfill()
|
message.sender == TestConstants.testNickname1 {
|
||||||
|
bobReceivedMessage = true
|
||||||
|
bobReceivesMessage()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Alice sends private message to Bob
|
||||||
// Alice sends private message to Bob
|
alice.sendPrivateMessage(
|
||||||
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(
|
|
||||||
TestConstants.testMessage1,
|
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
|
recipientNickname: TestConstants.testNickname2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
wait(for: [bobExpectation, charlieExpectation], timeout: TestConstants.shortTimeout)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Delivery Acknowledgment Tests
|
@Test func privateMessageNotReceivedByOthers() async {
|
||||||
|
alice.simulateConnection(with: bob)
|
||||||
// NOTE: DeliveryTracker has been removed in BLEService.
|
alice.simulateConnection(with: charlie)
|
||||||
// Delivery tracking is now handled internally.
|
|
||||||
|
await confirmation("Bob receives private message") { bobReceivesMessage in
|
||||||
|
bob.messageDeliveryHandler = { message in
|
||||||
// MARK: - Message Retry Tests
|
if message.content == TestConstants.testMessage1 && message.isPrivate {
|
||||||
|
bobReceivesMessage()
|
||||||
// NOTE: MessageRetryService has been removed in BLEService.
|
}
|
||||||
// Retry logic is now handled internally.
|
}
|
||||||
|
|
||||||
|
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
|
// MARK: - End-to-End Encryption Tests
|
||||||
|
|
||||||
func testPrivateMessageEncryption() {
|
@Test func privateMessageEncryption() async {
|
||||||
simulateConnection(alice, bob)
|
alice.simulateConnection(with: bob)
|
||||||
|
|
||||||
// Setup Noise sessions
|
// Setup Noise sessions
|
||||||
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
||||||
@@ -125,175 +119,144 @@ final class PrivateChatE2ETests: XCTestCase {
|
|||||||
|
|
||||||
// Establish encrypted session
|
// Establish encrypted session
|
||||||
do {
|
do {
|
||||||
let handshake1 = try aliceManager.initiateHandshake(with: TestConstants.testPeerID2)
|
let handshake1 = try aliceManager.initiateHandshake(with: bob.peerID)
|
||||||
let handshake2 = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: handshake1)!
|
let handshake2 = try bobManager.handleIncomingHandshake(from: alice.peerID, message: handshake1)!
|
||||||
let handshake3 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: handshake2)!
|
let handshake3 = try aliceManager.handleIncomingHandshake(from: bob.peerID, message: handshake2)!
|
||||||
_ = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: handshake3)
|
_ = try bobManager.handleIncomingHandshake(from: alice.peerID, message: handshake3)
|
||||||
} catch {
|
} catch {
|
||||||
XCTFail("Failed to establish Noise session: \(error)")
|
Issue.record("Failed to establish Noise session: \(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Encrypted message received")
|
await confirmation("Encrypted message received") { receiveEncryptedMessage in
|
||||||
|
// Setup packet handlers for encryption
|
||||||
// Setup packet handlers for encryption
|
alice.packetDeliveryHandler = { packet in
|
||||||
alice.packetDeliveryHandler = { packet in
|
// Encrypt outgoing private messages
|
||||||
// Encrypt outgoing private messages
|
if packet.type == 0x01,
|
||||||
if packet.type == 0x01,
|
let message = BitchatMessage(packet.payload),
|
||||||
let message = BitchatMessage(packet.payload),
|
message.isPrivate {
|
||||||
message.isPrivate {
|
do {
|
||||||
do {
|
let encrypted = try aliceManager.encrypt(packet.payload, for: bob.peerID)
|
||||||
let encrypted = try aliceManager.encrypt(packet.payload, for: TestConstants.testPeerID2)
|
let encryptedPacket = BitchatPacket(
|
||||||
let encryptedPacket = BitchatPacket(
|
type: 0x02, // Encrypted message type
|
||||||
type: 0x02, // Encrypted message type
|
senderID: packet.senderID,
|
||||||
senderID: packet.senderID,
|
recipientID: packet.recipientID,
|
||||||
recipientID: packet.recipientID,
|
timestamp: packet.timestamp,
|
||||||
timestamp: packet.timestamp,
|
payload: encrypted,
|
||||||
payload: encrypted,
|
signature: packet.signature,
|
||||||
signature: packet.signature,
|
ttl: packet.ttl
|
||||||
ttl: packet.ttl
|
)
|
||||||
)
|
self.bob.simulateIncomingPacket(encryptedPacket)
|
||||||
self.bob.simulateIncomingPacket(encryptedPacket)
|
} catch {
|
||||||
} catch {
|
Issue.record("Encryption failed: \(error)")
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
XCTFail("Decryption failed: \(error)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
bob.packetDeliveryHandler = { packet in
|
||||||
// Send encrypted private message
|
// Decrypt incoming encrypted messages
|
||||||
alice.sendPrivateMessage(
|
if packet.type == 0x02 {
|
||||||
TestConstants.testMessage1,
|
do {
|
||||||
to: TestConstants.testPeerID2,
|
let decrypted = try bobManager.decrypt(packet.payload, from: alice.peerID)
|
||||||
recipientNickname: TestConstants.testNickname2
|
if let message = BitchatMessage(decrypted) {
|
||||||
)
|
#expect(message.content == TestConstants.testMessage1)
|
||||||
|
#expect(message.isPrivate)
|
||||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
receiveEncryptedMessage()
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
// MARK: - Multi-hop Private Message Tests
|
Issue.record("Decryption failed: \(error)")
|
||||||
|
}
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Send encrypted private message
|
||||||
// Send many private messages
|
|
||||||
for i in 0..<messageCount {
|
|
||||||
alice.sendPrivateMessage(
|
alice.sendPrivateMessage(
|
||||||
"Private message \(i)",
|
TestConstants.testMessage1,
|
||||||
to: TestConstants.testPeerID2,
|
to: TestConstants.testPeerID2,
|
||||||
recipientNickname: TestConstants.testNickname2
|
recipientNickname: TestConstants.testNickname2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.longTimeout)
|
|
||||||
XCTAssertEqual(receivedCount, messageCount)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testLargePrivateMessage() {
|
// MARK: - Multi-hop Private Message Tests
|
||||||
simulateConnection(alice, bob)
|
|
||||||
|
@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
|
let messageCount = 100
|
||||||
if message.content == TestConstants.testLongMessage && message.isPrivate {
|
var receivedCount = 0
|
||||||
expectation.fulfill()
|
|
||||||
|
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
|
@Test func largePrivateMessage() async {
|
||||||
|
alice.simulateConnection(with: bob)
|
||||||
// NOTE: This test relied on MessageRetryService which has been removed
|
|
||||||
|
await confirmation("Large private message received") { receiveLargeMessage in
|
||||||
func testDuplicateAckPrevention() throws {
|
bob.messageDeliveryHandler = { message in
|
||||||
throw XCTSkip("DeliveryTracker/ACK flow removed; test not applicable")
|
if message.content == TestConstants.testLongMessage && message.isPrivate {
|
||||||
}
|
receiveLargeMessage()
|
||||||
|
}
|
||||||
// MARK: - Helper Methods
|
}
|
||||||
|
|
||||||
private func createMockService(peerID: String, nickname: String) -> MockBluetoothMeshService {
|
alice.sendPrivateMessage(
|
||||||
let service = MockBluetoothMeshService()
|
TestConstants.testLongMessage,
|
||||||
service.myPeerID = peerID
|
to: TestConstants.testPeerID2,
|
||||||
service.mockNickname = nickname
|
recipientNickname: TestConstants.testNickname2
|
||||||
service._testRegister()
|
)
|
||||||
return service
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private func simulateConnection(_ peer1: MockBluetoothMeshService, _ peer2: MockBluetoothMeshService) {
|
|
||||||
peer1.simulateConnectedPeer(peer2.peerID)
|
|
||||||
peer2.simulateConnectedPeer(peer1.peerID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,440 +6,389 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import XCTest
|
import Testing
|
||||||
|
import struct Foundation.UUID
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class PublicChatE2ETests: XCTestCase {
|
@Suite(.serialized)
|
||||||
|
struct PublicChatE2ETests {
|
||||||
|
|
||||||
var alice: MockBluetoothMeshService!
|
private let alice: MockBLEService
|
||||||
var bob: MockBluetoothMeshService!
|
private let bob: MockBLEService
|
||||||
var charlie: MockBluetoothMeshService!
|
private let charlie: MockBLEService
|
||||||
var david: MockBluetoothMeshService!
|
private let david: MockBLEService
|
||||||
|
|
||||||
var receivedMessages: [String: [BitchatMessage]] = [:]
|
private var receivedMessages: [String: [BitchatMessage]] = [:]
|
||||||
|
|
||||||
override func setUp() {
|
init() {
|
||||||
super.setUp()
|
// Create mock services with unique peer IDs to avoid any collision
|
||||||
MockBLEService.resetTestBus()
|
alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1)
|
||||||
|
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2)
|
||||||
// Create mock services
|
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3)
|
||||||
alice = createMockService(peerID: TestConstants.testPeerID1, nickname: TestConstants.testNickname1)
|
david = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname4)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Basic Broadcasting Tests
|
// MARK: - Basic Broadcasting Tests
|
||||||
|
|
||||||
func testSimplePublicMessage() {
|
@Test func simplePublicMessage() async {
|
||||||
// Connect Alice and Bob
|
alice.simulateConnection(with: bob)
|
||||||
simulateConnection(alice, bob)
|
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Bob receives message")
|
await confirmation("Bob receives message") { bobReceivesMessage in
|
||||||
|
bob.messageDeliveryHandler = { message in
|
||||||
bob.messageDeliveryHandler = { message in
|
if message.content == TestConstants.testMessage1 && message.sender == TestConstants.testNickname1 {
|
||||||
if message.content == TestConstants.testMessage1 && message.sender == TestConstants.testNickname1 {
|
bobReceivesMessage()
|
||||||
expectation.fulfill()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
@Test func multiRecipientBroadcast() async {
|
||||||
// Connect Alice to Bob and Charlie
|
alice.simulateConnection(with: bob)
|
||||||
simulateConnection(alice, bob)
|
alice.simulateConnection(with: charlie)
|
||||||
simulateConnection(alice, charlie)
|
|
||||||
|
|
||||||
let bobExpectation = XCTestExpectation(description: "Bob receives message")
|
var bobReceivedMessage = false
|
||||||
let charlieExpectation = XCTestExpectation(description: "Charlie receives message")
|
var charlieReceivedMessage = false
|
||||||
|
|
||||||
bob.messageDeliveryHandler = { message in
|
await confirmation("Both recieve message", expectedCount: 2) { receiveMessage in
|
||||||
if message.content == TestConstants.testMessage1 {
|
bob.messageDeliveryHandler = { message in
|
||||||
bobExpectation.fulfill()
|
if message.content == TestConstants.testMessage1 {
|
||||||
|
if !bobReceivedMessage {
|
||||||
|
bobReceivedMessage = true
|
||||||
|
receiveMessage()
|
||||||
|
} else {
|
||||||
|
Issue.record("Bob received more than once")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
charlie.messageDeliveryHandler = { message in
|
||||||
charlie.messageDeliveryHandler = { message in
|
if message.content == TestConstants.testMessage1 {
|
||||||
if message.content == TestConstants.testMessage1 {
|
if !charlieReceivedMessage {
|
||||||
charlieExpectation.fulfill()
|
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
|
// MARK: - Message Routing and Relay Tests
|
||||||
|
|
||||||
func testMessageRelayChain() {
|
@Test func messageRelayChain() async {
|
||||||
// Linear topology: Alice -> Bob -> Charlie
|
// Linear topology: Alice -> Bob -> Charlie
|
||||||
simulateConnection(alice, bob)
|
alice.simulateConnection(with: bob)
|
||||||
simulateConnection(bob, charlie)
|
bob.simulateConnection(with: charlie)
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Charlie receives relayed message")
|
await confirmation("Charlie receives relayed message") { charlieReceivesMessage in
|
||||||
|
// Set up relay in Bob
|
||||||
// Set up relay in Bob
|
bob.packetDeliveryHandler = { packet in
|
||||||
bob.packetDeliveryHandler = { packet in
|
// Bob should relay to Charlie
|
||||||
// Bob should relay to Charlie
|
if let message = BitchatMessage(packet.payload),
|
||||||
if let message = BitchatMessage(packet.payload),
|
message.sender == TestConstants.testNickname1 {
|
||||||
message.sender == TestConstants.testNickname1 {
|
|
||||||
|
// Create relay message
|
||||||
// Create relay message
|
let relayMessage = BitchatMessage(
|
||||||
let relayMessage = BitchatMessage(
|
id: message.id,
|
||||||
id: message.id,
|
sender: message.sender,
|
||||||
sender: message.sender,
|
content: message.content,
|
||||||
content: message.content,
|
timestamp: message.timestamp,
|
||||||
timestamp: message.timestamp,
|
isRelay: true,
|
||||||
isRelay: true,
|
originalSender: message.sender,
|
||||||
originalSender: message.sender,
|
isPrivate: message.isPrivate,
|
||||||
isPrivate: message.isPrivate,
|
recipientNickname: message.recipientNickname,
|
||||||
recipientNickname: message.recipientNickname,
|
senderPeerID: message.senderPeerID,
|
||||||
senderPeerID: message.senderPeerID,
|
mentions: message.mentions
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Simulate relay to Charlie
|
if let relayPayload = relayMessage.toBinaryPayload() {
|
||||||
self.charlie.simulateIncomingPacket(relayPacket)
|
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
|
||||||
charlie.messageDeliveryHandler = { message in
|
if message.content == TestConstants.testMessage1 &&
|
||||||
if message.content == TestConstants.testMessage1 &&
|
message.originalSender == TestConstants.testNickname1 &&
|
||||||
message.originalSender == TestConstants.testNickname1 &&
|
message.isRelay {
|
||||||
message.isRelay {
|
charlieReceivesMessage()
|
||||||
expectation.fulfill()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// Topology: Alice -> Bob -> Charlie -> David
|
||||||
simulateConnection(alice, bob)
|
alice.simulateConnection(with: bob)
|
||||||
simulateConnection(bob, charlie)
|
bob.simulateConnection(with: charlie)
|
||||||
simulateConnection(charlie, david)
|
charlie.simulateConnection(with: david)
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "David receives multi-hop message")
|
await confirmation("David receives multi-hop message") { davidReceivesMessage in
|
||||||
|
// Set up relay chain
|
||||||
// Set up relay chain
|
setupRelayHandler(bob, nextHops: [charlie])
|
||||||
setupRelayHandler(bob, nextHops: [charlie])
|
setupRelayHandler(charlie, nextHops: [david])
|
||||||
setupRelayHandler(charlie, nextHops: [david])
|
|
||||||
// Allow handlers to install
|
david.messageDeliveryHandler = { message in
|
||||||
let sendDelay = DispatchTime.now() + 0.05
|
if message.content == TestConstants.testMessage1 &&
|
||||||
|
message.originalSender == TestConstants.testNickname1 &&
|
||||||
david.messageDeliveryHandler = { message in
|
message.isRelay {
|
||||||
if message.content == TestConstants.testMessage1 &&
|
davidReceivesMessage()
|
||||||
message.originalSender == TestConstants.testNickname1 &&
|
}
|
||||||
message.isRelay {
|
|
||||||
expectation.fulfill()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// MARK: - TTL (Time To Live) Tests
|
||||||
|
|
||||||
func testTTLDecrement() {
|
@Test func ttlDecrement() async {
|
||||||
// Create a chain longer than TTL
|
// Create a chain longer than TTL
|
||||||
let nodes = [alice!, bob!, charlie!, david!]
|
let nodes = [alice, bob, charlie, david]
|
||||||
|
|
||||||
// Connect in chain
|
// Connect in chain
|
||||||
for i in 0..<nodes.count-1 {
|
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 {
|
if i > 0 && i < nodes.count-1 {
|
||||||
setupRelayHandler(nodes[i], nextHops: [nodes[i+1]])
|
setupRelayHandler(nodes[i], nextHops: [nodes[i+1]])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Message dropped due to TTL")
|
await confirmation("Message dropped due to TTL", expectedCount: 0) { receiveMessage in
|
||||||
expectation.isInverted = true // Should NOT be fulfilled
|
david.messageDeliveryHandler = { message in
|
||||||
|
if message.content == TestConstants.testMessage1 {
|
||||||
david.messageDeliveryHandler = { message in
|
receiveMessage() // This should not happen
|
||||||
if message.content == TestConstants.testMessage1 {
|
}
|
||||||
expectation.fulfill() // 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() {
|
@Test func zeroTTLNotRelayed() async {
|
||||||
simulateConnection(alice, bob)
|
alice.simulateConnection(with: bob)
|
||||||
simulateConnection(bob, charlie)
|
bob.simulateConnection(with: charlie)
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Zero TTL message not relayed")
|
await confirmation("Zero TTL message not relayed", expectedCount: 0) { receiveMessage in
|
||||||
expectation.isInverted = true
|
charlie.messageDeliveryHandler = { message in
|
||||||
|
if message.content == "Zero TTL message" {
|
||||||
charlie.messageDeliveryHandler = { message in
|
receiveMessage() // Should not happen
|
||||||
if message.content == "Zero TTL message" {
|
}
|
||||||
expectation.fulfill() // 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
|
// MARK: - Duplicate Detection Tests
|
||||||
|
|
||||||
func testDuplicateMessagePrevention() {
|
@Test func duplicateMessagePrevention() async {
|
||||||
simulateConnection(alice, bob)
|
alice.simulateConnection(with: bob)
|
||||||
|
|
||||||
var messageCount = 0
|
var messageCount = 0
|
||||||
let expectation = XCTestExpectation(description: "Only one message received")
|
|
||||||
|
|
||||||
bob.messageDeliveryHandler = { message in
|
await confirmation("Only one message received") { receiveMessage in
|
||||||
if message.content == TestConstants.testMessage1 {
|
bob.messageDeliveryHandler = { message in
|
||||||
messageCount += 1
|
if message.content == TestConstants.testMessage1 {
|
||||||
if messageCount == 1 {
|
receiveMessage()
|
||||||
// Send duplicate after small delay
|
messageCount += 1
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
if messageCount == 1 {
|
||||||
self.alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil, messageID: message.id)
|
// 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
|
var messageCount = 0
|
||||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
|
||||||
|
|
||||||
// Wait to ensure duplicate would have been received
|
await confirmation("Only one message received", expectedCount: 2) { receiveMessage in
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
bob.messageDeliveryHandler = { message in
|
||||||
XCTAssertEqual(messageCount, 1)
|
if message.content == TestConstants.testMessage1 {
|
||||||
expectation.fulfill()
|
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
|
// MARK: - Mention Tests
|
||||||
|
|
||||||
func testMessageWithMentions() {
|
@Test func messageWithMentions() async {
|
||||||
simulateConnection(alice, bob)
|
alice.simulateConnection(with: bob)
|
||||||
simulateConnection(alice, charlie)
|
alice.simulateConnection(with: charlie)
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Mentioned users receive notification")
|
|
||||||
|
|
||||||
var mentionedUsers: Set<String> = []
|
var mentionedUsers: Set<String> = []
|
||||||
|
|
||||||
bob.messageDeliveryHandler = { message in
|
await confirmation("Mentioned users receive notification", expectedCount: 2) { receiveMention in
|
||||||
if let mentions = message.mentions, mentions.contains(TestConstants.testNickname2) {
|
bob.messageDeliveryHandler = { message in
|
||||||
mentionedUsers.insert(TestConstants.testNickname2)
|
if message.mentions?.contains(TestConstants.testNickname2) == true {
|
||||||
|
mentionedUsers.insert(TestConstants.testNickname2)
|
||||||
|
receiveMention()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
charlie.messageDeliveryHandler = { message in
|
||||||
charlie.messageDeliveryHandler = { message in
|
if message.mentions?.contains(TestConstants.testNickname3) == true {
|
||||||
if let mentions = message.mentions, mentions.contains(TestConstants.testNickname3) {
|
mentionedUsers.insert(TestConstants.testNickname3)
|
||||||
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
|
#expect(mentionedUsers == [TestConstants.testNickname2, TestConstants.testNickname3])
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Network Topology Tests
|
// MARK: - Network Topology Tests
|
||||||
|
|
||||||
func testMeshTopologyBroadcast() {
|
@Test func meshTopologyBroadcast() async {
|
||||||
// Create mesh: Everyone connected to everyone
|
// 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 i in 0..<nodes.count {
|
||||||
for j in i+1..<nodes.count {
|
for j in i+1..<nodes.count {
|
||||||
simulateConnection(nodes[i], nodes[j])
|
nodes[i].simulateConnection(with: nodes[j])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var receivedCount = 0
|
await confirmation("All nodes receive message", expectedCount: 3) { receiveMessage in
|
||||||
let expectation = XCTestExpectation(description: "All nodes receive message")
|
for (index, node) in nodes.enumerated() where index > 0 {
|
||||||
|
node.messageDeliveryHandler = { message in
|
||||||
for (index, node) in nodes.enumerated() where index > 0 {
|
if message.content == TestConstants.testMessage1 {
|
||||||
node.messageDeliveryHandler = { message in
|
receiveMessage()
|
||||||
if message.content == TestConstants.testMessage1 {
|
|
||||||
receivedCount += 1
|
|
||||||
if receivedCount == 3 { // Bob, Charlie, David
|
|
||||||
expectation.fulfill()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// Partial mesh: Alice -> Bob, Bob -> Charlie, Charlie -> David, David -> Alice
|
||||||
simulateConnection(alice, bob)
|
alice.simulateConnection(with: bob)
|
||||||
simulateConnection(bob, charlie)
|
bob.simulateConnection(with: charlie)
|
||||||
simulateConnection(charlie, david)
|
charlie.simulateConnection(with: david)
|
||||||
simulateConnection(david, alice)
|
david.simulateConnection(with: alice)
|
||||||
|
|
||||||
// Setup relay handlers
|
// Setup relay handlers
|
||||||
setupRelayHandler(bob, nextHops: [charlie])
|
setupRelayHandler(bob, nextHops: [charlie])
|
||||||
setupRelayHandler(charlie, nextHops: [david])
|
setupRelayHandler(charlie, nextHops: [david])
|
||||||
setupRelayHandler(david, nextHops: [alice])
|
setupRelayHandler(david, nextHops: [alice])
|
||||||
let sendDelay2 = DispatchTime.now() + 0.05
|
|
||||||
|
|
||||||
var receivedCount = 0
|
await confirmation("Message reaches all nodes once", expectedCount: 3) { receiveMessage in
|
||||||
let expectation = XCTestExpectation(description: "Message reaches all nodes once")
|
for node in [bob, charlie, david] {
|
||||||
|
node.messageDeliveryHandler = { message in
|
||||||
let checkCompletion = {
|
if message.content == TestConstants.testMessage1 {
|
||||||
if receivedCount == 3 { // Bob, Charlie, David should receive
|
receiveMessage()
|
||||||
expectation.fulfill()
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for node in [bob!, charlie!, david!] {
|
|
||||||
node.messageDeliveryHandler = { message in
|
|
||||||
if message.content == TestConstants.testMessage1 {
|
|
||||||
receivedCount += 1
|
|
||||||
checkCompletion()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// MARK: - Performance and Stress Tests
|
||||||
|
|
||||||
func testHighVolumeMessaging() {
|
@Test func highVolumeMessaging() async {
|
||||||
simulateConnection(alice, bob)
|
alice.simulateConnection(with: bob)
|
||||||
|
|
||||||
let messageCount = 100
|
let messageCount = 100
|
||||||
var receivedCount = 0
|
|
||||||
let expectation = XCTestExpectation(description: "All messages received")
|
|
||||||
|
|
||||||
bob.messageDeliveryHandler = { message in
|
await confirmation("All messages received", expectedCount: messageCount) { receiveMessage in
|
||||||
if message.sender == TestConstants.testNickname1 {
|
bob.messageDeliveryHandler = { message in
|
||||||
receivedCount += 1
|
if message.sender == TestConstants.testNickname1 {
|
||||||
if receivedCount == messageCount {
|
receiveMessage()
|
||||||
expectation.fulfill()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Send many messages rapidly
|
||||||
// Send many messages rapidly
|
for i in 0..<messageCount {
|
||||||
for i in 0..<messageCount {
|
alice.sendMessage("Message \(i)", mentions: [], to: nil)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func largeMessageBroadcast() async {
|
||||||
|
alice.simulateConnection(with: bob)
|
||||||
|
|
||||||
// Send large message
|
await confirmation("Large message received") { receiveLargeMessage in
|
||||||
alice.sendMessage(TestConstants.testLongMessage, mentions: [], to: nil)
|
bob.messageDeliveryHandler = { message in
|
||||||
|
if message.content == TestConstants.testLongMessage {
|
||||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
receiveLargeMessage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
alice.sendMessage(TestConstants.testLongMessage, mentions: [], to: nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
// MARK: - Helper Methods
|
||||||
|
|
||||||
private func createMockService(peerID: String, nickname: String) -> MockBluetoothMeshService {
|
private func setupRelayHandler(_ node: MockBLEService, nextHops: [MockBLEService]) {
|
||||||
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]) {
|
|
||||||
node.packetDeliveryHandler = { packet in
|
node.packetDeliveryHandler = { packet in
|
||||||
// Check if should relay
|
// Check if should relay
|
||||||
guard packet.ttl > 1 else { return }
|
guard packet.ttl > 1 else { return }
|
||||||
|
|
||||||
if let message = BitchatMessage(packet.payload) {
|
if let message = BitchatMessage(packet.payload) {
|
||||||
// Don't relay own messages
|
// Don't relay own messages
|
||||||
guard message.senderPeerID != node.peerID else { return }
|
guard message.senderPeerID?.id != node.peerID else { return }
|
||||||
|
|
||||||
// Create relay message
|
// Create relay message
|
||||||
let relayMessage = BitchatMessage(
|
let relayMessage = BitchatMessage(
|
||||||
@@ -458,7 +407,7 @@ final class PublicChatE2ETests: XCTestCase {
|
|||||||
if let relayPayload = relayMessage.toBinaryPayload() {
|
if let relayPayload = relayMessage.toBinaryPayload() {
|
||||||
let relayPacket = BitchatPacket(
|
let relayPacket = BitchatPacket(
|
||||||
type: packet.type,
|
type: packet.type,
|
||||||
senderID: node.peerID.data(using: .utf8)!,
|
senderID: node.peerID.id.data(using: .utf8)!,
|
||||||
recipientID: packet.recipientID,
|
recipientID: packet.recipientID,
|
||||||
timestamp: packet.timestamp,
|
timestamp: packet.timestamp,
|
||||||
payload: relayPayload,
|
payload: relayPayload,
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import XCTest
|
|||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class FontBitchatTests: XCTestCase {
|
final class FontBitchatTests: XCTestCase {
|
||||||
func testMonospacedMapping() {
|
// func testMonospacedMapping() {
|
||||||
XCTAssertEqual(Font.bitchatSystem(size: 10, design: .monospaced), Font.system(.caption2, design: .monospaced))
|
// 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: 14, design: .monospaced), Font.system(.body, design: .monospaced))
|
||||||
XCTAssertEqual(Font.bitchatSystem(size: 20, design: .monospaced), Font.system(.title2, design: .monospaced))
|
// XCTAssertEqual(Font.bitchatSystem(size: 20, design: .monospaced), Font.system(.title2, design: .monospaced))
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
func testWeightIsPreserved() {
|
// func testWeightIsPreserved() {
|
||||||
let bold = Font.bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
// let bold = Font.bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||||
XCTAssertEqual(bold, Font.system(.body, design: .monospaced).weight(.bold))
|
// XCTAssertEqual(bold, Font.system(.body, design: .monospaced).weight(.bold))
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,48 +6,149 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import XCTest
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
import CoreBluetooth
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class FragmentationTests: XCTestCase {
|
struct FragmentationTests {
|
||||||
|
|
||||||
private var mockKeychain: MockKeychain!
|
private let mockKeychain: MockKeychain
|
||||||
private var mockIdentityManager: MockIdentityManager!
|
private let mockIdentityManager: MockIdentityManager
|
||||||
|
|
||||||
override func setUp() {
|
init() {
|
||||||
super.setUp()
|
|
||||||
mockKeychain = MockKeychain()
|
mockKeychain = MockKeychain()
|
||||||
mockIdentityManager = MockIdentityManager(mockKeychain)
|
mockIdentityManager = MockIdentityManager(mockKeychain)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tearDown() {
|
@Test("Reassembly from fragments delivers a public message")
|
||||||
mockKeychain = nil
|
func reassemblyFromFragmentsDeliversPublicMessage() async throws {
|
||||||
mockIdentityManager = nil
|
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
|
||||||
super.tearDown()
|
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 {
|
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 didReceiveMessage(_ message: BitchatMessage) {}
|
||||||
func didConnectToPeer(_ peerID: String) {}
|
func didConnectToPeer(_ peerID: PeerID) {}
|
||||||
func didDisconnectFromPeer(_ peerID: String) {}
|
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
||||||
func didUpdatePeerList(_ peers: [String]) {}
|
func didUpdatePeerList(_ peers: [PeerID]) {}
|
||||||
func isFavorite(fingerprint: String) -> Bool { false }
|
func isFavorite(fingerprint: String) -> Bool { false }
|
||||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
||||||
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
||||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {
|
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||||
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
|
||||||
publicMessages.append((peerID, nickname, content))
|
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)
|
// 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 content = String(repeating: "A", count: size)
|
||||||
let payload = Data(content.utf8)
|
let payload = Data(content.utf8)
|
||||||
let pkt = BitchatPacket(
|
let pkt = BitchatPacket(
|
||||||
type: MessageType.message.rawValue,
|
type: MessageType.message.rawValue,
|
||||||
senderID: Data(hexString: senderShortHex) ?? Data(),
|
senderID: Data(hexString: senderShortHex.id) ?? Data(),
|
||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -88,101 +189,4 @@ final class FragmentationTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
return packets
|
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 {
|
final class GeohashBookmarksStoreTests: XCTestCase {
|
||||||
let storeKey = "locationChannel.bookmarks"
|
let storeKey = "locationChannel.bookmarks"
|
||||||
|
var storage: UserDefaults!
|
||||||
|
var store: GeohashBookmarksStore!
|
||||||
|
|
||||||
override func setUp() {
|
override func setUp() {
|
||||||
super.setUp()
|
super.setUp()
|
||||||
// Clear persisted state before each test
|
// Unique instance for each test to avoid race condition
|
||||||
UserDefaults.standard.removeObject(forKey: storeKey)
|
storage = UserDefaults(suiteName: UUID().uuidString)
|
||||||
GeohashBookmarksStore.shared._resetForTesting()
|
store = GeohashBookmarksStore(storage: storage!)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tearDown() {
|
override func tearDown() {
|
||||||
// Clean after each test
|
storage.removeObject(forKey: storeKey)
|
||||||
UserDefaults.standard.removeObject(forKey: storeKey)
|
store._resetForTesting()
|
||||||
GeohashBookmarksStore.shared._resetForTesting()
|
store = nil
|
||||||
|
storage = nil
|
||||||
super.tearDown()
|
super.tearDown()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testToggleAndNormalize() {
|
func testToggleAndNormalize() {
|
||||||
let store = GeohashBookmarksStore.shared
|
|
||||||
// Start clean
|
// Start clean
|
||||||
XCTAssertTrue(store.bookmarks.isEmpty)
|
XCTAssertTrue(store.bookmarks.isEmpty)
|
||||||
|
|
||||||
@@ -35,12 +37,11 @@ final class GeohashBookmarksStoreTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testPersistenceWritten() throws {
|
func testPersistenceWritten() throws {
|
||||||
let store = GeohashBookmarksStore.shared
|
|
||||||
store.toggle("ezs42")
|
store.toggle("ezs42")
|
||||||
store.toggle("u4pruy")
|
store.toggle("u4pruy")
|
||||||
|
|
||||||
// Verify persisted JSON contains both (order not enforced here)
|
// 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")
|
XCTFail("No persisted data found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ private final class RecordingDelegate: GossipSyncManager.Delegate {
|
|||||||
onSend?()
|
onSend?()
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendPacket(to peerID: String, packet: BitchatPacket) {
|
func sendPacket(to peerID: PeerID, packet: BitchatPacket) {
|
||||||
sendPacket(packet)
|
sendPacket(packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -587,11 +587,10 @@ final class IntegrationTests: XCTestCase {
|
|||||||
|
|
||||||
// MARK: - Helper Methods
|
// MARK: - Helper Methods
|
||||||
|
|
||||||
private func createNode(_ name: String, peerID: String) {
|
private func createNode(_ name: String, peerID: PeerID) {
|
||||||
let node = MockBluetoothMeshService()
|
let node = MockBluetoothMeshService()
|
||||||
node.myPeerID = peerID
|
node.myPeerID = peerID
|
||||||
node.mockNickname = name
|
node.mockNickname = name
|
||||||
node._testRegister()
|
|
||||||
nodes[name] = node
|
nodes[name] = node
|
||||||
|
|
||||||
// Create Noise manager
|
// 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
|
@MainActor
|
||||||
final class LocationNotesManagerTests: XCTestCase {
|
final class LocationNotesManagerTests: XCTestCase {
|
||||||
func testSubscribeWithoutRelaysSetsNoRelaysState() {
|
// func testSubscribeWithoutRelaysSetsNoRelaysState() {
|
||||||
var subscribeCalled = false
|
// var subscribeCalled = false
|
||||||
let deps = LocationNotesDependencies(
|
// let deps = LocationNotesDependencies(
|
||||||
relayLookup: { _, _ in [] },
|
// relayLookup: { _, _ in [] },
|
||||||
subscribe: { _, _, _, _, _ in
|
// subscribe: { _, _, _, _, _ in
|
||||||
subscribeCalled = true
|
// subscribeCalled = true
|
||||||
},
|
// },
|
||||||
unsubscribe: { _ in },
|
// unsubscribe: { _ in },
|
||||||
sendEvent: { _, _ in },
|
// sendEvent: { _, _ in },
|
||||||
deriveIdentity: { _ in fatalError("should not derive identity") },
|
// deriveIdentity: { _ in fatalError("should not derive identity") },
|
||||||
now: { Date() }
|
// now: { Date() }
|
||||||
)
|
// )
|
||||||
|
//
|
||||||
let manager = LocationNotesManager(geohash: "abcd1234", dependencies: deps)
|
// let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
||||||
|
//
|
||||||
XCTAssertFalse(subscribeCalled)
|
// XCTAssertFalse(subscribeCalled)
|
||||||
XCTAssertEqual(manager.state, .noRelays)
|
// XCTAssertEqual(manager.state, .noRelays)
|
||||||
XCTAssertTrue(manager.initialLoadComplete)
|
// XCTAssertTrue(manager.initialLoadComplete)
|
||||||
XCTAssertEqual(manager.errorMessage, "No geo relays available near this location. Try again soon.")
|
// 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(
|
// func testSendWhenNoRelaysSurfacesError() {
|
||||||
relayLookup: { _, _ in [] },
|
// var sendCalled = false
|
||||||
subscribe: { _, _, _, _, _ in },
|
// let deps = LocationNotesDependencies(
|
||||||
unsubscribe: { _ in },
|
// relayLookup: { _, _ in [] },
|
||||||
sendEvent: { _, _ in sendCalled = true },
|
// subscribe: { _, _, _, _, _ in },
|
||||||
deriveIdentity: { _ in throw TestError.shouldNotDerive },
|
// unsubscribe: { _ in },
|
||||||
now: { Date() }
|
// sendEvent: { _, _ in sendCalled = true },
|
||||||
)
|
// deriveIdentity: { _ in throw TestError.shouldNotDerive },
|
||||||
|
// now: { Date() }
|
||||||
let manager = LocationNotesManager(geohash: "zzzzzzzz", dependencies: deps)
|
// )
|
||||||
manager.send(content: "hello", nickname: "tester")
|
//
|
||||||
|
// let manager = LocationNotesManager(geohash: "zzzzzzzz", dependencies: deps)
|
||||||
XCTAssertFalse(sendCalled)
|
// manager.send(content: "hello", nickname: "tester")
|
||||||
XCTAssertEqual(manager.state, .noRelays)
|
//
|
||||||
XCTAssertEqual(manager.errorMessage, "No geo relays available near this location. Try again soon.")
|
// 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() {
|
func testSubscribeUsesGeoRelaysAndAppendsNotes() {
|
||||||
var relaysCaptured: [String] = []
|
var relaysCaptured: [String] = []
|
||||||
@@ -62,7 +66,7 @@ final class LocationNotesManagerTests: XCTestCase {
|
|||||||
now: { Date() }
|
now: { Date() }
|
||||||
)
|
)
|
||||||
|
|
||||||
let manager = LocationNotesManager(geohash: "abcd1234", dependencies: deps)
|
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
||||||
XCTAssertEqual(relaysCaptured, ["wss://relay.one"])
|
XCTAssertEqual(relaysCaptured, ["wss://relay.one"])
|
||||||
XCTAssertEqual(manager.state, .loading)
|
XCTAssertEqual(manager.state, .loading)
|
||||||
|
|
||||||
@@ -70,7 +74,7 @@ final class LocationNotesManagerTests: XCTestCase {
|
|||||||
pubkey: "pub",
|
pubkey: "pub",
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
kind: .textNote,
|
kind: .textNote,
|
||||||
tags: [["g", "abcd1234"]],
|
tags: [["g", "u4pruydq"]],
|
||||||
content: "hi"
|
content: "hi"
|
||||||
)
|
)
|
||||||
event.id = "event1"
|
event.id = "event1"
|
||||||
@@ -98,7 +102,7 @@ final class LocationNotesCounterTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
|
|
||||||
let counter = LocationNotesCounter(testDependencies: deps)
|
let counter = LocationNotesCounter(testDependencies: deps)
|
||||||
counter.subscribe(geohash: "abcdefgh")
|
counter.subscribe(geohash: "u4pruydq")
|
||||||
|
|
||||||
XCTAssertFalse(subscribeCalled)
|
XCTAssertFalse(subscribeCalled)
|
||||||
XCTAssertFalse(counter.relayAvailable)
|
XCTAssertFalse(counter.relayAvailable)
|
||||||
@@ -122,13 +126,13 @@ final class LocationNotesCounterTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
|
|
||||||
let counter = LocationNotesCounter(testDependencies: deps)
|
let counter = LocationNotesCounter(testDependencies: deps)
|
||||||
counter.subscribe(geohash: "abcdefgh")
|
counter.subscribe(geohash: "u4pruydq")
|
||||||
|
|
||||||
var first = NostrEvent(
|
var first = NostrEvent(
|
||||||
pubkey: "pub",
|
pubkey: "pub",
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
kind: .textNote,
|
kind: .textNote,
|
||||||
tags: [["g", "abcdefgh"]],
|
tags: [["g", "u4pruydq"]],
|
||||||
content: "a"
|
content: "a"
|
||||||
)
|
)
|
||||||
first.id = "eventA"
|
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