mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 18:25:19 +00:00
Compare commits
173
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b38e9a23c | ||
|
|
2686d9c82c | ||
|
|
832e1f006c | ||
|
|
090c4cd919 | ||
|
|
37c2b3a8c2 | ||
|
|
6f1c879237 | ||
|
|
81f5101390 | ||
|
|
d56674706d | ||
|
|
33bcfa3147 | ||
|
|
3a54160793 | ||
|
|
5d2745eae6 | ||
|
|
5138ce4a33 | ||
|
|
07a4997192 | ||
|
|
2d703f4dd9 | ||
|
|
56b9457fcf | ||
|
|
9e57bce5c3 | ||
|
|
6eef030386 | ||
|
|
9d8754099d | ||
|
|
7fae4e71fb | ||
|
|
76d8b3fa7f | ||
|
|
b8a8b940b7 | ||
|
|
7a5ab52f4c | ||
|
|
d2edb651c1 | ||
|
|
858e878959 | ||
|
|
fa0a15cbcf | ||
|
|
3e680f40bf | ||
|
|
ec3c16176e | ||
|
|
2a8de7e048 | ||
|
|
e9dd30ccbf | ||
|
|
af28faa91d | ||
|
|
b33fe8086d | ||
|
|
5495874b5a | ||
|
|
0fca551966 | ||
|
|
0492480598 | ||
|
|
86f46c8b90 | ||
|
|
7057fe75b0 | ||
|
|
873788b24f | ||
|
|
b6dd31b285 | ||
|
|
ff7e5a2c2c | ||
|
|
708a5e33cd | ||
|
|
f893f0605a | ||
|
|
ce33132a0f | ||
|
|
c538837300 | ||
|
|
6888bfa351 | ||
|
|
2fed4b7c31 | ||
|
|
b956e407ff | ||
|
|
477cc7fa05 | ||
|
|
dffce9d63f | ||
|
|
792eaa3166 | ||
|
|
6ff9b59ff2 | ||
|
|
bc04cd0bde | ||
|
|
3fe417395e | ||
|
|
e35bd57c6e | ||
|
|
5aefb05a8b | ||
|
|
e4cbf382ce | ||
|
|
7609c6eeba | ||
|
|
f37acf7e4b | ||
|
|
cf528b0daf | ||
|
|
209ccb4ade | ||
|
|
1876e85f8b | ||
|
|
109b7d0e03 | ||
|
|
09a319fb0f | ||
|
|
97ca55cc54 | ||
|
|
394836f247 | ||
|
|
97fc21c23f | ||
|
|
79bd4af912 | ||
|
|
96c78ef5a2 | ||
|
|
01256f3041 | ||
|
|
2edbe2bbbd | ||
|
|
a91978c10e | ||
|
|
14c4e586fc | ||
|
|
83779240ae | ||
|
|
5034732515 | ||
|
|
b6ce4fae43 | ||
|
|
98fa02cc16 | ||
|
|
0812cafd55 | ||
|
|
450955525c | ||
|
|
475bc70c71 | ||
|
|
af6136c01c | ||
|
|
b839ce5f6c | ||
|
|
0776c9813c | ||
|
|
0dd999af6b | ||
|
|
c3a1af7023 | ||
|
|
880813f256 | ||
|
|
64fb634166 | ||
|
|
13b19fb8eb | ||
|
|
d4967ae9c3 | ||
|
|
435744a977 | ||
|
|
70caa9e24a | ||
|
|
5084f87fe5 | ||
|
|
8f56e4f0fb | ||
|
|
aca44f9f55 | ||
|
|
3f00cf9467 | ||
|
|
40e54a5120 | ||
|
|
7040d9ecb0 | ||
|
|
88bafb41cc | ||
|
|
fb43a8b0f5 | ||
|
|
eb35608fa1 | ||
|
|
b81ae0b4c0 | ||
|
|
b6d42261d0 | ||
|
|
3d914dcf46 | ||
|
|
b3ec5eeda0 | ||
|
|
47d75ab9d8 | ||
|
|
3479c7d5df | ||
|
|
8a0727fcf7 | ||
|
|
6588861e34 | ||
|
|
a1647901e5 | ||
|
|
23249f3e41 | ||
|
|
ad4103bacc | ||
|
|
e3149fa098 | ||
|
|
987ba2e694 | ||
|
|
615273a63e | ||
|
|
4563c22d2d | ||
|
|
9f74266527 | ||
|
|
239064e4eb | ||
|
|
d371131ad5 | ||
|
|
d994ccf012 | ||
|
|
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 |
@@ -7,6 +7,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
update-relay-data:
|
||||
@@ -17,24 +18,54 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch GeoRelays
|
||||
run: |
|
||||
wget https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
||||
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
||||
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
||||
|
||||
- name: Check for changes
|
||||
id: git-check
|
||||
- name: Configure git
|
||||
run: |
|
||||
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Commit and push changes
|
||||
if: steps.git-check.outputs.changes == 'true'
|
||||
git config user.email "action@github.com"
|
||||
git config user.name "GitHub Action"
|
||||
|
||||
- name: Create update branch if changes
|
||||
id: create_branch
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
# exit early if no changes
|
||||
if git diff --quiet --relays/online_relays_gps.csv; then
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# branch name with timestamp
|
||||
BRANCH="update-georelays-$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
git checkout -b "$BRANCH"
|
||||
|
||||
git add relays/online_relays_gps.csv
|
||||
git commit -m "Automated update of relay data - $(date -u)"
|
||||
git push
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
git commit -m "Automated update of relay data - $(date -u --rfc-3339=seconds)"
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Push branch
|
||||
if: steps.create_branch.outputs.changed == 'true'
|
||||
run: |
|
||||
git push --set-upstream origin "${{ steps.create_branch.outputs.branch }}"
|
||||
|
||||
- name: Create pull request
|
||||
if: steps.create_branch.outputs.changed == 'true'
|
||||
uses: peter-evans/create-pull-request@v5
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: Automated update of relay data
|
||||
branch: ${{ steps.create_branch.outputs.branch }}
|
||||
base: main
|
||||
title: Automated update of relay data
|
||||
body: |
|
||||
This PR was created automatically by the scheduled workflow. It updates relays/online_relays_gps.csv from the GeoRelays source.
|
||||
labels: automated, georelays
|
||||
|
||||
- name: No changes
|
||||
if: steps.create_branch.outputs.changed != 'true'
|
||||
run: echo "No changes to relays/online_relays_gps.csv"
|
||||
@@ -24,4 +24,4 @@ jobs:
|
||||
run: swift build
|
||||
|
||||
- name: Run Tests
|
||||
run: swift test --parallel --disable-swift-testing # so it only runs xctests: https://github.com/swiftlang/swift-package-manager/issues/8529#issuecomment-2815711345
|
||||
run: swift test --parallel
|
||||
|
||||
+5
-4
@@ -9,9 +9,6 @@ plans/
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
|
||||
## User settings
|
||||
xcuserdata/
|
||||
|
||||
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
||||
*.xcscmblueprint
|
||||
*.xccheckout
|
||||
@@ -57,7 +54,8 @@ iOSInjectionProject/
|
||||
|
||||
## Xcode project
|
||||
*.xcodeproj/project.xcworkspace/
|
||||
*.xcodeproj/xcshareddata/
|
||||
## Xcode User settings
|
||||
xcuserdata/
|
||||
|
||||
## Python
|
||||
__pycache__/
|
||||
@@ -68,6 +66,9 @@ __pycache__/
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
## Cache
|
||||
.cache/
|
||||
|
||||
# Local build results
|
||||
.Result*/
|
||||
.Result*.xcresult/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
MARKETING_VERSION = 1.4.3
|
||||
MARKETING_VERSION = 1.5.0
|
||||
CURRENT_PROJECT_VERSION = 1
|
||||
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||
|
||||
@@ -14,8 +14,11 @@ default:
|
||||
# Check prerequisites
|
||||
check:
|
||||
@echo "Checking prerequisites..."
|
||||
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ Xcode not found. Install Xcode from App Store" && exit 1)
|
||||
@security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
|
||||
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install Xcode from App Store" && exit 1)
|
||||
@xcode-select -p | grep -q "Xcode.app" || (echo "❌ Full Xcode required, not just command line tools. Install from App Store and run:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
|
||||
@test -d "/Applications/Xcode.app" || (echo "❌ Xcode.app not found in Applications folder. Install from App Store" && exit 1)
|
||||
@xcodebuild -version >/dev/null 2>&1 || (echo "❌ Xcode not properly configured. Try:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
|
||||
@security find-identity -v -p codesigning | grep -q "Apple Development\|Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
|
||||
@echo "✅ All prerequisites met"
|
||||
|
||||
# Backup original files
|
||||
@@ -44,7 +47,7 @@ patch-for-macos: backup
|
||||
# Build the macOS app
|
||||
build: #check generate
|
||||
@echo "Building BitChat for macOS..."
|
||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||
|
||||
# Run the macOS app
|
||||
run: build
|
||||
|
||||
+10
-13
@@ -4,6 +4,7 @@ import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "bitchat",
|
||||
defaultLocalization: "en",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.macOS(.v13)
|
||||
@@ -15,6 +16,7 @@ let package = Package(
|
||||
),
|
||||
],
|
||||
dependencies:[
|
||||
.package(path: "localPackages/Tor"),
|
||||
.package(path: "localPackages/BitLogger"),
|
||||
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
||||
],
|
||||
@@ -24,8 +26,7 @@ let package = Package(
|
||||
dependencies: [
|
||||
.product(name: "P256K", package: "swift-secp256k1"),
|
||||
.product(name: "BitLogger", package: "BitLogger"),
|
||||
.target(name: "TorC"),
|
||||
.target(name: "tor-nolzma")
|
||||
.product(name: "Tor", package: "Tor")
|
||||
],
|
||||
path: "bitchat",
|
||||
exclude: [
|
||||
@@ -34,20 +35,12 @@ let package = Package(
|
||||
"bitchat.entitlements",
|
||||
"bitchat-macOS.entitlements",
|
||||
"LaunchScreen.storyboard",
|
||||
"Services/Tor/C/"
|
||||
"ViewModels/Extensions/README.md"
|
||||
],
|
||||
linkerSettings: [
|
||||
.linkedLibrary("z")
|
||||
resources: [
|
||||
.process("Localizable.xcstrings")
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "TorC",
|
||||
path: "bitchat/Services/Tor/C"
|
||||
),
|
||||
.binaryTarget(
|
||||
name: "tor-nolzma",
|
||||
path: "Frameworks/tor-nolzma.xcframework"
|
||||
),
|
||||
.testTarget(
|
||||
name: "bitchatTests",
|
||||
dependencies: ["bitchat"],
|
||||
@@ -55,6 +48,10 @@ let package = Package(
|
||||
exclude: [
|
||||
"Info.plist",
|
||||
"README.md"
|
||||
],
|
||||
resources: [
|
||||
.process("Localization"),
|
||||
.process("Noise")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
Generated
+66
-21
@@ -7,15 +7,14 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; };
|
||||
0481A3A02E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */; };
|
||||
0481A3A12E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */; };
|
||||
048A88812E76FD18000FBCDD /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; };
|
||||
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };
|
||||
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
|
||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
|
||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; };
|
||||
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; };
|
||||
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA802E7706A80032EA8A /* Tor */; };
|
||||
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */; };
|
||||
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||
/* End PBXBuildFile section */
|
||||
@@ -58,11 +57,10 @@
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
0481A35C2E6DA18600FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; };
|
||||
0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = "tor-nolzma.xcframework"; sourceTree = "<group>"; };
|
||||
61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = bitchatShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
96D0D41CA19EE5A772AA8434 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = "tor-nolzma.xcframework"; path = "localPackages/Tor/Frameworks/tor-nolzma.xcframework"; sourceTree = "<group>"; };
|
||||
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = relays/online_relays_gps.csv; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
@@ -87,7 +85,6 @@
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
LaunchScreen.storyboard,
|
||||
Services/Tor/C/include/.gitkeep,
|
||||
);
|
||||
target = 0576A29205865664C0937536 /* bitchat_macOS */;
|
||||
};
|
||||
@@ -98,6 +95,24 @@
|
||||
);
|
||||
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
||||
};
|
||||
C5E027A52ECCDFD700BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_macOS" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
Localization/PrimaryLocalizationKeys.json,
|
||||
README.md,
|
||||
);
|
||||
target = 47FF23248747DD7CB666CB91 /* bitchatTests_macOS */;
|
||||
};
|
||||
C5E027A82ECCDFE200BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_iOS" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
Localization/PrimaryLocalizationKeys.json,
|
||||
README.md,
|
||||
);
|
||||
target = 6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
@@ -121,6 +136,10 @@
|
||||
};
|
||||
A6E32D412E762EAE0032EA8A /* bitchatTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
C5E027A82ECCDFE200BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_iOS" target */,
|
||||
C5E027A52ECCDFD700BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_macOS" target */,
|
||||
);
|
||||
path = bitchatTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -136,32 +155,22 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
files = (
|
||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
|
||||
048A88812E76FD18000FBCDD /* libz.tbd in Frameworks */,
|
||||
0481A3A02E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */,
|
||||
3EE336D150427F736F32B56C /* P256K in Frameworks */,
|
||||
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
|
||||
);
|
||||
};
|
||||
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
files = (
|
||||
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */,
|
||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
||||
0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */,
|
||||
0481A3A12E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */,
|
||||
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
||||
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
|
||||
);
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
0481A3542E6D877600FC845E /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */,
|
||||
0481A35C2E6DA18600FC845E /* libz.tbd */,
|
||||
);
|
||||
path = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
18198ED912AAF495D8AF7763 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -170,8 +179,8 @@
|
||||
A6E32D212E762EAB0032EA8A /* bitchatShareExtension */,
|
||||
A6E32D412E762EAE0032EA8A /* bitchatTests */,
|
||||
A6E367C92E76469E0032EA8A /* Configs */,
|
||||
0481A3542E6D877600FC845E /* Frameworks */,
|
||||
9F37F9F2C353B58AC809E93B /* Products */,
|
||||
A6F183FB2E948783006A9046 /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -187,6 +196,14 @@
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A6F183FB2E948783006A9046 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -207,6 +224,7 @@
|
||||
packageProductDependencies = (
|
||||
B1D9136AA0083366353BFA2F /* P256K */,
|
||||
A6E3E5712E7703760032EA8A /* BitLogger */,
|
||||
A6E3EA802E7706A80032EA8A /* Tor */,
|
||||
);
|
||||
productName = bitchat_macOS;
|
||||
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
||||
@@ -217,6 +235,7 @@
|
||||
buildConfigurationList = 1C27B5BA3DB46DDF0DBFEF62 /* Build configuration list for PBXNativeTarget "bitchatTests_macOS" */;
|
||||
buildPhases = (
|
||||
5C22AA7B9ACC5A861445C769 /* Sources */,
|
||||
C5E027A42ECCDFD700BD6012 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -249,6 +268,7 @@
|
||||
buildConfigurationList = 38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget "bitchatTests_iOS" */;
|
||||
buildPhases = (
|
||||
865C8403EF02C089369A9FCB /* Sources */,
|
||||
C5E027A72ECCDFE200BD6012 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -284,6 +304,7 @@
|
||||
packageProductDependencies = (
|
||||
4EB6BA1B8464F1EA38F4E286 /* P256K */,
|
||||
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
||||
A6E3EA7E2E7706720032EA8A /* Tor */,
|
||||
);
|
||||
productName = bitchat_iOS;
|
||||
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
||||
@@ -315,6 +336,7 @@
|
||||
ne,
|
||||
"pt-BR",
|
||||
ru,
|
||||
tr,
|
||||
uk,
|
||||
"zh-Hans",
|
||||
);
|
||||
@@ -323,6 +345,7 @@
|
||||
packageReferences = (
|
||||
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
||||
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 90;
|
||||
projectDirPath = "";
|
||||
@@ -344,6 +367,16 @@
|
||||
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */,
|
||||
);
|
||||
};
|
||||
C5E027A42ECCDFD700BD6012 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
files = (
|
||||
);
|
||||
};
|
||||
C5E027A72ECCDFE200BD6012 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
files = (
|
||||
);
|
||||
};
|
||||
CD6E8F32BC38357473954F97 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
files = (
|
||||
@@ -880,6 +913,10 @@
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = localPackages/BitLogger;
|
||||
};
|
||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = localPackages/Tor;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
@@ -907,6 +944,14 @@
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = BitLogger;
|
||||
};
|
||||
A6E3EA7E2E7706720032EA8A /* Tor */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Tor;
|
||||
};
|
||||
A6E3EA802E7706A80032EA8A /* Tor */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Tor;
|
||||
};
|
||||
B1D9136AA0083366353BFA2F /* P256K */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1640"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "57CA17A36A2532A6CFF367BB"
|
||||
BuildableName = "bitchatShareExtension.appex"
|
||||
BlueprintName = "bitchatShareExtension"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
codeCoverageEnabled = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<CodeCoverageTargets>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</CodeCoverageTargets>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES"
|
||||
testExecutionOrdering = "random">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "6CB97DF2EA57234CB3E563B8"
|
||||
BuildableName = "bitchatTests_iOS.xctest"
|
||||
BlueprintName = "bitchatTests_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "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 = "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>
|
||||
@@ -13,7 +13,6 @@
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "image-1024 1.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
@@ -25,7 +24,6 @@
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"filename" : "image-1024 2.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 11 KiB |
+23
-10
@@ -6,6 +6,7 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Tor
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
|
||||
@@ -25,11 +26,15 @@ struct BitchatApp: App {
|
||||
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
||||
#endif
|
||||
|
||||
private let idBridge = NostrIdentityBridge()
|
||||
|
||||
init() {
|
||||
let keychain = KeychainManager()
|
||||
let idBridge = self.idBridge
|
||||
_chatViewModel = StateObject(
|
||||
wrappedValue: ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: SecureIdentityStateManager(keychain)
|
||||
)
|
||||
)
|
||||
@@ -48,15 +53,14 @@ struct BitchatApp: App {
|
||||
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
||||
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
||||
let nickname = chatViewModel.nickname
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
||||
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
||||
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
|
||||
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
|
||||
}
|
||||
#if os(iOS)
|
||||
|
||||
appDelegate.chatViewModel = chatViewModel
|
||||
#elseif os(macOS)
|
||||
appDelegate.chatViewModel = chatViewModel
|
||||
#endif
|
||||
|
||||
// Initialize network activation policy; will start Tor/Nostr only when allowed
|
||||
NetworkActivationService.shared.start()
|
||||
// Check for shared content
|
||||
@@ -184,6 +188,10 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
chatViewModel?.applicationWillTerminate()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -216,7 +224,7 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
// Get peer ID from userInfo
|
||||
if let peerID = userInfo["peerID"] as? String {
|
||||
DispatchQueue.main.async {
|
||||
self.chatViewModel?.startPrivateChat(with: peerID)
|
||||
self.chatViewModel?.startPrivateChat(with: PeerID(str: peerID))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,10 +249,15 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
// Get peer ID from userInfo
|
||||
if let peerID = userInfo["peerID"] as? String {
|
||||
// Don't show notification if the private chat is already open
|
||||
if chatViewModel?.selectedPrivateChatPeer == peerID {
|
||||
completionHandler([])
|
||||
return
|
||||
// Access main-actor-isolated property via Task
|
||||
Task { @MainActor in
|
||||
if self.chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) {
|
||||
completionHandler([])
|
||||
} else {
|
||||
completionHandler([.banner, .sound])
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
// Suppress geohash activity notification if we're already in that geohash channel
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
enum ImageUtilsError: Error {
|
||||
case invalidImage
|
||||
case encodingFailed
|
||||
}
|
||||
|
||||
enum ImageUtils {
|
||||
private static let compressionQuality: CGFloat = 0.82
|
||||
private static let targetImageBytes: Int = 45_000
|
||||
|
||||
static func processImage(at url: URL, maxDimension: CGFloat = 448) throws -> URL {
|
||||
// Security H1: Check file size BEFORE reading into memory
|
||||
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||
guard let fileSize = attrs[.size] as? Int else {
|
||||
throw ImageUtilsError.invalidImage
|
||||
}
|
||||
// Allow up to 10MB source images (will be scaled down)
|
||||
guard fileSize <= 10 * 1024 * 1024 else {
|
||||
throw ImageUtilsError.invalidImage
|
||||
}
|
||||
|
||||
let data = try Data(contentsOf: url)
|
||||
#if os(iOS)
|
||||
guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||
return try processImage(image, maxDimension: maxDimension)
|
||||
#else
|
||||
guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||
return try processImage(image, maxDimension: maxDimension)
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448) throws -> URL {
|
||||
return try autoreleasepool {
|
||||
// Scale the image first
|
||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||
|
||||
// Get CGImage from UIImage - this is the key to stripping metadata
|
||||
guard let cgImage = scaled.cgImage else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
|
||||
// Use CGImageDestination to encode without metadata (same as macOS)
|
||||
var quality = compressionQuality
|
||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
|
||||
// Compress to target size
|
||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||
quality -= 0.1
|
||||
autoreleasepool {
|
||||
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||
jpegData = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let outputURL = try makeOutputURL()
|
||||
try jpegData.write(to: outputURL, options: .atomic)
|
||||
return outputURL
|
||||
}
|
||||
}
|
||||
|
||||
private static func scaledImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage {
|
||||
let size = image.size
|
||||
let maxSide = max(size.width, size.height)
|
||||
guard maxSide > maxDimension else { return image }
|
||||
let scale = maxDimension / maxSide
|
||||
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
|
||||
|
||||
// Draw into a new context to get a clean CGImage without metadata
|
||||
UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0)
|
||||
image.draw(in: CGRect(origin: .zero, size: newSize))
|
||||
let rendered = UIGraphicsGetImageFromCurrentImageContext()
|
||||
UIGraphicsEndImageContext()
|
||||
return rendered ?? image
|
||||
}
|
||||
|
||||
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||
guard let data = CFDataCreateMutable(nil, 0) else {
|
||||
return nil
|
||||
}
|
||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||
return nil
|
||||
}
|
||||
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||
// By only specifying compression quality and no metadata keys,
|
||||
// we ensure a clean JPEG with no privacy-leaking information
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageDestinationLossyCompressionQuality: quality
|
||||
]
|
||||
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
return nil
|
||||
}
|
||||
return data as Data
|
||||
}
|
||||
#else
|
||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448) throws -> URL {
|
||||
return try autoreleasepool {
|
||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
let width = inputCG.width
|
||||
let height = inputCG.height
|
||||
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
||||
guard let context = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: colorSpace,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||
guard let cgImage = context.makeImage() else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
var quality = compressionQuality
|
||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||
quality -= 0.1
|
||||
autoreleasepool {
|
||||
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||
jpegData = next
|
||||
}
|
||||
}
|
||||
}
|
||||
let outputURL = try makeOutputURL()
|
||||
try jpegData.write(to: outputURL, options: .atomic)
|
||||
return outputURL
|
||||
}
|
||||
}
|
||||
|
||||
private static func scaledImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage {
|
||||
let size = image.size
|
||||
let maxSide = max(size.width, size.height)
|
||||
guard maxSide > maxDimension else { return image }
|
||||
let scale = maxDimension / maxSide
|
||||
let newSize = NSSize(width: size.width * scale, height: size.height * scale)
|
||||
let scaledImage = NSImage(size: newSize)
|
||||
scaledImage.lockFocus()
|
||||
image.draw(in: NSRect(origin: .zero, size: newSize),
|
||||
from: NSRect(origin: .zero, size: size),
|
||||
operation: .copy,
|
||||
fraction: 1.0)
|
||||
scaledImage.unlockFocus()
|
||||
return scaledImage
|
||||
}
|
||||
|
||||
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||
guard let data = CFDataCreateMutable(nil, 0) else {
|
||||
return nil
|
||||
}
|
||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||
return nil
|
||||
}
|
||||
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||
// By only specifying compression quality and no metadata keys,
|
||||
// we ensure a clean JPEG with no privacy-leaking information
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageDestinationLossyCompressionQuality: quality
|
||||
]
|
||||
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
return nil
|
||||
}
|
||||
return data as Data
|
||||
}
|
||||
#endif
|
||||
|
||||
private static func makeOutputURL() throws -> URL {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||
let fileName = "img_\(formatter.string(from: Date())).jpg"
|
||||
|
||||
let directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||
return directory.appendingPathComponent(fileName)
|
||||
}
|
||||
|
||||
private static func applicationFilesDirectory() throws -> URL {
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
return base.appendingPathComponent("files", isDirectory: true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import BitLogger
|
||||
|
||||
/// Controls playback for a single voice note and coordinates exclusive playback across the app.
|
||||
final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlayerDelegate {
|
||||
@Published private(set) var isPlaying: Bool = false
|
||||
@Published private(set) var currentTime: TimeInterval = 0
|
||||
@Published private(set) var duration: TimeInterval = 0
|
||||
@Published private(set) var progress: Double = 0
|
||||
|
||||
private var player: AVAudioPlayer?
|
||||
private var timer: Timer?
|
||||
private var url: URL
|
||||
|
||||
init(url: URL) {
|
||||
self.url = url
|
||||
super.init()
|
||||
// Don't load anything eagerly - wait until user interaction or view is fully displayed
|
||||
}
|
||||
|
||||
func loadDuration() {
|
||||
guard duration == 0 else { return }
|
||||
|
||||
DispatchQueue.global(qos: .utility).async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
do {
|
||||
let player = try AVAudioPlayer(contentsOf: self.url)
|
||||
let loadedDuration = player.duration
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self, self.duration == 0 else { return }
|
||||
self.duration = loadedDuration
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Failed to load audio duration: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
timer?.invalidate()
|
||||
}
|
||||
|
||||
func replaceURL(_ url: URL) {
|
||||
guard url != self.url else { return }
|
||||
stop()
|
||||
self.url = url
|
||||
player = nil
|
||||
duration = 0
|
||||
// Duration will be loaded on demand when needed
|
||||
}
|
||||
|
||||
func togglePlayback() {
|
||||
isPlaying ? pause() : play()
|
||||
}
|
||||
|
||||
func play() {
|
||||
guard ensurePlayerReady() else { return }
|
||||
VoiceNotePlaybackCoordinator.shared.activate(self)
|
||||
player?.play()
|
||||
startTimer()
|
||||
updateProgress()
|
||||
isPlaying = true
|
||||
}
|
||||
|
||||
func pause() {
|
||||
player?.pause()
|
||||
stopTimer()
|
||||
updateProgress()
|
||||
isPlaying = false
|
||||
}
|
||||
|
||||
func stop() {
|
||||
player?.stop()
|
||||
player?.currentTime = 0
|
||||
stopTimer()
|
||||
updateProgress()
|
||||
isPlaying = false
|
||||
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
||||
}
|
||||
|
||||
func seek(to fraction: Double) {
|
||||
guard ensurePlayerReady() else { return }
|
||||
let clamped = max(0, min(1, fraction))
|
||||
if let player = player {
|
||||
player.currentTime = clamped * player.duration
|
||||
if isPlaying {
|
||||
player.play()
|
||||
}
|
||||
updateProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AVAudioPlayerDelegate
|
||||
|
||||
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
|
||||
// Delegate callback may be on background thread - ensure main thread for UI updates
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.stopTimer()
|
||||
self.updateProgress()
|
||||
self.isPlaying = false
|
||||
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func preparePlayer(for url: URL) {
|
||||
// Prepare player synchronously (only called when playback is requested)
|
||||
do {
|
||||
let player = try AVAudioPlayer(contentsOf: url)
|
||||
player.delegate = self
|
||||
player.prepareToPlay()
|
||||
self.player = player
|
||||
duration = player.duration
|
||||
currentTime = player.currentTime
|
||||
progress = duration > 0 ? currentTime / duration : 0
|
||||
} catch {
|
||||
SecureLogger.error("Voice note playback failed for \(url.lastPathComponent): \(error)", category: .session)
|
||||
player = nil
|
||||
duration = 0
|
||||
currentTime = 0
|
||||
progress = 0
|
||||
}
|
||||
}
|
||||
|
||||
private func ensurePlayerReady() -> Bool {
|
||||
if player == nil {
|
||||
preparePlayer(for: url)
|
||||
}
|
||||
#if os(iOS)
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
|
||||
try session.setActive(true, options: [])
|
||||
} catch {
|
||||
SecureLogger.error("Failed to activate audio session: \(error)", category: .session)
|
||||
}
|
||||
#endif
|
||||
return player != nil
|
||||
}
|
||||
|
||||
private func startTimer() {
|
||||
if timer != nil { return }
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
|
||||
self?.updateProgress()
|
||||
}
|
||||
if let timer = timer {
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopTimer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
private func updateProgress() {
|
||||
guard let player = player else {
|
||||
currentTime = 0
|
||||
duration = 0
|
||||
progress = 0
|
||||
return
|
||||
}
|
||||
currentTime = player.currentTime
|
||||
duration = player.duration
|
||||
progress = duration > 0 ? currentTime / duration : 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensures only one voice note plays at a time.
|
||||
final class VoiceNotePlaybackCoordinator {
|
||||
static let shared = VoiceNotePlaybackCoordinator()
|
||||
|
||||
private weak var activeController: VoiceNotePlaybackController?
|
||||
|
||||
private init() {}
|
||||
|
||||
func activate(_ controller: VoiceNotePlaybackController) {
|
||||
if activeController === controller {
|
||||
return
|
||||
}
|
||||
activeController?.pause()
|
||||
activeController = controller
|
||||
}
|
||||
|
||||
func deactivate(_ controller: VoiceNotePlaybackController) {
|
||||
if activeController === controller {
|
||||
activeController = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
/// Manages audio capture for mesh voice notes with predictable encoding settings.
|
||||
/// Recording runs on an internal serial queue to avoid AVAudioSession contention.
|
||||
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
||||
enum RecorderError: Error {
|
||||
case microphoneAccessDenied
|
||||
case recorderInitializationFailed
|
||||
case recordingInProgress
|
||||
}
|
||||
|
||||
static let shared = VoiceRecorder()
|
||||
|
||||
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
|
||||
private let paddingInterval: TimeInterval = 0.5
|
||||
private let maxRecordingDuration: TimeInterval = 120
|
||||
|
||||
private var recorder: AVAudioRecorder?
|
||||
private var currentURL: URL?
|
||||
private var stopWorkItem: DispatchWorkItem?
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: - Permissions
|
||||
|
||||
@discardableResult
|
||||
func requestPermission() async -> Bool {
|
||||
#if os(iOS)
|
||||
return await withCheckedContinuation { continuation in
|
||||
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
||||
continuation.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
return await withCheckedContinuation { continuation in
|
||||
AVCaptureDevice.requestAccess(for: .audio) { granted in
|
||||
continuation.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
#else
|
||||
return true
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Recording Lifecycle
|
||||
|
||||
func startRecording() throws -> URL {
|
||||
try queue.sync {
|
||||
if recorder?.isRecording == true {
|
||||
throw RecorderError.recordingInProgress
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
guard session.recordPermission == .granted else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
||||
)
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
#endif
|
||||
|
||||
let outputURL = try makeOutputURL()
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 16_000,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderBitRateKey: 16_000
|
||||
]
|
||||
|
||||
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
||||
audioRecorder.delegate = self
|
||||
audioRecorder.isMeteringEnabled = true
|
||||
audioRecorder.prepareToRecord()
|
||||
audioRecorder.record(forDuration: maxRecordingDuration)
|
||||
|
||||
recorder = audioRecorder
|
||||
currentURL = outputURL
|
||||
stopWorkItem?.cancel()
|
||||
stopWorkItem = nil
|
||||
return outputURL
|
||||
}
|
||||
}
|
||||
|
||||
func stopRecording(completion: @escaping (URL?) -> Void) {
|
||||
queue.async { [weak self] in
|
||||
guard let self = self, let recorder = self.recorder, recorder.isRecording else {
|
||||
completion(self?.currentURL)
|
||||
return
|
||||
}
|
||||
|
||||
let item = DispatchWorkItem { [weak self] in
|
||||
guard let self = self else { return }
|
||||
recorder.stop()
|
||||
self.cleanupSession()
|
||||
let url = self.currentURL
|
||||
self.recorder = nil
|
||||
self.currentURL = url
|
||||
completion(url)
|
||||
}
|
||||
self.stopWorkItem = item
|
||||
self.queue.asyncAfter(deadline: .now() + self.paddingInterval, execute: item)
|
||||
}
|
||||
}
|
||||
|
||||
func cancelRecording() {
|
||||
queue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.stopWorkItem?.cancel()
|
||||
self.stopWorkItem = nil
|
||||
if let recorder = self.recorder, recorder.isRecording {
|
||||
recorder.stop()
|
||||
}
|
||||
self.cleanupSession()
|
||||
if let url = self.currentURL {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
self.recorder = nil
|
||||
self.currentURL = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Metering
|
||||
|
||||
func currentAveragePower() -> Float {
|
||||
queue.sync {
|
||||
recorder?.updateMeters()
|
||||
return recorder?.averagePower(forChannel: 0) ?? -160
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func makeOutputURL() throws -> URL {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||
let fileName = "voice_\(formatter.string(from: Date())).m4a"
|
||||
|
||||
let baseDirectory = try applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||
return baseDirectory.appendingPathComponent(fileName)
|
||||
}
|
||||
|
||||
private func applicationFilesDirectory() throws -> URL {
|
||||
#if os(iOS)
|
||||
return try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
.appendingPathComponent("files", isDirectory: true)
|
||||
#else
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
return base.appendingPathComponent("files", isDirectory: true)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func cleanupSession() {
|
||||
#if os(iOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import BitLogger
|
||||
|
||||
/// Generates and caches downsampled waveforms for audio files so UI rendering is cheap.
|
||||
final class WaveformCache {
|
||||
static let shared = WaveformCache()
|
||||
|
||||
private let queue = DispatchQueue(label: "com.bitchat.waveform-cache", attributes: .concurrent)
|
||||
private var cache: [URL: (waveform: [Float], lastAccess: Date)] = [:]
|
||||
private let maxCacheSize = 20 // Limit cache to prevent unbounded memory growth
|
||||
|
||||
private init() {}
|
||||
|
||||
func cachedWaveform(for url: URL) -> [Float]? {
|
||||
queue.sync {
|
||||
guard let entry = cache[url] else { return nil }
|
||||
return entry.waveform
|
||||
}
|
||||
}
|
||||
|
||||
func waveform(for url: URL, bins: Int = 120, completion: @escaping ([Float]) -> Void) {
|
||||
queue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Check cache (read-only, no update needed on cache hit for performance)
|
||||
if let entry = self.cache[url] {
|
||||
DispatchQueue.main.async { completion(entry.waveform) }
|
||||
return
|
||||
}
|
||||
|
||||
guard let computed = self.computeWaveform(url: url, bins: bins) else {
|
||||
DispatchQueue.main.async { completion([]) }
|
||||
return
|
||||
}
|
||||
|
||||
self.queue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Evict oldest entry if cache is full
|
||||
if self.cache.count >= self.maxCacheSize {
|
||||
if let oldest = self.cache.min(by: { $0.value.lastAccess < $1.value.lastAccess }) {
|
||||
self.cache.removeValue(forKey: oldest.key)
|
||||
}
|
||||
}
|
||||
|
||||
self.cache[url] = (computed, Date())
|
||||
}
|
||||
DispatchQueue.main.async { completion(computed) }
|
||||
}
|
||||
}
|
||||
|
||||
func purge(url: URL) {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
self?.cache.removeValue(forKey: url)
|
||||
}
|
||||
}
|
||||
|
||||
func purgeAll() {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
self?.cache.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
private func computeWaveform(url: URL, bins: Int) -> [Float]? {
|
||||
guard bins > 0 else { return nil }
|
||||
// Use autoreleasepool to manage memory from audio buffer allocations
|
||||
return autoreleasepool {
|
||||
do {
|
||||
let audioFile = try AVAudioFile(forReading: url)
|
||||
let length = Int(audioFile.length)
|
||||
guard length > 0 else { return nil }
|
||||
|
||||
guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: AVAudioFrameCount(length)) else {
|
||||
return nil
|
||||
}
|
||||
try audioFile.read(into: buffer, frameCount: AVAudioFrameCount(length))
|
||||
guard let channelData = buffer.floatChannelData else { return nil }
|
||||
|
||||
let channelCount = Int(audioFile.processingFormat.channelCount)
|
||||
let frameLength = Int(buffer.frameLength)
|
||||
let samplesPerBin = max(1, frameLength / bins)
|
||||
|
||||
var magnitudes: [Float] = Array(repeating: 0, count: bins)
|
||||
for bin in 0..<bins {
|
||||
let start = bin * samplesPerBin
|
||||
let end = min(frameLength, start + samplesPerBin)
|
||||
if start >= end { break }
|
||||
|
||||
var sum: Float = 0
|
||||
var sampleCount = 0
|
||||
for frame in start..<end {
|
||||
var sampleValue: Float = 0
|
||||
for channel in 0..<channelCount {
|
||||
sampleValue += fabsf(channelData[channel][frame])
|
||||
}
|
||||
sum += sampleValue / Float(channelCount)
|
||||
sampleCount += 1
|
||||
}
|
||||
magnitudes[bin] = sampleCount > 0 ? sum / Float(sampleCount) : 0
|
||||
}
|
||||
|
||||
if let maxMagnitude = magnitudes.max(), maxMagnitude > 0 {
|
||||
magnitudes = magnitudes.map { min($0 / maxMagnitude, 1.0) }
|
||||
}
|
||||
return magnitudes
|
||||
} catch {
|
||||
SecureLogger.error("Waveform extraction failed for \(url.lastPathComponent): \(error)", category: .session)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ import Foundation
|
||||
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
|
||||
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
|
||||
struct EphemeralIdentity {
|
||||
let peerID: String // 8 random bytes
|
||||
let peerID: PeerID // 8 random bytes
|
||||
let sessionStart: Date
|
||||
var handshakeState: HandshakeState
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ protocol SecureIdentityStateManagerProtocol {
|
||||
|
||||
// MARK: Cryptographic Identities
|
||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?)
|
||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity]
|
||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity]
|
||||
func updateSocialIdentity(_ identity: SocialIdentity)
|
||||
|
||||
// MARK: Favorites Management
|
||||
@@ -121,12 +121,12 @@ protocol SecureIdentityStateManagerProtocol {
|
||||
func getBlockedNostrPubkeys() -> Set<String>
|
||||
|
||||
// MARK: Ephemeral Session Management
|
||||
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState)
|
||||
func updateHandshakeState(peerID: String, state: HandshakeState)
|
||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState)
|
||||
func updateHandshakeState(peerID: PeerID, state: HandshakeState)
|
||||
|
||||
// MARK: Cleanup
|
||||
func clearAllIdentityData()
|
||||
func removeEphemeralSession(peerID: String)
|
||||
func removeEphemeralSession(peerID: PeerID)
|
||||
|
||||
// MARK: Verification
|
||||
func setVerified(fingerprint: String, verified: Bool)
|
||||
@@ -143,7 +143,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
private let encryptionKeyName = "identityCacheEncryptionKey"
|
||||
|
||||
// In-memory state
|
||||
private var ephemeralSessions: [String: EphemeralIdentity] = [:]
|
||||
private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
|
||||
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
||||
private var cache: IdentityCache = IdentityCache()
|
||||
|
||||
@@ -321,11 +321,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
/// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID
|
||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
|
||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity] {
|
||||
queue.sync {
|
||||
// Defensive: ensure hex and correct length
|
||||
guard peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] }
|
||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
|
||||
guard peerID.isShort else { return [] }
|
||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,7 +455,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
// MARK: - Ephemeral Session Management
|
||||
|
||||
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState = .none) {
|
||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
||||
peerID: peerID,
|
||||
@@ -465,7 +465,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
func updateHandshakeState(peerID: String, state: HandshakeState) {
|
||||
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.ephemeralSessions[peerID]?.handshakeState = state
|
||||
|
||||
@@ -493,7 +493,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
func removeEphemeralSession(peerID: String) {
|
||||
func removeEphemeralSession(peerID: PeerID) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.ephemeralSessions.removeValue(forKey: peerID)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,10 @@
|
||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,192 +0,0 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
Bitchat
|
||||
|
||||
Base English localization entries. Keep keys sorted alphabetically.
|
||||
*/
|
||||
|
||||
"app_info.app_name" = "bitchat";
|
||||
"app_info.close" = "close";
|
||||
"app_info.done" = "DONE";
|
||||
"app_info.features.encryption.description" = "private messages encrypted with noise protocol";
|
||||
"app_info.features.encryption.title" = "end-to-end encryption";
|
||||
"app_info.features.extended_range.description" = "messages relay through peers, going the distance";
|
||||
"app_info.features.extended_range.title" = "extended range";
|
||||
"app_info.features.favorites.description" = "get notified when your favorite people join";
|
||||
"app_info.features.favorites.title" = "favorites";
|
||||
"app_info.features.geohash.description" = "geohash channels to chat with people in nearby regions over decentralized anonymous relays";
|
||||
"app_info.features.geohash.title" = "local channels";
|
||||
"app_info.features.mentions.description" = "use @nickname to notify specific people";
|
||||
"app_info.features.mentions.title" = "mentions";
|
||||
"app_info.features.offline.description" = "works without internet using Bluetooth low energy";
|
||||
"app_info.features.offline.title" = "offline communication";
|
||||
"app_info.features.title" = "FEATURES";
|
||||
"app_info.how_to_use.change_channels" = "• tap #mesh to change channels";
|
||||
"app_info.how_to_use.clear_chat" = "• triple-tap chat to clear";
|
||||
"app_info.how_to_use.commands" = "• type / for commands";
|
||||
"app_info.how_to_use.open_sidebar" = "• tap people icon for sidebar";
|
||||
"app_info.how_to_use.set_nickname" = "• set your nickname by tapping it";
|
||||
"app_info.how_to_use.start_dm" = "• tap a peer's name to start a DM";
|
||||
"app_info.how_to_use.title" = "HOW TO USE";
|
||||
"app_info.privacy.ephemeral.description" = "new peer ID generated regularly";
|
||||
"app_info.privacy.ephemeral.title" = "ephemeral identity";
|
||||
"app_info.privacy.no_tracking.description" = "no servers, accounts, or data collection";
|
||||
"app_info.privacy.no_tracking.title" = "no tracking";
|
||||
"app_info.privacy.panic.description" = "triple-tap logo to instantly clear all data";
|
||||
"app_info.privacy.panic.title" = "panic mode";
|
||||
"app_info.privacy.title" = "PRIVACY";
|
||||
"app_info.tagline" = "sidegroupchat";
|
||||
"app_info.warning.message" = "private message security has not yet been fully audited. do not use for critical situations until this warning disappears.";
|
||||
"app_info.warning.title" = "WARNING";
|
||||
"common.cancel" = "cancel";
|
||||
"common.close" = "close";
|
||||
"common.copy" = "copy";
|
||||
"common.ok" = "OK";
|
||||
"common.toggle.off" = "off";
|
||||
"common.toggle.on" = "on";
|
||||
"common.unknown" = "unknown";
|
||||
"content.accessibility.add_favorite" = "add to favorites";
|
||||
"content.accessibility.available_nostr" = "available via Nostr";
|
||||
"content.accessibility.back_to_main_chat" = "back to main chat";
|
||||
"content.accessibility.connected_mesh" = "connected via mesh";
|
||||
"content.accessibility.encryption_status" = "encryption status: %@";
|
||||
"content.accessibility.location_channels" = "location channels";
|
||||
"content.accessibility.location_notes" = "location notes for this place";
|
||||
"content.accessibility.open_unread_private_chat" = "open unread private chat";
|
||||
"content.accessibility.private_chat_header" = "private chat with %@";
|
||||
"content.accessibility.reachable_mesh" = "reachable via mesh";
|
||||
"content.accessibility.remove_favorite" = "remove from favorites";
|
||||
"content.accessibility.send_hint_empty" = "enter a message to send";
|
||||
"content.accessibility.send_hint_ready" = "double tap to send";
|
||||
"content.accessibility.send_message" = "send message";
|
||||
"content.accessibility.toggle_bookmark" = "toggle bookmark for #%@";
|
||||
"content.accessibility.toggle_favorite_hint" = "double tap to toggle favorite status";
|
||||
"content.accessibility.view_fingerprint_hint" = "tap to view encryption fingerprint";
|
||||
"content.actions.block" = "block";
|
||||
"content.actions.direct_message" = "direct message";
|
||||
"content.actions.hug" = "hug";
|
||||
"content.actions.mention" = "mention";
|
||||
"content.actions.slap" = "slap";
|
||||
"content.actions.title" = "actions";
|
||||
"content.alert.bluetooth_required.off" = "bluetooth is turned off. please turn on bluetooth in settings to use bitchat.";
|
||||
"content.alert.bluetooth_required.permission" = "bitchat needs bluetooth permission to connect with nearby devices. please enable bluetooth access in settings.";
|
||||
"content.alert.bluetooth_required.settings" = "settings";
|
||||
"content.alert.bluetooth_required.title" = "bluetooth required";
|
||||
"content.alert.bluetooth_required.unsupported" = "this device does not support bluetooth. bitchat requires bluetooth to function.";
|
||||
"content.alert.screenshot.message" = "screenshots of location channels will reveal your location. think before sharing publicly.";
|
||||
"content.alert.screenshot.title" = "heads up";
|
||||
"content.commands.block" = "block or list blocked peers";
|
||||
"content.commands.clear" = "clear chat messages";
|
||||
"content.commands.favorite" = "add to favorites";
|
||||
"content.commands.hug" = "send someone a warm hug";
|
||||
"content.commands.message" = "send private message";
|
||||
"content.commands.slap" = "slap someone with a trout";
|
||||
"content.commands.unblock" = "unblock a peer";
|
||||
"content.commands.unfavorite" = "remove from favorites";
|
||||
"content.commands.who" = "see who's online";
|
||||
"content.delivery.delivered_members" = "delivered to %1$d of %2$d members";
|
||||
"content.delivery.delivered_to" = "delivered to %@";
|
||||
"content.delivery.failed" = "failed: %@";
|
||||
"content.delivery.read_by" = "read by %@";
|
||||
"content.delivery.reason.blocked" = "user is blocked";
|
||||
"content.delivery.reason.self" = "cannot message yourself";
|
||||
"content.delivery.reason.send_error" = "send error";
|
||||
"content.delivery.reason.unknown_recipient" = "unknown recipient";
|
||||
"content.delivery.reason.unreachable" = "peer not reachable";
|
||||
"content.header.people" = "PEOPLE";
|
||||
"content.help.verification" = "verification: show my QR or scan a friend";
|
||||
"content.input.message_placeholder" = "type a message...";
|
||||
"content.input.nickname_placeholder" = "nickname";
|
||||
"content.location.enable" = "enable location";
|
||||
"content.message.copy" = "copy message";
|
||||
"content.message.show_less" = "show less";
|
||||
"content.message.show_more" = "show more";
|
||||
"content.notes.location_unavailable" = "location unavailable";
|
||||
"content.notes.title" = "notes";
|
||||
"content.payment.cashu" = "pay via cashu";
|
||||
"content.payment.lightning" = "pay via lightning";
|
||||
"encryption.accessibility.establishing" = "establishing encryption";
|
||||
"encryption.accessibility.failed" = "encryption failed";
|
||||
"encryption.accessibility.not_encrypted" = "not encrypted";
|
||||
"encryption.accessibility.secured" = "encrypted";
|
||||
"encryption.accessibility.verified" = "encrypted and verified";
|
||||
"encryption.status.establishing" = "sstablishing encryption...";
|
||||
"encryption.status.failed" = "encryption failed";
|
||||
"encryption.status.not_encrypted" = "not encrypted";
|
||||
"encryption.status.secured" = "encrypted";
|
||||
"encryption.status.verified" = "encrypted & verified";
|
||||
"fingerprint.action.mark_verified" = "mark as verified";
|
||||
"fingerprint.action.remove_verification" = "remove verification";
|
||||
"fingerprint.badge.not_verified" = "⚠️ NOT VERIFIED";
|
||||
"fingerprint.badge.verified" = "✓ VERIFIED";
|
||||
"fingerprint.handshake_pending" = "not available - handshake in progress";
|
||||
"fingerprint.message.verified" = "uou have verified this person's identity.";
|
||||
"fingerprint.message.verify_hint" = "compare these fingerprints with %@ using a secure channel.";
|
||||
"fingerprint.their_label" = "their fingerprint:";
|
||||
"fingerprint.title" = "security verification";
|
||||
"fingerprint.your_label" = "your fingerprint:";
|
||||
"geohash_people.action.block" = "block";
|
||||
"geohash_people.action.unblock" = "unblock";
|
||||
"geohash_people.none_nearby" = "nobody around...";
|
||||
"geohash_people.tooltip.blocked" = "blocked in geohash";
|
||||
"geohash_people.you_suffix" = " (you)";
|
||||
"location_channels.action.open_settings" = "open settings";
|
||||
"location_channels.action.remove_access" = "remove location access";
|
||||
"location_channels.action.request_permissions" = "get location and my geohashes";
|
||||
"location_channels.action.teleport" = "teleport";
|
||||
"location_channels.bookmarked_section_title" = "bookmarked";
|
||||
"location_channels.description" = "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.";
|
||||
"location_channels.error.invalid_geohash" = "invalid geohash";
|
||||
"location_channels.loading_nearby" = "finding nearby channels…";
|
||||
"location_channels.mesh_label" = "mesh";
|
||||
"location_channels.permission_denied" = "location permission denied. enable in settings to use location channels.";
|
||||
"location_channels.subtitle_prefix" = "#%@ • %@";
|
||||
"location_channels.subtitle_with_name" = "%1$@ • %2$@";
|
||||
"location_channels.title" = "#location channels";
|
||||
"location_channels.tor.subtitle" = "hides your IP for location channels. recommended: on.";
|
||||
"location_channels.tor.title" = "tor routing";
|
||||
"location_levels.block" = "block";
|
||||
"location_levels.building" = "building";
|
||||
"location_levels.city" = "city";
|
||||
"location_levels.neighborhood" = "neighborhood";
|
||||
"location_levels.province" = "province";
|
||||
"location_levels.region" = "region";
|
||||
"location_notes.action.dismiss" = "dismiss";
|
||||
"location_notes.action.retry" = "retry";
|
||||
"location_notes.description" = "add short permanent notes to this location for other visitors to find.";
|
||||
"location_notes.empty_subtitle" = "be the first to add one for this spot.";
|
||||
"location_notes.empty_title" = "no notes yet";
|
||||
"location_notes.error.failed_to_send" = "failed to send note. %@";
|
||||
"location_notes.error.no_relays" = "no geo relays available near this location. try again soon.";
|
||||
"location_notes.loading_notes" = "loading notes…";
|
||||
"location_notes.loading_recent" = "loading recent notes…";
|
||||
"location_notes.no_relays_nearby" = "no geo relays nearby";
|
||||
"location_notes.placeholder" = "add a note for this place";
|
||||
"location_notes.relays_paused" = "geo relays unavailable; notes paused";
|
||||
"location_notes.relays_retry_hint" = "notes rely on geo relays. check connection and try again.";
|
||||
"mesh_peers.tooltip.new_messages" = "new messages";
|
||||
"system.chat.blocked" = "cannot start chat with %@: person is blocked.";
|
||||
"system.chat.requires_favorite" = "cannot start chat with %@: mutual favorite required for offline messaging.";
|
||||
"system.common.user" = "user";
|
||||
"system.dm.blocked_generic" = "cannot send message: person is blocked.";
|
||||
"system.dm.blocked_recipient" = "cannot send message to %@: person is blocked.";
|
||||
"system.dm.unreachable" = "cannot send message to %@ - peer is not reachable via mesh or nostr.";
|
||||
"system.geohash.blocked" = "blocked %@ in geohash chats";
|
||||
"system.geohash.unblocked" = "unblocked %@ in geohash chats";
|
||||
"system.location.not_in_channel" = "cannot send: not in a location channel";
|
||||
"system.location.send_failed" = "failed to send to location channel";
|
||||
"system.tor.dev_bypass" = "development build: Tor bypass enabled.";
|
||||
"system.tor.restarted" = "tor restarted. network routing restored.";
|
||||
"system.tor.restarting" = "tor restarting to recover connectivity...";
|
||||
"system.tor.started" = "tor started. routing all chats via tor for IP privacy.";
|
||||
"system.tor.starting" = "starting tor...";
|
||||
"verification.my_qr.accessibility_label" = "verification QR code";
|
||||
"verification.my_qr.title" = "scan to verify me";
|
||||
"verification.my_qr.unavailable" = "QR unavailable";
|
||||
"verification.scan.paste_prompt" = "paste QR content to validate:";
|
||||
"verification.scan.prompt_friend" = "scan a friend's QR";
|
||||
"verification.scan.status.invalid" = "invalid or expired QR payload";
|
||||
"verification.scan.status.no_peer" = "could not find matching peer";
|
||||
"verification.scan.status.requested" = "verification requested for %@";
|
||||
"verification.scan.validate" = "validate";
|
||||
"verification.sheet.title" = "VERIFY";
|
||||
@@ -1,54 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>location_notes.header</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>#%@ • %#@note_count@</string>
|
||||
<key>note_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d note</string>
|
||||
<key>other</key>
|
||||
<string>%d notes</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>location_channels.row_title</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%@ [%#@people_count@]</string>
|
||||
<key>people_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d person</string>
|
||||
<key>other</key>
|
||||
<string>%d people</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>content.accessibility.people_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@people@</string>
|
||||
<key>people</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d person</string>
|
||||
<key>other</key>
|
||||
<string>%d people</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,190 +0,0 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
bitchat (Arabic)
|
||||
*/
|
||||
|
||||
"app_info.app_name" = "bitchat";
|
||||
"app_info.close" = "إغلاق";
|
||||
"app_info.done" = "تم";
|
||||
"app_info.features.encryption.description" = "الرسائل الخاصة مشفرة ببروتوكول noise";
|
||||
"app_info.features.encryption.title" = "تشفير طرف لطرف";
|
||||
"app_info.features.extended_range.description" = "يُعاد تمرير الرسائل بين الأقران لتصل لمسافات أبعد";
|
||||
"app_info.features.extended_range.title" = "نطاق ممتد";
|
||||
"app_info.features.favorites.description" = "تلقَّ تنبيهات عندما ينضم أحباؤك";
|
||||
"app_info.features.favorites.title" = "المفضلة";
|
||||
"app_info.features.geohash.description" = "قنوات geohash للدردشة مع أشخاص قريبين عبر مرحلات لامركزية مجهولة";
|
||||
"app_info.features.geohash.title" = "قنوات محلية";
|
||||
"app_info.features.mentions.description" = "استخدم @nickname لتنبيه أشخاص محددين";
|
||||
"app_info.features.mentions.title" = "إشارات";
|
||||
"app_info.features.offline.description" = "يعمل بدون إنترنت باستخدام bluetooth منخفض الطاقة";
|
||||
"app_info.features.offline.title" = "تواصل بدون اتصال";
|
||||
"app_info.features.title" = "مزايا";
|
||||
"app_info.how_to_use.change_channels" = "• اضغط #mesh لتغيير القناة";
|
||||
"app_info.how_to_use.clear_chat" = "• اضغط الدردشة ثلاث مرات للمسح";
|
||||
"app_info.how_to_use.commands" = "• اكتب / لعرض الأوامر";
|
||||
"app_info.how_to_use.open_sidebar" = "• اضغط أيقونة الأشخاص لفتح الشريط الجانبي";
|
||||
"app_info.how_to_use.set_nickname" = "• اضبط لقبك بلمسه";
|
||||
"app_info.how_to_use.start_dm" = "• اضغط اسم القرين لبدء رسائل خاصة";
|
||||
"app_info.how_to_use.title" = "طريقة الاستخدام";
|
||||
"app_info.privacy.ephemeral.description" = "يُولد معرف قرين جديد بانتظام";
|
||||
"app_info.privacy.ephemeral.title" = "هوية مؤقتة";
|
||||
"app_info.privacy.no_tracking.description" = "لا خوادم أو حسابات أو جمع بيانات";
|
||||
"app_info.privacy.no_tracking.title" = "لا تتبع";
|
||||
"app_info.privacy.panic.description" = "اضغط الشعار ثلاث مرات لمسح كل البيانات فوراً";
|
||||
"app_info.privacy.panic.title" = "وضع الذعر";
|
||||
"app_info.privacy.title" = "خصوصية";
|
||||
"app_info.tagline" = "sidegroupchat";
|
||||
"app_info.warning.message" = "أمان الرسائل الخاصة لم يتم تدقيقه بالكامل بعد. لا تستخدمها في الحالات الحرجة حتى يختفي هذا التحذير.";
|
||||
"app_info.warning.title" = "تحذير";
|
||||
"common.cancel" = "إلغاء";
|
||||
"common.close" = "إغلاق";
|
||||
"common.copy" = "نسخ";
|
||||
"common.ok" = "موافق";
|
||||
"common.toggle.off" = "إيقاف";
|
||||
"common.toggle.on" = "تشغيل";
|
||||
"common.unknown" = "غير معروف";
|
||||
"content.accessibility.add_favorite" = "إضافة إلى المفضلة";
|
||||
"content.accessibility.available_nostr" = "متاح عبر nostr";
|
||||
"content.accessibility.back_to_main_chat" = "عودة إلى الدردشة الرئيسية";
|
||||
"content.accessibility.connected_mesh" = "متصل عبر mesh";
|
||||
"content.accessibility.encryption_status" = "حالة التشفير: %@";
|
||||
"content.accessibility.location_channels" = "قنوات الموقع";
|
||||
"content.accessibility.location_notes" = "ملاحظات الموقع لهذا المكان";
|
||||
"content.accessibility.open_unread_private_chat" = "فتح دردشة خاصة غير مقروءة";
|
||||
"content.accessibility.private_chat_header" = "دردشة خاصة مع %@";
|
||||
"content.accessibility.reachable_mesh" = "قابل للوصول عبر mesh";
|
||||
"content.accessibility.remove_favorite" = "إزالة من المفضلة";
|
||||
"content.accessibility.send_hint_empty" = "أدخل رسالة للإرسال";
|
||||
"content.accessibility.send_hint_ready" = "اضغط مرتين للإرسال";
|
||||
"content.accessibility.send_message" = "إرسال رسالة";
|
||||
"content.accessibility.toggle_bookmark" = "تبديل الإشارة لـ #%@";
|
||||
"content.accessibility.toggle_favorite_hint" = "اضغط مرتين لتبديل حالة المفضلة";
|
||||
"content.accessibility.view_fingerprint_hint" = "اضغط لمشاهدة بصمة التشفير";
|
||||
"content.actions.block" = "حظر";
|
||||
"content.actions.direct_message" = "رسالة مباشرة";
|
||||
"content.actions.hug" = "عناق";
|
||||
"content.actions.mention" = "ذكر";
|
||||
"content.actions.slap" = "صفعة";
|
||||
"content.actions.title" = "إجراءات";
|
||||
"content.alert.bluetooth_required.off" = "bluetooth متوقف. فعّل bluetooth في الإعدادات لاستخدام bitchat.";
|
||||
"content.alert.bluetooth_required.permission" = "تحتاج bitchat إلى إذن bluetooth للاتصال بالأجهزة القريبة. فعّل الوصول في الإعدادات.";
|
||||
"content.alert.bluetooth_required.settings" = "الإعدادات";
|
||||
"content.alert.bluetooth_required.title" = "مطلوب bluetooth";
|
||||
"content.alert.bluetooth_required.unsupported" = "هذا الجهاز لا يدعم bluetooth. يحتاج bitchat إلى bluetooth للعمل.";
|
||||
"content.alert.screenshot.message" = "لقطات قنوات الموقع تكشف موقعك. فكر قبل المشاركة علناً.";
|
||||
"content.alert.screenshot.title" = "تنبيه";
|
||||
"content.commands.block" = "حظر أو عرض المحظورين";
|
||||
"content.commands.clear" = "مسح رسائل الدردشة";
|
||||
"content.commands.favorite" = "إضافة للمفضلة";
|
||||
"content.commands.hug" = "إرسال عناق دافئ";
|
||||
"content.commands.message" = "إرسال رسالة خاصة";
|
||||
"content.commands.slap" = "صفع شخص بسمكة تراوت";
|
||||
"content.commands.unblock" = "إلغاء حظر قرين";
|
||||
"content.commands.unfavorite" = "إزالة من المفضلة";
|
||||
"content.commands.who" = "عرض من هو متصل";
|
||||
"content.delivery.delivered_members" = "تم التسليم إلى %1$d من %2$d عضو";
|
||||
"content.delivery.delivered_to" = "سُلّم إلى %@";
|
||||
"content.delivery.failed" = "فشل: %@";
|
||||
"content.delivery.read_by" = "قُرِئ بواسطة %@";
|
||||
"content.delivery.reason.blocked" = "المستخدم محظور";
|
||||
"content.delivery.reason.self" = "لا يمكن الإرسال لنفسك";
|
||||
"content.delivery.reason.send_error" = "خطأ في الإرسال";
|
||||
"content.delivery.reason.unknown_recipient" = "مستلم غير معروف";
|
||||
"content.delivery.reason.unreachable" = "القرين غير متاح";
|
||||
"content.header.people" = "أشخاص";
|
||||
"content.help.verification" = "التحقق: عرض رمز qr الخاص بي أو مسح صديق";
|
||||
"content.input.message_placeholder" = "اكتب رسالة...";
|
||||
"content.input.nickname_placeholder" = "لقب";
|
||||
"content.location.enable" = "تفعيل الموقع";
|
||||
"content.message.copy" = "نسخ الرسالة";
|
||||
"content.message.show_less" = "عرض أقل";
|
||||
"content.message.show_more" = "عرض المزيد";
|
||||
"content.notes.location_unavailable" = "الموقع غير متاح";
|
||||
"content.notes.title" = "ملاحظات";
|
||||
"content.payment.cashu" = "الدفع عبر cashu";
|
||||
"content.payment.lightning" = "الدفع عبر lightning";
|
||||
"encryption.accessibility.establishing" = "جار إعداد التشفير";
|
||||
"encryption.accessibility.failed" = "فشل التشفير";
|
||||
"encryption.accessibility.not_encrypted" = "غير مشفر";
|
||||
"encryption.accessibility.secured" = "مشفر";
|
||||
"encryption.accessibility.verified" = "مشفر ومُتحقق";
|
||||
"encryption.status.establishing" = "جار إعداد التشفير...";
|
||||
"encryption.status.failed" = "فشل التشفير";
|
||||
"encryption.status.not_encrypted" = "غير مشفر";
|
||||
"encryption.status.secured" = "مشفر";
|
||||
"encryption.status.verified" = "مشفر ومُتحقق";
|
||||
"fingerprint.action.mark_verified" = "وضع علامة تم التحقق";
|
||||
"fingerprint.action.remove_verification" = "إزالة التحقق";
|
||||
"fingerprint.badge.not_verified" = "⚠️ غير مُتحقق";
|
||||
"fingerprint.badge.verified" = "✓ مُتحقق";
|
||||
"fingerprint.handshake_pending" = "غير متاح - جار تنفيذ handshake";
|
||||
"fingerprint.message.verified" = "لقد تحققت من هوية هذا الشخص.";
|
||||
"fingerprint.message.verify_hint" = "قارن هذه البصمات مع %@ عبر قناة آمنة.";
|
||||
"fingerprint.their_label" = "بصمتهم:";
|
||||
"fingerprint.title" = "تحقق الأمان";
|
||||
"fingerprint.your_label" = "بصمتك:";
|
||||
"geohash_people.action.block" = "حظر";
|
||||
"geohash_people.action.unblock" = "إلغاء الحظر";
|
||||
"geohash_people.none_nearby" = "لا أحد قريب...";
|
||||
"geohash_people.tooltip.blocked" = "محظور في geohash";
|
||||
"geohash_people.you_suffix" = " (أنت)";
|
||||
"location_channels.action.open_settings" = "فتح الإعدادات";
|
||||
"location_channels.action.remove_access" = "إزالة صلاحية الموقع";
|
||||
"location_channels.action.request_permissions" = "جلب موقعي و geohash";
|
||||
"location_channels.action.teleport" = "انتقال فوري";
|
||||
"location_channels.bookmarked_section_title" = "محفوظ";
|
||||
"location_channels.description" = "تحدث مع أشخاص قريبين عبر قنوات geohash. نشارك geohash تقريبي فقط، وليس gps الدقيق. يتم إخفاء عنوان ip لأن كل المرور يمر عبر tor.";
|
||||
"location_channels.error.invalid_geohash" = "geohash غير صالح";
|
||||
"location_channels.loading_nearby" = "جار البحث عن قنوات قريبة…";
|
||||
"location_channels.mesh_label" = "mesh";
|
||||
"location_channels.permission_denied" = "تم رفض إذن الموقع. فعّله في الإعدادات لاستخدام قنوات الموقع.";
|
||||
"location_channels.subtitle_prefix" = "#%@ • %@";
|
||||
"location_channels.subtitle_with_name" = "%1$@ • %2$@";
|
||||
"location_channels.title" = "#قنوات الموقع";
|
||||
"location_channels.tor.subtitle" = "يخفي ip لقنوات الموقع. الموصى به: تشغيل.";
|
||||
"location_channels.tor.title" = "توجيه tor";
|
||||
"location_levels.block" = "مربع";
|
||||
"location_levels.building" = "مبنى";
|
||||
"location_levels.city" = "مدينة";
|
||||
"location_levels.neighborhood" = "حي";
|
||||
"location_levels.province" = "مقاطعة";
|
||||
"location_levels.region" = "منطقة";
|
||||
"location_notes.action.dismiss" = "إغلاق";
|
||||
"location_notes.action.retry" = "إعادة المحاولة";
|
||||
"location_notes.description" = "أضف ملاحظات قصيرة دائمة لهذا المكان ليجدها الآخرون.";
|
||||
"location_notes.empty_subtitle" = "كن أول من يضيف هنا.";
|
||||
"location_notes.empty_title" = "لا توجد ملاحظات بعد";
|
||||
"location_notes.error.failed_to_send" = "تعذر إرسال الملاحظة. %@";
|
||||
"location_notes.error.no_relays" = "لا توجد مرحلات جغرافية قريبة من هذا المكان. حاول لاحقاً.";
|
||||
"location_notes.loading_notes" = "جار تحميل الملاحظات…";
|
||||
"location_notes.loading_recent" = "جار تحميل الملاحظات الحديثة…";
|
||||
"location_notes.no_relays_nearby" = "لا مرحلات جغرافية قريبة";
|
||||
"location_notes.placeholder" = "أضف ملاحظة لهذا المكان";
|
||||
"location_notes.relays_paused" = "المرحلات الجغرافية غير متاحة؛ الملاحظات متوقفة";
|
||||
"location_notes.relays_retry_hint" = "الملاحظات تعتمد على المرحلات الجغرافية. تحقق من الاتصال ثم أعد المحاولة.";
|
||||
"mesh_peers.tooltip.new_messages" = "رسائل جديدة";
|
||||
"system.chat.blocked" = "لا يمكن بدء دردشة مع %@: المستخدم محظور.";
|
||||
"system.chat.requires_favorite" = "لا يمكن بدء دردشة مع %@: يجب أن تكونا مفضلين متبادلين للتشغيل بدون اتصال.";
|
||||
"system.common.user" = "مستخدم";
|
||||
"system.dm.blocked_generic" = "تعذر الإرسال: المستخدم محظور.";
|
||||
"system.dm.blocked_recipient" = "لا يمكن الإرسال إلى %@: المستخدم محظور.";
|
||||
"system.dm.unreachable" = "لا يمكن الإرسال إلى %@: المستلم غير متاح عبر mesh أو nostr.";
|
||||
"system.geohash.blocked" = "تم حظر %@ في محادثات geohash";
|
||||
"system.geohash.unblocked" = "تم إلغاء حظر %@ في محادثات geohash";
|
||||
"system.location.not_in_channel" = "تعذر الإرسال: لست داخل قناة موقع";
|
||||
"system.location.send_failed" = "تعذر الإرسال إلى قناة الموقع";
|
||||
"system.tor.dev_bypass" = "بناء تطوير: تجاوز tor مفعل.";
|
||||
"system.tor.restarted" = "tor أُعيد تشغيله. تمت استعادة التوجيه.";
|
||||
"system.tor.restarting" = "tor يعاد تشغيله لاستعادة الاتصال...";
|
||||
"system.tor.started" = "tor يعمل. كل الدردشة تمر عبر tor للخصوصية.";
|
||||
"system.tor.starting" = "يتم تشغيل tor...";
|
||||
"verification.my_qr.accessibility_label" = "رمز qr للتحقق";
|
||||
"verification.my_qr.title" = "امسح للتحقق مني";
|
||||
"verification.my_qr.unavailable" = "qr غير متاح";
|
||||
"verification.scan.paste_prompt" = "الصق محتوى qr للتحقق:";
|
||||
"verification.scan.prompt_friend" = "امسح qr لصديق";
|
||||
"verification.scan.status.invalid" = "qr غير صالح أو منتهٍ";
|
||||
"verification.scan.status.no_peer" = "لم يتم العثور على قرين مطابق";
|
||||
"verification.scan.status.requested" = "تم طلب التحقق لـ %@";
|
||||
"verification.scan.validate" = "تحقق";
|
||||
"verification.sheet.title" = "تحقق";
|
||||
@@ -1,78 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>location_notes.header</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>#%@ • %#@note_count@</string>
|
||||
<key>note_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>zero</key>
|
||||
<string>%d ملاحظات</string>
|
||||
<key>one</key>
|
||||
<string>%d ملاحظة</string>
|
||||
<key>two</key>
|
||||
<string>%d ملاحظتان</string>
|
||||
<key>few</key>
|
||||
<string>%d ملاحظات</string>
|
||||
<key>many</key>
|
||||
<string>%d ملاحظة</string>
|
||||
<key>other</key>
|
||||
<string>%d ملاحظة</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>location_channels.row_title</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%@ [%#@people_count@]</string>
|
||||
<key>people_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>zero</key>
|
||||
<string>%d أشخاص</string>
|
||||
<key>one</key>
|
||||
<string>%d شخص</string>
|
||||
<key>two</key>
|
||||
<string>%d شخصان</string>
|
||||
<key>few</key>
|
||||
<string>%d أشخاص</string>
|
||||
<key>many</key>
|
||||
<string>%d شخص</string>
|
||||
<key>other</key>
|
||||
<string>%d شخص</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>content.accessibility.people_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@people@</string>
|
||||
<key>people</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>zero</key>
|
||||
<string>%d أشخاص</string>
|
||||
<key>one</key>
|
||||
<string>%d شخص</string>
|
||||
<key>two</key>
|
||||
<string>%d شخصان</string>
|
||||
<key>few</key>
|
||||
<string>%d أشخاص</string>
|
||||
<key>many</key>
|
||||
<string>%d شخص</string>
|
||||
<key>other</key>
|
||||
<string>%d شخص</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,190 +0,0 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
bitchat (German)
|
||||
*/
|
||||
|
||||
"app_info.app_name" = "bitchat";
|
||||
"app_info.close" = "schließen";
|
||||
"app_info.done" = "FERTIG";
|
||||
"app_info.features.encryption.description" = "private nachrichten werden mit dem noise-protokoll verschlüsselt";
|
||||
"app_info.features.encryption.title" = "end-to-end-verschlüsselung";
|
||||
"app_info.features.extended_range.description" = "nachrichten werden zwischen peers weitergeleitet und reichen weiter";
|
||||
"app_info.features.extended_range.title" = "erweiterte reichweite";
|
||||
"app_info.features.favorites.description" = "erhalte hinweise, wenn deine lieblingsmenschen online kommen";
|
||||
"app_info.features.favorites.title" = "favoriten";
|
||||
"app_info.features.geohash.description" = "geohash-kanäle zum chatten mit menschen in der nähe über dezentrale anonyme relays";
|
||||
"app_info.features.geohash.title" = "lokale kanäle";
|
||||
"app_info.features.mentions.description" = "nutze @nickname, um bestimmte personen zu benachrichtigen";
|
||||
"app_info.features.mentions.title" = "erwähnungen";
|
||||
"app_info.features.offline.description" = "funktioniert ohne internet per bluetooth low energy";
|
||||
"app_info.features.offline.title" = "offline-kommunikation";
|
||||
"app_info.features.title" = "FUNKTIONEN";
|
||||
"app_info.how_to_use.change_channels" = "• tippe auf #mesh, um den kanal zu wechseln";
|
||||
"app_info.how_to_use.clear_chat" = "• tippe den chat dreimal, um ihn zu leeren";
|
||||
"app_info.how_to_use.commands" = "• tippe /, um befehle zu sehen";
|
||||
"app_info.how_to_use.open_sidebar" = "• tippe auf das personen-icon, um die seitenleiste zu öffnen";
|
||||
"app_info.how_to_use.set_nickname" = "• tippe auf deinen nickname, um ihn zu ändern";
|
||||
"app_info.how_to_use.start_dm" = "• tippe auf den namen eines peers, um eine pn zu starten";
|
||||
"app_info.how_to_use.title" = "SO FUNKTIONIERT'S";
|
||||
"app_info.privacy.ephemeral.description" = "neue peer-id wird regelmäßig erzeugt";
|
||||
"app_info.privacy.ephemeral.title" = "flüchtige identität";
|
||||
"app_info.privacy.no_tracking.description" = "keine server, konten oder datensammlung";
|
||||
"app_info.privacy.no_tracking.title" = "kein tracking";
|
||||
"app_info.privacy.panic.description" = "tippe dreimal auf das logo, um alle daten sofort zu löschen";
|
||||
"app_info.privacy.panic.title" = "panikmodus";
|
||||
"app_info.privacy.title" = "PRIVATSPHÄRE";
|
||||
"app_info.tagline" = "sidegroupchat";
|
||||
"app_info.warning.message" = "die sicherheit privater nachrichten wurde noch nicht vollständig geprüft. nutze sie nicht für kritische situationen, solange dieser hinweis erscheint.";
|
||||
"app_info.warning.title" = "WARNUNG";
|
||||
"common.cancel" = "abbrechen";
|
||||
"common.close" = "schließen";
|
||||
"common.copy" = "kopieren";
|
||||
"common.ok" = "OK";
|
||||
"common.toggle.off" = "aus";
|
||||
"common.toggle.on" = "an";
|
||||
"common.unknown" = "unbekannt";
|
||||
"content.accessibility.add_favorite" = "zu favoriten hinzufügen";
|
||||
"content.accessibility.available_nostr" = "verfügbar über nostr";
|
||||
"content.accessibility.back_to_main_chat" = "zurück zum hauptchat";
|
||||
"content.accessibility.connected_mesh" = "verbunden über mesh";
|
||||
"content.accessibility.encryption_status" = "verschlüsselungsstatus: %@";
|
||||
"content.accessibility.location_channels" = "kanäle für standorte";
|
||||
"content.accessibility.location_notes" = "standortnotizen für diesen ort";
|
||||
"content.accessibility.open_unread_private_chat" = "ungelesene privatnachricht öffnen";
|
||||
"content.accessibility.private_chat_header" = "privatchat mit %@";
|
||||
"content.accessibility.reachable_mesh" = "erreichbar über mesh";
|
||||
"content.accessibility.remove_favorite" = "aus favoriten entfernen";
|
||||
"content.accessibility.send_hint_empty" = "gib eine nachricht zum senden ein";
|
||||
"content.accessibility.send_hint_ready" = "doppelt tippen zum senden";
|
||||
"content.accessibility.send_message" = "nachricht senden";
|
||||
"content.accessibility.toggle_bookmark" = "bookmark für #%@ umschalten";
|
||||
"content.accessibility.toggle_favorite_hint" = "doppelt tippen, um favoritenstatus zu wechseln";
|
||||
"content.accessibility.view_fingerprint_hint" = "tippe, um den verschlüsselungs-fingerprint zu sehen";
|
||||
"content.actions.block" = "blockieren";
|
||||
"content.actions.direct_message" = "direktnachricht";
|
||||
"content.actions.hug" = "umarmen";
|
||||
"content.actions.mention" = "erwähnen";
|
||||
"content.actions.slap" = "ohrfeige";
|
||||
"content.actions.title" = "aktionen";
|
||||
"content.alert.bluetooth_required.off" = "bluetooth ist ausgeschaltet. aktiviere bluetooth in den einstellungen, um bitchat zu verwenden.";
|
||||
"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";
|
||||
"content.alert.bluetooth_required.title" = "bluetooth erforderlich";
|
||||
"content.alert.bluetooth_required.unsupported" = "dieses gerät unterstützt kein bluetooth. bitchat benötigt bluetooth zum funktionieren.";
|
||||
"content.alert.screenshot.message" = "screenshots von standortkanälen verraten deinen standort. überleg dir das teilen vorher gut.";
|
||||
"content.alert.screenshot.title" = "achtung";
|
||||
"content.commands.block" = "blocked peers anzeigen oder blockieren";
|
||||
"content.commands.clear" = "chatnachrichten löschen";
|
||||
"content.commands.favorite" = "zu favoriten hinzufügen";
|
||||
"content.commands.hug" = "eine warme umarmung senden";
|
||||
"content.commands.message" = "privatnachricht senden";
|
||||
"content.commands.slap" = "jemandem eine forelle um die ohren schlagen";
|
||||
"content.commands.unblock" = "peer entsperren";
|
||||
"content.commands.unfavorite" = "aus favoriten entfernen";
|
||||
"content.commands.who" = "sehen, wer online ist";
|
||||
"content.delivery.delivered_members" = "zugestellt an %1$d von %2$d mitgliedern";
|
||||
"content.delivery.delivered_to" = "zugestellt an %@";
|
||||
"content.delivery.failed" = "fehlgeschlagen: %@";
|
||||
"content.delivery.read_by" = "gelesen von %@";
|
||||
"content.delivery.reason.blocked" = "nutzer blockiert";
|
||||
"content.delivery.reason.self" = "kann nicht an dich selbst senden";
|
||||
"content.delivery.reason.send_error" = "sende-fehler";
|
||||
"content.delivery.reason.unknown_recipient" = "unbekannter empfänger";
|
||||
"content.delivery.reason.unreachable" = "peer nicht erreichbar";
|
||||
"content.header.people" = "PERSONEN";
|
||||
"content.help.verification" = "verifizierung: meinen qr zeigen oder freund scannen";
|
||||
"content.input.message_placeholder" = "nachricht eingeben...";
|
||||
"content.input.nickname_placeholder" = "nickname";
|
||||
"content.location.enable" = "standort aktivieren";
|
||||
"content.message.copy" = "nachricht kopieren";
|
||||
"content.message.show_less" = "weniger anzeigen";
|
||||
"content.message.show_more" = "mehr anzeigen";
|
||||
"content.notes.location_unavailable" = "standort nicht verfügbar";
|
||||
"content.notes.title" = "notizen";
|
||||
"content.payment.cashu" = "per cashu bezahlen";
|
||||
"content.payment.lightning" = "per lightning bezahlen";
|
||||
"encryption.accessibility.establishing" = "verschlüsselung wird aufgebaut";
|
||||
"encryption.accessibility.failed" = "verschlüsselung fehlgeschlagen";
|
||||
"encryption.accessibility.not_encrypted" = "nicht verschlüsselt";
|
||||
"encryption.accessibility.secured" = "verschlüsselt";
|
||||
"encryption.accessibility.verified" = "verschlüsselt und verifiziert";
|
||||
"encryption.status.establishing" = "verschlüsselung wird aufgebaut...";
|
||||
"encryption.status.failed" = "verschlüsselung fehlgeschlagen";
|
||||
"encryption.status.not_encrypted" = "nicht verschlüsselt";
|
||||
"encryption.status.secured" = "verschlüsselt";
|
||||
"encryption.status.verified" = "verschlüsselt und verifiziert";
|
||||
"fingerprint.action.mark_verified" = "als verifiziert markieren";
|
||||
"fingerprint.action.remove_verification" = "verifizierung entfernen";
|
||||
"fingerprint.badge.not_verified" = "⚠️ NICHT VERIFIZIERT";
|
||||
"fingerprint.badge.verified" = "✓ VERIFIZIERT";
|
||||
"fingerprint.handshake_pending" = "nicht verfügbar – handshake läuft";
|
||||
"fingerprint.message.verified" = "du hast die identität dieser person verifiziert.";
|
||||
"fingerprint.message.verify_hint" = "vergleiche diese fingerabdrücke mit %@ über einen sicheren kanal.";
|
||||
"fingerprint.their_label" = "deren fingerabdruck:";
|
||||
"fingerprint.title" = "sicherheitsverifizierung";
|
||||
"fingerprint.your_label" = "dein fingerabdruck:";
|
||||
"geohash_people.action.block" = "blockieren";
|
||||
"geohash_people.action.unblock" = "entsperren";
|
||||
"geohash_people.none_nearby" = "niemand in der nähe...";
|
||||
"geohash_people.tooltip.blocked" = "in geohash blockiert";
|
||||
"geohash_people.you_suffix" = " (du)";
|
||||
"location_channels.action.open_settings" = "einstellungen öffnen";
|
||||
"location_channels.action.remove_access" = "standortzugriff entfernen";
|
||||
"location_channels.action.request_permissions" = "standort und geohash abrufen";
|
||||
"location_channels.action.teleport" = "teleportieren";
|
||||
"location_channels.bookmarked_section_title" = "gespeichert";
|
||||
"location_channels.description" = "chatte mit menschen in deiner nähe über geohash-kanäle. geteilt wird nur ein grober geohash, niemals exakte gps-daten. deine ip bleibt verborgen, weil der gesamte verkehr über tor läuft.";
|
||||
"location_channels.error.invalid_geohash" = "ungültiger geohash";
|
||||
"location_channels.loading_nearby" = "suche nach kanälen in der nähe…";
|
||||
"location_channels.mesh_label" = "mesh";
|
||||
"location_channels.permission_denied" = "standortberechtigung verweigert. aktiviere sie in den einstellungen für standortkanäle.";
|
||||
"location_channels.subtitle_prefix" = "#%@ • %@";
|
||||
"location_channels.subtitle_with_name" = "%1$@ • %2$@";
|
||||
"location_channels.title" = "#standort-kanäle";
|
||||
"location_channels.tor.subtitle" = "verbirgt deine ip für standortkanäle. empfohlen: an.";
|
||||
"location_channels.tor.title" = "tor-routing";
|
||||
"location_levels.block" = "block";
|
||||
"location_levels.building" = "gebäude";
|
||||
"location_levels.city" = "stadt";
|
||||
"location_levels.neighborhood" = "viertel";
|
||||
"location_levels.province" = "bundesland";
|
||||
"location_levels.region" = "region";
|
||||
"location_notes.action.dismiss" = "schließen";
|
||||
"location_notes.action.retry" = "erneut versuchen";
|
||||
"location_notes.description" = "füge diesem ort kurze dauerhafte notizen hinzu, damit andere sie finden.";
|
||||
"location_notes.empty_subtitle" = "sei die erste person, die hier eine notiz hinterlässt.";
|
||||
"location_notes.empty_title" = "noch keine notizen";
|
||||
"location_notes.error.failed_to_send" = "notiz konnte nicht gesendet werden. %@";
|
||||
"location_notes.error.no_relays" = "keine geo-relays in der nähe verfügbar. versuch es später erneut.";
|
||||
"location_notes.loading_notes" = "notizen werden geladen…";
|
||||
"location_notes.loading_recent" = "aktuelle notizen werden geladen…";
|
||||
"location_notes.no_relays_nearby" = "keine geo-relays in der nähe";
|
||||
"location_notes.placeholder" = "notiz für diesen ort hinzufügen";
|
||||
"location_notes.relays_paused" = "geo-relays nicht verfügbar; notizen pausiert";
|
||||
"location_notes.relays_retry_hint" = "notizen hängen von geo-relays ab. prüfe die verbindung und versuch es erneut.";
|
||||
"mesh_peers.tooltip.new_messages" = "neue nachrichten";
|
||||
"system.chat.blocked" = "chat mit %@ kann nicht gestartet werden: nutzer blockiert.";
|
||||
"system.chat.requires_favorite" = "chat mit %@ kann nicht gestartet werden: gegenseitige favoriten für offline nötig.";
|
||||
"system.common.user" = "nutzer";
|
||||
"system.dm.blocked_generic" = "senden nicht möglich: nutzer blockiert.";
|
||||
"system.dm.blocked_recipient" = "senden an %@ nicht möglich: nutzer blockiert.";
|
||||
"system.dm.unreachable" = "senden an %@ nicht möglich: empfänger über mesh oder nostr nicht erreichbar.";
|
||||
"system.geohash.blocked" = "%@ wurde in geohash-chats blockiert";
|
||||
"system.geohash.unblocked" = "%@ wurde in geohash-chats entsperrt";
|
||||
"system.location.not_in_channel" = "senden fehlgeschlagen: du bist nicht in einem standortkanal";
|
||||
"system.location.send_failed" = "konnte nicht an den standortkanal senden";
|
||||
"system.tor.dev_bypass" = "dev-build: tor-bypass aktiv.";
|
||||
"system.tor.restarted" = "tor wurde neu gestartet. routing wiederhergestellt.";
|
||||
"system.tor.restarting" = "tor startet neu, um die verbindung herzustellen...";
|
||||
"system.tor.started" = "tor läuft. der gesamte chat wird über tor geleitet.";
|
||||
"system.tor.starting" = "tor wird gestartet...";
|
||||
"verification.my_qr.accessibility_label" = "verifizierungs-qr-code";
|
||||
"verification.my_qr.title" = "scanne, um mich zu verifizieren";
|
||||
"verification.my_qr.unavailable" = "qr nicht verfügbar";
|
||||
"verification.scan.paste_prompt" = "füge den qr-inhalt zum prüfen ein:";
|
||||
"verification.scan.prompt_friend" = "scanne den qr eines freundes";
|
||||
"verification.scan.status.invalid" = "qr ungültig oder abgelaufen";
|
||||
"verification.scan.status.no_peer" = "kein passender peer gefunden";
|
||||
"verification.scan.status.requested" = "verifizierung für %@ angefordert";
|
||||
"verification.scan.validate" = "prüfen";
|
||||
"verification.sheet.title" = "VERIFIZIEREN";
|
||||
@@ -1,54 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>location_notes.header</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>#%@ • %#@note_count@</string>
|
||||
<key>note_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d notiz</string>
|
||||
<key>other</key>
|
||||
<string>%d notizen</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>location_channels.row_title</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%@ [%#@people_count@]</string>
|
||||
<key>people_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d person</string>
|
||||
<key>other</key>
|
||||
<string>%d personen</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>content.accessibility.people_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@people@</string>
|
||||
<key>people</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d person</string>
|
||||
<key>other</key>
|
||||
<string>%d personen</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,190 +0,0 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
bitchat (Spanish)
|
||||
*/
|
||||
|
||||
"app_info.app_name" = "bitchat";
|
||||
"app_info.close" = "cerrar";
|
||||
"app_info.done" = "LISTO";
|
||||
"app_info.features.encryption.description" = "mensajes privados cifrados con el protocolo Noise";
|
||||
"app_info.features.encryption.title" = "cifrado de extremo a extremo";
|
||||
"app_info.features.extended_range.description" = "los mensajes se retransmiten entre pares y llegan lejos";
|
||||
"app_info.features.extended_range.title" = "alcance ampliado";
|
||||
"app_info.features.favorites.description" = "recibe avisos cuando tus personas favoritas se conecten";
|
||||
"app_info.features.favorites.title" = "favoritos";
|
||||
"app_info.features.geohash.description" = "canales geohash para chatear con personas en regiones cercanas a través de relays descentralizados anónimos";
|
||||
"app_info.features.geohash.title" = "canales locales";
|
||||
"app_info.features.mentions.description" = "usa @nickname para avisar a personas concretas";
|
||||
"app_info.features.mentions.title" = "menciones";
|
||||
"app_info.features.offline.description" = "funciona sin internet utilizando Bluetooth de bajo consumo";
|
||||
"app_info.features.offline.title" = "comunicación sin conexión";
|
||||
"app_info.features.title" = "FUNCIONES";
|
||||
"app_info.how_to_use.change_channels" = "• toca #mesh para cambiar de canal";
|
||||
"app_info.how_to_use.clear_chat" = "• toca tres veces el chat para limpiarlo";
|
||||
"app_info.how_to_use.commands" = "• escribe / para ver los comandos";
|
||||
"app_info.how_to_use.open_sidebar" = "• toca el ícono de personas para abrir la barra lateral";
|
||||
"app_info.how_to_use.set_nickname" = "• define tu apodo tocándolo";
|
||||
"app_info.how_to_use.start_dm" = "• toca el nombre de un participante para iniciar un MD";
|
||||
"app_info.how_to_use.title" = "CÓMO USARLO";
|
||||
"app_info.privacy.ephemeral.description" = "nuevo ID de peer generado periódicamente";
|
||||
"app_info.privacy.ephemeral.title" = "identidad efímera";
|
||||
"app_info.privacy.no_tracking.description" = "sin servidores, cuentas ni recopilación de datos";
|
||||
"app_info.privacy.no_tracking.title" = "sin seguimiento";
|
||||
"app_info.privacy.panic.description" = "toca el logotipo tres veces para borrar todos los datos al instante";
|
||||
"app_info.privacy.panic.title" = "modo pánico";
|
||||
"app_info.privacy.title" = "PRIVACIDAD";
|
||||
"app_info.tagline" = "sidegroupchat";
|
||||
"app_info.warning.message" = "la seguridad de los mensajes privados aún no ha sido auditada por completo. no lo uses en situaciones críticas hasta que este aviso desaparezca.";
|
||||
"app_info.warning.title" = "ADVERTENCIA";
|
||||
"common.cancel" = "cancelar";
|
||||
"common.close" = "cerrar";
|
||||
"common.copy" = "copiar";
|
||||
"common.ok" = "aceptar";
|
||||
"common.toggle.off" = "desactivado";
|
||||
"common.toggle.on" = "activado";
|
||||
"common.unknown" = "desconocido";
|
||||
"content.accessibility.add_favorite" = "agregar a favoritos";
|
||||
"content.accessibility.available_nostr" = "disponible vía Nostr";
|
||||
"content.accessibility.back_to_main_chat" = "volver al chat principal";
|
||||
"content.accessibility.connected_mesh" = "conectado por mesh";
|
||||
"content.accessibility.encryption_status" = "estado de cifrado: %@";
|
||||
"content.accessibility.location_channels" = "canales de ubicación";
|
||||
"content.accessibility.location_notes" = "notas de ubicación de este lugar";
|
||||
"content.accessibility.open_unread_private_chat" = "abrir chat privado sin leer";
|
||||
"content.accessibility.private_chat_header" = "chat privado con %@";
|
||||
"content.accessibility.reachable_mesh" = "disponible por mesh";
|
||||
"content.accessibility.remove_favorite" = "quitar de favoritos";
|
||||
"content.accessibility.send_hint_empty" = "introduce un mensaje para enviarlo";
|
||||
"content.accessibility.send_hint_ready" = "toca dos veces para enviar";
|
||||
"content.accessibility.send_message" = "enviar mensaje";
|
||||
"content.accessibility.toggle_bookmark" = "alternar marcador para #%@";
|
||||
"content.accessibility.toggle_favorite_hint" = "toca dos veces para alternar el estado de favorito";
|
||||
"content.accessibility.view_fingerprint_hint" = "toca para ver la huella de cifrado";
|
||||
"content.actions.block" = "bloquear";
|
||||
"content.actions.direct_message" = "mensaje directo";
|
||||
"content.actions.hug" = "abrazo";
|
||||
"content.actions.mention" = "mencionar";
|
||||
"content.actions.slap" = "bofetada";
|
||||
"content.actions.title" = "acciones";
|
||||
"content.alert.bluetooth_required.off" = "bluetooth está desactivado. Actívalo en Ajustes para usar BitChat.";
|
||||
"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";
|
||||
"content.alert.bluetooth_required.title" = "se requiere Bluetooth";
|
||||
"content.alert.bluetooth_required.unsupported" = "este dispositivo no admite Bluetooth. BitChat necesita Bluetooth para funcionar.";
|
||||
"content.alert.screenshot.message" = "las capturas de pantalla de los canales de ubicación revelarán tu ubicación. Piensa antes de compartirlas públicamente.";
|
||||
"content.alert.screenshot.title" = "atención";
|
||||
"content.commands.block" = "bloquear o listar usuarios bloqueados";
|
||||
"content.commands.clear" = "borrar los mensajes del chat";
|
||||
"content.commands.favorite" = "agregar a favoritos";
|
||||
"content.commands.hug" = "enviar un abrazo caluroso";
|
||||
"content.commands.message" = "enviar mensaje privado";
|
||||
"content.commands.slap" = "abofetear a alguien con una trucha";
|
||||
"content.commands.unblock" = "desbloquear a un usuario";
|
||||
"content.commands.unfavorite" = "quitar de favoritos";
|
||||
"content.commands.who" = "ver quién está en línea";
|
||||
"content.delivery.delivered_members" = "entregado a %1$d de %2$d miembros";
|
||||
"content.delivery.delivered_to" = "entregado a %@";
|
||||
"content.delivery.failed" = "falló: %@";
|
||||
"content.delivery.read_by" = "leído por %@";
|
||||
"content.delivery.reason.blocked" = "el usuario está bloqueado";
|
||||
"content.delivery.reason.self" = "no puedes enviarte mensajes a ti mismo";
|
||||
"content.delivery.reason.send_error" = "error al enviar";
|
||||
"content.delivery.reason.unknown_recipient" = "destinatario desconocido";
|
||||
"content.delivery.reason.unreachable" = "el destinatario no es alcanzable";
|
||||
"content.header.people" = "PERSONAS";
|
||||
"content.help.verification" = "verificación: mostrar mi QR o escanear a un amigo";
|
||||
"content.input.message_placeholder" = "escribe un mensaje...";
|
||||
"content.input.nickname_placeholder" = "apodo";
|
||||
"content.location.enable" = "activar ubicación";
|
||||
"content.message.copy" = "copiar mensaje";
|
||||
"content.message.show_less" = "mostrar menos";
|
||||
"content.message.show_more" = "mostrar más";
|
||||
"content.notes.location_unavailable" = "ubicación no disponible";
|
||||
"content.notes.title" = "notas";
|
||||
"content.payment.cashu" = "pagar con Cashu";
|
||||
"content.payment.lightning" = "pagar con Lightning";
|
||||
"encryption.accessibility.establishing" = "estableciendo cifrado";
|
||||
"encryption.accessibility.failed" = "cifrado fallido";
|
||||
"encryption.accessibility.not_encrypted" = "sin cifrar";
|
||||
"encryption.accessibility.secured" = "cifrado";
|
||||
"encryption.accessibility.verified" = "cifrado y verificado";
|
||||
"encryption.status.establishing" = "estableciendo cifrado...";
|
||||
"encryption.status.failed" = "cifrado fallido";
|
||||
"encryption.status.not_encrypted" = "sin cifrar";
|
||||
"encryption.status.secured" = "cifrado";
|
||||
"encryption.status.verified" = "cifrado y verificado";
|
||||
"fingerprint.action.mark_verified" = "marcar como verificado";
|
||||
"fingerprint.action.remove_verification" = "eliminar verificación";
|
||||
"fingerprint.badge.not_verified" = "⚠️ NO VERIFICADO";
|
||||
"fingerprint.badge.verified" = "✓ VERIFICADO";
|
||||
"fingerprint.handshake_pending" = "no disponible: el handshake está en curso";
|
||||
"fingerprint.message.verified" = "has verificado la identidad de esta persona.";
|
||||
"fingerprint.message.verify_hint" = "compara estas huellas con %@ mediante un canal seguro.";
|
||||
"fingerprint.their_label" = "huella de la otra persona:";
|
||||
"fingerprint.title" = "verificación de seguridad";
|
||||
"fingerprint.your_label" = "tu huella:";
|
||||
"geohash_people.action.block" = "bloquear";
|
||||
"geohash_people.action.unblock" = "desbloquear";
|
||||
"geohash_people.none_nearby" = "nadie cerca...";
|
||||
"geohash_people.tooltip.blocked" = "bloqueado en geohash";
|
||||
"geohash_people.you_suffix" = " (tú)";
|
||||
"location_channels.action.open_settings" = "abrir ajustes";
|
||||
"location_channels.action.remove_access" = "eliminar acceso a la ubicación";
|
||||
"location_channels.action.request_permissions" = "obtener mi ubicación y mis geohashes";
|
||||
"location_channels.action.teleport" = "teletransportar";
|
||||
"location_channels.bookmarked_section_title" = "marcados";
|
||||
"location_channels.description" = "chatea con personas cercanas usando canales geohash. Solo se comparte un geohash aproximado, nunca GPS exacto. Tu IP se oculta al enrutar todo el tráfico por Tor.";
|
||||
"location_channels.error.invalid_geohash" = "geohash no válido";
|
||||
"location_channels.loading_nearby" = "buscando canales cercanos…";
|
||||
"location_channels.mesh_label" = "mesh";
|
||||
"location_channels.permission_denied" = "permiso de ubicación denegado. Actívalo en Ajustes para usar los canales de ubicación.";
|
||||
"location_channels.subtitle_prefix" = "#%@ • %@";
|
||||
"location_channels.subtitle_with_name" = "%1$@ • %2$@";
|
||||
"location_channels.title" = "#canales de ubicación";
|
||||
"location_channels.tor.subtitle" = "oculta tu IP para los canales de ubicación. Recomendado: activado.";
|
||||
"location_channels.tor.title" = "enrutamiento Tor";
|
||||
"location_levels.block" = "bloque";
|
||||
"location_levels.building" = "edificio";
|
||||
"location_levels.city" = "ciudad";
|
||||
"location_levels.neighborhood" = "barrio";
|
||||
"location_levels.province" = "provincia";
|
||||
"location_levels.region" = "región";
|
||||
"location_notes.action.dismiss" = "descartar";
|
||||
"location_notes.action.retry" = "reintentar";
|
||||
"location_notes.description" = "añade notas permanentes cortas sobre este lugar para que otras personas las encuentren.";
|
||||
"location_notes.empty_subtitle" = "sé la primera persona en añadir una en este lugar.";
|
||||
"location_notes.empty_title" = "aún no hay notas";
|
||||
"location_notes.error.failed_to_send" = "no se pudo enviar la nota. %@";
|
||||
"location_notes.error.no_relays" = "no hay relays geográficos disponibles cerca de este lugar. Inténtalo de nuevo pronto.";
|
||||
"location_notes.loading_notes" = "cargando notas…";
|
||||
"location_notes.loading_recent" = "cargando notas recientes…";
|
||||
"location_notes.no_relays_nearby" = "no hay relays geográficos cercanos";
|
||||
"location_notes.placeholder" = "añade una nota para este lugar";
|
||||
"location_notes.relays_paused" = "relays geográficos no disponibles; notas en pausa";
|
||||
"location_notes.relays_retry_hint" = "las notas dependen de los relays geográficos. Comprueba la conexión e inténtalo de nuevo.";
|
||||
"mesh_peers.tooltip.new_messages" = "nuevos mensajes";
|
||||
"system.chat.blocked" = "no se puede iniciar un chat con %@: el usuario está bloqueado.";
|
||||
"system.chat.requires_favorite" = "no se puede iniciar un chat con %@: necesitas ser favoritos mutuos para mensajería sin conexión.";
|
||||
"system.common.user" = "usuario";
|
||||
"system.dm.blocked_generic" = "no se puede enviar el mensaje: el usuario está bloqueado.";
|
||||
"system.dm.blocked_recipient" = "no se puede enviar un mensaje a %@: el usuario está bloqueado.";
|
||||
"system.dm.unreachable" = "no se puede enviar un mensaje a %@: el destinatario no es alcanzable por mesh ni Nostr.";
|
||||
"system.geohash.blocked" = "se bloqueó a %@ en los chats geohash";
|
||||
"system.geohash.unblocked" = "se desbloqueó a %@ en los chats geohash";
|
||||
"system.location.not_in_channel" = "no se puede enviar: no estás en un canal de ubicación";
|
||||
"system.location.send_failed" = "no se pudo enviar al canal de ubicación";
|
||||
"system.tor.dev_bypass" = "compilación de desarrollo: bypass de Tor activado.";
|
||||
"system.tor.restarted" = "tor se reinició. Se restauró el enrutamiento de la red.";
|
||||
"system.tor.restarting" = "tor se está reiniciando para recuperar la conectividad...";
|
||||
"system.tor.started" = "tor se inició. Todo el chat se enruta por Tor para privacidad.";
|
||||
"system.tor.starting" = "iniciando Tor...";
|
||||
"verification.my_qr.accessibility_label" = "código QR de verificación";
|
||||
"verification.my_qr.title" = "escanea para verificarme";
|
||||
"verification.my_qr.unavailable" = "QR no disponible";
|
||||
"verification.scan.paste_prompt" = "pega el contenido del QR para validarlo:";
|
||||
"verification.scan.prompt_friend" = "escanea el QR de un amigo";
|
||||
"verification.scan.status.invalid" = "QR inválido o caducado";
|
||||
"verification.scan.status.no_peer" = "no se encontró un peer coincidente";
|
||||
"verification.scan.status.requested" = "se solicitó la verificación de %@";
|
||||
"verification.scan.validate" = "validar";
|
||||
"verification.sheet.title" = "VERIFICAR";
|
||||
@@ -1,54 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>location_notes.header</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>#%@ • %#@note_count@</string>
|
||||
<key>note_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d nota</string>
|
||||
<key>other</key>
|
||||
<string>%d notas</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>location_channels.row_title</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%@ [%#@people_count@]</string>
|
||||
<key>people_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d persona</string>
|
||||
<key>other</key>
|
||||
<string>%d personas</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>content.accessibility.people_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@people@</string>
|
||||
<key>people</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d persona</string>
|
||||
<key>other</key>
|
||||
<string>%d personas</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,190 +0,0 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
bitchat (French)
|
||||
*/
|
||||
|
||||
"app_info.app_name" = "bitchat";
|
||||
"app_info.close" = "fermer";
|
||||
"app_info.done" = "TERMINÉ";
|
||||
"app_info.features.encryption.description" = "messages privés chiffrés avec le protocole noise";
|
||||
"app_info.features.encryption.title" = "chiffrement de bout en bout";
|
||||
"app_info.features.extended_range.description" = "messages relayés entre pairs pour aller plus loin";
|
||||
"app_info.features.extended_range.title" = "portée étendue";
|
||||
"app_info.features.favorites.description" = "reçois une alerte quand tes personnes favorites arrivent";
|
||||
"app_info.features.favorites.title" = "favoris";
|
||||
"app_info.features.geohash.description" = "canaux geohash pour discuter avec des personnes proches via des relais décentralisés anonymes";
|
||||
"app_info.features.geohash.title" = "canaux locaux";
|
||||
"app_info.features.mentions.description" = "utilise @nickname pour avertir des personnes précises";
|
||||
"app_info.features.mentions.title" = "mentions";
|
||||
"app_info.features.offline.description" = "fonctionne sans internet avec le bluetooth basse énergie";
|
||||
"app_info.features.offline.title" = "communication hors ligne";
|
||||
"app_info.features.title" = "FONCTIONNALITÉS";
|
||||
"app_info.how_to_use.change_channels" = "• tape sur #mesh pour changer de canal";
|
||||
"app_info.how_to_use.clear_chat" = "• tape trois fois sur le chat pour le vider";
|
||||
"app_info.how_to_use.commands" = "• tape / pour voir les commandes";
|
||||
"app_info.how_to_use.open_sidebar" = "• tape sur l'icône personnes pour ouvrir la barre latérale";
|
||||
"app_info.how_to_use.set_nickname" = "• règle ton pseudo en le touchant";
|
||||
"app_info.how_to_use.start_dm" = "• tape sur le nom d'un pair pour démarrer un mp";
|
||||
"app_info.how_to_use.title" = "MODE D'EMPLOI";
|
||||
"app_info.privacy.ephemeral.description" = "nouvel id de pair généré régulièrement";
|
||||
"app_info.privacy.ephemeral.title" = "identité éphémère";
|
||||
"app_info.privacy.no_tracking.description" = "sans serveurs, comptes ni collecte de données";
|
||||
"app_info.privacy.no_tracking.title" = "sans suivi";
|
||||
"app_info.privacy.panic.description" = "tape trois fois sur le logo pour tout effacer instantanément";
|
||||
"app_info.privacy.panic.title" = "mode panique";
|
||||
"app_info.privacy.title" = "CONFIDENTIALITÉ";
|
||||
"app_info.tagline" = "sidegroupchat";
|
||||
"app_info.warning.message" = "la sécurité des messages privés n'a pas encore été entièrement auditée. n'utilise pas pour des situations critiques tant que cet avertissement reste.";
|
||||
"app_info.warning.title" = "AVERTISSEMENT";
|
||||
"common.cancel" = "annuler";
|
||||
"common.close" = "fermer";
|
||||
"common.copy" = "copier";
|
||||
"common.ok" = "OK";
|
||||
"common.toggle.off" = "désactivé";
|
||||
"common.toggle.on" = "activé";
|
||||
"common.unknown" = "inconnu";
|
||||
"content.accessibility.add_favorite" = "ajouter aux favoris";
|
||||
"content.accessibility.available_nostr" = "disponible via nostr";
|
||||
"content.accessibility.back_to_main_chat" = "retour au chat principal";
|
||||
"content.accessibility.connected_mesh" = "connecté via mesh";
|
||||
"content.accessibility.encryption_status" = "état du chiffrement : %@";
|
||||
"content.accessibility.location_channels" = "canaux de localisation";
|
||||
"content.accessibility.location_notes" = "notes de localisation pour cet endroit";
|
||||
"content.accessibility.open_unread_private_chat" = "ouvrir le chat privé non lu";
|
||||
"content.accessibility.private_chat_header" = "chat privé avec %@";
|
||||
"content.accessibility.reachable_mesh" = "joignable via mesh";
|
||||
"content.accessibility.remove_favorite" = "retirer des favoris";
|
||||
"content.accessibility.send_hint_empty" = "saisis un message à envoyer";
|
||||
"content.accessibility.send_hint_ready" = "tape deux fois pour envoyer";
|
||||
"content.accessibility.send_message" = "envoyer le message";
|
||||
"content.accessibility.toggle_bookmark" = "basculer le favori pour #%@";
|
||||
"content.accessibility.toggle_favorite_hint" = "tape deux fois pour basculer le statut favori";
|
||||
"content.accessibility.view_fingerprint_hint" = "tape pour voir l'empreinte de chiffrement";
|
||||
"content.actions.block" = "bloquer";
|
||||
"content.actions.direct_message" = "message direct";
|
||||
"content.actions.hug" = "câlin";
|
||||
"content.actions.mention" = "mentionner";
|
||||
"content.actions.slap" = "gifle";
|
||||
"content.actions.title" = "actions";
|
||||
"content.alert.bluetooth_required.off" = "bluetooth est désactivé. active le bluetooth dans réglages pour utiliser bitchat.";
|
||||
"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";
|
||||
"content.alert.bluetooth_required.title" = "bluetooth requis";
|
||||
"content.alert.bluetooth_required.unsupported" = "cet appareil ne prend pas en charge le bluetooth. bitchat en a besoin pour fonctionner.";
|
||||
"content.alert.screenshot.message" = "les captures des canaux de localisation révéleront ta position. réfléchis avant de partager publiquement.";
|
||||
"content.alert.screenshot.title" = "attention";
|
||||
"content.commands.block" = "bloquer ou lister les pairs bloqués";
|
||||
"content.commands.clear" = "effacer les messages du chat";
|
||||
"content.commands.favorite" = "ajouter aux favoris";
|
||||
"content.commands.hug" = "envoyer un câlin chaleureux";
|
||||
"content.commands.message" = "envoyer un message privé";
|
||||
"content.commands.slap" = "gifler quelqu'un avec une truite";
|
||||
"content.commands.unblock" = "débloquer un pair";
|
||||
"content.commands.unfavorite" = "retirer des favoris";
|
||||
"content.commands.who" = "voir qui est en ligne";
|
||||
"content.delivery.delivered_members" = "livré à %1$d sur %2$d membres";
|
||||
"content.delivery.delivered_to" = "livré à %@";
|
||||
"content.delivery.failed" = "échec : %@";
|
||||
"content.delivery.read_by" = "lu par %@";
|
||||
"content.delivery.reason.blocked" = "utilisateur bloqué";
|
||||
"content.delivery.reason.self" = "impossible d'envoyer à toi-même";
|
||||
"content.delivery.reason.send_error" = "erreur d'envoi";
|
||||
"content.delivery.reason.unknown_recipient" = "destinataire inconnu";
|
||||
"content.delivery.reason.unreachable" = "pair injoignable";
|
||||
"content.header.people" = "PERSONNES";
|
||||
"content.help.verification" = "vérification : afficher mon qr ou scanner un ami";
|
||||
"content.input.message_placeholder" = "écris un message...";
|
||||
"content.input.nickname_placeholder" = "pseudo";
|
||||
"content.location.enable" = "activer la localisation";
|
||||
"content.message.copy" = "copier le message";
|
||||
"content.message.show_less" = "afficher moins";
|
||||
"content.message.show_more" = "afficher plus";
|
||||
"content.notes.location_unavailable" = "localisation indisponible";
|
||||
"content.notes.title" = "notes";
|
||||
"content.payment.cashu" = "payer via cashu";
|
||||
"content.payment.lightning" = "payer via lightning";
|
||||
"encryption.accessibility.establishing" = "établissement du chiffrement";
|
||||
"encryption.accessibility.failed" = "chiffrement échoué";
|
||||
"encryption.accessibility.not_encrypted" = "non chiffré";
|
||||
"encryption.accessibility.secured" = "chiffré";
|
||||
"encryption.accessibility.verified" = "chiffré et vérifié";
|
||||
"encryption.status.establishing" = "mise en place du chiffrement...";
|
||||
"encryption.status.failed" = "chiffrement échoué";
|
||||
"encryption.status.not_encrypted" = "non chiffré";
|
||||
"encryption.status.secured" = "chiffré";
|
||||
"encryption.status.verified" = "chiffré et vérifié";
|
||||
"fingerprint.action.mark_verified" = "marquer comme vérifié";
|
||||
"fingerprint.action.remove_verification" = "retirer la vérification";
|
||||
"fingerprint.badge.not_verified" = "⚠️ NON VÉRIFIÉ";
|
||||
"fingerprint.badge.verified" = "✓ VÉRIFIÉ";
|
||||
"fingerprint.handshake_pending" = "indisponible - handshake en cours";
|
||||
"fingerprint.message.verified" = "tu as vérifié l'identité de cette personne.";
|
||||
"fingerprint.message.verify_hint" = "compare ces empreintes avec %@ via un canal sécurisé.";
|
||||
"fingerprint.their_label" = "leur empreinte :";
|
||||
"fingerprint.title" = "vérification de sécurité";
|
||||
"fingerprint.your_label" = "ton empreinte :";
|
||||
"geohash_people.action.block" = "bloquer";
|
||||
"geohash_people.action.unblock" = "débloquer";
|
||||
"geohash_people.none_nearby" = "personne à proximité...";
|
||||
"geohash_people.tooltip.blocked" = "bloqué dans geohash";
|
||||
"geohash_people.you_suffix" = " (toi)";
|
||||
"location_channels.action.open_settings" = "ouvrir réglages";
|
||||
"location_channels.action.remove_access" = "retirer l'accès localisation";
|
||||
"location_channels.action.request_permissions" = "obtenir ma localisation et mes geohash";
|
||||
"location_channels.action.teleport" = "téléporter";
|
||||
"location_channels.bookmarked_section_title" = "enregistrés";
|
||||
"location_channels.description" = "discute avec les personnes proches grâce aux canaux geohash. seul un geohash grossier est partagé, jamais de gps exact. ton ip reste cachée car tout le trafic passe par tor.";
|
||||
"location_channels.error.invalid_geohash" = "geohash invalide";
|
||||
"location_channels.loading_nearby" = "recherche de canaux proches…";
|
||||
"location_channels.mesh_label" = "mesh";
|
||||
"location_channels.permission_denied" = "autorisation de localisation refusée. active-la dans réglages pour utiliser les canaux.";
|
||||
"location_channels.subtitle_prefix" = "#%@ • %@";
|
||||
"location_channels.subtitle_with_name" = "%1$@ • %2$@";
|
||||
"location_channels.title" = "#canaux localisation";
|
||||
"location_channels.tor.subtitle" = "cache ton ip pour les canaux localisation. recommandé : activé.";
|
||||
"location_channels.tor.title" = "routage tor";
|
||||
"location_levels.block" = "bloc";
|
||||
"location_levels.building" = "bâtiment";
|
||||
"location_levels.city" = "ville";
|
||||
"location_levels.neighborhood" = "quartier";
|
||||
"location_levels.province" = "province";
|
||||
"location_levels.region" = "région";
|
||||
"location_notes.action.dismiss" = "ignorer";
|
||||
"location_notes.action.retry" = "réessayer";
|
||||
"location_notes.description" = "ajoute de courtes notes permanentes ici pour aider les autres.";
|
||||
"location_notes.empty_subtitle" = "sois la première personne à en ajouter ici.";
|
||||
"location_notes.empty_title" = "pas encore de notes";
|
||||
"location_notes.error.failed_to_send" = "impossible d'envoyer la note. %@";
|
||||
"location_notes.error.no_relays" = "aucun relais géo disponible près d'ici. réessaie bientôt.";
|
||||
"location_notes.loading_notes" = "chargement des notes…";
|
||||
"location_notes.loading_recent" = "chargement des notes récentes…";
|
||||
"location_notes.no_relays_nearby" = "aucun relais géo à proximité";
|
||||
"location_notes.placeholder" = "ajoute une note pour cet endroit";
|
||||
"location_notes.relays_paused" = "relais géo indisponibles ; notes en pause";
|
||||
"location_notes.relays_retry_hint" = "les notes dépendent des relais géo. vérifie la connexion et réessaie.";
|
||||
"mesh_peers.tooltip.new_messages" = "nouveaux messages";
|
||||
"system.chat.blocked" = "impossible de démarrer un chat avec %@ : utilisateur bloqué.";
|
||||
"system.chat.requires_favorite" = "impossible de démarrer un chat avec %@ : favoris mutuels requis pour le hors ligne.";
|
||||
"system.common.user" = "utilisateur";
|
||||
"system.dm.blocked_generic" = "envoi impossible : utilisateur bloqué.";
|
||||
"system.dm.blocked_recipient" = "impossible d'envoyer à %@ : utilisateur bloqué.";
|
||||
"system.dm.unreachable" = "impossible d'envoyer à %@ : destinataire injoignable via mesh ou nostr.";
|
||||
"system.geohash.blocked" = "%@ a été bloqué dans les chats geohash";
|
||||
"system.geohash.unblocked" = "%@ a été débloqué dans les chats geohash";
|
||||
"system.location.not_in_channel" = "envoi impossible : tu n'es pas dans un canal localisation";
|
||||
"system.location.send_failed" = "envoi au canal localisation impossible";
|
||||
"system.tor.dev_bypass" = "build de développement : bypass tor actif.";
|
||||
"system.tor.restarted" = "tor a redémarré. routage restauré.";
|
||||
"system.tor.restarting" = "tor redémarre pour rétablir la connectivité...";
|
||||
"system.tor.started" = "tor a démarré. tout le chat passe par tor pour la confidentialité.";
|
||||
"system.tor.starting" = "lancement de tor...";
|
||||
"verification.my_qr.accessibility_label" = "code qr de vérification";
|
||||
"verification.my_qr.title" = "scanne pour me vérifier";
|
||||
"verification.my_qr.unavailable" = "qr indisponible";
|
||||
"verification.scan.paste_prompt" = "colle le contenu du qr pour valider :";
|
||||
"verification.scan.prompt_friend" = "scanne le qr d'un ami";
|
||||
"verification.scan.status.invalid" = "qr invalide ou expiré";
|
||||
"verification.scan.status.no_peer" = "aucun pair correspondant trouvé";
|
||||
"verification.scan.status.requested" = "vérification demandée pour %@";
|
||||
"verification.scan.validate" = "valider";
|
||||
"verification.sheet.title" = "VÉRIFIER";
|
||||
@@ -1,54 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>location_notes.header</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>#%@ • %#@note_count@</string>
|
||||
<key>note_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d note</string>
|
||||
<key>other</key>
|
||||
<string>%d notes</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>location_channels.row_title</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%@ [%#@people_count@]</string>
|
||||
<key>people_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d personne</string>
|
||||
<key>other</key>
|
||||
<string>%d personnes</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>content.accessibility.people_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@people@</string>
|
||||
<key>people</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d personne</string>
|
||||
<key>other</key>
|
||||
<string>%d personnes</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,190 +0,0 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
bitchat (Hebrew)
|
||||
*/
|
||||
|
||||
"app_info.app_name" = "bitchat";
|
||||
"app_info.close" = "סגור";
|
||||
"app_info.done" = "בוצע";
|
||||
"app_info.features.encryption.description" = "הודעות פרטיות מוצפנות בפרוטוקול noise";
|
||||
"app_info.features.encryption.title" = "הצפנה מקצה לקצה";
|
||||
"app_info.features.extended_range.description" = "הודעות משודרות בין עמיתים ומגיעות רחוק יותר";
|
||||
"app_info.features.extended_range.title" = "טווח מורחב";
|
||||
"app_info.features.favorites.description" = "קבל התראות כשהאנשים המועדפים שלך מצטרפים";
|
||||
"app_info.features.favorites.title" = "מועדפים";
|
||||
"app_info.features.geohash.description" = "ערוצי geohash לשיחה עם אנשים קרובים דרך ממסרים אנונימיים מבוזרים";
|
||||
"app_info.features.geohash.title" = "ערוצים מקומיים";
|
||||
"app_info.features.mentions.description" = "השתמש ב-@nickname כדי להתריע לאנשים ספציפיים";
|
||||
"app_info.features.mentions.title" = "אזכורים";
|
||||
"app_info.features.offline.description" = "עובד בלי אינטרנט באמצעות bluetooth בתצריכת אנרגיה נמוכה";
|
||||
"app_info.features.offline.title" = "תקשורת לא מקוונת";
|
||||
"app_info.features.title" = "יכולות";
|
||||
"app_info.how_to_use.change_channels" = "• הקש על #mesh כדי להחליף ערוץ";
|
||||
"app_info.how_to_use.clear_chat" = "• הקש שלוש פעמים על הצ'אט כדי לנקות";
|
||||
"app_info.how_to_use.commands" = "• הקלד / כדי לראות פקודות";
|
||||
"app_info.how_to_use.open_sidebar" = "• הקש על אייקון האנשים כדי לפתוח סרגל צד";
|
||||
"app_info.how_to_use.set_nickname" = "• הקש על הכינוי שלך כדי לעדכן";
|
||||
"app_info.how_to_use.start_dm" = "• הקש על שם עמית כדי להתחיל הודעה פרטית";
|
||||
"app_info.how_to_use.title" = "איך להשתמש";
|
||||
"app_info.privacy.ephemeral.description" = "מזהה עמית חדש נוצר באופן קבוע";
|
||||
"app_info.privacy.ephemeral.title" = "זהות זמנית";
|
||||
"app_info.privacy.no_tracking.description" = "אין שרתים, חשבונות או איסוף נתונים";
|
||||
"app_info.privacy.no_tracking.title" = "ללא מעקב";
|
||||
"app_info.privacy.panic.description" = "הקש על הלוגו שלוש פעמים למחיקת כל הנתונים מייד";
|
||||
"app_info.privacy.panic.title" = "מצב בהלה";
|
||||
"app_info.privacy.title" = "פרטיות";
|
||||
"app_info.tagline" = "sidegroupchat";
|
||||
"app_info.warning.message" = "אבטחת ההודעות הפרטיות עדיין לא נבדקה במלואה. אל תשתמש למצבים קריטיים עד שהאזהרה תיעלם.";
|
||||
"app_info.warning.title" = "אזהרה";
|
||||
"common.cancel" = "ביטול";
|
||||
"common.close" = "סגור";
|
||||
"common.copy" = "העתק";
|
||||
"common.ok" = "OK";
|
||||
"common.toggle.off" = "כבוי";
|
||||
"common.toggle.on" = "פעיל";
|
||||
"common.unknown" = "לא ידוע";
|
||||
"content.accessibility.add_favorite" = "הוסף למועדפים";
|
||||
"content.accessibility.available_nostr" = "זמין דרך nostr";
|
||||
"content.accessibility.back_to_main_chat" = "חזרה לצ'אט הראשי";
|
||||
"content.accessibility.connected_mesh" = "מחובר דרך mesh";
|
||||
"content.accessibility.encryption_status" = "מצב הצפנה: %@";
|
||||
"content.accessibility.location_channels" = "ערוצי מיקום";
|
||||
"content.accessibility.location_notes" = "הערות מיקום למקום הזה";
|
||||
"content.accessibility.open_unread_private_chat" = "פתח צ'אט פרטי שלא נקרא";
|
||||
"content.accessibility.private_chat_header" = "צ'אט פרטי עם %@";
|
||||
"content.accessibility.reachable_mesh" = "זמין דרך mesh";
|
||||
"content.accessibility.remove_favorite" = "הסר מהמועדפים";
|
||||
"content.accessibility.send_hint_empty" = "הזן הודעה לשליחה";
|
||||
"content.accessibility.send_hint_ready" = "הקש פעמיים לשליחה";
|
||||
"content.accessibility.send_message" = "שלח הודעה";
|
||||
"content.accessibility.toggle_bookmark" = "החלף סימנייה עבור #%@";
|
||||
"content.accessibility.toggle_favorite_hint" = "הקש פעמיים כדי להחליף מצב מועדפים";
|
||||
"content.accessibility.view_fingerprint_hint" = "הקש להצגת טביעת ההצפנה";
|
||||
"content.actions.block" = "חסום";
|
||||
"content.actions.direct_message" = "הודעה ישירה";
|
||||
"content.actions.hug" = "חיבוק";
|
||||
"content.actions.mention" = "אזכור";
|
||||
"content.actions.slap" = "סטירה";
|
||||
"content.actions.title" = "פעולות";
|
||||
"content.alert.bluetooth_required.off" = "bluetooth כבוי. הפעל bluetooth בהגדרות כדי להשתמש ב-bitchat.";
|
||||
"content.alert.bluetooth_required.permission" = "bitchat צריכה הרשאת bluetooth כדי להתחבר למכשירים קרובים. אפשר גישה בהגדרות.";
|
||||
"content.alert.bluetooth_required.settings" = "הגדרות";
|
||||
"content.alert.bluetooth_required.title" = "נדרש bluetooth";
|
||||
"content.alert.bluetooth_required.unsupported" = "המכשיר הזה לא תומך ב-bluetooth. bitchat זקוקה ל-bluetooth כדי לעבוד.";
|
||||
"content.alert.screenshot.message" = "צילומי מסך של ערוצי מיקום יחשפו את מיקומך. חשב לפני שיתוף פומבי.";
|
||||
"content.alert.screenshot.title" = "שים לב";
|
||||
"content.commands.block" = "חסום או הצג עמיתים חסומים";
|
||||
"content.commands.clear" = "נקה הודעות צ'אט";
|
||||
"content.commands.favorite" = "הוסף למועדפים";
|
||||
"content.commands.hug" = "שלח חיבוק חם";
|
||||
"content.commands.message" = "שלח הודעה פרטית";
|
||||
"content.commands.slap" = "תן למישהו סטירת פורל";
|
||||
"content.commands.unblock" = "בטל חסימה לעמית";
|
||||
"content.commands.unfavorite" = "הסר מהמועדפים";
|
||||
"content.commands.who" = "ראה מי מחובר";
|
||||
"content.delivery.delivered_members" = "נמסר ל-%1$d מתוך %2$d חברים";
|
||||
"content.delivery.delivered_to" = "נמסר ל-%@";
|
||||
"content.delivery.failed" = "נכשל: %@";
|
||||
"content.delivery.read_by" = "נקרא על ידי %@";
|
||||
"content.delivery.reason.blocked" = "המשתמש חסום";
|
||||
"content.delivery.reason.self" = "אי אפשר לשלוח לעצמך";
|
||||
"content.delivery.reason.send_error" = "שגיאת שליחה";
|
||||
"content.delivery.reason.unknown_recipient" = "נמען לא ידוע";
|
||||
"content.delivery.reason.unreachable" = "עמית לא זמין";
|
||||
"content.header.people" = "אנשים";
|
||||
"content.help.verification" = "אימות: הצג את ה-qr שלי או סרוק חבר";
|
||||
"content.input.message_placeholder" = "כתוב הודעה...";
|
||||
"content.input.nickname_placeholder" = "כינוי";
|
||||
"content.location.enable" = "הפעל מיקום";
|
||||
"content.message.copy" = "העתק הודעה";
|
||||
"content.message.show_less" = "הצג פחות";
|
||||
"content.message.show_more" = "הצג עוד";
|
||||
"content.notes.location_unavailable" = "המיקום לא זמין";
|
||||
"content.notes.title" = "הערות";
|
||||
"content.payment.cashu" = "תשלום דרך cashu";
|
||||
"content.payment.lightning" = "תשלום דרך lightning";
|
||||
"encryption.accessibility.establishing" = "הצפנה בהקמה";
|
||||
"encryption.accessibility.failed" = "הצפנה נכשלה";
|
||||
"encryption.accessibility.not_encrypted" = "לא מוצפן";
|
||||
"encryption.accessibility.secured" = "מוצפן";
|
||||
"encryption.accessibility.verified" = "מוצפן ומאומת";
|
||||
"encryption.status.establishing" = "מקימים הצפנה...";
|
||||
"encryption.status.failed" = "הצפנה נכשלה";
|
||||
"encryption.status.not_encrypted" = "לא מוצפן";
|
||||
"encryption.status.secured" = "מוצפן";
|
||||
"encryption.status.verified" = "מוצפן ומאומת";
|
||||
"fingerprint.action.mark_verified" = "סמן כמאומת";
|
||||
"fingerprint.action.remove_verification" = "הסר אימות";
|
||||
"fingerprint.badge.not_verified" = "⚠️ לא מאומת";
|
||||
"fingerprint.badge.verified" = "✓ מאומת";
|
||||
"fingerprint.handshake_pending" = "לא זמין - handshake מתבצע";
|
||||
"fingerprint.message.verified" = "אישרת את זהותו של האדם הזה.";
|
||||
"fingerprint.message.verify_hint" = "השווה את הטביעות עם %@ בערוץ מאובטח.";
|
||||
"fingerprint.their_label" = "הטבעת שלהם:";
|
||||
"fingerprint.title" = "אימות אבטחה";
|
||||
"fingerprint.your_label" = "הטבעת שלך:";
|
||||
"geohash_people.action.block" = "חסום";
|
||||
"geohash_people.action.unblock" = "בטל חסימה";
|
||||
"geohash_people.none_nearby" = "אין אף אחד בסביבה...";
|
||||
"geohash_people.tooltip.blocked" = "חסום ב-geohash";
|
||||
"geohash_people.you_suffix" = " (אתה)";
|
||||
"location_channels.action.open_settings" = "פתח הגדרות";
|
||||
"location_channels.action.remove_access" = "הסר גישת מיקום";
|
||||
"location_channels.action.request_permissions" = "קבל את המיקום וה-geohash שלי";
|
||||
"location_channels.action.teleport" = "טלפורט";
|
||||
"location_channels.bookmarked_section_title" = "שמורים";
|
||||
"location_channels.description" = "שוחח עם אנשים קרובים בערוצי geohash. משתף רק geohash גס, אף פעם לא gps מדויק. כתובת ה-ip מוסתרת כי כל התעבורה עוברת דרך tor.";
|
||||
"location_channels.error.invalid_geohash" = "geohash לא תקף";
|
||||
"location_channels.loading_nearby" = "מחפש ערוצים קרובים…";
|
||||
"location_channels.mesh_label" = "mesh";
|
||||
"location_channels.permission_denied" = "הרשאת מיקום נדחתה. אפשר בהגדרות כדי להשתמש בערוצי מיקום.";
|
||||
"location_channels.subtitle_prefix" = "#%@ • %@";
|
||||
"location_channels.subtitle_with_name" = "%1$@ • %2$@";
|
||||
"location_channels.title" = "#ערוצי מיקום";
|
||||
"location_channels.tor.subtitle" = "מסתיר את ה-ip שלך לערוצי מיקום. מומלץ: פעיל.";
|
||||
"location_channels.tor.title" = "ניתוב tor";
|
||||
"location_levels.block" = "בלוק";
|
||||
"location_levels.building" = "מבנה";
|
||||
"location_levels.city" = "עיר";
|
||||
"location_levels.neighborhood" = "שכונה";
|
||||
"location_levels.province" = "מחוז";
|
||||
"location_levels.region" = "אזור";
|
||||
"location_notes.action.dismiss" = "סגור";
|
||||
"location_notes.action.retry" = "ניסיון שוב";
|
||||
"location_notes.description" = "הוסף הערות קצרות וקבועות למקום הזה כדי שאחרים ימצאו.";
|
||||
"location_notes.empty_subtitle" = "היה הראשון להוסיף כאן.";
|
||||
"location_notes.empty_title" = "אין הערות עדיין";
|
||||
"location_notes.error.failed_to_send" = "לא ניתן לשלוח את ההערה. %@";
|
||||
"location_notes.error.no_relays" = "אין ממסרי geo זמינים בקרבת מקום. נסה שוב מאוחר יותר.";
|
||||
"location_notes.loading_notes" = "טוען הערות…";
|
||||
"location_notes.loading_recent" = "טוען הערות אחרונות…";
|
||||
"location_notes.no_relays_nearby" = "אין ממסרי geo קרובים";
|
||||
"location_notes.placeholder" = "הוסף הערה למקום הזה";
|
||||
"location_notes.relays_paused" = "ממסרי geo אינם זמינים; הערות הושהו";
|
||||
"location_notes.relays_retry_hint" = "הערות תלויות בממסרי geo. בדוק את החיבור ונסה שוב.";
|
||||
"mesh_peers.tooltip.new_messages" = "הודעות חדשות";
|
||||
"system.chat.blocked" = "לא ניתן להתחיל צ'אט עם %@: המשתמש חסום.";
|
||||
"system.chat.requires_favorite" = "לא ניתן להתחיל צ'אט עם %@: נדרשים מועדפים הדדיים לאופליין.";
|
||||
"system.common.user" = "משתמש";
|
||||
"system.dm.blocked_generic" = "אי אפשר לשלוח: המשתמש חסום.";
|
||||
"system.dm.blocked_recipient" = "אי אפשר לשלוח ל-%@: המשתמש חסום.";
|
||||
"system.dm.unreachable" = "אי אפשר לשלוח ל-%@: הנמען אינו נגיש דרך mesh או nostr.";
|
||||
"system.geohash.blocked" = "%@ נחסם בצ'אטי geohash";
|
||||
"system.geohash.unblocked" = "%@ הוסר מהחסימה בצ'אטי geohash";
|
||||
"system.location.not_in_channel" = "אי אפשר לשלוח: אינך בערוץ מיקום";
|
||||
"system.location.send_failed" = "השליחה לערוץ המיקום נכשלה";
|
||||
"system.tor.dev_bypass" = "בנייה לפיתוח: עקיפת tor פעילה.";
|
||||
"system.tor.restarted" = "tor הופעל מחדש. הניתוב שוחזר.";
|
||||
"system.tor.restarting" = "tor מופעל מחדש להשבת החיבור...";
|
||||
"system.tor.started" = "tor פעיל. כל הצ'אט עובר דרך tor לפרטיות.";
|
||||
"system.tor.starting" = "tor מופעל...";
|
||||
"verification.my_qr.accessibility_label" = "קוד qr לאימות";
|
||||
"verification.my_qr.title" = "סרוק כדי לאמת";
|
||||
"verification.my_qr.unavailable" = "qr לא זמין";
|
||||
"verification.scan.paste_prompt" = "הדבק תוכן qr לאימות:";
|
||||
"verification.scan.prompt_friend" = "סרוק qr של חבר";
|
||||
"verification.scan.status.invalid" = "qr לא תקף או שפג תוקפו";
|
||||
"verification.scan.status.no_peer" = "לא נמצא עמית תואם";
|
||||
"verification.scan.status.requested" = "התבקש אימות עבור %@";
|
||||
"verification.scan.validate" = "אשר";
|
||||
"verification.sheet.title" = "אימות";
|
||||
@@ -1,66 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>location_notes.header</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>#%@ • %#@note_count@</string>
|
||||
<key>note_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d הערה</string>
|
||||
<key>two</key>
|
||||
<string>%d הערות</string>
|
||||
<key>many</key>
|
||||
<string>%d הערות</string>
|
||||
<key>other</key>
|
||||
<string>%d הערות</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>location_channels.row_title</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%@ [%#@people_count@]</string>
|
||||
<key>people_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d אדם</string>
|
||||
<key>two</key>
|
||||
<string>%d אנשים</string>
|
||||
<key>many</key>
|
||||
<string>%d אנשים</string>
|
||||
<key>other</key>
|
||||
<string>%d אנשים</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>content.accessibility.people_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@people@</string>
|
||||
<key>people</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d אדם</string>
|
||||
<key>two</key>
|
||||
<string>%d אנשים</string>
|
||||
<key>many</key>
|
||||
<string>%d אנשים</string>
|
||||
<key>other</key>
|
||||
<string>%d אנשים</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,190 +0,0 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
bitchat (Indonesian)
|
||||
*/
|
||||
|
||||
"app_info.app_name" = "bitchat";
|
||||
"app_info.close" = "tutup";
|
||||
"app_info.done" = "SELESAI";
|
||||
"app_info.features.encryption.description" = "pesan pribadi dienkripsi dengan protokol noise";
|
||||
"app_info.features.encryption.title" = "enkripsi ujung ke ujung";
|
||||
"app_info.features.extended_range.description" = "pesan diteruskan antar peer sehingga jangkauannya lebih jauh";
|
||||
"app_info.features.extended_range.title" = "jangkauan diperluas";
|
||||
"app_info.features.favorites.description" = "dapatkan notifikasi saat orang favoritmu bergabung";
|
||||
"app_info.features.favorites.title" = "favorit";
|
||||
"app_info.features.geohash.description" = "kanal geohash untuk ngobrol dengan orang di wilayah sekitar lewat relay anonim terdesentralisasi";
|
||||
"app_info.features.geohash.title" = "kanal lokal";
|
||||
"app_info.features.mentions.description" = "pakai @nickname untuk memberi tahu orang tertentu";
|
||||
"app_info.features.mentions.title" = "mention";
|
||||
"app_info.features.offline.description" = "bekerja tanpa internet memakai bluetooth low energy";
|
||||
"app_info.features.offline.title" = "komunikasi offline";
|
||||
"app_info.features.title" = "FITUR";
|
||||
"app_info.how_to_use.change_channels" = "• ketuk #mesh untuk ganti kanal";
|
||||
"app_info.how_to_use.clear_chat" = "• ketuk chat tiga kali untuk menghapus";
|
||||
"app_info.how_to_use.commands" = "• ketik / untuk melihat perintah";
|
||||
"app_info.how_to_use.open_sidebar" = "• ketuk ikon orang untuk membuka sidebar";
|
||||
"app_info.how_to_use.set_nickname" = "• atur nama panggilanmu dengan mengetuknya";
|
||||
"app_info.how_to_use.start_dm" = "• ketuk nama peer untuk mulai dm";
|
||||
"app_info.how_to_use.title" = "CARA PAKAI";
|
||||
"app_info.privacy.ephemeral.description" = "id peer baru dibuat secara berkala";
|
||||
"app_info.privacy.ephemeral.title" = "identitas sementara";
|
||||
"app_info.privacy.no_tracking.description" = "tanpa server, akun, atau pengumpulan data";
|
||||
"app_info.privacy.no_tracking.title" = "tanpa pelacakan";
|
||||
"app_info.privacy.panic.description" = "ketuk logo tiga kali untuk langsung menghapus semua data";
|
||||
"app_info.privacy.panic.title" = "mode panik";
|
||||
"app_info.privacy.title" = "PRIVASI";
|
||||
"app_info.tagline" = "sidegroupchat";
|
||||
"app_info.warning.message" = "keamanan pesan pribadi belum diaudit sepenuhnya. jangan dipakai untuk situasi kritis sampai peringatan ini hilang.";
|
||||
"app_info.warning.title" = "PERINGATAN";
|
||||
"common.cancel" = "batal";
|
||||
"common.close" = "tutup";
|
||||
"common.copy" = "salin";
|
||||
"common.ok" = "OK";
|
||||
"common.toggle.off" = "mati";
|
||||
"common.toggle.on" = "nyala";
|
||||
"common.unknown" = "tidak diketahui";
|
||||
"content.accessibility.add_favorite" = "tambah ke favorit";
|
||||
"content.accessibility.available_nostr" = "tersedia melalui nostr";
|
||||
"content.accessibility.back_to_main_chat" = "kembali ke chat utama";
|
||||
"content.accessibility.connected_mesh" = "terhubung lewat mesh";
|
||||
"content.accessibility.encryption_status" = "status enkripsi: %@";
|
||||
"content.accessibility.location_channels" = "kanal lokasi";
|
||||
"content.accessibility.location_notes" = "catatan lokasi untuk tempat ini";
|
||||
"content.accessibility.open_unread_private_chat" = "buka chat pribadi belum dibaca";
|
||||
"content.accessibility.private_chat_header" = "chat pribadi dengan %@";
|
||||
"content.accessibility.reachable_mesh" = "dapat dijangkau lewat mesh";
|
||||
"content.accessibility.remove_favorite" = "hapus dari favorit";
|
||||
"content.accessibility.send_hint_empty" = "masukkan pesan untuk dikirim";
|
||||
"content.accessibility.send_hint_ready" = "ketuk dua kali untuk mengirim";
|
||||
"content.accessibility.send_message" = "kirim pesan";
|
||||
"content.accessibility.toggle_bookmark" = "ubah penanda untuk #%@";
|
||||
"content.accessibility.toggle_favorite_hint" = "ketuk dua kali untuk mengubah status favorit";
|
||||
"content.accessibility.view_fingerprint_hint" = "ketuk untuk melihat sidik enkripsi";
|
||||
"content.actions.block" = "blokir";
|
||||
"content.actions.direct_message" = "pesan langsung";
|
||||
"content.actions.hug" = "peluk";
|
||||
"content.actions.mention" = "sebut";
|
||||
"content.actions.slap" = "tampar";
|
||||
"content.actions.title" = "aksi";
|
||||
"content.alert.bluetooth_required.off" = "bluetooth dimatikan. aktifkan bluetooth di pengaturan untuk memakai bitchat.";
|
||||
"content.alert.bluetooth_required.permission" = "bitchat memerlukan izin bluetooth untuk terhubung dengan perangkat dekat. aktifkan akses di pengaturan.";
|
||||
"content.alert.bluetooth_required.settings" = "pengaturan";
|
||||
"content.alert.bluetooth_required.title" = "butuh bluetooth";
|
||||
"content.alert.bluetooth_required.unsupported" = "perangkat ini tidak mendukung bluetooth. bitchat memerlukan bluetooth untuk berjalan.";
|
||||
"content.alert.screenshot.message" = "tangkapan layar kanal lokasi akan mengungkap lokasimu. pikirkan dulu sebelum membagikannya.";
|
||||
"content.alert.screenshot.title" = "perhatian";
|
||||
"content.commands.block" = "blokir atau lihat peer yang diblokir";
|
||||
"content.commands.clear" = "hapus pesan chat";
|
||||
"content.commands.favorite" = "tambah ke favorit";
|
||||
"content.commands.hug" = "kirim pelukan hangat";
|
||||
"content.commands.message" = "kirim pesan pribadi";
|
||||
"content.commands.slap" = "tampar seseorang dengan ikan trout";
|
||||
"content.commands.unblock" = "buka blokir peer";
|
||||
"content.commands.unfavorite" = "hapus dari favorit";
|
||||
"content.commands.who" = "lihat siapa yang online";
|
||||
"content.delivery.delivered_members" = "terkirim ke %1$d dari %2$d anggota";
|
||||
"content.delivery.delivered_to" = "terkirim ke %@";
|
||||
"content.delivery.failed" = "gagal: %@";
|
||||
"content.delivery.read_by" = "dibaca oleh %@";
|
||||
"content.delivery.reason.blocked" = "pengguna diblokir";
|
||||
"content.delivery.reason.self" = "tidak bisa kirim ke diri sendiri";
|
||||
"content.delivery.reason.send_error" = "kesalahan pengiriman";
|
||||
"content.delivery.reason.unknown_recipient" = "penerima tidak dikenal";
|
||||
"content.delivery.reason.unreachable" = "peer tidak dapat dijangkau";
|
||||
"content.header.people" = "ORANG";
|
||||
"content.help.verification" = "verifikasi: tampilkan qr-ku atau pindai teman";
|
||||
"content.input.message_placeholder" = "ketik pesan...";
|
||||
"content.input.nickname_placeholder" = "nama panggilan";
|
||||
"content.location.enable" = "aktifkan lokasi";
|
||||
"content.message.copy" = "salin pesan";
|
||||
"content.message.show_less" = "tampilkan lebih sedikit";
|
||||
"content.message.show_more" = "tampilkan lebih banyak";
|
||||
"content.notes.location_unavailable" = "lokasi tidak tersedia";
|
||||
"content.notes.title" = "catatan";
|
||||
"content.payment.cashu" = "bayar via cashu";
|
||||
"content.payment.lightning" = "bayar via lightning";
|
||||
"encryption.accessibility.establishing" = "menyiapkan enkripsi";
|
||||
"encryption.accessibility.failed" = "enkripsi gagal";
|
||||
"encryption.accessibility.not_encrypted" = "tidak terenkripsi";
|
||||
"encryption.accessibility.secured" = "terenkripsi";
|
||||
"encryption.accessibility.verified" = "terenkripsi dan terverifikasi";
|
||||
"encryption.status.establishing" = "menyiapkan enkripsi...";
|
||||
"encryption.status.failed" = "enkripsi gagal";
|
||||
"encryption.status.not_encrypted" = "tidak terenkripsi";
|
||||
"encryption.status.secured" = "terenkripsi";
|
||||
"encryption.status.verified" = "terenkripsi dan terverifikasi";
|
||||
"fingerprint.action.mark_verified" = "tandai sebagai terverifikasi";
|
||||
"fingerprint.action.remove_verification" = "hapus verifikasi";
|
||||
"fingerprint.badge.not_verified" = "⚠️ BELUM TERVERIFIKASI";
|
||||
"fingerprint.badge.verified" = "✓ TERVERIFIKASI";
|
||||
"fingerprint.handshake_pending" = "tidak tersedia - handshake sedang berlangsung";
|
||||
"fingerprint.message.verified" = "kamu sudah memverifikasi identitas orang ini.";
|
||||
"fingerprint.message.verify_hint" = "bandingkan sidik ini dengan %@ lewat kanal aman.";
|
||||
"fingerprint.their_label" = "sidik mereka:";
|
||||
"fingerprint.title" = "verifikasi keamanan";
|
||||
"fingerprint.your_label" = "sidikmu:";
|
||||
"geohash_people.action.block" = "blokir";
|
||||
"geohash_people.action.unblock" = "buka blokir";
|
||||
"geohash_people.none_nearby" = "tidak ada siapa pun...";
|
||||
"geohash_people.tooltip.blocked" = "diblokir di geohash";
|
||||
"geohash_people.you_suffix" = " (kamu)";
|
||||
"location_channels.action.open_settings" = "buka pengaturan";
|
||||
"location_channels.action.remove_access" = "cabut akses lokasi";
|
||||
"location_channels.action.request_permissions" = "ambil lokasiku dan geohash";
|
||||
"location_channels.action.teleport" = "teleport";
|
||||
"location_channels.bookmarked_section_title" = "disimpan";
|
||||
"location_channels.description" = "ngobrol dengan orang terdekat lewat kanal geohash. hanya geohash kasar yang dibagikan, tidak pernah gps tepat. alamat ip-mu tersembunyi karena seluruh trafik lewat tor.";
|
||||
"location_channels.error.invalid_geohash" = "geohash tidak valid";
|
||||
"location_channels.loading_nearby" = "mencari kanal sekitar…";
|
||||
"location_channels.mesh_label" = "mesh";
|
||||
"location_channels.permission_denied" = "izin lokasi ditolak. aktifkan di pengaturan untuk memakai kanal lokasi.";
|
||||
"location_channels.subtitle_prefix" = "#%@ • %@";
|
||||
"location_channels.subtitle_with_name" = "%1$@ • %2$@";
|
||||
"location_channels.title" = "#kanal lokasi";
|
||||
"location_channels.tor.subtitle" = "menyembunyikan ip-mu untuk kanal lokasi. disarankan: aktif.";
|
||||
"location_channels.tor.title" = "perutean tor";
|
||||
"location_levels.block" = "blok";
|
||||
"location_levels.building" = "gedung";
|
||||
"location_levels.city" = "kota";
|
||||
"location_levels.neighborhood" = "lingkungan";
|
||||
"location_levels.province" = "provinsi";
|
||||
"location_levels.region" = "wilayah";
|
||||
"location_notes.action.dismiss" = "tutup";
|
||||
"location_notes.action.retry" = "coba lagi";
|
||||
"location_notes.description" = "tambahkan catatan permanen singkat di tempat ini agar orang lain menemukannya.";
|
||||
"location_notes.empty_subtitle" = "jadilah orang pertama yang menambahkannya di sini.";
|
||||
"location_notes.empty_title" = "belum ada catatan";
|
||||
"location_notes.error.failed_to_send" = "tidak bisa mengirim catatan. %@";
|
||||
"location_notes.error.no_relays" = "tidak ada relay geo tersedia dekat sini. coba lagi nanti.";
|
||||
"location_notes.loading_notes" = "memuat catatan…";
|
||||
"location_notes.loading_recent" = "memuat catatan terbaru…";
|
||||
"location_notes.no_relays_nearby" = "tidak ada relay geo di dekatmu";
|
||||
"location_notes.placeholder" = "tambahkan catatan untuk tempat ini";
|
||||
"location_notes.relays_paused" = "relay geo tidak tersedia; catatan dijeda";
|
||||
"location_notes.relays_retry_hint" = "catatan bergantung pada relay geo. cek koneksi lalu coba lagi.";
|
||||
"mesh_peers.tooltip.new_messages" = "pesan baru";
|
||||
"system.chat.blocked" = "tidak bisa mulai chat dengan %@: pengguna diblokir.";
|
||||
"system.chat.requires_favorite" = "tidak bisa mulai chat dengan %@: butuh favorit bersama untuk offline.";
|
||||
"system.common.user" = "pengguna";
|
||||
"system.dm.blocked_generic" = "tidak bisa mengirim: pengguna diblokir.";
|
||||
"system.dm.blocked_recipient" = "tidak bisa mengirim ke %@: pengguna diblokir.";
|
||||
"system.dm.unreachable" = "tidak bisa mengirim ke %@: penerima tidak dapat dijangkau lewat mesh atau nostr.";
|
||||
"system.geohash.blocked" = "%@ diblokir di chat geohash";
|
||||
"system.geohash.unblocked" = "%@ dibuka blokirnya di chat geohash";
|
||||
"system.location.not_in_channel" = "gagal mengirim: kamu tidak berada di kanal lokasi";
|
||||
"system.location.send_failed" = "gagal mengirim ke kanal lokasi";
|
||||
"system.tor.dev_bypass" = "build pengembangan: bypass tor aktif.";
|
||||
"system.tor.restarted" = "tor dimulai ulang. perutean dipulihkan.";
|
||||
"system.tor.restarting" = "tor sedang dimulai ulang untuk memulihkan konektivitas...";
|
||||
"system.tor.started" = "tor berjalan. seluruh chat dirutekan lewat tor demi privasi.";
|
||||
"system.tor.starting" = "menjalankan tor...";
|
||||
"verification.my_qr.accessibility_label" = "kode qr verifikasi";
|
||||
"verification.my_qr.title" = "pindai untuk verifikasi";
|
||||
"verification.my_qr.unavailable" = "qr tidak tersedia";
|
||||
"verification.scan.paste_prompt" = "tempel konten qr untuk validasi:";
|
||||
"verification.scan.prompt_friend" = "pindai qr teman";
|
||||
"verification.scan.status.invalid" = "qr tidak valid atau kedaluwarsa";
|
||||
"verification.scan.status.no_peer" = "tidak ada peer yang cocok";
|
||||
"verification.scan.status.requested" = "verifikasi diminta untuk %@";
|
||||
"verification.scan.validate" = "validasi";
|
||||
"verification.sheet.title" = "VERIFIKASI";
|
||||
@@ -1,54 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>location_notes.header</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>#%@ • %#@note_count@</string>
|
||||
<key>note_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d catatan</string>
|
||||
<key>other</key>
|
||||
<string>%d catatan</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>location_channels.row_title</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%@ [%#@people_count@]</string>
|
||||
<key>people_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d orang</string>
|
||||
<key>other</key>
|
||||
<string>%d orang</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>content.accessibility.people_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@people@</string>
|
||||
<key>people</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d orang</string>
|
||||
<key>other</key>
|
||||
<string>%d orang</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,190 +0,0 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
bitchat (Italian)
|
||||
*/
|
||||
|
||||
"app_info.app_name" = "bitchat";
|
||||
"app_info.close" = "chiudi";
|
||||
"app_info.done" = "FATTO";
|
||||
"app_info.features.encryption.description" = "messaggi privati cifrati con il protocollo noise";
|
||||
"app_info.features.encryption.title" = "crittografia end-to-end";
|
||||
"app_info.features.extended_range.description" = "i messaggi vengono inoltrati tra peer per arrivare più lontano";
|
||||
"app_info.features.extended_range.title" = "portata estesa";
|
||||
"app_info.features.favorites.description" = "ricevi avvisi quando entrano le tue persone preferite";
|
||||
"app_info.features.favorites.title" = "preferiti";
|
||||
"app_info.features.geohash.description" = "canali geohash per chattare con persone vicine tramite relay anonimi decentralizzati";
|
||||
"app_info.features.geohash.title" = "canali locali";
|
||||
"app_info.features.mentions.description" = "usa @nickname per avvisare persone specifiche";
|
||||
"app_info.features.mentions.title" = "menzioni";
|
||||
"app_info.features.offline.description" = "funziona senza internet usando bluetooth a basso consumo";
|
||||
"app_info.features.offline.title" = "comunicazione offline";
|
||||
"app_info.features.title" = "FUNZIONI";
|
||||
"app_info.how_to_use.change_channels" = "• tocca #mesh per cambiare canale";
|
||||
"app_info.how_to_use.clear_chat" = "• tocca tre volte la chat per svuotarla";
|
||||
"app_info.how_to_use.commands" = "• digita / per vedere i comandi";
|
||||
"app_info.how_to_use.open_sidebar" = "• tocca l'icona persone per aprire la barra laterale";
|
||||
"app_info.how_to_use.set_nickname" = "• imposta il tuo nickname toccandolo";
|
||||
"app_info.how_to_use.start_dm" = "• tocca il nome di un peer per avviare un dm";
|
||||
"app_info.how_to_use.title" = "COME SI USA";
|
||||
"app_info.privacy.ephemeral.description" = "nuovo id peer generato regolarmente";
|
||||
"app_info.privacy.ephemeral.title" = "identità effimera";
|
||||
"app_info.privacy.no_tracking.description" = "niente server, account o raccolta dati";
|
||||
"app_info.privacy.no_tracking.title" = "senza tracciamento";
|
||||
"app_info.privacy.panic.description" = "tocca il logo tre volte per cancellare subito tutti i dati";
|
||||
"app_info.privacy.panic.title" = "modalità panico";
|
||||
"app_info.privacy.title" = "PRIVACY";
|
||||
"app_info.tagline" = "sidegroupchat";
|
||||
"app_info.warning.message" = "la sicurezza dei messaggi privati non è stata ancora auditata completamente. non usarli in situazioni critiche finché questo avviso resta.";
|
||||
"app_info.warning.title" = "AVVISO";
|
||||
"common.cancel" = "annulla";
|
||||
"common.close" = "chiudi";
|
||||
"common.copy" = "copia";
|
||||
"common.ok" = "OK";
|
||||
"common.toggle.off" = "spento";
|
||||
"common.toggle.on" = "acceso";
|
||||
"common.unknown" = "sconosciuto";
|
||||
"content.accessibility.add_favorite" = "aggiungi ai preferiti";
|
||||
"content.accessibility.available_nostr" = "disponibile via nostr";
|
||||
"content.accessibility.back_to_main_chat" = "torna alla chat principale";
|
||||
"content.accessibility.connected_mesh" = "connesso tramite mesh";
|
||||
"content.accessibility.encryption_status" = "stato crittografia: %@";
|
||||
"content.accessibility.location_channels" = "canali posizione";
|
||||
"content.accessibility.location_notes" = "note di posizione per questo posto";
|
||||
"content.accessibility.open_unread_private_chat" = "apri chat privata non letta";
|
||||
"content.accessibility.private_chat_header" = "chat privata con %@";
|
||||
"content.accessibility.reachable_mesh" = "raggiungibile via mesh";
|
||||
"content.accessibility.remove_favorite" = "rimuovi dai preferiti";
|
||||
"content.accessibility.send_hint_empty" = "inserisci un messaggio da inviare";
|
||||
"content.accessibility.send_hint_ready" = "tocca due volte per inviare";
|
||||
"content.accessibility.send_message" = "invia messaggio";
|
||||
"content.accessibility.toggle_bookmark" = "cambia segnalibro per #%@";
|
||||
"content.accessibility.toggle_favorite_hint" = "tocca due volte per cambiare stato preferito";
|
||||
"content.accessibility.view_fingerprint_hint" = "tocca per vedere l'impronta di cifratura";
|
||||
"content.actions.block" = "blocca";
|
||||
"content.actions.direct_message" = "messaggio diretto";
|
||||
"content.actions.hug" = "abbraccia";
|
||||
"content.actions.mention" = "menziona";
|
||||
"content.actions.slap" = "schiaffo";
|
||||
"content.actions.title" = "azioni";
|
||||
"content.alert.bluetooth_required.off" = "bluetooth è disattivato. attiva bluetooth nelle impostazioni per usare bitchat.";
|
||||
"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";
|
||||
"content.alert.bluetooth_required.title" = "serve bluetooth";
|
||||
"content.alert.bluetooth_required.unsupported" = "questo dispositivo non supporta bluetooth. bitchat richiede bluetooth per funzionare.";
|
||||
"content.alert.screenshot.message" = "gli screenshot dei canali posizione rivelano la tua posizione. pensaci prima di condividerli.";
|
||||
"content.alert.screenshot.title" = "attenzione";
|
||||
"content.commands.block" = "blocca o mostra i peer bloccati";
|
||||
"content.commands.clear" = "svuota la chat";
|
||||
"content.commands.favorite" = "aggiungi ai preferiti";
|
||||
"content.commands.hug" = "invia un caldo abbraccio";
|
||||
"content.commands.message" = "invia messaggio privato";
|
||||
"content.commands.slap" = "schiaffeggia qualcuno con una trota";
|
||||
"content.commands.unblock" = "sblocca un peer";
|
||||
"content.commands.unfavorite" = "rimuovi dai preferiti";
|
||||
"content.commands.who" = "vedi chi è online";
|
||||
"content.delivery.delivered_members" = "consegnato a %1$d di %2$d membri";
|
||||
"content.delivery.delivered_to" = "consegnato a %@";
|
||||
"content.delivery.failed" = "non riuscito: %@";
|
||||
"content.delivery.read_by" = "letto da %@";
|
||||
"content.delivery.reason.blocked" = "utente bloccato";
|
||||
"content.delivery.reason.self" = "impossibile inviarti il messaggio";
|
||||
"content.delivery.reason.send_error" = "errore di invio";
|
||||
"content.delivery.reason.unknown_recipient" = "destinatario sconosciuto";
|
||||
"content.delivery.reason.unreachable" = "peer irraggiungibile";
|
||||
"content.header.people" = "PERSONE";
|
||||
"content.help.verification" = "verifica: mostra il mio qr o scansiona un amico";
|
||||
"content.input.message_placeholder" = "scrivi un messaggio...";
|
||||
"content.input.nickname_placeholder" = "nickname";
|
||||
"content.location.enable" = "attiva posizione";
|
||||
"content.message.copy" = "copia messaggio";
|
||||
"content.message.show_less" = "mostra meno";
|
||||
"content.message.show_more" = "mostra di più";
|
||||
"content.notes.location_unavailable" = "posizione non disponibile";
|
||||
"content.notes.title" = "note";
|
||||
"content.payment.cashu" = "paga con cashu";
|
||||
"content.payment.lightning" = "paga con lightning";
|
||||
"encryption.accessibility.establishing" = "avvio crittografia";
|
||||
"encryption.accessibility.failed" = "crittografia fallita";
|
||||
"encryption.accessibility.not_encrypted" = "non crittografato";
|
||||
"encryption.accessibility.secured" = "crittografato";
|
||||
"encryption.accessibility.verified" = "crittografato e verificato";
|
||||
"encryption.status.establishing" = "avvio della crittografia...";
|
||||
"encryption.status.failed" = "crittografia fallita";
|
||||
"encryption.status.not_encrypted" = "non crittografato";
|
||||
"encryption.status.secured" = "crittografato";
|
||||
"encryption.status.verified" = "crittografato e verificato";
|
||||
"fingerprint.action.mark_verified" = "segna come verificato";
|
||||
"fingerprint.action.remove_verification" = "rimuovi verifica";
|
||||
"fingerprint.badge.not_verified" = "⚠️ NON VERIFICATO";
|
||||
"fingerprint.badge.verified" = "✓ VERIFICATO";
|
||||
"fingerprint.handshake_pending" = "non disponibile - handshake in corso";
|
||||
"fingerprint.message.verified" = "hai verificato l'identità di questa persona.";
|
||||
"fingerprint.message.verify_hint" = "confronta queste impronte con %@ tramite un canale sicuro.";
|
||||
"fingerprint.their_label" = "impronta loro:";
|
||||
"fingerprint.title" = "verifica di sicurezza";
|
||||
"fingerprint.your_label" = "tua impronta:";
|
||||
"geohash_people.action.block" = "blocca";
|
||||
"geohash_people.action.unblock" = "sblocca";
|
||||
"geohash_people.none_nearby" = "nessuno nei dintorni...";
|
||||
"geohash_people.tooltip.blocked" = "bloccato su geohash";
|
||||
"geohash_people.you_suffix" = " (tu)";
|
||||
"location_channels.action.open_settings" = "apri impostazioni";
|
||||
"location_channels.action.remove_access" = "revoca accesso alla posizione";
|
||||
"location_channels.action.request_permissions" = "ottieni la mia posizione e i geohash";
|
||||
"location_channels.action.teleport" = "teletrasporto";
|
||||
"location_channels.bookmarked_section_title" = "salvati";
|
||||
"location_channels.description" = "chatta con le persone vicine tramite canali geohash. condividiamo solo geohash approssimativi, mai il gps esatto. il tuo ip resta nascosto perché tutto il traffico passa da tor.";
|
||||
"location_channels.error.invalid_geohash" = "geohash non valido";
|
||||
"location_channels.loading_nearby" = "ricerca canali vicini…";
|
||||
"location_channels.mesh_label" = "mesh";
|
||||
"location_channels.permission_denied" = "autorizzazione posizione negata. abilitala nelle impostazioni per usare i canali.";
|
||||
"location_channels.subtitle_prefix" = "#%@ • %@";
|
||||
"location_channels.subtitle_with_name" = "%1$@ • %2$@";
|
||||
"location_channels.title" = "#canali posizione";
|
||||
"location_channels.tor.subtitle" = "nasconde il tuo ip per i canali posizione. consigliato: attivo.";
|
||||
"location_channels.tor.title" = "instradamento tor";
|
||||
"location_levels.block" = "isolato";
|
||||
"location_levels.building" = "edificio";
|
||||
"location_levels.city" = "città";
|
||||
"location_levels.neighborhood" = "quartiere";
|
||||
"location_levels.province" = "provincia";
|
||||
"location_levels.region" = "regione";
|
||||
"location_notes.action.dismiss" = "chiudi";
|
||||
"location_notes.action.retry" = "riprova";
|
||||
"location_notes.description" = "aggiungi brevi note permanenti su questo luogo per chi verrà.";
|
||||
"location_notes.empty_subtitle" = "fai tu la prima nota qui.";
|
||||
"location_notes.empty_title" = "ancora nessuna nota";
|
||||
"location_notes.error.failed_to_send" = "impossibile inviare la nota. %@";
|
||||
"location_notes.error.no_relays" = "nessun relay geo disponibile qui vicino. riprova presto.";
|
||||
"location_notes.loading_notes" = "caricamento note…";
|
||||
"location_notes.loading_recent" = "caricamento note recenti…";
|
||||
"location_notes.no_relays_nearby" = "nessun relay geo vicino";
|
||||
"location_notes.placeholder" = "aggiungi una nota per questo posto";
|
||||
"location_notes.relays_paused" = "relay geo non disponibili; note in pausa";
|
||||
"location_notes.relays_retry_hint" = "le note dipendono dai relay geo. controlla la connessione e riprova.";
|
||||
"mesh_peers.tooltip.new_messages" = "nuovi messaggi";
|
||||
"system.chat.blocked" = "impossibile avviare una chat con %@: utente bloccato.";
|
||||
"system.chat.requires_favorite" = "impossibile avviare una chat con %@: servono preferiti reciproci per l'offline.";
|
||||
"system.common.user" = "utente";
|
||||
"system.dm.blocked_generic" = "invio non riuscito: utente bloccato.";
|
||||
"system.dm.blocked_recipient" = "impossibile inviare a %@: utente bloccato.";
|
||||
"system.dm.unreachable" = "impossibile inviare a %@: destinatario irraggiungibile via mesh o nostr.";
|
||||
"system.geohash.blocked" = "%@ è stato bloccato nei chat geohash";
|
||||
"system.geohash.unblocked" = "%@ è stato sbloccato nei chat geohash";
|
||||
"system.location.not_in_channel" = "invio fallito: non sei in un canale posizione";
|
||||
"system.location.send_failed" = "impossibile inviare al canale posizione";
|
||||
"system.tor.dev_bypass" = "build di sviluppo: bypass tor attivo.";
|
||||
"system.tor.restarted" = "tor è stato riavviato. instradamento ripristinato.";
|
||||
"system.tor.restarting" = "tor si sta riavviando per ripristinare la connettività...";
|
||||
"system.tor.started" = "tor è avviato. tutta la chat passa da tor per la privacy.";
|
||||
"system.tor.starting" = "avvio tor...";
|
||||
"verification.my_qr.accessibility_label" = "codice qr di verifica";
|
||||
"verification.my_qr.title" = "scansiona per verificarmi";
|
||||
"verification.my_qr.unavailable" = "qr non disponibile";
|
||||
"verification.scan.paste_prompt" = "incolla il contenuto del qr per convalidare:";
|
||||
"verification.scan.prompt_friend" = "scansiona il qr di un amico";
|
||||
"verification.scan.status.invalid" = "qr non valido o scaduto";
|
||||
"verification.scan.status.no_peer" = "nessun peer corrispondente trovato";
|
||||
"verification.scan.status.requested" = "verifica richiesta per %@";
|
||||
"verification.scan.validate" = "convalida";
|
||||
"verification.sheet.title" = "VERIFICA";
|
||||
@@ -1,54 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>location_notes.header</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>#%@ • %#@note_count@</string>
|
||||
<key>note_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d nota</string>
|
||||
<key>other</key>
|
||||
<string>%d note</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>location_channels.row_title</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%@ [%#@people_count@]</string>
|
||||
<key>people_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d persona</string>
|
||||
<key>other</key>
|
||||
<string>%d persone</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>content.accessibility.people_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@people@</string>
|
||||
<key>people</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d persona</string>
|
||||
<key>other</key>
|
||||
<string>%d persone</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,190 +0,0 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
bitchat (Japanese)
|
||||
*/
|
||||
|
||||
"app_info.app_name" = "bitchat";
|
||||
"app_info.close" = "閉じる";
|
||||
"app_info.done" = "完了";
|
||||
"app_info.features.encryption.description" = "プライベートメッセージはnoiseプロトコルで暗号化されます";
|
||||
"app_info.features.encryption.title" = "エンドツーエンド暗号";
|
||||
"app_info.features.extended_range.description" = "メッセージはピア間でリレーされより遠くに届きます";
|
||||
"app_info.features.extended_range.title" = "拡張レンジ";
|
||||
"app_info.features.favorites.description" = "お気に入りの人が参加したら通知を受け取れます";
|
||||
"app_info.features.favorites.title" = "お気に入り";
|
||||
"app_info.features.geohash.description" = "geohashチャンネルで近くの人と匿名分散リレー越しにチャット";
|
||||
"app_info.features.geohash.title" = "ローカルチャンネル";
|
||||
"app_info.features.mentions.description" = "@nicknameで特定の人に通知";
|
||||
"app_info.features.mentions.title" = "メンション";
|
||||
"app_info.features.offline.description" = "bluetooth low energyでインターネットなしでも動作";
|
||||
"app_info.features.offline.title" = "オフライン通信";
|
||||
"app_info.features.title" = "機能";
|
||||
"app_info.how_to_use.change_channels" = "• #meshをタップしてチャンネルを切り替え";
|
||||
"app_info.how_to_use.clear_chat" = "• チャットを3回タップするとクリア";
|
||||
"app_info.how_to_use.commands" = "• /を入力してコマンド表示";
|
||||
"app_info.how_to_use.open_sidebar" = "• 人アイコンをタップしてサイドバーを開く";
|
||||
"app_info.how_to_use.set_nickname" = "• ニックネームをタップして設定";
|
||||
"app_info.how_to_use.start_dm" = "• ピアの名前をタップしてdm開始";
|
||||
"app_info.how_to_use.title" = "使い方";
|
||||
"app_info.privacy.ephemeral.description" = "新しいpeer idが定期的に生成されます";
|
||||
"app_info.privacy.ephemeral.title" = "一時的なアイデンティティ";
|
||||
"app_info.privacy.no_tracking.description" = "サーバーもアカウントもデータ収集もなし";
|
||||
"app_info.privacy.no_tracking.title" = "追跡なし";
|
||||
"app_info.privacy.panic.description" = "ロゴを3回タップすると全データを即削除";
|
||||
"app_info.privacy.panic.title" = "パニックモード";
|
||||
"app_info.privacy.title" = "プライバシー";
|
||||
"app_info.tagline" = "sidegroupchat";
|
||||
"app_info.warning.message" = "プライベートメッセージの安全性はまだ完全に監査されていません。この警告が消えるまで重要な場面では使わないでください。";
|
||||
"app_info.warning.title" = "警告";
|
||||
"common.cancel" = "キャンセル";
|
||||
"common.close" = "閉じる";
|
||||
"common.copy" = "コピー";
|
||||
"common.ok" = "OK";
|
||||
"common.toggle.off" = "オフ";
|
||||
"common.toggle.on" = "オン";
|
||||
"common.unknown" = "不明";
|
||||
"content.accessibility.add_favorite" = "お気に入りに追加";
|
||||
"content.accessibility.available_nostr" = "nostrで利用可能";
|
||||
"content.accessibility.back_to_main_chat" = "メインチャットに戻る";
|
||||
"content.accessibility.connected_mesh" = "mesh経由で接続";
|
||||
"content.accessibility.encryption_status" = "暗号状態: %@";
|
||||
"content.accessibility.location_channels" = "ロケーションチャンネル";
|
||||
"content.accessibility.location_notes" = "この場所のロケーションノート";
|
||||
"content.accessibility.open_unread_private_chat" = "未読のプライベートチャットを開く";
|
||||
"content.accessibility.private_chat_header" = "%@とのプライベートチャット";
|
||||
"content.accessibility.reachable_mesh" = "meshで到達可能";
|
||||
"content.accessibility.remove_favorite" = "お気に入りから削除";
|
||||
"content.accessibility.send_hint_empty" = "送信するメッセージを入力";
|
||||
"content.accessibility.send_hint_ready" = "ダブルタップで送信";
|
||||
"content.accessibility.send_message" = "メッセージ送信";
|
||||
"content.accessibility.toggle_bookmark" = "#%@のブックマークを切り替え";
|
||||
"content.accessibility.toggle_favorite_hint" = "ダブルタップでお気に入り状態を切り替え";
|
||||
"content.accessibility.view_fingerprint_hint" = "暗号フィンガープリントを見る";
|
||||
"content.actions.block" = "ブロック";
|
||||
"content.actions.direct_message" = "ダイレクトメッセージ";
|
||||
"content.actions.hug" = "ハグ";
|
||||
"content.actions.mention" = "メンション";
|
||||
"content.actions.slap" = "ビンタ";
|
||||
"content.actions.title" = "アクション";
|
||||
"content.alert.bluetooth_required.off" = "bluetoothがオフです。設定でbluetoothをオンにしてbitchatを使ってください。";
|
||||
"content.alert.bluetooth_required.permission" = "bitchatは近くのデバイスと接続するためbluetooth権限が必要です。設定でアクセスを有効にしてください。";
|
||||
"content.alert.bluetooth_required.settings" = "設定";
|
||||
"content.alert.bluetooth_required.title" = "bluetoothが必要";
|
||||
"content.alert.bluetooth_required.unsupported" = "このデバイスはbluetoothをサポートしていません。bitchatにはbluetoothが必要です。";
|
||||
"content.alert.screenshot.message" = "ロケーションチャンネルのスクリーンショットはあなたの場所を明かします。公開前によく考えてください。";
|
||||
"content.alert.screenshot.title" = "注意";
|
||||
"content.commands.block" = "ブロックまたはブロック済みを表示";
|
||||
"content.commands.clear" = "チャットをクリア";
|
||||
"content.commands.favorite" = "お気に入りに追加";
|
||||
"content.commands.hug" = "あたたかいハグを送る";
|
||||
"content.commands.message" = "プライベートメッセージを送る";
|
||||
"content.commands.slap" = "誰かをトラウトでたたく";
|
||||
"content.commands.unblock" = "ピアのブロックを解除";
|
||||
"content.commands.unfavorite" = "お気に入りから外す";
|
||||
"content.commands.who" = "オンラインの人を見る";
|
||||
"content.delivery.delivered_members" = "%2$d人中%1$d人に配信";
|
||||
"content.delivery.delivered_to" = "%@に配信";
|
||||
"content.delivery.failed" = "失敗: %@";
|
||||
"content.delivery.read_by" = "%@が既読";
|
||||
"content.delivery.reason.blocked" = "ユーザーをブロック中";
|
||||
"content.delivery.reason.self" = "自分には送れません";
|
||||
"content.delivery.reason.send_error" = "送信エラー";
|
||||
"content.delivery.reason.unknown_recipient" = "不明な宛先";
|
||||
"content.delivery.reason.unreachable" = "ピアに到達できません";
|
||||
"content.header.people" = "ユーザー";
|
||||
"content.help.verification" = "検証: 自分のqrを表示するか友達をスキャン";
|
||||
"content.input.message_placeholder" = "メッセージを入力...";
|
||||
"content.input.nickname_placeholder" = "ニックネーム";
|
||||
"content.location.enable" = "位置情報を有効化";
|
||||
"content.message.copy" = "メッセージをコピー";
|
||||
"content.message.show_less" = "表示を減らす";
|
||||
"content.message.show_more" = "さらに表示";
|
||||
"content.notes.location_unavailable" = "位置情報を取得できません";
|
||||
"content.notes.title" = "ノート";
|
||||
"content.payment.cashu" = "cashuで支払う";
|
||||
"content.payment.lightning" = "lightningで支払う";
|
||||
"encryption.accessibility.establishing" = "暗号を確立しています";
|
||||
"encryption.accessibility.failed" = "暗号に失敗";
|
||||
"encryption.accessibility.not_encrypted" = "未暗号";
|
||||
"encryption.accessibility.secured" = "暗号化済み";
|
||||
"encryption.accessibility.verified" = "暗号化し検証済み";
|
||||
"encryption.status.establishing" = "暗号を確立中...";
|
||||
"encryption.status.failed" = "暗号に失敗";
|
||||
"encryption.status.not_encrypted" = "未暗号";
|
||||
"encryption.status.secured" = "暗号化済み";
|
||||
"encryption.status.verified" = "暗号化し検証済み";
|
||||
"fingerprint.action.mark_verified" = "検証済みにする";
|
||||
"fingerprint.action.remove_verification" = "検証を削除";
|
||||
"fingerprint.badge.not_verified" = "⚠️ 未検証";
|
||||
"fingerprint.badge.verified" = "✓ 検証済み";
|
||||
"fingerprint.handshake_pending" = "利用不可 - handshake進行中";
|
||||
"fingerprint.message.verified" = "この人の身元を確認しました。";
|
||||
"fingerprint.message.verify_hint" = "これらのフィンガープリントを%@と安全なチャネルで比較";
|
||||
"fingerprint.their_label" = "相手のフィンガープリント:";
|
||||
"fingerprint.title" = "セキュリティ検証";
|
||||
"fingerprint.your_label" = "あなたのフィンガープリント:";
|
||||
"geohash_people.action.block" = "ブロック";
|
||||
"geohash_people.action.unblock" = "ブロック解除";
|
||||
"geohash_people.none_nearby" = "近くに誰もいません...";
|
||||
"geohash_people.tooltip.blocked" = "geohashでブロック中";
|
||||
"geohash_people.you_suffix" = " (あなた)";
|
||||
"location_channels.action.open_settings" = "設定を開く";
|
||||
"location_channels.action.remove_access" = "位置アクセスを解除";
|
||||
"location_channels.action.request_permissions" = "位置情報とgeohashを取得";
|
||||
"location_channels.action.teleport" = "テレポート";
|
||||
"location_channels.bookmarked_section_title" = "保存済み";
|
||||
"location_channels.description" = "geohashチャンネルで近くの人と会話。共有されるのはざっくりしたgeohashだけで正確なgpsは含みません。全トラフィックをtor経由にすることであなたのipを隠します。";
|
||||
"location_channels.error.invalid_geohash" = "無効なgeohash";
|
||||
"location_channels.loading_nearby" = "近くのチャンネルを検索中…";
|
||||
"location_channels.mesh_label" = "mesh";
|
||||
"location_channels.permission_denied" = "位置情報の許可が拒否されました。チャンネルを使うには設定で許可してください。";
|
||||
"location_channels.subtitle_prefix" = "#%@ • %@";
|
||||
"location_channels.subtitle_with_name" = "%1$@ • %2$@";
|
||||
"location_channels.title" = "#ロケーションチャンネル";
|
||||
"location_channels.tor.subtitle" = "ロケーションチャンネル用にipを隠します。推奨: オン";
|
||||
"location_channels.tor.title" = "torルーティング";
|
||||
"location_levels.block" = "ブロック";
|
||||
"location_levels.building" = "建物";
|
||||
"location_levels.city" = "都市";
|
||||
"location_levels.neighborhood" = "近所";
|
||||
"location_levels.province" = "州";
|
||||
"location_levels.region" = "地域";
|
||||
"location_notes.action.dismiss" = "閉じる";
|
||||
"location_notes.action.retry" = "再試行";
|
||||
"location_notes.description" = "他の人が見つけられるようこの場所に短いノートを追加";
|
||||
"location_notes.empty_subtitle" = "ここで最初のノートを残そう。";
|
||||
"location_notes.empty_title" = "ノートはまだありません";
|
||||
"location_notes.error.failed_to_send" = "ノートを送信できませんでした。%@";
|
||||
"location_notes.error.no_relays" = "近くに利用できるジオリレーがありません。後で再試行してください。";
|
||||
"location_notes.loading_notes" = "ノートを読み込み中…";
|
||||
"location_notes.loading_recent" = "最新ノートを読み込み中…";
|
||||
"location_notes.no_relays_nearby" = "近くにジオリレーなし";
|
||||
"location_notes.placeholder" = "この場所のノートを追加";
|
||||
"location_notes.relays_paused" = "ジオリレーが利用不可: ノート一時停止";
|
||||
"location_notes.relays_retry_hint" = "ノートはジオリレーに依存します。接続を確認して再試行してください。";
|
||||
"mesh_peers.tooltip.new_messages" = "新しいメッセージ";
|
||||
"system.chat.blocked" = "%@とはチャットできません: ユーザーをブロック中。";
|
||||
"system.chat.requires_favorite" = "%@とはチャットできません: オフラインには相互のお気に入りが必要です。";
|
||||
"system.common.user" = "ユーザー";
|
||||
"system.dm.blocked_generic" = "送信できません: ユーザーをブロック中。";
|
||||
"system.dm.blocked_recipient" = "%@に送れません: ユーザーをブロック中。";
|
||||
"system.dm.unreachable" = "%@に送れません: 受信者はmeshやnostrで到達できません。";
|
||||
"system.geohash.blocked" = "%@をgeohashチャットでブロックしました";
|
||||
"system.geohash.unblocked" = "%@のgeohashチャットでのブロックを解除しました";
|
||||
"system.location.not_in_channel" = "送信失敗: ロケーションチャンネルに参加していません";
|
||||
"system.location.send_failed" = "ロケーションチャンネルに送信できませんでした";
|
||||
"system.tor.dev_bypass" = "開発ビルド: torバイパスが有効です。";
|
||||
"system.tor.restarted" = "torを再起動しました。ルーティングを復旧。";
|
||||
"system.tor.restarting" = "接続回復のためtorを再起動しています...";
|
||||
"system.tor.started" = "torを起動しました。全チャットをtor経由で配信します。";
|
||||
"system.tor.starting" = "torを起動中...";
|
||||
"verification.my_qr.accessibility_label" = "検証用qrコード";
|
||||
"verification.my_qr.title" = "スキャンして確認";
|
||||
"verification.my_qr.unavailable" = "qrは利用不可";
|
||||
"verification.scan.paste_prompt" = "確認するqr内容を貼り付け:";
|
||||
"verification.scan.prompt_friend" = "友達のqrをスキャン";
|
||||
"verification.scan.status.invalid" = "qrが無効または期限切れ";
|
||||
"verification.scan.status.no_peer" = "該当するピアが見つかりません";
|
||||
"verification.scan.status.requested" = "%@の検証をリクエストしました";
|
||||
"verification.scan.validate" = "確認";
|
||||
"verification.sheet.title" = "確認";
|
||||
@@ -1,54 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>location_notes.header</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>#%@ • %#@note_count@</string>
|
||||
<key>note_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d件のノート</string>
|
||||
<key>other</key>
|
||||
<string>%d件のノート</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>location_channels.row_title</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%@ [%#@people_count@]</string>
|
||||
<key>people_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d人</string>
|
||||
<key>other</key>
|
||||
<string>%d人</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>content.accessibility.people_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@people@</string>
|
||||
<key>people</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d人</string>
|
||||
<key>other</key>
|
||||
<string>%d人</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,190 +0,0 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
bitchat (Nepali)
|
||||
*/
|
||||
|
||||
"app_info.app_name" = "bitchat";
|
||||
"app_info.close" = "बन्द";
|
||||
"app_info.done" = "सम्पन्न";
|
||||
"app_info.features.encryption.description" = "व्यक्तिगत सन्देशहरू noise प्रोटोकलले सङ्केत गर्छ";
|
||||
"app_info.features.encryption.title" = "एन्ड-टु-एन्ड सङ्केत";
|
||||
"app_info.features.extended_range.description" = "सन्देशहरू सहकर्मीमार्फत रिले भएर टाढासम्म पुग्छन्";
|
||||
"app_info.features.extended_range.title" = "विस्तारित पहुँच";
|
||||
"app_info.features.favorites.description" = "तिम्रा मनपर्ने मानिस जोडिएपछि सूचनाहरू पाऊ";
|
||||
"app_info.features.favorites.title" = "मनपर्ने";
|
||||
"app_info.features.geohash.description" = "geohash च्यानलहरूले नजिकका व्यक्तिसँग विकेन्द्रित गोप्य रिलेबाट कुराकानी गर्न मद्दत गर्छ";
|
||||
"app_info.features.geohash.title" = "स्थानीय च्यानल";
|
||||
"app_info.features.mentions.description" = "विशेष व्यक्तिलाई सूचित गर्न @nickname प्रयोग गर";
|
||||
"app_info.features.mentions.title" = "उल्लेख";
|
||||
"app_info.features.offline.description" = "bluetooth low energy प्रयोग गरेर इन्टरनेट बिना काम गर्छ";
|
||||
"app_info.features.offline.title" = "अफलाइन सञ्चार";
|
||||
"app_info.features.title" = "विशेषता";
|
||||
"app_info.how_to_use.change_channels" = "• च्यानल बदल्न #mesh ट्याप गर";
|
||||
"app_info.how_to_use.clear_chat" = "• च्याट खाली गर्न तीन पटक ट्याप गर";
|
||||
"app_info.how_to_use.commands" = "• आदेशहरू हेर्न / टाइप गर";
|
||||
"app_info.how_to_use.open_sidebar" = "• साइडबार खोल्न मान्छे आइकन ट्याप गर";
|
||||
"app_info.how_to_use.set_nickname" = "• आफ्नो उपनाममा ट्याप गरेर मिलाऊ";
|
||||
"app_info.how_to_use.start_dm" = "• dm सुरु गर्न कुनै सहकर्मीको नाम ट्याप गर";
|
||||
"app_info.how_to_use.title" = "प्रयोग गर्ने तरिका";
|
||||
"app_info.privacy.ephemeral.description" = "नयाँ peer id नियमित रूपमा सिर्जना हुन्छ";
|
||||
"app_info.privacy.ephemeral.title" = "क्षणिक पहिचान";
|
||||
"app_info.privacy.no_tracking.description" = "सर्भर, खाताहरू वा तथ्याङ्क सङ्कलन छैन";
|
||||
"app_info.privacy.no_tracking.title" = "ट्र्याकिङ छैन";
|
||||
"app_info.privacy.panic.description" = "लगो तीन पटक ट्याप गर्दा सबै डाटा तुरुन्त मेटिन्छ";
|
||||
"app_info.privacy.panic.title" = "घबराहट मोड";
|
||||
"app_info.privacy.title" = "गोपनीयता";
|
||||
"app_info.tagline" = "sidegroupchat";
|
||||
"app_info.warning.message" = "व्यक्तिगत सन्देशको सुरक्षा पूर्ण रूपमा अडिट भएको छैन। यो चेतावनी हट्दासम्म गम्भीर अवस्थामा प्रयोग नगर्नु।";
|
||||
"app_info.warning.title" = "चेतावनी";
|
||||
"common.cancel" = "रद्द";
|
||||
"common.close" = "बन्द";
|
||||
"common.copy" = "प्रतिलिपि";
|
||||
"common.ok" = "ठिक";
|
||||
"common.toggle.off" = "अफ";
|
||||
"common.toggle.on" = "अन";
|
||||
"common.unknown" = "अज्ञात";
|
||||
"content.accessibility.add_favorite" = "मनपर्नेमा थप";
|
||||
"content.accessibility.available_nostr" = "nostr मार्फत उपलब्ध";
|
||||
"content.accessibility.back_to_main_chat" = "मुख्य च्याटमा फर्क";
|
||||
"content.accessibility.connected_mesh" = "mesh मार्फत जडान";
|
||||
"content.accessibility.encryption_status" = "सङ्केतको अवस्था: %@";
|
||||
"content.accessibility.location_channels" = "स्थान च्यानल";
|
||||
"content.accessibility.location_notes" = "यस ठाउँका स्थान नोटहरू";
|
||||
"content.accessibility.open_unread_private_chat" = "नपढिएको निजी च्याट खोल";
|
||||
"content.accessibility.private_chat_header" = "%@ सँग निजी च्याट";
|
||||
"content.accessibility.reachable_mesh" = "mesh मार्फत पहुँचयोग्य";
|
||||
"content.accessibility.remove_favorite" = "मनपर्नेबाट हटाउ";
|
||||
"content.accessibility.send_hint_empty" = "पठाउन सन्देश लेख";
|
||||
"content.accessibility.send_hint_ready" = "पठाउन दोहोरो ट्याप गर";
|
||||
"content.accessibility.send_message" = "सन्देश पठाउ";
|
||||
"content.accessibility.toggle_bookmark" = "#%@ का लागि बुकमार्क बदल";
|
||||
"content.accessibility.toggle_favorite_hint" = "मनपर्ने स्थिति बदल्न दोहोरो ट्याप गर";
|
||||
"content.accessibility.view_fingerprint_hint" = "सङ्केत फिङ्गरप्रिन्ट हेर्न ट्याप गर";
|
||||
"content.actions.block" = "ब्लक";
|
||||
"content.actions.direct_message" = "प्रत्यक्ष सन्देश";
|
||||
"content.actions.hug" = "अँगालो";
|
||||
"content.actions.mention" = "उल्लेख";
|
||||
"content.actions.slap" = "थप्पड";
|
||||
"content.actions.title" = "कार्य";
|
||||
"content.alert.bluetooth_required.off" = "bluetooth बन्द छ। bitchat प्रयोग गर्न bluetooth सेटिङमा अन गर।";
|
||||
"content.alert.bluetooth_required.permission" = "bitchat लाई नजिकका उपकरणसँग जडान हुन bluetooth अनुमति चाहिन्छ। सेटिङमा पहुँच सक्षम गर।";
|
||||
"content.alert.bluetooth_required.settings" = "सेटिङ";
|
||||
"content.alert.bluetooth_required.title" = "bluetooth आवश्यक";
|
||||
"content.alert.bluetooth_required.unsupported" = "यो उपकरणले bluetooth समर्थन गर्दैन। bitchat चलाउन bluetooth चाहिन्छ।";
|
||||
"content.alert.screenshot.message" = "स्थान च्यानलको स्क्रिनसटले तिम्रो स्थान खुलाउँछ। सार्वजनिकरूपमा बाँड्नु अघि सोच।";
|
||||
"content.alert.screenshot.title" = "ध्यान";
|
||||
"content.commands.block" = "ब्लक गर वा ब्लक गरिएको सूची देखाउ";
|
||||
"content.commands.clear" = "च्याट सन्देश खाली गर";
|
||||
"content.commands.favorite" = "मनपर्नेमा थप";
|
||||
"content.commands.hug" = "न्यानो अँगालो पठाउ";
|
||||
"content.commands.message" = "निजी सन्देश पठाउ";
|
||||
"content.commands.slap" = "कसैलाई ट्राउटले थप्पड दे";
|
||||
"content.commands.unblock" = "पीयर अनब्लक गर";
|
||||
"content.commands.unfavorite" = "मनपर्नेबाट हटाउ";
|
||||
"content.commands.who" = "अनलाइन को-को छन् हेर्नु";
|
||||
"content.delivery.delivered_members" = "%2$d सदस्यमध्ये %1$d जनालाई पुर्याइयो";
|
||||
"content.delivery.delivered_to" = "%@ लाई पुर्याइयो";
|
||||
"content.delivery.failed" = "असफल: %@";
|
||||
"content.delivery.read_by" = "%@ ले पढ्यो";
|
||||
"content.delivery.reason.blocked" = "प्रयोगकर्ता ब्लक गरिएको";
|
||||
"content.delivery.reason.self" = "आफूलाई पठाउन मिल्दैन";
|
||||
"content.delivery.reason.send_error" = "पठाउने त्रुटि";
|
||||
"content.delivery.reason.unknown_recipient" = "अज्ञात प्राप्तकर्ता";
|
||||
"content.delivery.reason.unreachable" = "पीयर पहुँचयोग्य छैन";
|
||||
"content.header.people" = "मानिस";
|
||||
"content.help.verification" = "प्रमाणीकरण: मेरो qr देखाउ वा साथी स्क्यान गर";
|
||||
"content.input.message_placeholder" = "सन्देश टाइप गर...";
|
||||
"content.input.nickname_placeholder" = "उपनाम";
|
||||
"content.location.enable" = "स्थान सक्षम गर";
|
||||
"content.message.copy" = "सन्देश प्रतिलिपि गर";
|
||||
"content.message.show_less" = "थोरै देखाउ";
|
||||
"content.message.show_more" = "थप देखाउ";
|
||||
"content.notes.location_unavailable" = "स्थान उपलब्ध छैन";
|
||||
"content.notes.title" = "नोट";
|
||||
"content.payment.cashu" = "cashu मार्फत तिर्नु";
|
||||
"content.payment.lightning" = "lightning मार्फत तिर्नु";
|
||||
"encryption.accessibility.establishing" = "सङ्केत सेट हुँदै";
|
||||
"encryption.accessibility.failed" = "सङ्केत असफल";
|
||||
"encryption.accessibility.not_encrypted" = "सङ्केत छैन";
|
||||
"encryption.accessibility.secured" = "सङ्केत गरिएको";
|
||||
"encryption.accessibility.verified" = "सङ्केत र प्रमाणित";
|
||||
"encryption.status.establishing" = "सङ्केत सेट गर्दै...";
|
||||
"encryption.status.failed" = "सङ्केत असफल";
|
||||
"encryption.status.not_encrypted" = "सङ्केत छैन";
|
||||
"encryption.status.secured" = "सङ्केत गरिएको";
|
||||
"encryption.status.verified" = "सङ्केत र प्रमाणित";
|
||||
"fingerprint.action.mark_verified" = "प्रमाणित चिन्ह लगाउ";
|
||||
"fingerprint.action.remove_verification" = "प्रमाणीकरण हटाउ";
|
||||
"fingerprint.badge.not_verified" = "⚠️ प्रमाणित छैन";
|
||||
"fingerprint.badge.verified" = "✓ प्रमाणित";
|
||||
"fingerprint.handshake_pending" = "उपलब्ध छैन - handshake हुँदै";
|
||||
"fingerprint.message.verified" = "तिमीले यस व्यक्तिको पहिचान प्रमाणित गरेको छौ.";
|
||||
"fingerprint.message.verify_hint" = "यी फिङ्गरप्रिन्टहरू %@ सँग सुरक्षित च्यानलमा तुलना गर।";
|
||||
"fingerprint.their_label" = "उनको फिङ्गरप्रिन्ट:";
|
||||
"fingerprint.title" = "सुरक्षा प्रमाणीकरण";
|
||||
"fingerprint.your_label" = "तिम्रो फिङ्गरप्रिन्ट:";
|
||||
"geohash_people.action.block" = "ब्लक";
|
||||
"geohash_people.action.unblock" = "अनब्लक";
|
||||
"geohash_people.none_nearby" = "वरिपरि कोही छैन...";
|
||||
"geohash_people.tooltip.blocked" = "geohash मा ब्लक";
|
||||
"geohash_people.you_suffix" = " (तिमी)";
|
||||
"location_channels.action.open_settings" = "सेटिङ खोल";
|
||||
"location_channels.action.remove_access" = "स्थान पहुँच हटाउ";
|
||||
"location_channels.action.request_permissions" = "मेरो स्थान र geohash प्राप्त गर";
|
||||
"location_channels.action.teleport" = "टेलिपोर्ट";
|
||||
"location_channels.bookmarked_section_title" = "बुकमार्क";
|
||||
"location_channels.description" = "geohash च्यानलबाट नजिकका मानिससँग कुरा गर। केवल मोटामो geohash साझा हुन्छ, कहिल्यै सहि gps होइन। सबै ट्राफिक tor मार्फत गएका कारण तिम्रो ip लुकेको हुन्छ।";
|
||||
"location_channels.error.invalid_geohash" = "अवैध geohash";
|
||||
"location_channels.loading_nearby" = "नजिकका च्यानल खोज्दै…";
|
||||
"location_channels.mesh_label" = "mesh";
|
||||
"location_channels.permission_denied" = "स्थान अनुमति अस्वीकार। स्थान च्यानल प्रयोग गर्न सेटिङमा सक्षम गर।";
|
||||
"location_channels.subtitle_prefix" = "#%@ • %@";
|
||||
"location_channels.subtitle_with_name" = "%1$@ • %2$@";
|
||||
"location_channels.title" = "#स्थान च्यानल";
|
||||
"location_channels.tor.subtitle" = "स्थान च्यानलका लागि तिम्रो ip लुकाउँछ। सिफारिस: अन।";
|
||||
"location_channels.tor.title" = "tor रूटिङ";
|
||||
"location_levels.block" = "ब्लक";
|
||||
"location_levels.building" = "भवन";
|
||||
"location_levels.city" = "सहर";
|
||||
"location_levels.neighborhood" = "छिमेक";
|
||||
"location_levels.province" = "प्रदेश";
|
||||
"location_levels.region" = "क्षेत्र";
|
||||
"location_notes.action.dismiss" = "बन्द गर";
|
||||
"location_notes.action.retry" = "फेरि प्रयास गर";
|
||||
"location_notes.description" = "अन्यले भेटून् भनी यस स्थानमा छोटो स्थायी नोट थप।";
|
||||
"location_notes.empty_subtitle" = "यस ठाउँमा नोट थप्ने पहिलो व्यक्ती बन।";
|
||||
"location_notes.empty_title" = "अहिले नोट छैन";
|
||||
"location_notes.error.failed_to_send" = "नोट पठाउन सकेन। %@";
|
||||
"location_notes.error.no_relays" = "यस स्थान नजिक georelay उपलब्ध छैन। केही बेरपछि प्रयास गर।";
|
||||
"location_notes.loading_notes" = "नोट लोड हुँदै…";
|
||||
"location_notes.loading_recent" = "हालैका नोट लोड गर्दै…";
|
||||
"location_notes.no_relays_nearby" = "नजिक georelay छैन";
|
||||
"location_notes.placeholder" = "यस स्थानका लागि नोट थप";
|
||||
"location_notes.relays_paused" = "georelay उपलब्ध छैन; नोट रोकिएको";
|
||||
"location_notes.relays_retry_hint" = "नोट georelay मा निर्भर छन्। जडान जाँच गरेर फेरि प्रयास गर.";
|
||||
"mesh_peers.tooltip.new_messages" = "नयाँ सन्देश";
|
||||
"system.chat.blocked" = "%@ सँग च्याट सुरु गर्न मिलेन: प्रयोगकर्ता ब्लक गरिएको";
|
||||
"system.chat.requires_favorite" = "%@ सँग च्याट सुरु गर्न मिलेन: अफलाइनका लागि दुवै मनपर्ने हुनुपर्छ";
|
||||
"system.common.user" = "प्रयोगकर्ता";
|
||||
"system.dm.blocked_generic" = "पठाउन मिलेन: प्रयोगकर्ता ब्लक";
|
||||
"system.dm.blocked_recipient" = "%@ लाई पठाउन मिलेन: प्रयोगकर्ता ब्लक";
|
||||
"system.dm.unreachable" = "%@ लाई पठाउन मिलेन: प्राप्तकर्ता mesh वा nostr बाट उपलब्ध छैन";
|
||||
"system.geohash.blocked" = "%@ लाई geohash च्याटमा ब्लक गरियो";
|
||||
"system.geohash.unblocked" = "%@ लाई geohash च्याटमा अनब्लक गरियो";
|
||||
"system.location.not_in_channel" = "पठाउन मिलेन: तिमी स्थान च्यानलमा छैनौ";
|
||||
"system.location.send_failed" = "स्थान च्यानलमा पठाउन सकेन";
|
||||
"system.tor.dev_bypass" = "डेभ बिल्ड: tor बाइपास सक्षम।";
|
||||
"system.tor.restarted" = "tor फेरि सुरु भयो। रूटिङ पुनःस्थापित।";
|
||||
"system.tor.restarting" = "tor जडान फर्काउन पुनः सुरु हुँदैछ...";
|
||||
"system.tor.started" = "tor सुरु भयो। गोपनीयताका लागि पूरा च्याट tor मार्फत जान्छ।";
|
||||
"system.tor.starting" = "tor सुरु हुँदै...";
|
||||
"verification.my_qr.accessibility_label" = "प्रमाणीकरण qr कोड";
|
||||
"verification.my_qr.title" = "मलाई प्रमाणित गर्न स्क्यान गर";
|
||||
"verification.my_qr.unavailable" = "qr उपलब्ध छैन";
|
||||
"verification.scan.paste_prompt" = "प्रमाणित गर्न qr सामग्री पेस्ट गर:";
|
||||
"verification.scan.prompt_friend" = "साथीको qr स्क्यान गर";
|
||||
"verification.scan.status.invalid" = "qr अवैध या म्याद सकिएको";
|
||||
"verification.scan.status.no_peer" = "मिल्ने peer फेला परेन";
|
||||
"verification.scan.status.requested" = "%@ को लागि प्रमाणीकरण अनुरोध भयो";
|
||||
"verification.scan.validate" = "प्रमाणित गर";
|
||||
"verification.sheet.title" = "प्रमाणित";
|
||||
@@ -1,54 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>location_notes.header</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>#%@ • %#@note_count@</string>
|
||||
<key>note_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d नोट</string>
|
||||
<key>other</key>
|
||||
<string>%d नोटहरू</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>location_channels.row_title</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%@ [%#@people_count@]</string>
|
||||
<key>people_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d व्यक्ति</string>
|
||||
<key>other</key>
|
||||
<string>%d व्यक्तिहरू</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>content.accessibility.people_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@people@</string>
|
||||
<key>people</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d व्यक्ति</string>
|
||||
<key>other</key>
|
||||
<string>%d व्यक्तिहरू</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,190 +0,0 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
bitchat (Portuguese - Brazil)
|
||||
*/
|
||||
|
||||
"app_info.app_name" = "bitchat";
|
||||
"app_info.close" = "fechar";
|
||||
"app_info.done" = "CONCLUÍDO";
|
||||
"app_info.features.encryption.description" = "mensagens privadas criptografadas com o protocolo noise";
|
||||
"app_info.features.encryption.title" = "criptografia ponto a ponto";
|
||||
"app_info.features.extended_range.description" = "mensagens retransmitidas entre pares para alcançar mais longe";
|
||||
"app_info.features.extended_range.title" = "alcance estendido";
|
||||
"app_info.features.favorites.description" = "receba avisos quando suas pessoas favoritas entrarem";
|
||||
"app_info.features.favorites.title" = "favoritos";
|
||||
"app_info.features.geohash.description" = "canais geohash para conversar com pessoas em regiões próximas por relays descentralizados anônimos";
|
||||
"app_info.features.geohash.title" = "canais locais";
|
||||
"app_info.features.mentions.description" = "use @nickname para notificar pessoas específicas";
|
||||
"app_info.features.mentions.title" = "menções";
|
||||
"app_info.features.offline.description" = "funciona sem internet usando bluetooth de baixa energia";
|
||||
"app_info.features.offline.title" = "comunicação offline";
|
||||
"app_info.features.title" = "RECURSOS";
|
||||
"app_info.how_to_use.change_channels" = "• toque #mesh para trocar de canal";
|
||||
"app_info.how_to_use.clear_chat" = "• toque o chat três vezes para limpar";
|
||||
"app_info.how_to_use.commands" = "• digite / para ver comandos";
|
||||
"app_info.how_to_use.open_sidebar" = "• toque o ícone de pessoas para abrir a barra lateral";
|
||||
"app_info.how_to_use.set_nickname" = "• defina seu apelido tocando nele";
|
||||
"app_info.how_to_use.start_dm" = "• toque o nome de um par para iniciar um dm";
|
||||
"app_info.how_to_use.title" = "COMO USAR";
|
||||
"app_info.privacy.ephemeral.description" = "novo id de peer gerado regularmente";
|
||||
"app_info.privacy.ephemeral.title" = "identidade efêmera";
|
||||
"app_info.privacy.no_tracking.description" = "sem servidores, contas ou coleta de dados";
|
||||
"app_info.privacy.no_tracking.title" = "sem rastreamento";
|
||||
"app_info.privacy.panic.description" = "toque o logo três vezes para limpar todos os dados instantaneamente";
|
||||
"app_info.privacy.panic.title" = "modo pânico";
|
||||
"app_info.privacy.title" = "PRIVACIDADE";
|
||||
"app_info.tagline" = "sidegroupchat";
|
||||
"app_info.warning.message" = "a segurança das mensagens privadas ainda não foi totalmente auditada. não use em situações críticas até que este aviso desapareça.";
|
||||
"app_info.warning.title" = "AVISO";
|
||||
"common.cancel" = "cancelar";
|
||||
"common.close" = "fechar";
|
||||
"common.copy" = "copiar";
|
||||
"common.ok" = "OK";
|
||||
"common.toggle.off" = "desligado";
|
||||
"common.toggle.on" = "ligado";
|
||||
"common.unknown" = "desconhecido";
|
||||
"content.accessibility.add_favorite" = "adicionar aos favoritos";
|
||||
"content.accessibility.available_nostr" = "disponível via nostr";
|
||||
"content.accessibility.back_to_main_chat" = "voltar ao chat principal";
|
||||
"content.accessibility.connected_mesh" = "conectado por mesh";
|
||||
"content.accessibility.encryption_status" = "status da criptografia: %@";
|
||||
"content.accessibility.location_channels" = "canais de localização";
|
||||
"content.accessibility.location_notes" = "notas de localização deste lugar";
|
||||
"content.accessibility.open_unread_private_chat" = "abrir chat privado não lido";
|
||||
"content.accessibility.private_chat_header" = "chat privado com %@";
|
||||
"content.accessibility.reachable_mesh" = "alcançável por mesh";
|
||||
"content.accessibility.remove_favorite" = "remover dos favoritos";
|
||||
"content.accessibility.send_hint_empty" = "digite uma mensagem para enviar";
|
||||
"content.accessibility.send_hint_ready" = "toque duas vezes para enviar";
|
||||
"content.accessibility.send_message" = "enviar mensagem";
|
||||
"content.accessibility.toggle_bookmark" = "alternar favorito para #%@";
|
||||
"content.accessibility.toggle_favorite_hint" = "toque duas vezes para alternar status de favorito";
|
||||
"content.accessibility.view_fingerprint_hint" = "toque para ver a impressão de criptografia";
|
||||
"content.actions.block" = "bloquear";
|
||||
"content.actions.direct_message" = "mensagem direta";
|
||||
"content.actions.hug" = "abraço";
|
||||
"content.actions.mention" = "mencionar";
|
||||
"content.actions.slap" = "tapa";
|
||||
"content.actions.title" = "ações";
|
||||
"content.alert.bluetooth_required.off" = "bluetooth está desligado. ative o bluetooth em ajustes para usar bitchat.";
|
||||
"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";
|
||||
"content.alert.bluetooth_required.title" = "bluetooth necessário";
|
||||
"content.alert.bluetooth_required.unsupported" = "este dispositivo não suporta bluetooth. bitchat precisa de bluetooth para funcionar.";
|
||||
"content.alert.screenshot.message" = "capturas de canais de localização revelam sua localização. pense antes de compartilhar publicamente.";
|
||||
"content.alert.screenshot.title" = "atenção";
|
||||
"content.commands.block" = "bloquear ou listar pares bloqueados";
|
||||
"content.commands.clear" = "limpar mensagens do chat";
|
||||
"content.commands.favorite" = "adicionar aos favoritos";
|
||||
"content.commands.hug" = "enviar um abraço quente";
|
||||
"content.commands.message" = "enviar mensagem privada";
|
||||
"content.commands.slap" = "dar um tapa em alguém com uma truta";
|
||||
"content.commands.unblock" = "desbloquear um par";
|
||||
"content.commands.unfavorite" = "remover dos favoritos";
|
||||
"content.commands.who" = "ver quem está online";
|
||||
"content.delivery.delivered_members" = "entregue a %1$d de %2$d membros";
|
||||
"content.delivery.delivered_to" = "entregue para %@";
|
||||
"content.delivery.failed" = "falhou: %@";
|
||||
"content.delivery.read_by" = "lido por %@";
|
||||
"content.delivery.reason.blocked" = "usuário bloqueado";
|
||||
"content.delivery.reason.self" = "não é possível enviar mensagem para si mesmo";
|
||||
"content.delivery.reason.send_error" = "erro ao enviar";
|
||||
"content.delivery.reason.unknown_recipient" = "destinatário desconhecido";
|
||||
"content.delivery.reason.unreachable" = "par inalcançável";
|
||||
"content.header.people" = "PESSOAS";
|
||||
"content.help.verification" = "verificação: mostrar meu qr ou escanear um amigo";
|
||||
"content.input.message_placeholder" = "digite uma mensagem...";
|
||||
"content.input.nickname_placeholder" = "apelido";
|
||||
"content.location.enable" = "habilitar localização";
|
||||
"content.message.copy" = "copiar mensagem";
|
||||
"content.message.show_less" = "mostrar menos";
|
||||
"content.message.show_more" = "mostrar mais";
|
||||
"content.notes.location_unavailable" = "localização indisponível";
|
||||
"content.notes.title" = "notas";
|
||||
"content.payment.cashu" = "pagar via cashu";
|
||||
"content.payment.lightning" = "pagar via lightning";
|
||||
"encryption.accessibility.establishing" = "estabelecendo criptografia";
|
||||
"encryption.accessibility.failed" = "falha na criptografia";
|
||||
"encryption.accessibility.not_encrypted" = "não criptografado";
|
||||
"encryption.accessibility.secured" = "criptografado";
|
||||
"encryption.accessibility.verified" = "criptografado e verificado";
|
||||
"encryption.status.establishing" = "estabelecendo criptografia...";
|
||||
"encryption.status.failed" = "falha na criptografia";
|
||||
"encryption.status.not_encrypted" = "não criptografado";
|
||||
"encryption.status.secured" = "criptografado";
|
||||
"encryption.status.verified" = "criptografado e verificado";
|
||||
"fingerprint.action.mark_verified" = "marcar como verificado";
|
||||
"fingerprint.action.remove_verification" = "remover verificação";
|
||||
"fingerprint.badge.not_verified" = "⚠️ NÃO VERIFICADO";
|
||||
"fingerprint.badge.verified" = "✓ VERIFICADO";
|
||||
"fingerprint.handshake_pending" = "indisponível - handshake em andamento";
|
||||
"fingerprint.message.verified" = "você verificou a identidade dessa pessoa.";
|
||||
"fingerprint.message.verify_hint" = "compare essas impressões com %@ usando um canal seguro.";
|
||||
"fingerprint.their_label" = "impressão digital deles:";
|
||||
"fingerprint.title" = "verificação de segurança";
|
||||
"fingerprint.your_label" = "sua impressão digital:";
|
||||
"geohash_people.action.block" = "bloquear";
|
||||
"geohash_people.action.unblock" = "desbloquear";
|
||||
"geohash_people.none_nearby" = "ninguém por perto...";
|
||||
"geohash_people.tooltip.blocked" = "bloqueado em geohash";
|
||||
"geohash_people.you_suffix" = " (você)";
|
||||
"location_channels.action.open_settings" = "abrir ajustes";
|
||||
"location_channels.action.remove_access" = "remover acesso à localização";
|
||||
"location_channels.action.request_permissions" = "obter localização e meus geohashes";
|
||||
"location_channels.action.teleport" = "teletransportar";
|
||||
"location_channels.bookmarked_section_title" = "marcados";
|
||||
"location_channels.description" = "converse com pessoas próximas usando canais geohash. apenas um geohash grosseiro é compartilhado, nunca gps exato. seu ip fica oculto ao rotear todo o tráfego por tor.";
|
||||
"location_channels.error.invalid_geohash" = "geohash inválido";
|
||||
"location_channels.loading_nearby" = "procurando canais próximos…";
|
||||
"location_channels.mesh_label" = "mesh";
|
||||
"location_channels.permission_denied" = "permissão de localização negada. habilite em ajustes para usar canais de localização.";
|
||||
"location_channels.subtitle_prefix" = "#%@ • %@";
|
||||
"location_channels.subtitle_with_name" = "%1$@ • %2$@";
|
||||
"location_channels.title" = "#canais de localização";
|
||||
"location_channels.tor.subtitle" = "oculta seu ip para canais de localização. recomendado: ligado.";
|
||||
"location_channels.tor.title" = "roteamento tor";
|
||||
"location_levels.block" = "quadra";
|
||||
"location_levels.building" = "prédio";
|
||||
"location_levels.city" = "cidade";
|
||||
"location_levels.neighborhood" = "bairro";
|
||||
"location_levels.province" = "estado";
|
||||
"location_levels.region" = "região";
|
||||
"location_notes.action.dismiss" = "dispensar";
|
||||
"location_notes.action.retry" = "tentar novamente";
|
||||
"location_notes.description" = "adicione notas curtas permanentes neste local para outras pessoas encontrarem.";
|
||||
"location_notes.empty_subtitle" = "seja a primeira pessoa a adicionar uma aqui.";
|
||||
"location_notes.empty_title" = "nenhuma nota ainda";
|
||||
"location_notes.error.failed_to_send" = "não foi possível enviar a nota. %@";
|
||||
"location_notes.error.no_relays" = "nenhum relay geográfico disponível perto deste local. tente novamente em breve.";
|
||||
"location_notes.loading_notes" = "carregando notas…";
|
||||
"location_notes.loading_recent" = "carregando notas recentes…";
|
||||
"location_notes.no_relays_nearby" = "nenhum relay geográfico próximo";
|
||||
"location_notes.placeholder" = "adicione uma nota para este lugar";
|
||||
"location_notes.relays_paused" = "relays geográficos indisponíveis; notas pausadas";
|
||||
"location_notes.relays_retry_hint" = "notas dependem de relays geográficos. verifique a conexão e tente de novo.";
|
||||
"mesh_peers.tooltip.new_messages" = "novas mensagens";
|
||||
"system.chat.blocked" = "não é possível iniciar chat com %@: usuário bloqueado.";
|
||||
"system.chat.requires_favorite" = "não é possível iniciar chat com %@: vocês precisam ser favoritos mútuos para mensagens offline.";
|
||||
"system.common.user" = "usuário";
|
||||
"system.dm.blocked_generic" = "não foi possível enviar: usuário bloqueado.";
|
||||
"system.dm.blocked_recipient" = "não é possível enviar mensagem para %@: usuário bloqueado.";
|
||||
"system.dm.unreachable" = "não é possível enviar mensagem para %@: destinatário inalcançável por mesh ou nostr.";
|
||||
"system.geohash.blocked" = "%@ foi bloqueado nos chats geohash";
|
||||
"system.geohash.unblocked" = "%@ foi desbloqueado nos chats geohash";
|
||||
"system.location.not_in_channel" = "não foi possível enviar: você não está em um canal de localização";
|
||||
"system.location.send_failed" = "não foi possível enviar para o canal de localização";
|
||||
"system.tor.dev_bypass" = "compilação de desenvolvimento: bypass de tor ativo.";
|
||||
"system.tor.restarted" = "tor reiniciou. roteamento restaurado.";
|
||||
"system.tor.restarting" = "tor está reiniciando para recuperar conectividade...";
|
||||
"system.tor.started" = "tor iniciou. todo o chat é roteado por tor para privacidade.";
|
||||
"system.tor.starting" = "iniciando tor...";
|
||||
"verification.my_qr.accessibility_label" = "código qr de verificação";
|
||||
"verification.my_qr.title" = "escaneie para me verificar";
|
||||
"verification.my_qr.unavailable" = "qr indisponível";
|
||||
"verification.scan.paste_prompt" = "cole o conteúdo do qr para validar:";
|
||||
"verification.scan.prompt_friend" = "escaneie o qr de um amigo";
|
||||
"verification.scan.status.invalid" = "qr inválido ou expirado";
|
||||
"verification.scan.status.no_peer" = "nenhum peer correspondente encontrado";
|
||||
"verification.scan.status.requested" = "verificação solicitada para %@";
|
||||
"verification.scan.validate" = "validar";
|
||||
"verification.sheet.title" = "VERIFICAR";
|
||||
@@ -1,54 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>location_notes.header</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>#%@ • %#@note_count@</string>
|
||||
<key>note_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d nota</string>
|
||||
<key>other</key>
|
||||
<string>%d notas</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>location_channels.row_title</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%@ [%#@people_count@]</string>
|
||||
<key>people_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d pessoa</string>
|
||||
<key>other</key>
|
||||
<string>%d pessoas</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>content.accessibility.people_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@people@</string>
|
||||
<key>people</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d pessoa</string>
|
||||
<key>other</key>
|
||||
<string>%d pessoas</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,190 +0,0 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
bitchat (Russian)
|
||||
*/
|
||||
|
||||
"app_info.app_name" = "bitchat";
|
||||
"app_info.close" = "закрыть";
|
||||
"app_info.done" = "ГОТОВО";
|
||||
"app_info.features.encryption.description" = "личные сообщения шифруются протоколом noise";
|
||||
"app_info.features.encryption.title" = "сквозное шифрование";
|
||||
"app_info.features.extended_range.description" = "сообщения ретранслируются между пирами и уходят дальше";
|
||||
"app_info.features.extended_range.title" = "расширенный радиус";
|
||||
"app_info.features.favorites.description" = "получай уведомления, когда подключаются любимые люди";
|
||||
"app_info.features.favorites.title" = "избранное";
|
||||
"app_info.features.geohash.description" = "каналы geohash для чата с людьми поблизости через децентрализованные анонимные реле";
|
||||
"app_info.features.geohash.title" = "локальные каналы";
|
||||
"app_info.features.mentions.description" = "используй @nickname, чтобы уведомить конкретных людей";
|
||||
"app_info.features.mentions.title" = "упоминания";
|
||||
"app_info.features.offline.description" = "работает без интернета через bluetooth low energy";
|
||||
"app_info.features.offline.title" = "офлайн-связь";
|
||||
"app_info.features.title" = "ВОЗМОЖНОСТИ";
|
||||
"app_info.how_to_use.change_channels" = "• нажми #mesh, чтобы сменить канал";
|
||||
"app_info.how_to_use.clear_chat" = "• тройной тап по чату очистит его";
|
||||
"app_info.how_to_use.commands" = "• введи /, чтобы увидеть команды";
|
||||
"app_info.how_to_use.open_sidebar" = "• нажми на иконку людей, чтобы открыть боковое меню";
|
||||
"app_info.how_to_use.set_nickname" = "• коснись своего ника, чтобы изменить его";
|
||||
"app_info.how_to_use.start_dm" = "• нажми имя пользователя, чтобы начать лс";
|
||||
"app_info.how_to_use.title" = "КАК ИСПОЛЬЗОВАТЬ";
|
||||
"app_info.privacy.ephemeral.description" = "новый id пира создаётся регулярно";
|
||||
"app_info.privacy.ephemeral.title" = "эфемерная личность";
|
||||
"app_info.privacy.no_tracking.description" = "без серверов, аккаунтов и сбора данных";
|
||||
"app_info.privacy.no_tracking.title" = "без трекинга";
|
||||
"app_info.privacy.panic.description" = "тройной тап по логотипу мгновенно очищает все данные";
|
||||
"app_info.privacy.panic.title" = "режим паники";
|
||||
"app_info.privacy.title" = "КОНФИДЕНЦИАЛЬНОСТЬ";
|
||||
"app_info.tagline" = "sidegroupchat";
|
||||
"app_info.warning.message" = "безопасность приватных сообщений ещё не прошла полный аудит. не используй для критичных случаев, пока предупреждение не исчезнет.";
|
||||
"app_info.warning.title" = "ПРЕДУПРЕЖДЕНИЕ";
|
||||
"common.cancel" = "отмена";
|
||||
"common.close" = "закрыть";
|
||||
"common.copy" = "копировать";
|
||||
"common.ok" = "OK";
|
||||
"common.toggle.off" = "выкл";
|
||||
"common.toggle.on" = "вкл";
|
||||
"common.unknown" = "неизвестно";
|
||||
"content.accessibility.add_favorite" = "добавить в избранное";
|
||||
"content.accessibility.available_nostr" = "доступно через nostr";
|
||||
"content.accessibility.back_to_main_chat" = "назад в основной чат";
|
||||
"content.accessibility.connected_mesh" = "подключено через mesh";
|
||||
"content.accessibility.encryption_status" = "статус шифрования: %@";
|
||||
"content.accessibility.location_channels" = "каналы локации";
|
||||
"content.accessibility.location_notes" = "заметки для этого места";
|
||||
"content.accessibility.open_unread_private_chat" = "открыть непрочитанный приватный чат";
|
||||
"content.accessibility.private_chat_header" = "приватный чат с %@";
|
||||
"content.accessibility.reachable_mesh" = "достижим через mesh";
|
||||
"content.accessibility.remove_favorite" = "убрать из избранного";
|
||||
"content.accessibility.send_hint_empty" = "введи сообщение для отправки";
|
||||
"content.accessibility.send_hint_ready" = "дважды тапни, чтобы отправить";
|
||||
"content.accessibility.send_message" = "отправить сообщение";
|
||||
"content.accessibility.toggle_bookmark" = "переключить закладку для #%@";
|
||||
"content.accessibility.toggle_favorite_hint" = "дважды тапни, чтобы переключить статус избранного";
|
||||
"content.accessibility.view_fingerprint_hint" = "нажми, чтобы увидеть криптографический отпечаток";
|
||||
"content.actions.block" = "заблокировать";
|
||||
"content.actions.direct_message" = "личное сообщение";
|
||||
"content.actions.hug" = "обнять";
|
||||
"content.actions.mention" = "упомянуть";
|
||||
"content.actions.slap" = "дать леща";
|
||||
"content.actions.title" = "действия";
|
||||
"content.alert.bluetooth_required.off" = "bluetooth выключен. включи bluetooth в настройках, чтобы использовать bitchat.";
|
||||
"content.alert.bluetooth_required.permission" = "bitchat нужен доступ к bluetooth, чтобы соединяться с ближайшими устройствами. включи разрешение в настройках.";
|
||||
"content.alert.bluetooth_required.settings" = "настройки";
|
||||
"content.alert.bluetooth_required.title" = "bluetooth обязателен";
|
||||
"content.alert.bluetooth_required.unsupported" = "это устройство не поддерживает bluetooth. bitchat нужен bluetooth для работы.";
|
||||
"content.alert.screenshot.message" = "скриншоты каналов местоположения раскроют твою позицию. подумай, прежде чем делиться публично.";
|
||||
"content.alert.screenshot.title" = "внимание";
|
||||
"content.commands.block" = "заблокировать или показать заблокированных";
|
||||
"content.commands.clear" = "очистить чат";
|
||||
"content.commands.favorite" = "добавить в избранное";
|
||||
"content.commands.hug" = "отправить тёплое объятие";
|
||||
"content.commands.message" = "отправить приватное сообщение";
|
||||
"content.commands.slap" = "дать кому-то пощёчину форелью";
|
||||
"content.commands.unblock" = "разблокировать пира";
|
||||
"content.commands.unfavorite" = "убрать из избранного";
|
||||
"content.commands.who" = "посмотреть, кто онлайн";
|
||||
"content.delivery.delivered_members" = "доставлено %1$d из %2$d участников";
|
||||
"content.delivery.delivered_to" = "доставлено %@";
|
||||
"content.delivery.failed" = "ошибка: %@";
|
||||
"content.delivery.read_by" = "прочитано %@";
|
||||
"content.delivery.reason.blocked" = "пользователь заблокирован";
|
||||
"content.delivery.reason.self" = "нельзя отправить себе";
|
||||
"content.delivery.reason.send_error" = "ошибка отправки";
|
||||
"content.delivery.reason.unknown_recipient" = "неизвестный получатель";
|
||||
"content.delivery.reason.unreachable" = "пир недостижим";
|
||||
"content.header.people" = "ЛЮДИ";
|
||||
"content.help.verification" = "верификация: показать мой qr или сканировать друга";
|
||||
"content.input.message_placeholder" = "напиши сообщение...";
|
||||
"content.input.nickname_placeholder" = "ник";
|
||||
"content.location.enable" = "включить локацию";
|
||||
"content.message.copy" = "копировать сообщение";
|
||||
"content.message.show_less" = "показать меньше";
|
||||
"content.message.show_more" = "показать больше";
|
||||
"content.notes.location_unavailable" = "локация недоступна";
|
||||
"content.notes.title" = "заметки";
|
||||
"content.payment.cashu" = "оплатить через cashu";
|
||||
"content.payment.lightning" = "оплатить через lightning";
|
||||
"encryption.accessibility.establishing" = "устанавливается шифрование";
|
||||
"encryption.accessibility.failed" = "шифрование не удалось";
|
||||
"encryption.accessibility.not_encrypted" = "не зашифровано";
|
||||
"encryption.accessibility.secured" = "зашифровано";
|
||||
"encryption.accessibility.verified" = "зашифровано и проверено";
|
||||
"encryption.status.establishing" = "устанавливаем шифрование...";
|
||||
"encryption.status.failed" = "шифрование не удалось";
|
||||
"encryption.status.not_encrypted" = "не зашифровано";
|
||||
"encryption.status.secured" = "зашифровано";
|
||||
"encryption.status.verified" = "зашифровано и проверено";
|
||||
"fingerprint.action.mark_verified" = "пометить как проверено";
|
||||
"fingerprint.action.remove_verification" = "удалить проверку";
|
||||
"fingerprint.badge.not_verified" = "⚠️ НЕ ПРОВЕРЕНО";
|
||||
"fingerprint.badge.verified" = "✓ ПРОВЕРЕНО";
|
||||
"fingerprint.handshake_pending" = "недоступно — handshake выполняется";
|
||||
"fingerprint.message.verified" = "ты подтвердил личность этого человека.";
|
||||
"fingerprint.message.verify_hint" = "сравни эти отпечатки с %@ по безопасному каналу.";
|
||||
"fingerprint.their_label" = "их отпечаток:";
|
||||
"fingerprint.title" = "проверка безопасности";
|
||||
"fingerprint.your_label" = "твой отпечаток:";
|
||||
"geohash_people.action.block" = "заблокировать";
|
||||
"geohash_people.action.unblock" = "разблокировать";
|
||||
"geohash_people.none_nearby" = "никого рядом...";
|
||||
"geohash_people.tooltip.blocked" = "заблокирован в geohash";
|
||||
"geohash_people.you_suffix" = " (ты)";
|
||||
"location_channels.action.open_settings" = "открыть настройки";
|
||||
"location_channels.action.remove_access" = "отключить доступ к локации";
|
||||
"location_channels.action.request_permissions" = "получить мою локацию и geohash";
|
||||
"location_channels.action.teleport" = "телепорт";
|
||||
"location_channels.bookmarked_section_title" = "закреплённые";
|
||||
"location_channels.description" = "общайся с людьми рядом через каналы geohash. делится только грубый geohash, без точного gps. твой ip скрывается за счёт маршрутизации трафика через tor.";
|
||||
"location_channels.error.invalid_geohash" = "некорректный geohash";
|
||||
"location_channels.loading_nearby" = "поиск каналов рядом…";
|
||||
"location_channels.mesh_label" = "mesh";
|
||||
"location_channels.permission_denied" = "доступ к локации запрещён. включи разрешение в настройках, чтобы использовать каналы.";
|
||||
"location_channels.subtitle_prefix" = "#%@ • %@";
|
||||
"location_channels.subtitle_with_name" = "%1$@ • %2$@";
|
||||
"location_channels.title" = "#каналы локации";
|
||||
"location_channels.tor.subtitle" = "скрывает твой ip для каналов локации. рекомендуем включить.";
|
||||
"location_channels.tor.title" = "маршрутизация tor";
|
||||
"location_levels.block" = "квартал";
|
||||
"location_levels.building" = "здание";
|
||||
"location_levels.city" = "город";
|
||||
"location_levels.neighborhood" = "район";
|
||||
"location_levels.province" = "область";
|
||||
"location_levels.region" = "регион";
|
||||
"location_notes.action.dismiss" = "закрыть";
|
||||
"location_notes.action.retry" = "повторить";
|
||||
"location_notes.description" = "добавь короткие постоянные заметки об этом месте для других.";
|
||||
"location_notes.empty_subtitle" = "стань первым, кто добавит здесь заметку.";
|
||||
"location_notes.empty_title" = "заметок пока нет";
|
||||
"location_notes.error.failed_to_send" = "не удалось отправить заметку. %@";
|
||||
"location_notes.error.no_relays" = "рядом нет георелеев. попробуй позже.";
|
||||
"location_notes.loading_notes" = "загрузка заметок…";
|
||||
"location_notes.loading_recent" = "загрузка свежих заметок…";
|
||||
"location_notes.no_relays_nearby" = "рядом нет георелеев";
|
||||
"location_notes.placeholder" = "добавь заметку для этого места";
|
||||
"location_notes.relays_paused" = "геореле недоступны; заметки приостановлены";
|
||||
"location_notes.relays_retry_hint" = "заметки зависят от георелеев. проверь подключение и попробуй снова.";
|
||||
"mesh_peers.tooltip.new_messages" = "новые сообщения";
|
||||
"system.chat.blocked" = "нельзя начать чат с %@: пользователь заблокирован.";
|
||||
"system.chat.requires_favorite" = "нельзя начать чат с %@: нужны взаимные избранные для офлайна.";
|
||||
"system.common.user" = "пользователь";
|
||||
"system.dm.blocked_generic" = "отправка невозможна: пользователь заблокирован.";
|
||||
"system.dm.blocked_recipient" = "нельзя отправить %@: пользователь заблокирован.";
|
||||
"system.dm.unreachable" = "нельзя отправить %@: адресат недоступен через mesh или nostr.";
|
||||
"system.geohash.blocked" = "%@ заблокирован в geohash-чатах";
|
||||
"system.geohash.unblocked" = "%@ разблокирован в geohash-чатах";
|
||||
"system.location.not_in_channel" = "отправка невозможна: ты не в канале локации";
|
||||
"system.location.send_failed" = "не удалось отправить в канал локации";
|
||||
"system.tor.dev_bypass" = "dev-сборка: обход tor включён.";
|
||||
"system.tor.restarted" = "tor перезапущен. маршрутизация восстановлена.";
|
||||
"system.tor.restarting" = "tor перезапускается, чтобы вернуть связь...";
|
||||
"system.tor.started" = "tor запущен. весь чат идёт через tor для приватности.";
|
||||
"system.tor.starting" = "запуск tor...";
|
||||
"verification.my_qr.accessibility_label" = "qr-код проверки";
|
||||
"verification.my_qr.title" = "отсканируй, чтобы подтвердить меня";
|
||||
"verification.my_qr.unavailable" = "qr недоступен";
|
||||
"verification.scan.paste_prompt" = "вставь содержимое qr для проверки:";
|
||||
"verification.scan.prompt_friend" = "отсканируй qr друга";
|
||||
"verification.scan.status.invalid" = "qr недействителен или просрочен";
|
||||
"verification.scan.status.no_peer" = "соответствующий пир не найден";
|
||||
"verification.scan.status.requested" = "проверка запрошена для %@";
|
||||
"verification.scan.validate" = "проверить";
|
||||
"verification.sheet.title" = "ПРОВЕРИТЬ";
|
||||
@@ -1,66 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>location_notes.header</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>#%@ • %#@note_count@</string>
|
||||
<key>note_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d заметка</string>
|
||||
<key>few</key>
|
||||
<string>%d заметки</string>
|
||||
<key>many</key>
|
||||
<string>%d заметок</string>
|
||||
<key>other</key>
|
||||
<string>%d заметки</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>location_channels.row_title</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%@ [%#@people_count@]</string>
|
||||
<key>people_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d человек</string>
|
||||
<key>few</key>
|
||||
<string>%d человека</string>
|
||||
<key>many</key>
|
||||
<string>%d человек</string>
|
||||
<key>other</key>
|
||||
<string>%d человека</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>content.accessibility.people_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@people@</string>
|
||||
<key>people</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d человек</string>
|
||||
<key>few</key>
|
||||
<string>%d человека</string>
|
||||
<key>many</key>
|
||||
<string>%d человек</string>
|
||||
<key>other</key>
|
||||
<string>%d человека</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,190 +0,0 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
bitchat (Ukrainian)
|
||||
*/
|
||||
|
||||
"app_info.app_name" = "bitchat";
|
||||
"app_info.close" = "закрити";
|
||||
"app_info.done" = "ГОТОВО";
|
||||
"app_info.features.encryption.description" = "приватні повідомлення шифруються протоколом noise";
|
||||
"app_info.features.encryption.title" = "скрізьове шифрування";
|
||||
"app_info.features.extended_range.description" = "повідомлення ретранслюються між пірами й долітають далі";
|
||||
"app_info.features.extended_range.title" = "розширена дальність";
|
||||
"app_info.features.favorites.description" = "отримуй сповіщення, коли підключаються улюблені люди";
|
||||
"app_info.features.favorites.title" = "вибране";
|
||||
"app_info.features.geohash.description" = "канали geohash для спілкування з людьми поблизу через децентралізовані анонімні ретранслятори";
|
||||
"app_info.features.geohash.title" = "локальні канали";
|
||||
"app_info.features.mentions.description" = "використовуй @nickname, щоб сповістити конкретних людей";
|
||||
"app_info.features.mentions.title" = "згадки";
|
||||
"app_info.features.offline.description" = "працює без інтернету через bluetooth low energy";
|
||||
"app_info.features.offline.title" = "офлайн-зв'язок";
|
||||
"app_info.features.title" = "МОЖЛИВОСТІ";
|
||||
"app_info.how_to_use.change_channels" = "• торкнися #mesh, щоб змінити канал";
|
||||
"app_info.how_to_use.clear_chat" = "• торкни чат тричі, щоб очистити";
|
||||
"app_info.how_to_use.commands" = "• введи /, щоб побачити команди";
|
||||
"app_info.how_to_use.open_sidebar" = "• торкни піктограму людей, щоб відкрити бічну панель";
|
||||
"app_info.how_to_use.set_nickname" = "• змінюй свій нік, торкаючись його";
|
||||
"app_info.how_to_use.start_dm" = "• торкни ім'я піра, щоб почати приватний чат";
|
||||
"app_info.how_to_use.title" = "ЯК КОРИСТУВАТИСЯ";
|
||||
"app_info.privacy.ephemeral.description" = "новий id піра генерується регулярно";
|
||||
"app_info.privacy.ephemeral.title" = "ефемерна ідентичність";
|
||||
"app_info.privacy.no_tracking.description" = "жодних серверів, обліковок чи збору даних";
|
||||
"app_info.privacy.no_tracking.title" = "без відстеження";
|
||||
"app_info.privacy.panic.description" = "тричі торкни логотип, щоб миттєво стерти всі дані";
|
||||
"app_info.privacy.panic.title" = "режим паніки";
|
||||
"app_info.privacy.title" = "КОНФІДЕНЦІЙНІСТЬ";
|
||||
"app_info.tagline" = "sidegroupchat";
|
||||
"app_info.warning.message" = "безпека приватних повідомлень ще не пройшла повний аудит. не використовуй для критичних ситуацій, поки це попередження не зникне.";
|
||||
"app_info.warning.title" = "ПОПЕРЕДЖЕННЯ";
|
||||
"common.cancel" = "скасувати";
|
||||
"common.close" = "закрити";
|
||||
"common.copy" = "скопіювати";
|
||||
"common.ok" = "OK";
|
||||
"common.toggle.off" = "вимк";
|
||||
"common.toggle.on" = "увімк";
|
||||
"common.unknown" = "невідомо";
|
||||
"content.accessibility.add_favorite" = "додати до вибраного";
|
||||
"content.accessibility.available_nostr" = "доступно через nostr";
|
||||
"content.accessibility.back_to_main_chat" = "назад до основного чату";
|
||||
"content.accessibility.connected_mesh" = "з'єднано через mesh";
|
||||
"content.accessibility.encryption_status" = "стан шифрування: %@";
|
||||
"content.accessibility.location_channels" = "канали локації";
|
||||
"content.accessibility.location_notes" = "замітки про це місце";
|
||||
"content.accessibility.open_unread_private_chat" = "відкрити непрочитаний приватний чат";
|
||||
"content.accessibility.private_chat_header" = "приватний чат з %@";
|
||||
"content.accessibility.reachable_mesh" = "досяжно через mesh";
|
||||
"content.accessibility.remove_favorite" = "видалити з вибраного";
|
||||
"content.accessibility.send_hint_empty" = "введи повідомлення для надсилання";
|
||||
"content.accessibility.send_hint_ready" = "торкни двічі, щоб надіслати";
|
||||
"content.accessibility.send_message" = "надіслати повідомлення";
|
||||
"content.accessibility.toggle_bookmark" = "перемкнути закладку для #%@";
|
||||
"content.accessibility.toggle_favorite_hint" = "торкни двічі, щоб змінити статус вибраного";
|
||||
"content.accessibility.view_fingerprint_hint" = "торкни, щоб переглянути криптографічний відбиток";
|
||||
"content.actions.block" = "заблокувати";
|
||||
"content.actions.direct_message" = "приватне повідомлення";
|
||||
"content.actions.hug" = "обійняти";
|
||||
"content.actions.mention" = "згадати";
|
||||
"content.actions.slap" = "ляпас";
|
||||
"content.actions.title" = "дії";
|
||||
"content.alert.bluetooth_required.off" = "bluetooth вимкнений. увімкни bluetooth у налаштуваннях, щоб користуватися bitchat.";
|
||||
"content.alert.bluetooth_required.permission" = "bitchat потребує дозволу bluetooth для з'єднання з пристроями поруч. ввімкни доступ у налаштуваннях.";
|
||||
"content.alert.bluetooth_required.settings" = "налаштування";
|
||||
"content.alert.bluetooth_required.title" = "потрібен bluetooth";
|
||||
"content.alert.bluetooth_required.unsupported" = "цей пристрій не підтримує bluetooth. bitchat потрібен bluetooth для роботи.";
|
||||
"content.alert.screenshot.message" = "скріншоти каналів локації розкриють твоє місце. подумай, перш ніж ділитися публічно.";
|
||||
"content.alert.screenshot.title" = "увага";
|
||||
"content.commands.block" = "заблокувати або показати заблокованих";
|
||||
"content.commands.clear" = "очистити чат";
|
||||
"content.commands.favorite" = "додати до вибраного";
|
||||
"content.commands.hug" = "відправити теплі обійми";
|
||||
"content.commands.message" = "надіслати приватне повідомлення";
|
||||
"content.commands.slap" = "лупнути когось фореллю";
|
||||
"content.commands.unblock" = "розблокувати піра";
|
||||
"content.commands.unfavorite" = "видалити з вибраного";
|
||||
"content.commands.who" = "подивитися, хто онлайн";
|
||||
"content.delivery.delivered_members" = "доставлено %1$d з %2$d учасників";
|
||||
"content.delivery.delivered_to" = "доставлено %@";
|
||||
"content.delivery.failed" = "не вдалося: %@";
|
||||
"content.delivery.read_by" = "прочитано %@";
|
||||
"content.delivery.reason.blocked" = "користувач заблокований";
|
||||
"content.delivery.reason.self" = "не можна надіслати собі";
|
||||
"content.delivery.reason.send_error" = "помилка надсилання";
|
||||
"content.delivery.reason.unknown_recipient" = "невідомий одержувач";
|
||||
"content.delivery.reason.unreachable" = "пір недосяжний";
|
||||
"content.header.people" = "ЛЮДИ";
|
||||
"content.help.verification" = "верифікація: показати мій qr або сканувати друга";
|
||||
"content.input.message_placeholder" = "напиши повідомлення...";
|
||||
"content.input.nickname_placeholder" = "нік";
|
||||
"content.location.enable" = "увімкнути локацію";
|
||||
"content.message.copy" = "скопіювати повідомлення";
|
||||
"content.message.show_less" = "показати менше";
|
||||
"content.message.show_more" = "показати більше";
|
||||
"content.notes.location_unavailable" = "локація недоступна";
|
||||
"content.notes.title" = "замітки";
|
||||
"content.payment.cashu" = "оплатити через cashu";
|
||||
"content.payment.lightning" = "оплатити через lightning";
|
||||
"encryption.accessibility.establishing" = "встановлюється шифрування";
|
||||
"encryption.accessibility.failed" = "шифрування не вдалося";
|
||||
"encryption.accessibility.not_encrypted" = "не зашифровано";
|
||||
"encryption.accessibility.secured" = "зашифровано";
|
||||
"encryption.accessibility.verified" = "зашифровано та перевірено";
|
||||
"encryption.status.establishing" = "встановлюємо шифрування...";
|
||||
"encryption.status.failed" = "шифрування не вдалося";
|
||||
"encryption.status.not_encrypted" = "не зашифровано";
|
||||
"encryption.status.secured" = "зашифровано";
|
||||
"encryption.status.verified" = "зашифровано та перевірено";
|
||||
"fingerprint.action.mark_verified" = "позначити як перевірено";
|
||||
"fingerprint.action.remove_verification" = "зняти перевірку";
|
||||
"fingerprint.badge.not_verified" = "⚠️ НЕ ПЕРЕВІРЕНО";
|
||||
"fingerprint.badge.verified" = "✓ ПЕРЕВІРЕНО";
|
||||
"fingerprint.handshake_pending" = "недоступно — handshake триває";
|
||||
"fingerprint.message.verified" = "ти підтвердив особу цієї людини.";
|
||||
"fingerprint.message.verify_hint" = "порівняй ці відбитки з %@ у безпечному каналі.";
|
||||
"fingerprint.their_label" = "їхній відбиток:";
|
||||
"fingerprint.title" = "перевірка безпеки";
|
||||
"fingerprint.your_label" = "твій відбиток:";
|
||||
"geohash_people.action.block" = "заблокувати";
|
||||
"geohash_people.action.unblock" = "розблокувати";
|
||||
"geohash_people.none_nearby" = "поруч нікого...";
|
||||
"geohash_people.tooltip.blocked" = "заблоковано в geohash";
|
||||
"geohash_people.you_suffix" = " (ти)";
|
||||
"location_channels.action.open_settings" = "відкрити налаштування";
|
||||
"location_channels.action.remove_access" = "відключити доступ до локації";
|
||||
"location_channels.action.request_permissions" = "отримати мою локацію та geohash";
|
||||
"location_channels.action.teleport" = "телепорт";
|
||||
"location_channels.bookmarked_section_title" = "закладені";
|
||||
"location_channels.description" = "спілкуйся з людьми поруч у каналах geohash. передається лише грубий geohash, без точного gps. твій ip приховується, бо весь трафік йде через tor.";
|
||||
"location_channels.error.invalid_geohash" = "некоректний geohash";
|
||||
"location_channels.loading_nearby" = "пошук каналів поруч…";
|
||||
"location_channels.mesh_label" = "mesh";
|
||||
"location_channels.permission_denied" = "доступ до локації заборонено. увімкни дозвіл у налаштуваннях, щоб користуватися каналами.";
|
||||
"location_channels.subtitle_prefix" = "#%@ • %@";
|
||||
"location_channels.subtitle_with_name" = "%1$@ • %2$@";
|
||||
"location_channels.title" = "#канали локації";
|
||||
"location_channels.tor.subtitle" = "приховує твій ip для каналів локації. рекомендовано ввімкнути.";
|
||||
"location_channels.tor.title" = "маршрутизація tor";
|
||||
"location_levels.block" = "квартал";
|
||||
"location_levels.building" = "будівля";
|
||||
"location_levels.city" = "місто";
|
||||
"location_levels.neighborhood" = "район";
|
||||
"location_levels.province" = "область";
|
||||
"location_levels.region" = "регіон";
|
||||
"location_notes.action.dismiss" = "закрити";
|
||||
"location_notes.action.retry" = "повторити";
|
||||
"location_notes.description" = "додай короткі постійні замітки про це місце для інших.";
|
||||
"location_notes.empty_subtitle" = "стань першим, хто додасть тут замітку.";
|
||||
"location_notes.empty_title" = "заміток ще немає";
|
||||
"location_notes.error.failed_to_send" = "не вдалося надіслати замітку. %@";
|
||||
"location_notes.error.no_relays" = "поруч немає гео-релеїв. спробуй пізніше.";
|
||||
"location_notes.loading_notes" = "завантаження заміток…";
|
||||
"location_notes.loading_recent" = "завантаження свіжих заміток…";
|
||||
"location_notes.no_relays_nearby" = "немає гео-релеїв поблизу";
|
||||
"location_notes.placeholder" = "додай замітку для цього місця";
|
||||
"location_notes.relays_paused" = "гео-релеї недоступні; замітки призупинено";
|
||||
"location_notes.relays_retry_hint" = "замітки залежать від гео-релеїв. перевір з'єднання й спробуй ще раз.";
|
||||
"mesh_peers.tooltip.new_messages" = "нові повідомлення";
|
||||
"system.chat.blocked" = "не можна почати чат з %@: користувач заблокований.";
|
||||
"system.chat.requires_favorite" = "не можна почати чат з %@: потрібне взаємне вибране для офлайна.";
|
||||
"system.common.user" = "користувач";
|
||||
"system.dm.blocked_generic" = "не вдалося надіслати: користувач заблокований.";
|
||||
"system.dm.blocked_recipient" = "неможливо надіслати %@: користувач заблокований.";
|
||||
"system.dm.unreachable" = "неможливо надіслати %@: одержувач недосяжний через mesh або nostr.";
|
||||
"system.geohash.blocked" = "%@ заблоковано в geohash-чатах";
|
||||
"system.geohash.unblocked" = "%@ розблоковано в geohash-чатах";
|
||||
"system.location.not_in_channel" = "не вдалося надіслати: ти не в каналі локації";
|
||||
"system.location.send_failed" = "не вдалося надіслати в канал локації";
|
||||
"system.tor.dev_bypass" = "dev-збірка: обхід tor увімкнено.";
|
||||
"system.tor.restarted" = "tor перезапущено. маршрутизацію відновлено.";
|
||||
"system.tor.restarting" = "tor перезапускається, щоб відновити підключення...";
|
||||
"system.tor.started" = "tor запущено. увесь чат іде через tor для приватності.";
|
||||
"system.tor.starting" = "запуск tor...";
|
||||
"verification.my_qr.accessibility_label" = "qr-код підтвердження";
|
||||
"verification.my_qr.title" = "скануй, щоб підтвердити мене";
|
||||
"verification.my_qr.unavailable" = "qr недоступний";
|
||||
"verification.scan.paste_prompt" = "встав вміст qr для перевірки:";
|
||||
"verification.scan.prompt_friend" = "скануй qr друга";
|
||||
"verification.scan.status.invalid" = "qr недійсний або прострочений";
|
||||
"verification.scan.status.no_peer" = "відповідний пір не знайдений";
|
||||
"verification.scan.status.requested" = "перевірка запитана для %@";
|
||||
"verification.scan.validate" = "перевірити";
|
||||
"verification.sheet.title" = "ПЕРЕВІРИТИ";
|
||||
@@ -1,66 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>location_notes.header</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>#%@ • %#@note_count@</string>
|
||||
<key>note_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d замітка</string>
|
||||
<key>few</key>
|
||||
<string>%d замітки</string>
|
||||
<key>many</key>
|
||||
<string>%d заміток</string>
|
||||
<key>other</key>
|
||||
<string>%d замітки</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>location_channels.row_title</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%@ [%#@people_count@]</string>
|
||||
<key>people_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d людина</string>
|
||||
<key>few</key>
|
||||
<string>%d людини</string>
|
||||
<key>many</key>
|
||||
<string>%d людей</string>
|
||||
<key>other</key>
|
||||
<string>%d людини</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>content.accessibility.people_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@people@</string>
|
||||
<key>people</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d людина</string>
|
||||
<key>few</key>
|
||||
<string>%d людини</string>
|
||||
<key>many</key>
|
||||
<string>%d людей</string>
|
||||
<key>other</key>
|
||||
<string>%d людини</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,190 +0,0 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
bitchat (Simplified Chinese)
|
||||
*/
|
||||
|
||||
"app_info.app_name" = "bitchat";
|
||||
"app_info.close" = "关闭";
|
||||
"app_info.done" = "完成";
|
||||
"app_info.features.encryption.description" = "私密消息使用 noise 协议加密";
|
||||
"app_info.features.encryption.title" = "端到端加密";
|
||||
"app_info.features.extended_range.description" = "消息通过同伴中继,传得更远";
|
||||
"app_info.features.extended_range.title" = "扩展范围";
|
||||
"app_info.features.favorites.description" = "你喜欢的人加入时立刻提醒";
|
||||
"app_info.features.favorites.title" = "收藏";
|
||||
"app_info.features.geohash.description" = "geohash 频道让你通过去中心化匿名中继与附近地区的人聊天";
|
||||
"app_info.features.geohash.title" = "本地频道";
|
||||
"app_info.features.mentions.description" = "使用 @nickname 提醒特定的人";
|
||||
"app_info.features.mentions.title" = "提及";
|
||||
"app_info.features.offline.description" = "利用低功耗 bluetooth 离线工作";
|
||||
"app_info.features.offline.title" = "离线通信";
|
||||
"app_info.features.title" = "功能";
|
||||
"app_info.how_to_use.change_channels" = "• 轻点 #mesh 切换频道";
|
||||
"app_info.how_to_use.clear_chat" = "• 三击聊天即可清除";
|
||||
"app_info.how_to_use.commands" = "• 输入 / 查看指令";
|
||||
"app_info.how_to_use.open_sidebar" = "• 轻点人物图标打开侧栏";
|
||||
"app_info.how_to_use.set_nickname" = "• 轻点昵称即可设置";
|
||||
"app_info.how_to_use.start_dm" = "• 轻点同伴名字开始 dm";
|
||||
"app_info.how_to_use.title" = "使用方法";
|
||||
"app_info.privacy.ephemeral.description" = "定期生成新的 peer id";
|
||||
"app_info.privacy.ephemeral.title" = "临时身份";
|
||||
"app_info.privacy.no_tracking.description" = "无服务器、无账号、无数据收集";
|
||||
"app_info.privacy.no_tracking.title" = "无跟踪";
|
||||
"app_info.privacy.panic.description" = "三击标志立即清除全部数据";
|
||||
"app_info.privacy.panic.title" = "紧急模式";
|
||||
"app_info.privacy.title" = "隐私";
|
||||
"app_info.tagline" = "sidegroupchat";
|
||||
"app_info.warning.message" = "私信安全尚未完全审计。在此警告消失前不要用于关键情境。";
|
||||
"app_info.warning.title" = "警告";
|
||||
"common.cancel" = "取消";
|
||||
"common.close" = "关闭";
|
||||
"common.copy" = "复制";
|
||||
"common.ok" = "确定";
|
||||
"common.toggle.off" = "关闭";
|
||||
"common.toggle.on" = "开启";
|
||||
"common.unknown" = "未知";
|
||||
"content.accessibility.add_favorite" = "加入收藏";
|
||||
"content.accessibility.available_nostr" = "通过 Nostr 可用";
|
||||
"content.accessibility.back_to_main_chat" = "返回主聊天";
|
||||
"content.accessibility.connected_mesh" = "通过 mesh 已连接";
|
||||
"content.accessibility.encryption_status" = "加密状态:%@";
|
||||
"content.accessibility.location_channels" = "位置频道";
|
||||
"content.accessibility.location_notes" = "此位置的笔记";
|
||||
"content.accessibility.open_unread_private_chat" = "打开未读私聊";
|
||||
"content.accessibility.private_chat_header" = "与 %@ 的私聊";
|
||||
"content.accessibility.reachable_mesh" = "可通过 mesh 到达";
|
||||
"content.accessibility.remove_favorite" = "移出收藏";
|
||||
"content.accessibility.send_hint_empty" = "输入要发送的消息";
|
||||
"content.accessibility.send_hint_ready" = "双击发送";
|
||||
"content.accessibility.send_message" = "发送消息";
|
||||
"content.accessibility.toggle_bookmark" = "切换 #%@ 的书签";
|
||||
"content.accessibility.toggle_favorite_hint" = "双击切换收藏状态";
|
||||
"content.accessibility.view_fingerprint_hint" = "轻点查看加密指纹";
|
||||
"content.actions.block" = "屏蔽";
|
||||
"content.actions.direct_message" = "私信";
|
||||
"content.actions.hug" = "拥抱";
|
||||
"content.actions.mention" = "提及";
|
||||
"content.actions.slap" = "拍打";
|
||||
"content.actions.title" = "操作";
|
||||
"content.alert.bluetooth_required.off" = "bluetooth 已关闭。请在设置中开启以使用 bitchat。";
|
||||
"content.alert.bluetooth_required.permission" = "bitchat 需要 bluetooth 权限以连接附近设备。请在设置中启用访问。";
|
||||
"content.alert.bluetooth_required.settings" = "设置";
|
||||
"content.alert.bluetooth_required.title" = "需要 bluetooth";
|
||||
"content.alert.bluetooth_required.unsupported" = "此设备不支持 bluetooth。bitchat 需要 bluetooth 才能运行。";
|
||||
"content.alert.screenshot.message" = "位置频道的截图会暴露你的位置。公开分享前请三思。";
|
||||
"content.alert.screenshot.title" = "注意";
|
||||
"content.commands.block" = "屏蔽或查看已屏蔽的同伴";
|
||||
"content.commands.clear" = "清除聊天消息";
|
||||
"content.commands.favorite" = "加入收藏";
|
||||
"content.commands.hug" = "送出温暖拥抱";
|
||||
"content.commands.message" = "发送私信";
|
||||
"content.commands.slap" = "用鳟鱼拍某人";
|
||||
"content.commands.unblock" = "取消屏蔽同伴";
|
||||
"content.commands.unfavorite" = "移出收藏";
|
||||
"content.commands.who" = "查看谁在线";
|
||||
"content.delivery.delivered_members" = "已送达 %2$d 人中的 %1$d 人";
|
||||
"content.delivery.delivered_to" = "已送达 %@";
|
||||
"content.delivery.failed" = "失败:%@";
|
||||
"content.delivery.read_by" = "已读:%@";
|
||||
"content.delivery.reason.blocked" = "用户已被屏蔽";
|
||||
"content.delivery.reason.self" = "不能给自己发消息";
|
||||
"content.delivery.reason.send_error" = "发送错误";
|
||||
"content.delivery.reason.unknown_recipient" = "未知收件人";
|
||||
"content.delivery.reason.unreachable" = "同伴不可达";
|
||||
"content.header.people" = "成员";
|
||||
"content.help.verification" = "验证:展示我的 qr 或扫描好友";
|
||||
"content.input.message_placeholder" = "输入消息...";
|
||||
"content.input.nickname_placeholder" = "昵称";
|
||||
"content.location.enable" = "启用位置";
|
||||
"content.message.copy" = "复制消息";
|
||||
"content.message.show_less" = "收起";
|
||||
"content.message.show_more" = "展开";
|
||||
"content.notes.location_unavailable" = "位置不可用";
|
||||
"content.notes.title" = "笔记";
|
||||
"content.payment.cashu" = "通过 cashu 支付";
|
||||
"content.payment.lightning" = "通过 lightning 支付";
|
||||
"encryption.accessibility.establishing" = "正在建立加密";
|
||||
"encryption.accessibility.failed" = "加密失败";
|
||||
"encryption.accessibility.not_encrypted" = "未加密";
|
||||
"encryption.accessibility.secured" = "已加密";
|
||||
"encryption.accessibility.verified" = "已加密并验证";
|
||||
"encryption.status.establishing" = "正在建立加密...";
|
||||
"encryption.status.failed" = "加密失败";
|
||||
"encryption.status.not_encrypted" = "未加密";
|
||||
"encryption.status.secured" = "已加密";
|
||||
"encryption.status.verified" = "已加密并验证";
|
||||
"fingerprint.action.mark_verified" = "标记为已验证";
|
||||
"fingerprint.action.remove_verification" = "移除验证";
|
||||
"fingerprint.badge.not_verified" = "⚠️ 未验证";
|
||||
"fingerprint.badge.verified" = "✓ 已验证";
|
||||
"fingerprint.handshake_pending" = "暂不可用 - handshake 进行中";
|
||||
"fingerprint.message.verified" = "你已经核实了此人的身份。";
|
||||
"fingerprint.message.verify_hint" = "通过安全渠道与 %@ 比对这些指纹。";
|
||||
"fingerprint.their_label" = "对方指纹:";
|
||||
"fingerprint.title" = "安全验证";
|
||||
"fingerprint.your_label" = "你的指纹:";
|
||||
"geohash_people.action.block" = "屏蔽";
|
||||
"geohash_people.action.unblock" = "取消屏蔽";
|
||||
"geohash_people.none_nearby" = "附近没人...";
|
||||
"geohash_people.tooltip.blocked" = "在 geohash 中已屏蔽";
|
||||
"geohash_people.you_suffix" = " (你)";
|
||||
"location_channels.action.open_settings" = "打开设置";
|
||||
"location_channels.action.remove_access" = "移除位置访问";
|
||||
"location_channels.action.request_permissions" = "获取位置和我的 geohash";
|
||||
"location_channels.action.teleport" = "瞬移";
|
||||
"location_channels.bookmarked_section_title" = "已收藏";
|
||||
"location_channels.description" = "使用 geohash 频道与附近的人聊天。只会共享粗略 geohash,从不泄露精确 GPS。所有流量通过 tor 路由来隐藏你的 IP。";
|
||||
"location_channels.error.invalid_geohash" = "无效的 geohash";
|
||||
"location_channels.loading_nearby" = "正在寻找附近频道…";
|
||||
"location_channels.mesh_label" = "mesh";
|
||||
"location_channels.permission_denied" = "位置权限被拒。请在设置中启用以使用位置频道。";
|
||||
"location_channels.subtitle_prefix" = "#%@ • %@";
|
||||
"location_channels.subtitle_with_name" = "%1$@ • %2$@";
|
||||
"location_channels.title" = "#位置频道";
|
||||
"location_channels.tor.subtitle" = "为位置频道隐藏你的 IP。推荐:开启。";
|
||||
"location_channels.tor.title" = "tor 路由";
|
||||
"location_levels.block" = "街区";
|
||||
"location_levels.building" = "楼栋";
|
||||
"location_levels.city" = "城市";
|
||||
"location_levels.neighborhood" = "社区";
|
||||
"location_levels.province" = "省份";
|
||||
"location_levels.region" = "区域";
|
||||
"location_notes.action.dismiss" = "关闭";
|
||||
"location_notes.action.retry" = "重试";
|
||||
"location_notes.description" = "为此地点添加简短的常驻笔记,方便其他访客发现。";
|
||||
"location_notes.empty_subtitle" = "成为这里的第一条笔记。";
|
||||
"location_notes.empty_title" = "尚无笔记";
|
||||
"location_notes.error.failed_to_send" = "无法发送笔记。%@";
|
||||
"location_notes.error.no_relays" = "附近没有可用的地理中继。稍后再试。";
|
||||
"location_notes.loading_notes" = "正在加载笔记…";
|
||||
"location_notes.loading_recent" = "正在加载最新笔记…";
|
||||
"location_notes.no_relays_nearby" = "附近没有地理中继";
|
||||
"location_notes.placeholder" = "为此地点添加笔记";
|
||||
"location_notes.relays_paused" = "地理中继不可用;笔记已暂停";
|
||||
"location_notes.relays_retry_hint" = "笔记依赖地理中继。检查连接后再试。";
|
||||
"mesh_peers.tooltip.new_messages" = "新消息";
|
||||
"system.chat.blocked" = "无法与 %@ 开始聊天:用户已被屏蔽。";
|
||||
"system.chat.requires_favorite" = "无法与 %@ 开始聊天:离线消息需要互相关注。";
|
||||
"system.common.user" = "用户";
|
||||
"system.dm.blocked_generic" = "无法发送:用户已被屏蔽。";
|
||||
"system.dm.blocked_recipient" = "无法向 %@ 发送:用户已被屏蔽。";
|
||||
"system.dm.unreachable" = "无法向 %@ 发送:对方无法通过 mesh 或 Nostr 到达。";
|
||||
"system.geohash.blocked" = "已在 geohash 聊天中屏蔽 %@";
|
||||
"system.geohash.unblocked" = "已在 geohash 聊天中解除屏蔽 %@";
|
||||
"system.location.not_in_channel" = "发送失败:你不在位置频道中";
|
||||
"system.location.send_failed" = "无法发送到位置频道";
|
||||
"system.tor.dev_bypass" = "开发构建:tor 绕过已启用。";
|
||||
"system.tor.restarted" = "tor 已重启。网络路由已恢复。";
|
||||
"system.tor.restarting" = "tor 正在重启以恢复连接...";
|
||||
"system.tor.started" = "tor 已启动。所有聊天通过 tor 路由以保护 IP。";
|
||||
"system.tor.starting" = "正在启动 tor...";
|
||||
"verification.my_qr.accessibility_label" = "验证 QR 码";
|
||||
"verification.my_qr.title" = "扫描验证我";
|
||||
"verification.my_qr.unavailable" = "QR 不可用";
|
||||
"verification.scan.paste_prompt" = "粘贴 QR 内容以验证:";
|
||||
"verification.scan.prompt_friend" = "扫描好友的 QR";
|
||||
"verification.scan.status.invalid" = "QR 无效或已过期";
|
||||
"verification.scan.status.no_peer" = "未找到匹配的同伴";
|
||||
"verification.scan.status.requested" = "已请求 %@ 的验证";
|
||||
"verification.scan.validate" = "验证";
|
||||
"verification.sheet.title" = "验证";
|
||||
@@ -1,54 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>location_notes.header</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>#%@ • %#@note_count@</string>
|
||||
<key>note_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d 条笔记</string>
|
||||
<key>other</key>
|
||||
<string>%d 条笔记</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>location_channels.row_title</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%@ [%#@people_count@]</string>
|
||||
<key>people_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d 人</string>
|
||||
<key>other</key>
|
||||
<string>%d 人</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>content.accessibility.people_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@people@</string>
|
||||
<key>people</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d 人</string>
|
||||
<key>other</key>
|
||||
<string>%d 人</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -21,7 +21,7 @@ final class BitchatMessage: Codable {
|
||||
let originalSender: String?
|
||||
let isPrivate: Bool
|
||||
let recipientNickname: String?
|
||||
let senderPeerID: String?
|
||||
let senderPeerID: PeerID?
|
||||
let mentions: [String]? // Array of mentioned nicknames
|
||||
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
||||
|
||||
@@ -42,7 +42,19 @@ final class BitchatMessage: Codable {
|
||||
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
|
||||
}
|
||||
|
||||
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil) {
|
||||
init(
|
||||
id: String? = nil,
|
||||
sender: String,
|
||||
content: String,
|
||||
timestamp: Date,
|
||||
isRelay: Bool,
|
||||
originalSender: String? = nil,
|
||||
isPrivate: Bool = false,
|
||||
recipientNickname: String? = nil,
|
||||
senderPeerID: PeerID? = nil,
|
||||
mentions: [String]? = nil,
|
||||
deliveryStatus: DeliveryStatus? = nil
|
||||
) {
|
||||
self.id = id ?? UUID().uuidString
|
||||
self.sender = sender
|
||||
self.content = content
|
||||
@@ -151,7 +163,7 @@ extension BitchatMessage {
|
||||
data.append(recipData.prefix(255))
|
||||
}
|
||||
|
||||
if let senderPeerID = senderPeerID, let peerData = senderPeerID.data(using: .utf8) {
|
||||
if let peerData = senderPeerID?.id.data(using: .utf8) {
|
||||
data.append(UInt8(min(peerData.count, 255)))
|
||||
data.append(peerData.prefix(255))
|
||||
}
|
||||
@@ -264,11 +276,11 @@ extension BitchatMessage {
|
||||
}
|
||||
}
|
||||
|
||||
var senderPeerID: String?
|
||||
var senderPeerID: PeerID?
|
||||
if hasSenderPeerID && offset < dataCopy.count {
|
||||
let length = Int(dataCopy[offset]); offset += 1
|
||||
if offset + length <= dataCopy.count {
|
||||
senderPeerID = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
|
||||
senderPeerID = PeerID(data: dataCopy[offset..<offset+length])
|
||||
offset += length
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,10 @@ struct BitchatPacket: Codable {
|
||||
let payload: Data
|
||||
var signature: Data?
|
||||
var ttl: UInt8
|
||||
var route: [Data]?
|
||||
|
||||
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
|
||||
self.version = 1
|
||||
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil) {
|
||||
self.version = version
|
||||
self.type = type
|
||||
self.senderID = senderID
|
||||
self.recipientID = recipientID
|
||||
@@ -31,15 +32,16 @@ struct BitchatPacket: Codable {
|
||||
self.payload = payload
|
||||
self.signature = signature
|
||||
self.ttl = ttl
|
||||
self.route = route
|
||||
}
|
||||
|
||||
// Convenience initializer for new binary format
|
||||
init(type: UInt8, ttl: UInt8, senderID: String, payload: Data) {
|
||||
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data) {
|
||||
self.version = 1
|
||||
self.type = type
|
||||
// Convert hex string peer ID to binary data (8 bytes)
|
||||
var senderData = Data()
|
||||
var tempID = senderID
|
||||
var tempID = senderID.id
|
||||
while tempID.count >= 2 {
|
||||
let hexByte = String(tempID.prefix(2))
|
||||
if let byte = UInt8(hexByte, radix: 16) {
|
||||
@@ -53,6 +55,7 @@ struct BitchatPacket: Codable {
|
||||
self.payload = payload
|
||||
self.signature = nil
|
||||
self.ttl = ttl
|
||||
self.route = nil
|
||||
}
|
||||
|
||||
var data: Data? {
|
||||
@@ -80,7 +83,9 @@ struct BitchatPacket: Codable {
|
||||
timestamp: timestamp,
|
||||
payload: payload,
|
||||
signature: nil, // Remove signature for signing
|
||||
ttl: 0 // Use fixed TTL=0 for signing to ensure relay compatibility
|
||||
ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility
|
||||
version: version,
|
||||
route: route
|
||||
)
|
||||
return BinaryProtocol.encode(unsignedPacket)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import Foundation
|
||||
import CoreBluetooth
|
||||
|
||||
/// Represents a peer in the BitChat network with all associated metadata
|
||||
struct BitchatPeer: Identifiable, Equatable {
|
||||
let id: String // Hex-encoded peer ID
|
||||
struct BitchatPeer: Equatable {
|
||||
let peerID: PeerID // Hex-encoded peer ID
|
||||
let noisePublicKey: Data
|
||||
let nickname: String
|
||||
let lastSeen: Date
|
||||
@@ -51,7 +51,7 @@ struct BitchatPeer: Identifiable, Equatable {
|
||||
|
||||
// Display helpers
|
||||
var displayName: String {
|
||||
nickname.isEmpty ? String(id.prefix(8)) : nickname
|
||||
nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname
|
||||
}
|
||||
|
||||
var statusIcon: String {
|
||||
@@ -73,14 +73,14 @@ struct BitchatPeer: Identifiable, Equatable {
|
||||
|
||||
// Initialize from mesh service data
|
||||
init(
|
||||
id: String,
|
||||
peerID: PeerID,
|
||||
noisePublicKey: Data,
|
||||
nickname: String,
|
||||
lastSeen: Date = Date(),
|
||||
isConnected: Bool = false,
|
||||
isReachable: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.peerID = peerID
|
||||
self.noisePublicKey = noisePublicKey
|
||||
self.nickname = nickname
|
||||
self.lastSeen = lastSeen
|
||||
@@ -93,8 +93,6 @@ struct BitchatPeer: Identifiable, Equatable {
|
||||
}
|
||||
|
||||
static func == (lhs: BitchatPeer, rhs: BitchatPeer) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
lhs.peerID == rhs.peerID
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//
|
||||
// CommandsInfo.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - CommandInfo Enum
|
||||
|
||||
enum CommandInfo: String, Identifiable {
|
||||
case block
|
||||
case clear
|
||||
case hug
|
||||
case message = "dm"
|
||||
case slap
|
||||
case unblock
|
||||
case who
|
||||
case favorite
|
||||
case unfavorite
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var alias: String { "/" + rawValue }
|
||||
|
||||
var placeholder: String? {
|
||||
switch self {
|
||||
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
|
||||
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
||||
case .clear, .who:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .block: String(localized: "content.commands.block")
|
||||
case .clear: String(localized: "content.commands.clear")
|
||||
case .hug: String(localized: "content.commands.hug")
|
||||
case .message: String(localized: "content.commands.message")
|
||||
case .slap: String(localized: "content.commands.slap")
|
||||
case .unblock: String(localized: "content.commands.unblock")
|
||||
case .who: String(localized: "content.commands.who")
|
||||
case .favorite: String(localized: "content.commands.favorite")
|
||||
case .unfavorite: String(localized: "content.commands.unfavorite")
|
||||
}
|
||||
}
|
||||
|
||||
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
||||
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who]
|
||||
if isGeoPublic || isGeoDM {
|
||||
return baseCommands + [.favorite, .unfavorite]
|
||||
}
|
||||
return baseCommands
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//
|
||||
// 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).lowercased()
|
||||
}
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
|
||||
/// Convenience init to "hide" hex-encoding implementation detail
|
||||
init?(hexData: Data?) {
|
||||
guard let hexData else { return nil }
|
||||
self.init(hexData: hexData)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
extension PeerID {
|
||||
var routingData: Data? {
|
||||
if let direct = Data(hexString: id), direct.count == 8 { return direct }
|
||||
if let bareData = Data(hexString: bare), bareData.count == 8 { return bareData }
|
||||
let short = toShort()
|
||||
return Data(hexString: short.id)
|
||||
}
|
||||
|
||||
init?(routingData: Data) {
|
||||
guard routingData.count == 8 else { return nil }
|
||||
self.init(hexData: routingData)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
/// Returns true if the `bare` id is all hex
|
||||
var isHex: Bool {
|
||||
bare.allSatisfy { $0.isHexDigit }
|
||||
}
|
||||
|
||||
/// Short routing IDs (exact 16-hex)
|
||||
var isShort: Bool {
|
||||
bare.count == Constants.hexIDLength && isHex
|
||||
}
|
||||
|
||||
/// 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: - CustomStringConvertible
|
||||
|
||||
extension PeerID: CustomStringConvertible {
|
||||
/// So it returns the actual `id` like before even inside another String
|
||||
var description: String {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,11 @@ import Foundation
|
||||
struct ReadReceipt: Codable {
|
||||
let originalMessageID: String
|
||||
let receiptID: String
|
||||
var readerID: String // Who read it
|
||||
var readerID: PeerID // Who read it
|
||||
let readerNickname: String
|
||||
let timestamp: Date
|
||||
|
||||
init(originalMessageID: String, readerID: String, readerNickname: String) {
|
||||
init(originalMessageID: String, readerID: PeerID, readerNickname: String) {
|
||||
self.originalMessageID = originalMessageID
|
||||
self.receiptID = UUID().uuidString
|
||||
self.readerID = readerID
|
||||
@@ -24,7 +24,7 @@ struct ReadReceipt: Codable {
|
||||
}
|
||||
|
||||
// For binary decoding
|
||||
private init(originalMessageID: String, receiptID: String, readerID: String, readerNickname: String, timestamp: Date) {
|
||||
private init(originalMessageID: String, receiptID: String, readerID: PeerID, readerNickname: String, timestamp: Date) {
|
||||
self.originalMessageID = originalMessageID
|
||||
self.receiptID = receiptID
|
||||
self.readerID = readerID
|
||||
@@ -48,7 +48,7 @@ struct ReadReceipt: Codable {
|
||||
data.appendUUID(receiptID)
|
||||
// ReaderID as 8-byte hex string
|
||||
var readerData = Data()
|
||||
var tempID = readerID
|
||||
var tempID = readerID.id
|
||||
while tempID.count >= 2 && readerData.count < 8 {
|
||||
let hexByte = String(tempID.prefix(2))
|
||||
if let byte = UInt8(hexByte, radix: 16) {
|
||||
@@ -78,8 +78,8 @@ struct ReadReceipt: Codable {
|
||||
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
|
||||
|
||||
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
||||
let readerID = readerIDData.hexEncodedString()
|
||||
guard InputValidator.validatePeerID(readerID) else { return nil }
|
||||
let readerID = PeerID(hexData: readerIDData)
|
||||
guard readerID.isValid else { return nil }
|
||||
|
||||
guard let timestamp = dataCopy.readDate(at: &offset),
|
||||
InputValidator.validateTimestamp(timestamp),
|
||||
|
||||
@@ -8,6 +8,14 @@ struct RequestSyncPacket {
|
||||
let p: Int
|
||||
let m: UInt32
|
||||
let data: Data
|
||||
let types: SyncTypeFlags?
|
||||
|
||||
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil) {
|
||||
self.p = p
|
||||
self.m = m
|
||||
self.data = data
|
||||
self.types = types
|
||||
}
|
||||
|
||||
func encode() -> Data {
|
||||
var out = Data()
|
||||
@@ -25,6 +33,9 @@ struct RequestSyncPacket {
|
||||
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
|
||||
// data
|
||||
putTLV(0x03, data)
|
||||
if let typesData = types?.toData() {
|
||||
putTLV(0x04, typesData)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -33,6 +44,7 @@ struct RequestSyncPacket {
|
||||
var p: Int? = nil
|
||||
var m: UInt32? = nil
|
||||
var payload: Data? = nil
|
||||
var types: SyncTypeFlags? = nil
|
||||
|
||||
while off + 3 <= data.count {
|
||||
let t = Int(data[off]); off += 1
|
||||
@@ -52,12 +64,16 @@ struct RequestSyncPacket {
|
||||
case 0x03:
|
||||
if v.count > maxAcceptBytes { return nil }
|
||||
payload = v
|
||||
case 0x04:
|
||||
if let decoded = SyncTypeFlags.decode(v) {
|
||||
types = decoded
|
||||
}
|
||||
default:
|
||||
break // forward compatible; ignore unknown TLVs
|
||||
}
|
||||
}
|
||||
|
||||
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
|
||||
return RequestSyncPacket(p: pp, m: mm, data: dd)
|
||||
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ final class NoiseCipherState {
|
||||
guard combinedPayload.count >= Self.NONCE_SIZE_BYTES else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// Extract 4-byte nonce (big-endian)
|
||||
let nonceData = combinedPayload.prefix(Self.NONCE_SIZE_BYTES)
|
||||
let extractedNonce = nonceData.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) -> UInt64 in
|
||||
@@ -233,18 +233,18 @@ final class NoiseCipherState {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
// Extract ciphertext (remaining bytes)
|
||||
let ciphertext = combinedPayload.dropFirst(Self.NONCE_SIZE_BYTES)
|
||||
|
||||
|
||||
return (nonce: extractedNonce, ciphertext: Data(ciphertext))
|
||||
}
|
||||
|
||||
|
||||
/// Convert nonce to 4-byte array (big-endian)
|
||||
private func nonceToBytes(_ nonce: UInt64) -> Data {
|
||||
var bytes = Data(count: Self.NONCE_SIZE_BYTES)
|
||||
withUnsafeBytes(of: nonce.bigEndian) { ptr in
|
||||
// Copy only the last 4 bytes from the 8-byte UInt64
|
||||
// Copy only the last 4 bytes from the 8-byte UInt64
|
||||
let sourceBytes = ptr.bindMemory(to: UInt8.self)
|
||||
bytes.replaceSubrange(0..<Self.NONCE_SIZE_BYTES, with: sourceBytes.suffix(Self.NONCE_SIZE_BYTES))
|
||||
}
|
||||
@@ -273,7 +273,7 @@ final class NoiseCipherState {
|
||||
let sealedBox = try ChaChaPoly.seal(plaintext, using: key, nonce: ChaChaPoly.Nonce(data: nonceData), authenticating: associatedData)
|
||||
// increment local nonce
|
||||
nonce += 1
|
||||
|
||||
|
||||
// Create combined payload: <nonce><ciphertext>
|
||||
let combinedPayload: Data
|
||||
if (useExtractedNonce) {
|
||||
@@ -287,7 +287,7 @@ final class NoiseCipherState {
|
||||
if currentNonce > Self.HIGH_NONCE_WARNING_THRESHOLD {
|
||||
SecureLogger.warning("High nonce value detected: \(currentNonce) - consider rekeying", category: .encryption)
|
||||
}
|
||||
|
||||
|
||||
return combinedPayload
|
||||
}
|
||||
|
||||
@@ -316,7 +316,7 @@ final class NoiseCipherState {
|
||||
SecureLogger.debug("Replay attack detected: nonce \(extractedNonce) rejected")
|
||||
throw NoiseError.replayDetected
|
||||
}
|
||||
|
||||
|
||||
// Split ciphertext and tag
|
||||
encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16)
|
||||
tag = actualCiphertext.suffix(16)
|
||||
@@ -397,7 +397,7 @@ final class NoiseSymmetricState {
|
||||
if nameData.count <= 32 {
|
||||
self.hash = nameData + Data(repeating: 0, count: 32 - nameData.count)
|
||||
} else {
|
||||
self.hash = Data(SHA256.hash(data: nameData))
|
||||
self.hash = nameData.sha256Hash()
|
||||
}
|
||||
self.chainingKey = self.hash
|
||||
}
|
||||
@@ -410,7 +410,7 @@ final class NoiseSymmetricState {
|
||||
}
|
||||
|
||||
func mixHash(_ data: Data) {
|
||||
hash = Data(SHA256.hash(data: hash + data))
|
||||
hash = (hash + data).sha256Hash()
|
||||
}
|
||||
|
||||
func mixKeyAndHash(_ inputKeyMaterial: Data) {
|
||||
@@ -451,13 +451,13 @@ final class NoiseSymmetricState {
|
||||
}
|
||||
}
|
||||
|
||||
func split() -> (NoiseCipherState, NoiseCipherState) {
|
||||
func split(useExtractedNonce: Bool) -> (NoiseCipherState, NoiseCipherState) {
|
||||
let output = hkdf(chainingKey: chainingKey, inputKeyMaterial: Data(), numOutputs: 2)
|
||||
let tempKey1 = SymmetricKey(data: output[0])
|
||||
let tempKey2 = SymmetricKey(data: output[1])
|
||||
|
||||
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: true)
|
||||
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: true)
|
||||
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: useExtractedNonce)
|
||||
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: useExtractedNonce)
|
||||
|
||||
return (c1, c2)
|
||||
}
|
||||
@@ -507,16 +507,24 @@ final class NoiseHandshakeState {
|
||||
private var messagePatterns: [[NoiseMessagePattern]] = []
|
||||
private var currentPattern = 0
|
||||
|
||||
// Test support: predetermined ephemeral keys for test vectors
|
||||
private var predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey?
|
||||
private var prologueData: Data
|
||||
|
||||
init(
|
||||
role: NoiseRole,
|
||||
pattern: NoisePattern,
|
||||
keychain: KeychainManagerProtocol,
|
||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil,
|
||||
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil
|
||||
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil,
|
||||
prologue: Data = Data(),
|
||||
predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey? = nil
|
||||
) {
|
||||
self.role = role
|
||||
self.pattern = pattern
|
||||
self.keychain = keychain
|
||||
self.prologueData = prologue
|
||||
self.predeterminedEphemeralKey = predeterminedEphemeralKey
|
||||
|
||||
// Initialize static keys
|
||||
if let localKey = localStaticKey {
|
||||
@@ -537,8 +545,8 @@ final class NoiseHandshakeState {
|
||||
}
|
||||
|
||||
private func mixPreMessageKeys() {
|
||||
// Mix prologue (empty for XX pattern normally)
|
||||
symmetricState.mixHash(Data()) // Empty prologue for XX pattern
|
||||
// Mix prologue
|
||||
symmetricState.mixHash(self.prologueData)
|
||||
// For XX pattern, no pre-message keys
|
||||
// For IK/NK patterns, we'd mix the responder's static key here
|
||||
switch pattern {
|
||||
@@ -556,15 +564,20 @@ final class NoiseHandshakeState {
|
||||
guard currentPattern < messagePatterns.count else {
|
||||
throw NoiseError.handshakeComplete
|
||||
}
|
||||
|
||||
|
||||
var messageBuffer = Data()
|
||||
let patterns = messagePatterns[currentPattern]
|
||||
|
||||
for pattern in patterns {
|
||||
switch pattern {
|
||||
case .e:
|
||||
// Generate ephemeral key
|
||||
localEphemeralPrivate = Curve25519.KeyAgreement.PrivateKey()
|
||||
// Generate ephemeral key (or use predetermined key for tests)
|
||||
if let predetermined = predeterminedEphemeralKey {
|
||||
localEphemeralPrivate = predetermined
|
||||
predeterminedEphemeralKey = nil
|
||||
} else {
|
||||
localEphemeralPrivate = Curve25519.KeyAgreement.PrivateKey()
|
||||
}
|
||||
localEphemeralPublic = localEphemeralPrivate!.publicKey
|
||||
messageBuffer.append(localEphemeralPublic!.rawRepresentation)
|
||||
symmetricState.mixHash(localEphemeralPublic!.rawRepresentation)
|
||||
@@ -652,7 +665,7 @@ final class NoiseHandshakeState {
|
||||
guard currentPattern < messagePatterns.count else {
|
||||
throw NoiseError.handshakeComplete
|
||||
}
|
||||
|
||||
|
||||
var buffer = message
|
||||
let patterns = messagePatterns[currentPattern]
|
||||
|
||||
@@ -767,7 +780,7 @@ final class NoiseHandshakeState {
|
||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)
|
||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||
|
||||
default:
|
||||
case .e, .s:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -776,12 +789,12 @@ final class NoiseHandshakeState {
|
||||
return currentPattern >= messagePatterns.count
|
||||
}
|
||||
|
||||
func getTransportCiphers() throws -> (send: NoiseCipherState, receive: NoiseCipherState) {
|
||||
func getTransportCiphers(useExtractedNonce: Bool) throws -> (send: NoiseCipherState, receive: NoiseCipherState) {
|
||||
guard isHandshakeComplete() else {
|
||||
throw NoiseError.handshakeNotComplete
|
||||
}
|
||||
|
||||
let (c1, c2) = symmetricState.split()
|
||||
let (c1, c2) = symmetricState.split(useExtractedNonce: useExtractedNonce)
|
||||
|
||||
// Initiator uses c1 for sending, c2 for receiving
|
||||
// Responder uses c2 for sending, c1 for receiving
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
//
|
||||
// NoiseRateLimiter.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
final class NoiseRateLimiter {
|
||||
private var handshakeTimestamps: [PeerID: [Date]] = [:]
|
||||
private var messageTimestamps: [PeerID: [Date]] = [:]
|
||||
|
||||
// Global rate limiting
|
||||
private var globalHandshakeTimestamps: [Date] = []
|
||||
private var globalMessageTimestamps: [Date] = []
|
||||
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
||||
|
||||
func allowHandshake(from peerID: PeerID) -> Bool {
|
||||
return queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
let oneMinuteAgo = now.addingTimeInterval(-60)
|
||||
|
||||
// Check global rate limit first
|
||||
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
|
||||
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
|
||||
SecureLogger.warning("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check per-peer rate limit
|
||||
var timestamps = handshakeTimestamps[peerID] ?? []
|
||||
timestamps = timestamps.filter { $0 > oneMinuteAgo }
|
||||
|
||||
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
|
||||
SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Record new handshake
|
||||
timestamps.append(now)
|
||||
handshakeTimestamps[peerID] = timestamps
|
||||
globalHandshakeTimestamps.append(now)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func allowMessage(from peerID: PeerID) -> Bool {
|
||||
return queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
let oneSecondAgo = now.addingTimeInterval(-1)
|
||||
|
||||
// Check global rate limit first
|
||||
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
|
||||
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
|
||||
SecureLogger.warning("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check per-peer rate limit
|
||||
var timestamps = messageTimestamps[peerID] ?? []
|
||||
timestamps = timestamps.filter { $0 > oneSecondAgo }
|
||||
|
||||
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
|
||||
SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Record new message
|
||||
timestamps.append(now)
|
||||
messageTimestamps[peerID] = timestamps
|
||||
globalMessageTimestamps.append(now)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func reset(for peerID: PeerID) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.handshakeTimestamps.removeValue(forKey: peerID)
|
||||
self.messageTimestamps.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func resetAll() {
|
||||
queue.async(flags: .barrier) {
|
||||
self.handshakeTimestamps.removeAll()
|
||||
self.messageTimestamps.removeAll()
|
||||
self.globalHandshakeTimestamps.removeAll()
|
||||
self.globalMessageTimestamps.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
//
|
||||
// NoiseSecurityConsiderations.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Security Constants
|
||||
|
||||
enum NoiseSecurityConstants {
|
||||
// Maximum message size to prevent memory exhaustion
|
||||
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
||||
|
||||
// Maximum handshake message size
|
||||
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
||||
|
||||
// Session timeout - sessions older than this should be renegotiated
|
||||
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
||||
|
||||
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
|
||||
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
|
||||
|
||||
// Handshake timeout - abandon incomplete handshakes
|
||||
static let handshakeTimeout: TimeInterval = 60 // 1 minute
|
||||
|
||||
// Maximum concurrent sessions per peer
|
||||
static let maxSessionsPerPeer = 3
|
||||
|
||||
// Rate limiting
|
||||
static let maxHandshakesPerMinute = 10
|
||||
static let maxMessagesPerSecond = 100
|
||||
|
||||
// Global rate limiting (across all peers)
|
||||
static let maxGlobalHandshakesPerMinute = 30
|
||||
static let maxGlobalMessagesPerSecond = 500
|
||||
}
|
||||
|
||||
// MARK: - Security Validations
|
||||
|
||||
struct NoiseSecurityValidator {
|
||||
|
||||
/// Validate message size
|
||||
static func validateMessageSize(_ data: Data) -> Bool {
|
||||
return data.count <= NoiseSecurityConstants.maxMessageSize
|
||||
}
|
||||
|
||||
/// Validate handshake message size
|
||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
||||
}
|
||||
|
||||
/// Validate peer ID format using unified validator
|
||||
static func validatePeerID(_ peerID: String) -> Bool {
|
||||
return InputValidator.validatePeerID(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Enhanced Noise Session with Security
|
||||
|
||||
final class SecureNoiseSession: NoiseSession {
|
||||
private(set) var messageCount: UInt64 = 0
|
||||
private let sessionStartTime = Date()
|
||||
private(set) var lastActivityTime = Date()
|
||||
|
||||
override func encrypt(_ plaintext: Data) throws -> Data {
|
||||
// Check session age
|
||||
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
||||
throw NoiseSecurityError.sessionExpired
|
||||
}
|
||||
|
||||
// Check message count
|
||||
if messageCount >= NoiseSecurityConstants.maxMessagesPerSession {
|
||||
throw NoiseSecurityError.sessionExhausted
|
||||
}
|
||||
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
let encrypted = try super.encrypt(plaintext)
|
||||
messageCount += 1
|
||||
lastActivityTime = Date()
|
||||
|
||||
return encrypted
|
||||
}
|
||||
|
||||
override func decrypt(_ ciphertext: Data) throws -> Data {
|
||||
// Check session age
|
||||
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
||||
throw NoiseSecurityError.sessionExpired
|
||||
}
|
||||
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
let decrypted = try super.decrypt(ciphertext)
|
||||
lastActivityTime = Date()
|
||||
|
||||
return decrypted
|
||||
}
|
||||
|
||||
func needsRenegotiation() -> Bool {
|
||||
// Check if we've used more than 90% of message limit
|
||||
let messageThreshold = UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
|
||||
if messageCount >= messageThreshold {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if last activity was more than 30 minutes ago
|
||||
if Date().timeIntervalSince(lastActivityTime) > NoiseSecurityConstants.sessionTimeout {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Testing Support
|
||||
#if DEBUG
|
||||
func setLastActivityTimeForTesting(_ date: Date) {
|
||||
lastActivityTime = date
|
||||
}
|
||||
|
||||
func setMessageCountForTesting(_ count: UInt64) {
|
||||
messageCount = count
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Rate Limiter
|
||||
|
||||
final class NoiseRateLimiter {
|
||||
private var handshakeTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
||||
private var messageTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
||||
|
||||
// Global rate limiting
|
||||
private var globalHandshakeTimestamps: [Date] = []
|
||||
private var globalMessageTimestamps: [Date] = []
|
||||
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
||||
|
||||
func allowHandshake(from peerID: String) -> Bool {
|
||||
return queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
let oneMinuteAgo = now.addingTimeInterval(-60)
|
||||
|
||||
// Check global rate limit first
|
||||
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
|
||||
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
|
||||
SecureLogger.warning("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check per-peer rate limit
|
||||
var timestamps = handshakeTimestamps[peerID] ?? []
|
||||
timestamps = timestamps.filter { $0 > oneMinuteAgo }
|
||||
|
||||
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
|
||||
SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Record new handshake
|
||||
timestamps.append(now)
|
||||
handshakeTimestamps[peerID] = timestamps
|
||||
globalHandshakeTimestamps.append(now)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func allowMessage(from peerID: String) -> Bool {
|
||||
return queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
let oneSecondAgo = now.addingTimeInterval(-1)
|
||||
|
||||
// Check global rate limit first
|
||||
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
|
||||
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
|
||||
SecureLogger.warning("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check per-peer rate limit
|
||||
var timestamps = messageTimestamps[peerID] ?? []
|
||||
timestamps = timestamps.filter { $0 > oneSecondAgo }
|
||||
|
||||
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
|
||||
SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Record new message
|
||||
timestamps.append(now)
|
||||
messageTimestamps[peerID] = timestamps
|
||||
globalMessageTimestamps.append(now)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func reset(for peerID: String) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.handshakeTimestamps.removeValue(forKey: peerID)
|
||||
self.messageTimestamps.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func resetAll() {
|
||||
queue.async(flags: .barrier) {
|
||||
self.handshakeTimestamps.removeAll()
|
||||
self.messageTimestamps.removeAll()
|
||||
self.globalHandshakeTimestamps.removeAll()
|
||||
self.globalMessageTimestamps.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Security Errors
|
||||
|
||||
enum NoiseSecurityError: Error {
|
||||
case sessionExpired
|
||||
case sessionExhausted
|
||||
case messageTooLarge
|
||||
case invalidPeerID
|
||||
case rateLimitExceeded
|
||||
case handshakeTimeout
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// NoiseSecurityConstants.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum NoiseSecurityConstants {
|
||||
// Maximum message size to prevent memory exhaustion
|
||||
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
||||
|
||||
// Maximum handshake message size
|
||||
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
||||
|
||||
// Session timeout - sessions older than this should be renegotiated
|
||||
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
||||
|
||||
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
|
||||
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
|
||||
|
||||
// Handshake timeout - abandon incomplete handshakes
|
||||
static let handshakeTimeout: TimeInterval = 60 // 1 minute
|
||||
|
||||
// Maximum concurrent sessions per peer
|
||||
static let maxSessionsPerPeer = 3
|
||||
|
||||
// Rate limiting
|
||||
static let maxHandshakesPerMinute = 10
|
||||
static let maxMessagesPerSecond = 100
|
||||
|
||||
// Global rate limiting (across all peers)
|
||||
static let maxGlobalHandshakesPerMinute = 30
|
||||
static let maxGlobalMessagesPerSecond = 500
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// NoiseSecurityError.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum NoiseSecurityError: Error {
|
||||
case sessionExpired
|
||||
case sessionExhausted
|
||||
case messageTooLarge
|
||||
case invalidPeerID
|
||||
case rateLimitExceeded
|
||||
case handshakeTimeout
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// NoiseSecurityValidator.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct NoiseSecurityValidator {
|
||||
|
||||
/// Validate message size
|
||||
static func validateMessageSize(_ data: Data) -> Bool {
|
||||
return data.count <= NoiseSecurityConstants.maxMessageSize
|
||||
}
|
||||
|
||||
/// Validate handshake message size
|
||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
||||
}
|
||||
}
|
||||
@@ -10,32 +10,8 @@ import BitLogger
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Noise Session State
|
||||
|
||||
enum NoiseSessionState: Equatable {
|
||||
case uninitialized
|
||||
case handshaking
|
||||
case established
|
||||
case failed(Error)
|
||||
|
||||
static func == (lhs: NoiseSessionState, rhs: NoiseSessionState) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.uninitialized, .uninitialized),
|
||||
(.handshaking, .handshaking),
|
||||
(.established, .established):
|
||||
return true
|
||||
case (.failed, .failed):
|
||||
return true // We don't compare the errors
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Noise Session
|
||||
|
||||
class NoiseSession {
|
||||
let peerID: String
|
||||
let peerID: PeerID
|
||||
let role: NoiseRole
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private var state: NoiseSessionState = .uninitialized
|
||||
@@ -55,7 +31,7 @@ class NoiseSession {
|
||||
private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent)
|
||||
|
||||
init(
|
||||
peerID: String,
|
||||
peerID: PeerID,
|
||||
role: NoiseRole,
|
||||
keychain: KeychainManagerProtocol,
|
||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
||||
@@ -127,7 +103,7 @@ class NoiseSession {
|
||||
// Check if handshake is complete
|
||||
if handshake.isHandshakeComplete() {
|
||||
// Get transport ciphers
|
||||
let (send, receive) = try handshake.getTransportCiphers()
|
||||
let (send, receive) = try handshake.getTransportCiphers(useExtractedNonce: true)
|
||||
sendCipher = send
|
||||
receiveCipher = receive
|
||||
|
||||
@@ -141,7 +117,7 @@ class NoiseSession {
|
||||
handshakeState = nil // Clear handshake state
|
||||
|
||||
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established")
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID))
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||
|
||||
return nil
|
||||
} else {
|
||||
@@ -153,7 +129,7 @@ class NoiseSession {
|
||||
// Check if handshake is complete after writing
|
||||
if handshake.isHandshakeComplete() {
|
||||
// Get transport ciphers
|
||||
let (send, receive) = try handshake.getTransportCiphers()
|
||||
let (send, receive) = try handshake.getTransportCiphers(useExtractedNonce: true)
|
||||
sendCipher = send
|
||||
receiveCipher = receive
|
||||
|
||||
@@ -167,7 +143,7 @@ class NoiseSession {
|
||||
handshakeState = nil // Clear handshake state
|
||||
|
||||
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established")
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID))
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||
}
|
||||
|
||||
return response
|
||||
@@ -220,12 +196,6 @@ class NoiseSession {
|
||||
}
|
||||
}
|
||||
|
||||
func getHandshakeHash() -> Data? {
|
||||
return sessionQueue.sync {
|
||||
return handshakeHash
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
sessionQueue.sync(flags: .barrier) {
|
||||
let wasEstablished = state == .established
|
||||
@@ -252,249 +222,8 @@ class NoiseSession {
|
||||
handshakeHash = nil
|
||||
|
||||
if wasEstablished {
|
||||
SecureLogger.info(.sessionExpired(peerID: peerID))
|
||||
SecureLogger.info(.sessionExpired(peerID: peerID.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session Manager
|
||||
|
||||
final class NoiseSessionManager {
|
||||
private var sessions: [String: NoiseSession] = [:]
|
||||
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||
|
||||
// Callbacks
|
||||
var onSessionEstablished: ((String, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
||||
var onSessionFailed: ((String, Error) -> Void)?
|
||||
|
||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||
self.localStaticKey = localStaticKey
|
||||
self.keychain = keychain
|
||||
}
|
||||
|
||||
// MARK: - Session Management
|
||||
|
||||
func createSession(for peerID: String, role: NoiseRole) -> NoiseSession {
|
||||
return managerQueue.sync(flags: .barrier) {
|
||||
let session = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: role,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = session
|
||||
return session
|
||||
}
|
||||
}
|
||||
|
||||
func getSession(for peerID: String) -> NoiseSession? {
|
||||
return managerQueue.sync {
|
||||
return sessions[peerID]
|
||||
}
|
||||
}
|
||||
|
||||
func removeSession(for peerID: String) {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
if let session = sessions[peerID] {
|
||||
if session.isEstablished() {
|
||||
SecureLogger.info(.sessionExpired(peerID: peerID))
|
||||
}
|
||||
// Clear sensitive data before removing
|
||||
session.reset()
|
||||
}
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func removeAllSessions() {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
for (_, session) in sessions {
|
||||
session.reset()
|
||||
}
|
||||
sessions.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
func getEstablishedSessions() -> [String: NoiseSession] {
|
||||
return managerQueue.sync {
|
||||
return sessions.filter { $0.value.isEstablished() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Handshake Helpers
|
||||
|
||||
func initiateHandshake(with peerID: String) throws -> Data {
|
||||
return try managerQueue.sync(flags: .barrier) {
|
||||
// Check if we already have an established session
|
||||
if let existingSession = sessions[peerID], existingSession.isEstablished() {
|
||||
// Session already established, don't recreate
|
||||
throw NoiseSessionError.alreadyEstablished
|
||||
}
|
||||
|
||||
// Remove any existing non-established session
|
||||
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
}
|
||||
|
||||
// Create new initiator session
|
||||
let session = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: .initiator,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = session
|
||||
|
||||
do {
|
||||
let handshakeData = try session.startHandshake()
|
||||
return handshakeData
|
||||
} catch {
|
||||
// Clean up failed session
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
SecureLogger.error(.handshakeFailed(peerID: peerID, error: error.localizedDescription))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleIncomingHandshake(from peerID: String, message: Data) throws -> Data? {
|
||||
// Process everything within the synchronized block to prevent race conditions
|
||||
return try managerQueue.sync(flags: .barrier) {
|
||||
var shouldCreateNew = false
|
||||
var existingSession: NoiseSession? = nil
|
||||
|
||||
if let existing = sessions[peerID] {
|
||||
// If we have an established session, the peer must have cleared their session
|
||||
// for a good reason (e.g., decryption failure, restart, etc.)
|
||||
// We should accept the new handshake to re-establish encryption
|
||||
if existing.isEstablished() {
|
||||
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
shouldCreateNew = true
|
||||
} else {
|
||||
// If we're in the middle of a handshake and receive a new initiation,
|
||||
// reset and start fresh (the other side may have restarted)
|
||||
if existing.getState() == .handshaking && message.count == 32 {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
shouldCreateNew = true
|
||||
} else {
|
||||
existingSession = existing
|
||||
}
|
||||
}
|
||||
} else {
|
||||
shouldCreateNew = true
|
||||
}
|
||||
|
||||
// Get or create session
|
||||
let session: NoiseSession
|
||||
if shouldCreateNew {
|
||||
let newSession = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: .responder,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = newSession
|
||||
session = newSession
|
||||
} else {
|
||||
session = existingSession!
|
||||
}
|
||||
|
||||
// Process the handshake message within the synchronized block
|
||||
do {
|
||||
let response = try session.processHandshakeMessage(message)
|
||||
|
||||
// Check if session is established after processing
|
||||
if session.isEstablished() {
|
||||
if let remoteKey = session.getRemoteStaticPublicKey() {
|
||||
// Schedule callback outside the synchronized block to prevent deadlock
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.onSessionEstablished?(peerID, remoteKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
} catch {
|
||||
// Reset the session on handshake failure so next attempt can start fresh
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
|
||||
// Schedule callback outside the synchronized block to prevent deadlock
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.onSessionFailed?(peerID, error)
|
||||
}
|
||||
|
||||
SecureLogger.error(.handshakeFailed(peerID: peerID, error: error.localizedDescription))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Encryption/Decryption
|
||||
|
||||
func encrypt(_ plaintext: Data, for peerID: String) throws -> Data {
|
||||
guard let session = getSession(for: peerID) else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
}
|
||||
|
||||
return try session.encrypt(plaintext)
|
||||
}
|
||||
|
||||
func decrypt(_ ciphertext: Data, from peerID: String) throws -> Data {
|
||||
guard let session = getSession(for: peerID) else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
}
|
||||
|
||||
return try session.decrypt(ciphertext)
|
||||
}
|
||||
|
||||
// MARK: - Key Management
|
||||
|
||||
func getRemoteStaticKey(for peerID: String) -> Curve25519.KeyAgreement.PublicKey? {
|
||||
return getSession(for: peerID)?.getRemoteStaticPublicKey()
|
||||
}
|
||||
|
||||
func getHandshakeHash(for peerID: String) -> Data? {
|
||||
return getSession(for: peerID)?.getHandshakeHash()
|
||||
}
|
||||
|
||||
// MARK: - Session Rekeying
|
||||
|
||||
func getSessionsNeedingRekey() -> [(peerID: String, needsRekey: Bool)] {
|
||||
return managerQueue.sync {
|
||||
var needingRekey: [(peerID: String, needsRekey: Bool)] = []
|
||||
|
||||
for (peerID, session) in sessions {
|
||||
if let secureSession = session as? SecureNoiseSession,
|
||||
secureSession.isEstablished(),
|
||||
secureSession.needsRenegotiation() {
|
||||
needingRekey.append((peerID: peerID, needsRekey: true))
|
||||
}
|
||||
}
|
||||
|
||||
return needingRekey
|
||||
}
|
||||
}
|
||||
|
||||
func initiateRekey(for peerID: String) throws {
|
||||
// Remove old session
|
||||
removeSession(for: peerID)
|
||||
|
||||
// Initiate new handshake
|
||||
_ = try initiateHandshake(with: peerID)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum NoiseSessionError: Error {
|
||||
case invalidState
|
||||
case notEstablished
|
||||
case sessionNotFound
|
||||
case handshakeFailed(Error)
|
||||
case alreadyEstablished
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// NoiseSessionError.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
enum NoiseSessionError: Error, Equatable {
|
||||
case invalidState
|
||||
case notEstablished
|
||||
case sessionNotFound
|
||||
case alreadyEstablished
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
//
|
||||
// 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 getSession(for peerID: PeerID) -> NoiseSession? {
|
||||
return managerQueue.sync {
|
||||
return sessions[peerID]
|
||||
}
|
||||
}
|
||||
|
||||
func removeSession(for peerID: PeerID) {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
if let session = sessions.removeValue(forKey: peerID) {
|
||||
session.reset() // Clear sensitive data before removing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeAllSessions() {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
for (_, session) in sessions {
|
||||
session.reset()
|
||||
}
|
||||
sessions.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//
|
||||
// SecureNoiseSession.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
final class SecureNoiseSession: NoiseSession {
|
||||
private(set) var messageCount: UInt64 = 0
|
||||
private let sessionStartTime = Date()
|
||||
private(set) var lastActivityTime = Date()
|
||||
|
||||
override func encrypt(_ plaintext: Data) throws -> Data {
|
||||
// Check session age
|
||||
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
||||
throw NoiseSecurityError.sessionExpired
|
||||
}
|
||||
|
||||
// Check message count
|
||||
if messageCount >= NoiseSecurityConstants.maxMessagesPerSession {
|
||||
throw NoiseSecurityError.sessionExhausted
|
||||
}
|
||||
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
let encrypted = try super.encrypt(plaintext)
|
||||
messageCount += 1
|
||||
lastActivityTime = Date()
|
||||
|
||||
return encrypted
|
||||
}
|
||||
|
||||
override func decrypt(_ ciphertext: Data) throws -> Data {
|
||||
// Check session age
|
||||
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
||||
throw NoiseSecurityError.sessionExpired
|
||||
}
|
||||
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
let decrypted = try super.decrypt(ciphertext)
|
||||
lastActivityTime = Date()
|
||||
|
||||
return decrypted
|
||||
}
|
||||
|
||||
func needsRenegotiation() -> Bool {
|
||||
// Check if we've used more than 90% of message limit
|
||||
let messageThreshold = UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
|
||||
if messageCount >= messageThreshold {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if last activity was more than 30 minutes ago
|
||||
if Date().timeIntervalSince(lastActivityTime) > NoiseSecurityConstants.sessionTimeout {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Testing Support
|
||||
#if DEBUG
|
||||
func setLastActivityTimeForTesting(_ date: Date) {
|
||||
lastActivityTime = date
|
||||
}
|
||||
|
||||
func setMessageCountForTesting(_ count: UInt64) {
|
||||
messageCount = count
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import Foundation
|
||||
|
||||
/// Bech32 encoding for Nostr (minimal implementation)
|
||||
enum Bech32 {
|
||||
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
||||
|
||||
static func encode(hrp: String, data: Data) throws -> String {
|
||||
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
|
||||
let checksum = createChecksum(hrp: hrp, values: values)
|
||||
let combined = values + checksum
|
||||
|
||||
return hrp + "1" + combined.map {
|
||||
let index = charset.index(charset.startIndex, offsetBy: Int($0))
|
||||
return String(charset[index])
|
||||
}.joined()
|
||||
}
|
||||
|
||||
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
|
||||
// Find the last occurrence of '1'
|
||||
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
|
||||
throw Bech32Error.invalidFormat
|
||||
}
|
||||
|
||||
let hrp = String(bech32String[..<separatorIndex])
|
||||
|
||||
// Validate HRP contains only ASCII characters
|
||||
for char in hrp {
|
||||
guard char.asciiValue != nil else {
|
||||
throw Bech32Error.invalidCharacter
|
||||
}
|
||||
}
|
||||
|
||||
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
|
||||
|
||||
// Convert characters to values
|
||||
var values = [UInt8]()
|
||||
for char in dataString {
|
||||
guard let index = charset.firstIndex(of: char) else {
|
||||
throw Bech32Error.invalidCharacter
|
||||
}
|
||||
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
|
||||
}
|
||||
|
||||
// Verify checksum
|
||||
guard values.count >= 6 else {
|
||||
throw Bech32Error.invalidChecksum
|
||||
}
|
||||
|
||||
let payloadValues = Array(values.dropLast(6))
|
||||
let checksum = Array(values.suffix(6))
|
||||
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
|
||||
|
||||
guard checksum == expectedChecksum else {
|
||||
throw Bech32Error.invalidChecksum
|
||||
}
|
||||
|
||||
// Convert back to bytes
|
||||
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
|
||||
return (hrp: hrp, data: Data(bytes))
|
||||
}
|
||||
|
||||
enum Bech32Error: Error {
|
||||
case invalidFormat
|
||||
case invalidCharacter
|
||||
case invalidChecksum
|
||||
}
|
||||
|
||||
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
|
||||
var acc = 0
|
||||
var bits = 0
|
||||
var result = [UInt8]()
|
||||
let maxv = (1 << to) - 1
|
||||
|
||||
for value in data {
|
||||
acc = (acc << from) | Int(value)
|
||||
bits += from
|
||||
|
||||
while bits >= to {
|
||||
bits -= to
|
||||
result.append(UInt8((acc >> bits) & maxv))
|
||||
}
|
||||
}
|
||||
|
||||
if pad && bits > 0 {
|
||||
result.append(UInt8((acc << (to - bits)) & maxv))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
|
||||
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
|
||||
let polymod = polymod(checksumValues) ^ 1
|
||||
var checksum = [UInt8]()
|
||||
|
||||
for i in 0..<6 {
|
||||
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
|
||||
}
|
||||
|
||||
return checksum
|
||||
}
|
||||
|
||||
private static func hrpExpand(_ hrp: String) -> [UInt8] {
|
||||
var result = [UInt8]()
|
||||
for c in hrp {
|
||||
guard let asciiValue = c.asciiValue else {
|
||||
return [] // Return empty array for invalid input
|
||||
}
|
||||
result.append(UInt8(asciiValue >> 5))
|
||||
}
|
||||
result.append(0)
|
||||
for c in hrp {
|
||||
guard let asciiValue = c.asciiValue else {
|
||||
return [] // Return empty array for invalid input
|
||||
}
|
||||
result.append(UInt8(asciiValue & 31))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func polymod(_ values: [UInt8]) -> Int {
|
||||
var chk = 1
|
||||
for value in values {
|
||||
let b = chk >> 25
|
||||
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
|
||||
for i in 0..<5 {
|
||||
if (b >> i) & 1 == 1 {
|
||||
chk ^= generator[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return chk
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import Tor
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
||||
@MainActor
|
||||
@@ -11,19 +17,32 @@ final class GeoRelayDirectory {
|
||||
}
|
||||
|
||||
static let shared = GeoRelayDirectory()
|
||||
|
||||
private(set) var entries: [Entry] = []
|
||||
private let cacheFileName = "georelays_cache.csv"
|
||||
private let lastFetchKey = "georelay.lastFetchAt"
|
||||
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
||||
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds // 24h
|
||||
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds
|
||||
|
||||
private var refreshTimer: Timer?
|
||||
private var retryTask: Task<Void, Never>?
|
||||
private var retryAttempt: Int = 0
|
||||
private var isFetching: Bool = false
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
|
||||
private init() {
|
||||
// Load cached or bundled data synchronously
|
||||
self.entries = self.loadLocalEntries()
|
||||
// Fire-and-forget remote refresh if stale
|
||||
entries = loadLocalEntries()
|
||||
registerObservers()
|
||||
startRefreshTimer()
|
||||
prefetchIfNeeded()
|
||||
}
|
||||
|
||||
deinit {
|
||||
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
||||
refreshTimer?.invalidate()
|
||||
retryTask?.cancel()
|
||||
}
|
||||
|
||||
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
||||
func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
|
||||
let center = Geohash.decodeCenter(geohash)
|
||||
@@ -32,52 +51,148 @@ final class GeoRelayDirectory {
|
||||
|
||||
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
|
||||
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
|
||||
guard !entries.isEmpty else { return [] }
|
||||
let sorted = entries
|
||||
.sorted { a, b in
|
||||
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
|
||||
guard !entries.isEmpty, count > 0 else { return [] }
|
||||
|
||||
if entries.count <= count {
|
||||
return entries
|
||||
.sorted { a, b in
|
||||
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
|
||||
}
|
||||
.map { "wss://\($0.host)" }
|
||||
}
|
||||
|
||||
var best: [(entry: Entry, distance: Double)] = []
|
||||
best.reserveCapacity(count)
|
||||
|
||||
for entry in entries {
|
||||
let distance = haversineKm(lat, lon, entry.lat, entry.lon)
|
||||
if best.count < count {
|
||||
let idx = best.firstIndex { $0.distance > distance } ?? best.count
|
||||
best.insert((entry, distance), at: idx)
|
||||
} else if let worstDistance = best.last?.distance, distance < worstDistance {
|
||||
let idx = best.firstIndex { $0.distance > distance } ?? best.count
|
||||
best.insert((entry, distance), at: idx)
|
||||
best.removeLast()
|
||||
}
|
||||
.prefix(count)
|
||||
return sorted.map { "wss://\($0.host)" }
|
||||
}
|
||||
|
||||
return best.map { "wss://\($0.entry.host)" }
|
||||
}
|
||||
|
||||
// MARK: - Remote Fetch
|
||||
func prefetchIfNeeded() {
|
||||
func prefetchIfNeeded(force: Bool = false) {
|
||||
guard !isFetching else { return }
|
||||
|
||||
let now = Date()
|
||||
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
||||
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
||||
|
||||
if !force {
|
||||
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
||||
} else if last != .distantPast,
|
||||
now.timeIntervalSince(last) < TransportConfig.geoRelayRetryInitialSeconds {
|
||||
// Skip forced fetches if we just refreshed moments ago.
|
||||
return
|
||||
}
|
||||
|
||||
cancelRetry()
|
||||
fetchRemote()
|
||||
}
|
||||
|
||||
private func fetchRemote() {
|
||||
let req = URLRequest(url: remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
||||
// Ensure Tor readiness before fetching (fail-closed by default)
|
||||
Task.detached {
|
||||
guard !isFetching else { return }
|
||||
isFetching = true
|
||||
|
||||
let request = URLRequest(
|
||||
url: remoteURL,
|
||||
cachePolicy: .reloadIgnoringLocalCacheData,
|
||||
timeoutInterval: 15
|
||||
)
|
||||
|
||||
Task.detached { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
if !ready {
|
||||
SecureLogger.warning("GeoRelayDirectory: Tor not ready; skipping remote fetch (fail-closed)", category: .session)
|
||||
await self.handleFetchFailure(.torNotReady)
|
||||
return
|
||||
}
|
||||
let task = TorURLSession.shared.session.dataTask(with: req) { [weak self] data, _, error in
|
||||
guard let self = self else { return }
|
||||
if let data = data, error == nil, let text = String(data: data, encoding: .utf8) {
|
||||
let parsed = GeoRelayDirectory.parseCSV(text)
|
||||
if !parsed.isEmpty {
|
||||
Task { @MainActor in
|
||||
self.entries = parsed
|
||||
self.persistCache(text)
|
||||
UserDefaults.standard.set(Date(), forKey: self.lastFetchKey)
|
||||
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let (data, _) = try await TorURLSession.shared.session.data(for: request)
|
||||
guard let text = String(data: data, encoding: .utf8) else {
|
||||
await self.handleFetchFailure(.invalidData)
|
||||
return
|
||||
}
|
||||
SecureLogger.warning("GeoRelayDirectory: remote fetch failed; keeping local entries", category: .session)
|
||||
|
||||
let parsed = GeoRelayDirectory.parseCSV(text)
|
||||
guard !parsed.isEmpty else {
|
||||
await self.handleFetchFailure(.invalidData)
|
||||
return
|
||||
}
|
||||
|
||||
await self.handleFetchSuccess(entries: parsed, csv: text)
|
||||
} catch {
|
||||
await self.handleFetchFailure(.network(error))
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
|
||||
private enum FetchFailure {
|
||||
case torNotReady
|
||||
case invalidData
|
||||
case network(Error)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
|
||||
entries = parsed
|
||||
persistCache(csv)
|
||||
UserDefaults.standard.set(Date(), forKey: lastFetchKey)
|
||||
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
||||
isFetching = false
|
||||
retryAttempt = 0
|
||||
cancelRetry()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleFetchFailure(_ reason: FetchFailure) {
|
||||
switch reason {
|
||||
case .torNotReady:
|
||||
SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session)
|
||||
case .invalidData:
|
||||
SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session)
|
||||
case .network(let error):
|
||||
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(error.localizedDescription)", category: .session)
|
||||
}
|
||||
isFetching = false
|
||||
scheduleRetry()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func scheduleRetry() {
|
||||
retryAttempt = min(retryAttempt + 1, 10)
|
||||
let base = TransportConfig.geoRelayRetryInitialSeconds
|
||||
let maxDelay = TransportConfig.geoRelayRetryMaxSeconds
|
||||
let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))
|
||||
let calculated = base * multiplier
|
||||
let delay = min(maxDelay, max(base, calculated))
|
||||
|
||||
cancelRetry()
|
||||
retryTask = Task { [weak self] in
|
||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||
await MainActor.run {
|
||||
self?.prefetchIfNeeded(force: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func cancelRetry() {
|
||||
retryTask?.cancel()
|
||||
retryTask = nil
|
||||
}
|
||||
|
||||
private func persistCache(_ text: String) {
|
||||
guard let url = cacheURL() else { return }
|
||||
do {
|
||||
@@ -90,30 +205,35 @@ final class GeoRelayDirectory {
|
||||
// MARK: - Loading
|
||||
private func loadLocalEntries() -> [Entry] {
|
||||
// Prefer cached file if present
|
||||
if let cache = self.cacheURL(),
|
||||
if let cache = cacheURL(),
|
||||
let data = try? Data(contentsOf: cache),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
let arr = Self.parseCSV(text)
|
||||
if !arr.isEmpty { return arr }
|
||||
}
|
||||
|
||||
// Try bundled resource(s)
|
||||
let bundleCandidates = [
|
||||
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
||||
].compactMap { $0 }
|
||||
|
||||
for url in bundleCandidates {
|
||||
if let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) {
|
||||
if let data = try? Data(contentsOf: url),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
let arr = Self.parseCSV(text)
|
||||
if !arr.isEmpty { return arr }
|
||||
}
|
||||
}
|
||||
|
||||
// Try filesystem path (development/test)
|
||||
if let cwd = FileManager.default.currentDirectoryPath as String?,
|
||||
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
return Self.parseCSV(text)
|
||||
}
|
||||
|
||||
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
||||
return []
|
||||
}
|
||||
@@ -121,7 +241,6 @@ final class GeoRelayDirectory {
|
||||
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
||||
var result: Set<Entry> = []
|
||||
let lines = text.split(whereSeparator: { $0.isNewline })
|
||||
// Skip header if present
|
||||
for (idx, raw) in lines.enumerated() {
|
||||
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if line.isEmpty { continue }
|
||||
@@ -142,11 +261,76 @@ final class GeoRelayDirectory {
|
||||
|
||||
private func cacheURL() -> URL? {
|
||||
do {
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let base = try FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir.appendingPathComponent(cacheFileName)
|
||||
} catch { return nil }
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Observers & Timers
|
||||
private func registerObservers() {
|
||||
let center = NotificationCenter.default
|
||||
|
||||
let torReady = center.addObserver(
|
||||
forName: .TorDidBecomeReady,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded(force: true)
|
||||
}
|
||||
}
|
||||
observers.append(torReady)
|
||||
|
||||
#if os(iOS)
|
||||
let didBecomeActive = center.addObserver(
|
||||
forName: UIApplication.didBecomeActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
observers.append(didBecomeActive)
|
||||
#elseif os(macOS)
|
||||
let didBecomeActive = center.addObserver(
|
||||
forName: NSApplication.didBecomeActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
observers.append(didBecomeActive)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func startRefreshTimer() {
|
||||
refreshTimer?.invalidate()
|
||||
let interval = TransportConfig.geoRelayRefreshCheckIntervalSeconds
|
||||
guard interval > 0 else { return }
|
||||
|
||||
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
refreshTimer = timer
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
|
||||
protocol KeychainHelperProtocol {
|
||||
func save(key: String, data: Data, service: String, accessible: CFString?)
|
||||
func load(key: String, service: String) -> Data?
|
||||
func delete(key: String, service: String)
|
||||
}
|
||||
|
||||
/// Keychain helper for secure storage
|
||||
struct KeychainHelper: KeychainHelperProtocol {
|
||||
func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data
|
||||
]
|
||||
if let accessible = accessible {
|
||||
query[kSecAttrAccessible as String] = accessible
|
||||
}
|
||||
|
||||
SecItemDelete(query as CFDictionary)
|
||||
SecItemAdd(query as CFDictionary, nil)
|
||||
}
|
||||
|
||||
func load(key: String, service: String) -> Data? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecReturnData as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status == errSecSuccess else { return nil }
|
||||
return result as? Data
|
||||
}
|
||||
|
||||
func delete(key: String, service: String) {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key
|
||||
]
|
||||
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import Foundation
|
||||
|
||||
struct NostrEmbeddedBitChat {
|
||||
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs.
|
||||
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
|
||||
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
|
||||
// TLV-encode the private message
|
||||
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
||||
guard let tlv = pm.encode() else { return nil }
|
||||
@@ -14,12 +14,12 @@ struct NostrEmbeddedBitChat {
|
||||
payload.append(tlv)
|
||||
|
||||
// Determine 8-byte recipient ID to embed
|
||||
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
||||
let recipientID = normalizeRecipientPeerID(recipientPeerID)
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||
recipientID: Data(hexString: recipientIDHex),
|
||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||
recipientID: Data(hexString: recipientID.id),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
@@ -31,18 +31,18 @@ struct NostrEmbeddedBitChat {
|
||||
}
|
||||
|
||||
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs.
|
||||
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
|
||||
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
|
||||
guard type == .delivered || type == .readReceipt else { return nil }
|
||||
|
||||
var payload = Data([type.rawValue])
|
||||
payload.append(Data(messageID.utf8))
|
||||
|
||||
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
||||
let recipientID = normalizeRecipientPeerID(recipientPeerID)
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||
recipientID: Data(hexString: recipientIDHex),
|
||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||
recipientID: Data(hexString: recipientID.id),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
@@ -54,7 +54,7 @@ struct NostrEmbeddedBitChat {
|
||||
}
|
||||
|
||||
/// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs).
|
||||
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: String) -> String? {
|
||||
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: PeerID) -> String? {
|
||||
guard type == .delivered || type == .readReceipt else { return nil }
|
||||
|
||||
var payload = Data([type.rawValue])
|
||||
@@ -62,7 +62,7 @@ struct NostrEmbeddedBitChat {
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
@@ -75,7 +75,7 @@ struct NostrEmbeddedBitChat {
|
||||
}
|
||||
|
||||
/// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs).
|
||||
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: String) -> String? {
|
||||
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: PeerID) -> String? {
|
||||
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
||||
guard let tlv = pm.encode() else { return nil }
|
||||
|
||||
@@ -84,7 +84,7 @@ struct NostrEmbeddedBitChat {
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
@@ -96,11 +96,11 @@ struct NostrEmbeddedBitChat {
|
||||
return "bitchat1:" + base64URLEncode(data)
|
||||
}
|
||||
|
||||
private static func normalizeRecipientPeerID(_ recipientPeerID: String) -> String {
|
||||
if let maybeData = Data(hexString: recipientPeerID) {
|
||||
private static func normalizeRecipientPeerID(_ recipientPeerID: PeerID) -> PeerID {
|
||||
if let maybeData = Data(hexString: recipientPeerID.id) {
|
||||
if maybeData.count == 32 {
|
||||
// Treat as Noise static public key; derive peerID from fingerprint
|
||||
return PeerIDUtils.derivePeerID(fromPublicKey: maybeData)
|
||||
return PeerID(publicKey: maybeData)
|
||||
} else if maybeData.count == 8 {
|
||||
// Already an 8-byte peer ID
|
||||
return recipientPeerID
|
||||
|
||||
@@ -1,50 +1,5 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import P256K
|
||||
import Security
|
||||
|
||||
// Keychain helper for secure storage
|
||||
struct KeychainHelper {
|
||||
static func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data
|
||||
]
|
||||
if let accessible = accessible {
|
||||
query[kSecAttrAccessible as String] = accessible
|
||||
}
|
||||
|
||||
SecItemDelete(query as CFDictionary)
|
||||
SecItemAdd(query as CFDictionary, nil)
|
||||
}
|
||||
|
||||
static func load(key: String, service: String) -> Data? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecReturnData as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status == errSecSuccess else { return nil }
|
||||
return result as? Data
|
||||
}
|
||||
|
||||
static func delete(key: String, service: String) {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key
|
||||
]
|
||||
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
|
||||
struct NostrIdentity: Codable {
|
||||
@@ -103,269 +58,3 @@ struct NostrIdentity: Codable {
|
||||
return publicKey.hexEncodedString()
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridge between Noise and Nostr identities
|
||||
struct NostrIdentityBridge {
|
||||
private static let keychainService = "chat.bitchat.nostr"
|
||||
private static let currentIdentityKey = "nostr-current-identity"
|
||||
private static let deviceSeedKey = "nostr-device-seed"
|
||||
// In-memory cache to avoid transient keychain access issues
|
||||
private static var deviceSeedCache: Data?
|
||||
|
||||
/// Get or create the current Nostr identity
|
||||
static func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
||||
// Check if we already have a Nostr identity
|
||||
if let existingData = KeychainHelper.load(key: currentIdentityKey, service: keychainService),
|
||||
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
|
||||
return identity
|
||||
}
|
||||
|
||||
// Generate new Nostr identity
|
||||
let nostrIdentity = try NostrIdentity.generate()
|
||||
|
||||
// Store it
|
||||
let data = try JSONEncoder().encode(nostrIdentity)
|
||||
KeychainHelper.save(key: currentIdentityKey, data: data, service: keychainService)
|
||||
|
||||
return nostrIdentity
|
||||
}
|
||||
|
||||
/// Associate a Nostr identity with a Noise public key (for favorites)
|
||||
static func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||
if let data = nostrPubkey.data(using: .utf8) {
|
||||
KeychainHelper.save(key: key, data: data, service: keychainService)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get Nostr public key associated with a Noise public key
|
||||
static func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||
guard let data = KeychainHelper.load(key: key, service: keychainService),
|
||||
let pubkey = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return pubkey
|
||||
}
|
||||
|
||||
/// Clear all Nostr identity associations and current identity
|
||||
static func clearAllAssociations() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService,
|
||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||
kSecReturnAttributes as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
||||
for item in items {
|
||||
var deleteQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService
|
||||
]
|
||||
if let account = item[kSecAttrAccount as String] as? String {
|
||||
deleteQuery[kSecAttrAccount as String] = account
|
||||
}
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
}
|
||||
} else if status == errSecItemNotFound {
|
||||
// nothing persisted; no action needed
|
||||
}
|
||||
|
||||
deviceSeedCache = nil
|
||||
}
|
||||
|
||||
// MARK: - Per-Geohash Identities (Location Channels)
|
||||
|
||||
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
|
||||
/// Stored only on device keychain.
|
||||
private static func getOrCreateDeviceSeed() -> Data {
|
||||
if let cached = deviceSeedCache { return cached }
|
||||
if let existing = KeychainHelper.load(key: deviceSeedKey, service: keychainService) {
|
||||
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
|
||||
KeychainHelper.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||
deviceSeedCache = existing
|
||||
return existing
|
||||
}
|
||||
var seed = Data(count: 32)
|
||||
_ = seed.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||
}
|
||||
// Ensure availability after first unlock to prevent unintended rotation when locked
|
||||
KeychainHelper.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||
deviceSeedCache = seed
|
||||
return seed
|
||||
}
|
||||
|
||||
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
||||
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
||||
/// if the candidate is not a valid secp256k1 private key.
|
||||
static func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
||||
let seed = getOrCreateDeviceSeed()
|
||||
guard let msg = geohash.data(using: .utf8) else {
|
||||
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
|
||||
}
|
||||
|
||||
func candidateKey(iteration: UInt32) -> Data {
|
||||
var input = Data(msg)
|
||||
var iterBE = iteration.bigEndian
|
||||
withUnsafeBytes(of: &iterBE) { bytes in
|
||||
input.append(contentsOf: bytes)
|
||||
}
|
||||
let code = CryptoKit.HMAC<CryptoKit.SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
|
||||
return Data(code)
|
||||
}
|
||||
|
||||
// Try a few iterations to ensure a valid key can be formed
|
||||
for i in 0..<10 {
|
||||
let keyData = candidateKey(iteration: UInt32(i))
|
||||
if let identity = try? NostrIdentity(privateKeyData: keyData) {
|
||||
return identity
|
||||
}
|
||||
}
|
||||
// As a final fallback, hash the seed+msg and try again
|
||||
var combined = Data()
|
||||
combined.append(seed)
|
||||
combined.append(msg)
|
||||
let fallback = Data(CryptoKit.SHA256.hash(data: combined))
|
||||
return try NostrIdentity(privateKeyData: fallback)
|
||||
}
|
||||
}
|
||||
|
||||
// Bech32 encoding for Nostr (minimal implementation)
|
||||
enum Bech32 {
|
||||
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
||||
|
||||
static func encode(hrp: String, data: Data) throws -> String {
|
||||
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
|
||||
let checksum = createChecksum(hrp: hrp, values: values)
|
||||
let combined = values + checksum
|
||||
|
||||
return hrp + "1" + combined.map {
|
||||
let index = charset.index(charset.startIndex, offsetBy: Int($0))
|
||||
return String(charset[index])
|
||||
}.joined()
|
||||
}
|
||||
|
||||
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
|
||||
// Find the last occurrence of '1'
|
||||
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
|
||||
throw Bech32Error.invalidFormat
|
||||
}
|
||||
|
||||
let hrp = String(bech32String[..<separatorIndex])
|
||||
|
||||
// Validate HRP contains only ASCII characters
|
||||
for char in hrp {
|
||||
guard char.asciiValue != nil else {
|
||||
throw Bech32Error.invalidCharacter
|
||||
}
|
||||
}
|
||||
|
||||
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
|
||||
|
||||
// Convert characters to values
|
||||
var values = [UInt8]()
|
||||
for char in dataString {
|
||||
guard let index = charset.firstIndex(of: char) else {
|
||||
throw Bech32Error.invalidCharacter
|
||||
}
|
||||
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
|
||||
}
|
||||
|
||||
// Verify checksum
|
||||
guard values.count >= 6 else {
|
||||
throw Bech32Error.invalidChecksum
|
||||
}
|
||||
|
||||
let payloadValues = Array(values.dropLast(6))
|
||||
let checksum = Array(values.suffix(6))
|
||||
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
|
||||
|
||||
guard checksum == expectedChecksum else {
|
||||
throw Bech32Error.invalidChecksum
|
||||
}
|
||||
|
||||
// Convert back to bytes
|
||||
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
|
||||
return (hrp: hrp, data: Data(bytes))
|
||||
}
|
||||
|
||||
enum Bech32Error: Error {
|
||||
case invalidFormat
|
||||
case invalidCharacter
|
||||
case invalidChecksum
|
||||
}
|
||||
|
||||
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
|
||||
var acc = 0
|
||||
var bits = 0
|
||||
var result = [UInt8]()
|
||||
let maxv = (1 << to) - 1
|
||||
|
||||
for value in data {
|
||||
acc = (acc << from) | Int(value)
|
||||
bits += from
|
||||
|
||||
while bits >= to {
|
||||
bits -= to
|
||||
result.append(UInt8((acc >> bits) & maxv))
|
||||
}
|
||||
}
|
||||
|
||||
if pad && bits > 0 {
|
||||
result.append(UInt8((acc << (to - bits)) & maxv))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
|
||||
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
|
||||
let polymod = polymod(checksumValues) ^ 1
|
||||
var checksum = [UInt8]()
|
||||
|
||||
for i in 0..<6 {
|
||||
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
|
||||
}
|
||||
|
||||
return checksum
|
||||
}
|
||||
|
||||
private static func hrpExpand(_ hrp: String) -> [UInt8] {
|
||||
var result = [UInt8]()
|
||||
for c in hrp {
|
||||
guard let asciiValue = c.asciiValue else {
|
||||
return [] // Return empty array for invalid input
|
||||
}
|
||||
result.append(UInt8(asciiValue >> 5))
|
||||
}
|
||||
result.append(0)
|
||||
for c in hrp {
|
||||
guard let asciiValue = c.asciiValue else {
|
||||
return [] // Return empty array for invalid input
|
||||
}
|
||||
result.append(UInt8(asciiValue & 31))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func polymod(_ values: [UInt8]) -> Int {
|
||||
var chk = 1
|
||||
for value in values {
|
||||
let b = chk >> 25
|
||||
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
|
||||
for i in 0..<5 {
|
||||
if (b >> i) & 1 == 1 {
|
||||
chk ^= generator[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return chk
|
||||
}
|
||||
}
|
||||
|
||||
// Data hex encoding extension moved to BinaryEncodingUtils.swift to avoid duplication
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Bridge between Noise and Nostr identities
|
||||
final class NostrIdentityBridge {
|
||||
private let keychainService = "chat.bitchat.nostr"
|
||||
private let currentIdentityKey = "nostr-current-identity"
|
||||
private let deviceSeedKey = "nostr-device-seed"
|
||||
// In-memory cache to avoid transient keychain access issues
|
||||
private var deviceSeedCache: Data?
|
||||
// Cache derived identities to avoid repeated crypto during view rendering
|
||||
private var derivedIdentityCache: [String: NostrIdentity] = [:]
|
||||
private let cacheLock = NSLock()
|
||||
|
||||
private let keychain: KeychainHelperProtocol
|
||||
|
||||
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
|
||||
self.keychain = keychain
|
||||
}
|
||||
|
||||
/// Get or create the current Nostr identity
|
||||
func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
||||
// Check if we already have a Nostr identity
|
||||
if let existingData = keychain.load(key: currentIdentityKey, service: keychainService),
|
||||
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
|
||||
return identity
|
||||
}
|
||||
|
||||
// Generate new Nostr identity
|
||||
let nostrIdentity = try NostrIdentity.generate()
|
||||
|
||||
// Store it
|
||||
let data = try JSONEncoder().encode(nostrIdentity)
|
||||
keychain.save(key: currentIdentityKey, data: data, service: keychainService, accessible: nil)
|
||||
|
||||
return nostrIdentity
|
||||
}
|
||||
|
||||
/// Associate a Nostr identity with a Noise public key (for favorites)
|
||||
func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||
if let data = nostrPubkey.data(using: .utf8) {
|
||||
keychain.save(key: key, data: data, service: keychainService, accessible: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get Nostr public key associated with a Noise public key
|
||||
func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||
guard let data = keychain.load(key: key, service: keychainService),
|
||||
let pubkey = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return pubkey
|
||||
}
|
||||
|
||||
/// Clear all Nostr identity associations and current identity
|
||||
func clearAllAssociations() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService,
|
||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||
kSecReturnAttributes as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
||||
for item in items {
|
||||
var deleteQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService
|
||||
]
|
||||
if let account = item[kSecAttrAccount as String] as? String {
|
||||
deleteQuery[kSecAttrAccount as String] = account
|
||||
}
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
}
|
||||
} else if status == errSecItemNotFound {
|
||||
// nothing persisted; no action needed
|
||||
}
|
||||
|
||||
deviceSeedCache = nil
|
||||
}
|
||||
|
||||
// MARK: - Per-Geohash Identities (Location Channels)
|
||||
|
||||
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
|
||||
/// Stored only on device keychain.
|
||||
private func getOrCreateDeviceSeed() -> Data {
|
||||
if let cached = deviceSeedCache { return cached }
|
||||
if let existing = keychain.load(key: deviceSeedKey, service: keychainService) {
|
||||
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
|
||||
keychain.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||
deviceSeedCache = existing
|
||||
return existing
|
||||
}
|
||||
var seed = Data(count: 32)
|
||||
_ = seed.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||
}
|
||||
// Ensure availability after first unlock to prevent unintended rotation when locked
|
||||
keychain.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||
deviceSeedCache = seed
|
||||
return seed
|
||||
}
|
||||
|
||||
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
||||
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
||||
/// if the candidate is not a valid secp256k1 private key.
|
||||
func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
||||
// Check cache first to avoid repeated crypto + keychain I/O during view rendering
|
||||
cacheLock.lock()
|
||||
if let cached = derivedIdentityCache[geohash] {
|
||||
cacheLock.unlock()
|
||||
return cached
|
||||
}
|
||||
cacheLock.unlock()
|
||||
|
||||
let seed = getOrCreateDeviceSeed()
|
||||
guard let msg = geohash.data(using: .utf8) else {
|
||||
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
|
||||
}
|
||||
|
||||
func candidateKey(iteration: UInt32) -> Data {
|
||||
var input = Data(msg)
|
||||
var iterBE = iteration.bigEndian
|
||||
withUnsafeBytes(of: &iterBE) { bytes in
|
||||
input.append(contentsOf: bytes)
|
||||
}
|
||||
let code = HMAC<SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
|
||||
return Data(code)
|
||||
}
|
||||
|
||||
// Try a few iterations to ensure a valid key can be formed
|
||||
for i in 0..<10 {
|
||||
let keyData = candidateKey(iteration: UInt32(i))
|
||||
if let identity = try? NostrIdentity(privateKeyData: keyData) {
|
||||
// Cache the result
|
||||
cacheLock.lock()
|
||||
derivedIdentityCache[geohash] = identity
|
||||
cacheLock.unlock()
|
||||
return identity
|
||||
}
|
||||
}
|
||||
// As a final fallback, hash the seed+msg and try again
|
||||
let fallback = (seed + msg).sha256Hash()
|
||||
let identity = try NostrIdentity(privateKeyData: fallback)
|
||||
|
||||
// Cache the result
|
||||
cacheLock.lock()
|
||||
derivedIdentityCache[geohash] = identity
|
||||
cacheLock.unlock()
|
||||
|
||||
return identity
|
||||
}
|
||||
}
|
||||
@@ -521,10 +521,7 @@ struct NostrEvent: Codable {
|
||||
] as [Any]
|
||||
|
||||
let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
||||
let hash = CryptoKit.SHA256.hash(data: data)
|
||||
let hashData = Data(hash)
|
||||
let hashHex = hash.compactMap { String(format: "%02x", $0) }.joined()
|
||||
return (hashHex, hashData)
|
||||
return (data.sha256Fingerprint(), data.sha256Hash())
|
||||
}
|
||||
|
||||
func jsonString() throws -> String {
|
||||
|
||||
@@ -2,6 +2,7 @@ import BitLogger
|
||||
import Foundation
|
||||
import Network
|
||||
import Combine
|
||||
import Tor
|
||||
|
||||
/// Manages WebSocket connections to Nostr relays
|
||||
@MainActor
|
||||
@@ -905,6 +906,16 @@ struct NostrFilter: Encodable {
|
||||
filter.limit = limit
|
||||
return filter
|
||||
}
|
||||
|
||||
// For location notes with neighbors: subscribe to multiple geohashes (center + neighbors)
|
||||
static func geohashNotes(_ geohashes: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
||||
var filter = NostrFilter()
|
||||
filter.kinds = [1]
|
||||
filter.since = since?.timeIntervalSince1970.toInt()
|
||||
filter.tagFilters = ["g": geohashes]
|
||||
filter.limit = limit
|
||||
return filter
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic coding key for tag filters
|
||||
|
||||
@@ -5,16 +5,27 @@ import CryptoKit
|
||||
/// Implements HChaCha20 to derive a subkey and reduces the 24-byte nonce to a 12-byte nonce
|
||||
/// as per XChaCha20 construction.
|
||||
enum XChaCha20Poly1305Compat {
|
||||
|
||||
/// Errors that can occur during XChaCha20-Poly1305 operations
|
||||
enum Error: Swift.Error {
|
||||
case invalidKeyLength(expected: Int, got: Int)
|
||||
case invalidNonceLength(expected: Int, got: Int)
|
||||
}
|
||||
|
||||
struct SealBox {
|
||||
let ciphertext: Data
|
||||
let tag: Data
|
||||
}
|
||||
|
||||
static func seal(plaintext: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> SealBox {
|
||||
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
|
||||
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
|
||||
guard key.count == 32 else {
|
||||
throw Error.invalidKeyLength(expected: 32, got: key.count)
|
||||
}
|
||||
guard nonce24.count == 24 else {
|
||||
throw Error.invalidNonceLength(expected: 24, got: nonce24.count)
|
||||
}
|
||||
|
||||
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
|
||||
let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16)))
|
||||
let nonce12 = derive12ByteNonce(from24: nonce24)
|
||||
let chachaKey = SymmetricKey(data: subkey)
|
||||
let nonce = try ChaChaPoly.Nonce(data: nonce12)
|
||||
@@ -23,10 +34,14 @@ enum XChaCha20Poly1305Compat {
|
||||
}
|
||||
|
||||
static func open(ciphertext: Data, tag: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> Data {
|
||||
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
|
||||
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
|
||||
guard key.count == 32 else {
|
||||
throw Error.invalidKeyLength(expected: 32, got: key.count)
|
||||
}
|
||||
guard nonce24.count == 24 else {
|
||||
throw Error.invalidNonceLength(expected: 24, got: nonce24.count)
|
||||
}
|
||||
|
||||
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
|
||||
let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16)))
|
||||
let nonce12 = derive12ByteNonce(from24: nonce24)
|
||||
let chachaKey = SymmetricKey(data: subkey)
|
||||
let box = try ChaChaPoly.SealedBox(nonce: ChaChaPoly.Nonce(data: nonce12), ciphertext: ciphertext, tag: tag)
|
||||
@@ -43,10 +58,14 @@ enum XChaCha20Poly1305Compat {
|
||||
return out
|
||||
}
|
||||
|
||||
private static func hchacha20(key: Data, nonce16: Data) -> Data {
|
||||
private static func hchacha20(key: Data, nonce16: Data) throws -> Data {
|
||||
// HChaCha20 based on the original ChaCha20 core with a 16-byte nonce.
|
||||
precondition(key.count == 32)
|
||||
precondition(nonce16.count == 16)
|
||||
guard key.count == 32 else {
|
||||
throw Error.invalidKeyLength(expected: 32, got: key.count)
|
||||
}
|
||||
guard nonce16.count == 16 else {
|
||||
throw Error.invalidNonceLength(expected: 16, got: nonce16.count)
|
||||
}
|
||||
|
||||
// Constants "expand 32-byte k"
|
||||
var state: [UInt32] = [
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Hex Encoding/Decoding
|
||||
|
||||
@@ -16,6 +17,11 @@ extension Data {
|
||||
}
|
||||
return self.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
func sha256Hex() -> String {
|
||||
let digest = SHA256.hash(data: self)
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
init?(hexString: String) {
|
||||
let len = hexString.count / 2
|
||||
@@ -197,7 +203,7 @@ extension Data {
|
||||
offset += 16
|
||||
|
||||
// Convert 16 bytes to UUID string format
|
||||
let uuid = uuidData.map { String(format: "%02x", $0) }.joined()
|
||||
let uuid = uuidData.hexEncodedString()
|
||||
|
||||
// Insert hyphens at proper positions: 8-4-4-4-12
|
||||
var result = ""
|
||||
|
||||
@@ -22,11 +22,11 @@
|
||||
///
|
||||
/// ## Wire Format
|
||||
/// ```
|
||||
/// Header (Fixed 13 bytes):
|
||||
/// +--------+------+-----+-----------+-------+----------------+
|
||||
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
|
||||
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes |
|
||||
/// +--------+------+-----+-----------+-------+----------------+
|
||||
/// Header (Fixed 14 bytes for v1, 16 bytes for v2):
|
||||
/// +--------+------+-----+-----------+-------+------------------+
|
||||
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
|
||||
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 or 4 bytes |
|
||||
/// +--------+------+-----+-----------+-------+------------------+
|
||||
///
|
||||
/// Variable sections:
|
||||
/// +----------+-------------+---------+------------+
|
||||
@@ -52,7 +52,7 @@
|
||||
/// ## Flag Bits
|
||||
/// - Bit 0: Has recipient ID (directed message)
|
||||
/// - Bit 1: Has signature (authenticated message)
|
||||
/// - Bit 2: Is compressed (LZ4 compression applied)
|
||||
/// - Bit 2: Is compressed (zlib compression applied)
|
||||
/// - Bits 3-7: Reserved for future use
|
||||
///
|
||||
/// ## Size Constraints
|
||||
@@ -89,6 +89,7 @@
|
||||
///
|
||||
|
||||
import Foundation
|
||||
import BitLogger
|
||||
|
||||
extension Data {
|
||||
func trimmingNullBytes() -> Data {
|
||||
@@ -105,83 +106,119 @@ extension Data {
|
||||
/// their binary wire format representation.
|
||||
/// - Note: All multi-byte values use network byte order (big-endian)
|
||||
struct BinaryProtocol {
|
||||
static let headerSize = 13
|
||||
static let v1HeaderSize = 14
|
||||
static let v2HeaderSize = 16
|
||||
static let senderIDSize = 8
|
||||
static let recipientIDSize = 8
|
||||
static let signatureSize = 64
|
||||
|
||||
// Field offsets within packet header
|
||||
struct Offsets {
|
||||
static let version = 0
|
||||
static let type = 1
|
||||
static let ttl = 2
|
||||
static let timestamp = 3
|
||||
static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8)
|
||||
}
|
||||
|
||||
static func headerSize(for version: UInt8) -> Int? {
|
||||
switch version {
|
||||
case 1: return v1HeaderSize
|
||||
case 2: return v2HeaderSize
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func lengthFieldSize(for version: UInt8) -> Int {
|
||||
return version == 2 ? 4 : 2
|
||||
}
|
||||
|
||||
struct Flags {
|
||||
static let hasRecipient: UInt8 = 0x01
|
||||
static let hasSignature: UInt8 = 0x02
|
||||
static let isCompressed: UInt8 = 0x04
|
||||
static let hasRoute: UInt8 = 0x08
|
||||
}
|
||||
|
||||
// Encode BitchatPacket to binary format
|
||||
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
|
||||
var data = Data()
|
||||
|
||||
|
||||
// Try to compress payload if beneficial
|
||||
let version = packet.version
|
||||
guard version == 1 || version == 2 else { return nil }
|
||||
|
||||
// Try to compress payload when beneficial, keeping original size for later decoding
|
||||
var payload = packet.payload
|
||||
var originalPayloadSize: UInt16? = nil
|
||||
var isCompressed = false
|
||||
|
||||
var originalPayloadSize: Int?
|
||||
if CompressionUtil.shouldCompress(payload) {
|
||||
if let compressedPayload = CompressionUtil.compress(payload) {
|
||||
// Store original size for decompression (2 bytes after payload)
|
||||
originalPayloadSize = UInt16(payload.count)
|
||||
// Only compress when we can represent the original length in the outbound frame
|
||||
let maxRepresentable = version == 2 ? Int(UInt32.max) : Int(UInt16.max)
|
||||
if payload.count <= maxRepresentable,
|
||||
let compressedPayload = CompressionUtil.compress(payload) {
|
||||
originalPayloadSize = payload.count
|
||||
payload = compressedPayload
|
||||
isCompressed = true
|
||||
|
||||
} else {
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
// Header
|
||||
// Reserve capacity to reduce reallocations. Estimate base size conservatively.
|
||||
// header(13) + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad
|
||||
let estimatedPayload = payload.count + (isCompressed ? 2 : 0)
|
||||
let estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255
|
||||
data.reserveCapacity(estimated)
|
||||
data.append(packet.version)
|
||||
|
||||
let lengthFieldBytes = lengthFieldSize(for: version)
|
||||
let originalRoute = packet.route ?? []
|
||||
if originalRoute.contains(where: { $0.isEmpty }) { return nil }
|
||||
let sanitizedRoute: [Data] = originalRoute.map { hop in
|
||||
if hop.count == senderIDSize { return hop }
|
||||
if hop.count > senderIDSize { return Data(hop.prefix(senderIDSize)) }
|
||||
var padded = hop
|
||||
padded.append(Data(repeating: 0, count: senderIDSize - hop.count))
|
||||
return padded
|
||||
}
|
||||
guard sanitizedRoute.count <= 255 else { return nil }
|
||||
|
||||
let hasRoute = !sanitizedRoute.isEmpty
|
||||
let routeLength = hasRoute ? 1 + sanitizedRoute.count * senderIDSize : 0
|
||||
let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0
|
||||
let payloadDataSize = routeLength + payload.count + originalSizeFieldBytes
|
||||
|
||||
if version == 1 && payloadDataSize > Int(UInt16.max) { return nil }
|
||||
if version == 2 && payloadDataSize > Int(UInt32.max) { return nil }
|
||||
|
||||
guard let headerSize = headerSize(for: version) else { return nil }
|
||||
let estimatedHeader = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize)
|
||||
let estimatedPayload = payloadDataSize
|
||||
let estimatedSignature = (packet.signature == nil ? 0 : signatureSize)
|
||||
var data = Data()
|
||||
data.reserveCapacity(estimatedHeader + estimatedPayload + estimatedSignature + 255)
|
||||
|
||||
data.append(version)
|
||||
data.append(packet.type)
|
||||
data.append(packet.ttl)
|
||||
|
||||
// Timestamp (8 bytes, big-endian)
|
||||
for i in (0..<8).reversed() {
|
||||
data.append(UInt8((packet.timestamp >> (i * 8)) & 0xFF))
|
||||
|
||||
for shift in stride(from: 56, through: 0, by: -8) {
|
||||
data.append(UInt8((packet.timestamp >> UInt64(shift)) & 0xFF))
|
||||
}
|
||||
|
||||
// Flags
|
||||
|
||||
var flags: UInt8 = 0
|
||||
if packet.recipientID != nil {
|
||||
flags |= Flags.hasRecipient
|
||||
}
|
||||
if packet.signature != nil {
|
||||
flags |= Flags.hasSignature
|
||||
}
|
||||
if isCompressed {
|
||||
flags |= Flags.isCompressed
|
||||
}
|
||||
if packet.recipientID != nil { flags |= Flags.hasRecipient }
|
||||
if packet.signature != nil { flags |= Flags.hasSignature }
|
||||
if isCompressed { flags |= Flags.isCompressed }
|
||||
if hasRoute { flags |= Flags.hasRoute }
|
||||
data.append(flags)
|
||||
|
||||
// Payload length (2 bytes, big-endian) - includes original size if compressed
|
||||
let payloadDataSize = payload.count + (isCompressed ? 2 : 0)
|
||||
let payloadLength = UInt16(payloadDataSize)
|
||||
|
||||
|
||||
data.append(UInt8((payloadLength >> 8) & 0xFF))
|
||||
data.append(UInt8(payloadLength & 0xFF))
|
||||
|
||||
// SenderID (exactly 8 bytes)
|
||||
|
||||
if version == 2 {
|
||||
let length = UInt32(payloadDataSize)
|
||||
for shift in stride(from: 24, through: 0, by: -8) {
|
||||
data.append(UInt8((length >> UInt32(shift)) & 0xFF))
|
||||
}
|
||||
} else {
|
||||
let length = UInt16(payloadDataSize)
|
||||
data.append(UInt8((length >> 8) & 0xFF))
|
||||
data.append(UInt8(length & 0xFF))
|
||||
}
|
||||
|
||||
let senderBytes = packet.senderID.prefix(senderIDSize)
|
||||
data.append(senderBytes)
|
||||
if senderBytes.count < senderIDSize {
|
||||
data.append(Data(repeating: 0, count: senderIDSize - senderBytes.count))
|
||||
}
|
||||
|
||||
// RecipientID (if present)
|
||||
|
||||
if let recipientID = packet.recipientID {
|
||||
let recipientBytes = recipientID.prefix(recipientIDSize)
|
||||
data.append(recipientBytes)
|
||||
@@ -189,30 +226,37 @@ struct BinaryProtocol {
|
||||
data.append(Data(repeating: 0, count: recipientIDSize - recipientBytes.count))
|
||||
}
|
||||
}
|
||||
|
||||
// Payload (with original size prepended if compressed)
|
||||
|
||||
if hasRoute {
|
||||
data.append(UInt8(sanitizedRoute.count))
|
||||
for hop in sanitizedRoute {
|
||||
data.append(hop)
|
||||
}
|
||||
}
|
||||
|
||||
if isCompressed, let originalSize = originalPayloadSize {
|
||||
// Prepend original size (2 bytes, big-endian)
|
||||
data.append(UInt8((originalSize >> 8) & 0xFF))
|
||||
data.append(UInt8(originalSize & 0xFF))
|
||||
if version == 2 {
|
||||
let value = UInt32(originalSize)
|
||||
for shift in stride(from: 24, through: 0, by: -8) {
|
||||
data.append(UInt8((value >> UInt32(shift)) & 0xFF))
|
||||
}
|
||||
} else {
|
||||
let value = UInt16(originalSize)
|
||||
data.append(UInt8((value >> 8) & 0xFF))
|
||||
data.append(UInt8(value & 0xFF))
|
||||
}
|
||||
}
|
||||
data.append(payload)
|
||||
|
||||
// Signature (if present)
|
||||
|
||||
if let signature = packet.signature {
|
||||
data.append(signature.prefix(signatureSize))
|
||||
}
|
||||
|
||||
|
||||
// Apply padding to standard block sizes for traffic analysis resistance
|
||||
|
||||
if padding {
|
||||
let optimalSize = MessagePadding.optimalBlockSize(for: data.count)
|
||||
let paddedData = MessagePadding.pad(data, toSize: optimalSize)
|
||||
return paddedData
|
||||
} else {
|
||||
// Caller explicitly requested no padding (e.g., BLE write path)
|
||||
return data
|
||||
return MessagePadding.pad(data, toSize: optimalSize)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// Decode binary data to BitchatPacket
|
||||
@@ -227,87 +271,129 @@ struct BinaryProtocol {
|
||||
|
||||
// Core decoding implementation used by decode(_:) with and without padding removal
|
||||
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
|
||||
// Minimum size: header + senderID
|
||||
guard raw.count >= headerSize + senderIDSize else { return nil }
|
||||
guard raw.count >= v1HeaderSize + senderIDSize else { return nil }
|
||||
|
||||
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
|
||||
guard let base = buf.baseAddress else { return nil }
|
||||
var offset = 0
|
||||
func require(_ n: Int) -> Bool { offset + n <= buf.count }
|
||||
// Read single byte
|
||||
func read8() -> UInt8? {
|
||||
guard require(1) else { return nil }
|
||||
let v = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee
|
||||
let value = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee
|
||||
offset += 1
|
||||
return v
|
||||
return value
|
||||
}
|
||||
// Read big-endian 16-bit
|
||||
func read16() -> UInt16? {
|
||||
guard require(2) else { return nil }
|
||||
let p = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
|
||||
let v = (UInt16(p[0]) << 8) | UInt16(p[1])
|
||||
let ptr = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
|
||||
let value = (UInt16(ptr[0]) << 8) | UInt16(ptr[1])
|
||||
offset += 2
|
||||
return v
|
||||
return value
|
||||
}
|
||||
func read32() -> UInt32? {
|
||||
guard require(4) else { return nil }
|
||||
let ptr = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
|
||||
let value = (UInt32(ptr[0]) << 24) | (UInt32(ptr[1]) << 16) | (UInt32(ptr[2]) << 8) | UInt32(ptr[3])
|
||||
offset += 4
|
||||
return value
|
||||
}
|
||||
// Copy N bytes into Data
|
||||
func readData(_ n: Int) -> Data? {
|
||||
guard require(n) else { return nil }
|
||||
let ptr = base.advanced(by: offset)
|
||||
let d = Data(bytes: ptr, count: n)
|
||||
let data = Data(bytes: ptr, count: n)
|
||||
offset += n
|
||||
return d
|
||||
return data
|
||||
}
|
||||
|
||||
// Version
|
||||
guard let version = read8(), version == 1 else { return nil }
|
||||
guard let type = read8() else { return nil }
|
||||
guard let ttl = read8() else { return nil }
|
||||
guard let version = read8(), version == 1 || version == 2 else { return nil }
|
||||
let lengthFieldBytes = lengthFieldSize(for: version)
|
||||
guard let headerSize = headerSize(for: version) else { return nil }
|
||||
let minimumRequired = headerSize + senderIDSize
|
||||
guard raw.count >= minimumRequired else { return nil }
|
||||
|
||||
// Timestamp 8 bytes BE
|
||||
guard require(8) else { return nil }
|
||||
var ts: UInt64 = 0
|
||||
guard let type = read8(), let ttl = read8() else { return nil }
|
||||
|
||||
var timestamp: UInt64 = 0
|
||||
for _ in 0..<8 {
|
||||
guard let b = read8() else { return nil }
|
||||
ts = (ts << 8) | UInt64(b)
|
||||
guard let byte = read8() else { return nil }
|
||||
timestamp = (timestamp << 8) | UInt64(byte)
|
||||
}
|
||||
|
||||
// Flags
|
||||
guard let flags = read8() else { return nil }
|
||||
let hasRecipient = (flags & Flags.hasRecipient) != 0
|
||||
let hasSignature = (flags & Flags.hasSignature) != 0
|
||||
let isCompressed = (flags & Flags.isCompressed) != 0
|
||||
|
||||
// Payload length
|
||||
guard let payloadLen = read16(), payloadLen <= 65535 else { return nil }
|
||||
let payloadLength: Int
|
||||
if version == 2 {
|
||||
guard let len = read32() else { return nil }
|
||||
payloadLength = Int(len)
|
||||
} else {
|
||||
guard let len = read16() else { return nil }
|
||||
payloadLength = Int(len)
|
||||
}
|
||||
|
||||
guard payloadLength >= 0 else { return nil }
|
||||
|
||||
// SenderID
|
||||
guard let senderID = readData(senderIDSize) else { return nil }
|
||||
|
||||
// Recipient
|
||||
var recipientID: Data? = nil
|
||||
if hasRecipient {
|
||||
recipientID = readData(recipientIDSize)
|
||||
if recipientID == nil { return nil }
|
||||
}
|
||||
|
||||
// Payload
|
||||
var route: [Data]? = nil
|
||||
var remainingPayloadBytes = payloadLength
|
||||
|
||||
if (flags & Flags.hasRoute) != 0 {
|
||||
guard remainingPayloadBytes >= 1, let routeCount = read8() else { return nil }
|
||||
remainingPayloadBytes -= 1
|
||||
if routeCount > 0 {
|
||||
var hops: [Data] = []
|
||||
for _ in 0..<Int(routeCount) {
|
||||
guard remainingPayloadBytes >= senderIDSize,
|
||||
let hop = readData(senderIDSize) else { return nil }
|
||||
remainingPayloadBytes -= senderIDSize
|
||||
hops.append(hop)
|
||||
}
|
||||
route = hops
|
||||
}
|
||||
}
|
||||
|
||||
let payload: Data
|
||||
if isCompressed {
|
||||
// Need original size (2 bytes)
|
||||
guard let origSize16 = read16() else { return nil }
|
||||
let originalSize = Int(origSize16)
|
||||
guard originalSize >= 0 && originalSize <= 1_048_576 else { return nil }
|
||||
let compSize = Int(payloadLen) - 2
|
||||
guard compSize >= 0, let compressed = readData(compSize) else { return nil }
|
||||
guard remainingPayloadBytes >= lengthFieldBytes else { return nil }
|
||||
let originalSize: Int
|
||||
if version == 2 {
|
||||
guard let rawSize = read32() else { return nil }
|
||||
originalSize = Int(rawSize)
|
||||
} else {
|
||||
guard let rawSize = read16() else { return nil }
|
||||
originalSize = Int(rawSize)
|
||||
}
|
||||
remainingPayloadBytes -= lengthFieldBytes
|
||||
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxFramedFileBytes else { return nil }
|
||||
let compressedSize = remainingPayloadBytes
|
||||
guard compressedSize > 0, let compressed = readData(compressedSize) else { return nil }
|
||||
remainingPayloadBytes = 0
|
||||
|
||||
let compressionRatio = Double(originalSize) / Double(compressedSize)
|
||||
guard compressionRatio <= 50_000.0 else {
|
||||
SecureLogger.warning("🚫 Suspicious compression ratio: \(String(format: "%.0f", compressionRatio)):1", category: .security)
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize),
|
||||
decompressed.count == originalSize else { return nil }
|
||||
payload = decompressed
|
||||
} else {
|
||||
guard let p = readData(Int(payloadLen)) else { return nil }
|
||||
payload = p
|
||||
guard remainingPayloadBytes >= 0,
|
||||
let rawPayload = readData(remainingPayloadBytes) else { return nil }
|
||||
remainingPayloadBytes = 0
|
||||
payload = rawPayload
|
||||
}
|
||||
|
||||
// Signature
|
||||
var signature: Data? = nil
|
||||
if hasSignature {
|
||||
signature = readData(signatureSize)
|
||||
@@ -320,10 +406,12 @@ struct BinaryProtocol {
|
||||
type: type,
|
||||
senderID: senderID,
|
||||
recipientID: recipientID,
|
||||
timestamp: ts,
|
||||
timestamp: timestamp,
|
||||
payload: payload,
|
||||
signature: signature,
|
||||
ttl: ttl
|
||||
ttl: ttl,
|
||||
version: version,
|
||||
route: route
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//
|
||||
// BitchatFilePacket.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import BitLogger
|
||||
|
||||
/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files).
|
||||
/// Mirrors the Android client specification to ensure cross-platform interoperability.
|
||||
struct BitchatFilePacket {
|
||||
var fileName: String?
|
||||
var fileSize: UInt64?
|
||||
var mimeType: String?
|
||||
var content: Data
|
||||
|
||||
/// Canonical TLV tags defined by the Android implementation.
|
||||
private enum TLVType: UInt8 {
|
||||
case fileName = 0x01
|
||||
case fileSize = 0x02
|
||||
case mimeType = 0x03
|
||||
case content = 0x04
|
||||
}
|
||||
|
||||
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
|
||||
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
|
||||
func encode() -> Data? {
|
||||
let resolvedSize = fileSize ?? UInt64(content.count)
|
||||
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
|
||||
guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }
|
||||
guard content.count <= Int(UInt32.max) else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||
|
||||
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
||||
var big = value.bigEndian
|
||||
withUnsafeBytes(of: &big) { data.append(contentsOf: $0) }
|
||||
}
|
||||
|
||||
var encoded = Data()
|
||||
|
||||
if let name = fileName, let nameData = name.data(using: .utf8), nameData.count <= Int(UInt16.max) {
|
||||
encoded.append(TLVType.fileName.rawValue)
|
||||
appendBE(UInt16(nameData.count), into: &encoded)
|
||||
encoded.append(nameData)
|
||||
}
|
||||
|
||||
encoded.append(TLVType.fileSize.rawValue)
|
||||
appendBE(UInt16(4), into: &encoded)
|
||||
appendBE(UInt32(resolvedSize), into: &encoded)
|
||||
|
||||
if let mime = mimeType, let mimeData = mime.data(using: .utf8), mimeData.count <= Int(UInt16.max) {
|
||||
encoded.append(TLVType.mimeType.rawValue)
|
||||
appendBE(UInt16(mimeData.count), into: &encoded)
|
||||
encoded.append(mimeData)
|
||||
}
|
||||
|
||||
encoded.append(TLVType.content.rawValue)
|
||||
appendBE(UInt32(content.count), into: &encoded)
|
||||
encoded.append(content)
|
||||
|
||||
return encoded
|
||||
}
|
||||
|
||||
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
|
||||
static func decode(_ data: Data) -> BitchatFilePacket? {
|
||||
var cursor = data.startIndex
|
||||
let end = data.endIndex
|
||||
|
||||
var fileName: String?
|
||||
var fileSize: UInt64?
|
||||
var mimeType: String?
|
||||
var content = Data()
|
||||
|
||||
while cursor < end {
|
||||
let typeRaw = data[cursor]
|
||||
cursor = data.index(after: cursor)
|
||||
|
||||
guard cursor <= end else { return nil }
|
||||
let tlvType = TLVType(rawValue: typeRaw)
|
||||
|
||||
func readBigEndianLength(bytes: Int) -> Int? {
|
||||
guard data.distance(from: cursor, to: end) >= bytes else { return nil }
|
||||
// Use UInt64 to prevent integer overflow during shift operations
|
||||
var result: UInt64 = 0
|
||||
for _ in 0..<bytes {
|
||||
result = (result << 8) | UInt64(data[cursor])
|
||||
cursor = data.index(after: cursor)
|
||||
}
|
||||
// Safely convert to Int with overflow check
|
||||
guard result <= Int.max else { return nil }
|
||||
return Int(result)
|
||||
}
|
||||
|
||||
let length: Int?
|
||||
if tlvType == .content {
|
||||
let snapshot = cursor
|
||||
let canonical = readBigEndianLength(bytes: 4)
|
||||
if let canonical = canonical,
|
||||
canonical <= data.distance(from: cursor, to: end) {
|
||||
length = canonical
|
||||
} else {
|
||||
cursor = snapshot
|
||||
length = readBigEndianLength(bytes: 2)
|
||||
}
|
||||
} else {
|
||||
length = readBigEndianLength(bytes: 2)
|
||||
}
|
||||
|
||||
guard let tlvLength = length, tlvLength >= 0 else { return nil }
|
||||
guard data.distance(from: cursor, to: end) >= tlvLength else { return nil }
|
||||
|
||||
let valueStart = cursor
|
||||
cursor = data.index(cursor, offsetBy: tlvLength)
|
||||
let value = data[valueStart..<cursor]
|
||||
|
||||
switch tlvType {
|
||||
case .fileName:
|
||||
fileName = String(data: Data(value), encoding: .utf8)
|
||||
case .fileSize:
|
||||
if tlvLength == 4 || tlvLength == 8 {
|
||||
var size: UInt64 = 0
|
||||
for byte in value {
|
||||
size = (size << 8) | UInt64(byte)
|
||||
}
|
||||
if size > UInt64(FileTransferLimits.maxPayloadBytes) {
|
||||
return nil
|
||||
}
|
||||
fileSize = size
|
||||
}
|
||||
case .mimeType:
|
||||
mimeType = String(data: Data(value), encoding: .utf8)
|
||||
case .content:
|
||||
let proposedSize = content.count + value.count
|
||||
if proposedSize > FileTransferLimits.maxPayloadBytes {
|
||||
return nil
|
||||
}
|
||||
content.append(contentsOf: value)
|
||||
case nil:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
guard !content.isEmpty else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||
return BitchatFilePacket(
|
||||
fileName: fileName,
|
||||
fileSize: fileSize ?? UInt64(content.count),
|
||||
mimeType: mimeType,
|
||||
content: content
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@
|
||||
///
|
||||
|
||||
import Foundation
|
||||
import CoreBluetooth
|
||||
|
||||
// MARK: - Message Types
|
||||
|
||||
@@ -78,6 +79,7 @@ enum MessageType: UInt8 {
|
||||
|
||||
// Fragmentation (simplified)
|
||||
case fragment = 0x20 // Single fragment type for large messages
|
||||
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
@@ -88,6 +90,7 @@ enum MessageType: UInt8 {
|
||||
case .noiseHandshake: return "noiseHandshake"
|
||||
case .noiseEncrypted: return "noiseEncrypted"
|
||||
case .fragment: return "fragment"
|
||||
case .fileTransfer: return "fileTransfer"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,7 +134,7 @@ enum LazyHandshakeState {
|
||||
// MARK: - Delivery Status
|
||||
|
||||
// Delivery status for messages
|
||||
enum DeliveryStatus: Codable, Equatable {
|
||||
enum DeliveryStatus: Codable, Equatable, Hashable {
|
||||
case sending
|
||||
case sent // Left our device
|
||||
case delivered(to: String, at: Date) // Confirmed by recipient
|
||||
@@ -161,18 +164,21 @@ enum DeliveryStatus: Codable, Equatable {
|
||||
|
||||
protocol BitchatDelegate: AnyObject {
|
||||
func didReceiveMessage(_ message: BitchatMessage)
|
||||
func didConnectToPeer(_ peerID: String)
|
||||
func didDisconnectFromPeer(_ peerID: String)
|
||||
func didUpdatePeerList(_ peers: [String])
|
||||
|
||||
func didConnectToPeer(_ peerID: PeerID)
|
||||
func didDisconnectFromPeer(_ peerID: PeerID)
|
||||
func didUpdatePeerList(_ peers: [PeerID])
|
||||
|
||||
// Optional method to check if a fingerprint belongs to a favorite peer
|
||||
func isFavorite(fingerprint: String) -> Bool
|
||||
|
||||
|
||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
|
||||
|
||||
// Low-level events for better separation of concerns
|
||||
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date)
|
||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||
|
||||
// Bluetooth state updates for user notifications
|
||||
func didUpdateBluetoothState(_ state: CBManagerState)
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
||||
}
|
||||
|
||||
// Provide default implementation to make it effectively optional
|
||||
@@ -185,11 +191,11 @@ extension BitchatDelegate {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||
// Default empty implementation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,14 @@ enum Geohash {
|
||||
return map
|
||||
}()
|
||||
|
||||
/// Validates a geohash string for building-level precision (8 characters).
|
||||
/// - Parameter geohash: The geohash string to validate
|
||||
/// - Returns: true if valid 8-character base32 geohash, false otherwise
|
||||
static func isValidBuildingGeohash(_ geohash: String) -> Bool {
|
||||
guard geohash.count == 8 else { return false }
|
||||
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
|
||||
}
|
||||
|
||||
/// Encodes the provided coordinates into a geohash string.
|
||||
/// - Parameters:
|
||||
/// - latitude: Latitude in degrees (-90...90)
|
||||
@@ -111,4 +119,57 @@ enum Geohash {
|
||||
}
|
||||
return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1)
|
||||
}
|
||||
|
||||
/// Returns all 8 neighboring geohash cells at the same precision.
|
||||
/// - Parameter geohash: Base32 geohash string.
|
||||
/// - Returns: Array of 8 neighboring geohashes (N, NE, E, SE, S, SW, W, NW order).
|
||||
static func neighbors(of geohash: String) -> [String] {
|
||||
guard !geohash.isEmpty else { return [] }
|
||||
|
||||
let precision = geohash.count
|
||||
let bounds = decodeBounds(geohash)
|
||||
let center = decodeCenter(geohash)
|
||||
|
||||
// Calculate cell dimensions
|
||||
let latHeight = bounds.latMax - bounds.latMin
|
||||
let lonWidth = bounds.lonMax - bounds.lonMin
|
||||
|
||||
// Helper to wrap longitude around ±180
|
||||
func wrapLongitude(_ lon: Double) -> Double {
|
||||
var wrapped = lon
|
||||
while wrapped > 180.0 { wrapped -= 360.0 }
|
||||
while wrapped < -180.0 { wrapped += 360.0 }
|
||||
return wrapped
|
||||
}
|
||||
|
||||
// Helper to clamp latitude to ±90
|
||||
func clampLatitude(_ lat: Double) -> Double {
|
||||
return max(-90.0, min(90.0, lat))
|
||||
}
|
||||
|
||||
// Calculate 8 neighbor centers
|
||||
let neighbors: [(lat: Double, lon: Double)] = [
|
||||
(center.lat + latHeight, center.lon), // N
|
||||
(center.lat + latHeight, center.lon + lonWidth), // NE
|
||||
(center.lat, center.lon + lonWidth), // E
|
||||
(center.lat - latHeight, center.lon + lonWidth), // SE
|
||||
(center.lat - latHeight, center.lon), // S
|
||||
(center.lat - latHeight, center.lon - lonWidth), // SW
|
||||
(center.lat, center.lon - lonWidth), // W
|
||||
(center.lat + latHeight, center.lon - lonWidth) // NW
|
||||
]
|
||||
|
||||
// Encode each neighbor, handling boundary conditions
|
||||
return neighbors.compactMap { neighbor in
|
||||
let lat = clampLatitude(neighbor.lat)
|
||||
let lon = wrapLongitude(neighbor.lon)
|
||||
|
||||
// Skip if we've crossed a pole (latitude clamped to boundary)
|
||||
if (neighbor.lat > 90.0 || neighbor.lat < -90.0) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return encode(latitude: lat, longitude: lon, precision: precision)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,17 +24,17 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .building:
|
||||
return L10n.string("location_levels.building", comment: "Name for building-level location channel")
|
||||
return String(localized: "location_levels.building", comment: "Name for building-level location channel")
|
||||
case .block:
|
||||
return L10n.string("location_levels.block", comment: "Name for block-level location channel")
|
||||
return String(localized: "location_levels.block", comment: "Name for block-level location channel")
|
||||
case .neighborhood:
|
||||
return L10n.string("location_levels.neighborhood", comment: "Name for neighborhood-level location channel")
|
||||
return String(localized: "location_levels.neighborhood", comment: "Name for neighborhood-level location channel")
|
||||
case .city:
|
||||
return L10n.string("location_levels.city", comment: "Name for city-level location channel")
|
||||
return String(localized: "location_levels.city", comment: "Name for city-level location channel")
|
||||
case .province:
|
||||
return L10n.string("location_levels.province", comment: "Name for province-level location channel")
|
||||
return String(localized: "location_levels.province", comment: "Name for province-level location channel")
|
||||
case .region:
|
||||
return L10n.string("location_levels.region", comment: "Name for region-level location channel")
|
||||
return String(localized: "location_levels.region", comment: "Name for region-level location channel")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,4 +116,18 @@ enum ChannelID: Equatable, Codable {
|
||||
case .location(let ch): return ch.geohash
|
||||
}
|
||||
}
|
||||
|
||||
var isMesh: Bool {
|
||||
switch self {
|
||||
case .mesh: true
|
||||
case .location: false
|
||||
}
|
||||
}
|
||||
|
||||
var isLocation: Bool {
|
||||
switch self {
|
||||
case .mesh: false
|
||||
case .location: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@ struct AnnouncementPacket {
|
||||
let nickname: String
|
||||
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
|
||||
let signingPublicKey: Data // Ed25519 public key for signing
|
||||
let directNeighbors: [Data]? // 8-byte peer IDs
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
case nickname = 0x01
|
||||
case noisePublicKey = 0x02
|
||||
case signingPublicKey = 0x03
|
||||
case directNeighbors = 0x04
|
||||
}
|
||||
|
||||
func encode() -> Data? {
|
||||
@@ -35,6 +37,16 @@ struct AnnouncementPacket {
|
||||
data.append(TLVType.signingPublicKey.rawValue)
|
||||
data.append(UInt8(signingPublicKey.count))
|
||||
data.append(signingPublicKey)
|
||||
|
||||
// TLV for direct neighbors (optional)
|
||||
if let neighbors = directNeighbors, !neighbors.isEmpty {
|
||||
let neighborsData = neighbors.prefix(10).reduce(Data()) { $0 + $1 }
|
||||
if !neighborsData.isEmpty && neighborsData.count % 8 == 0 {
|
||||
data.append(TLVType.directNeighbors.rawValue)
|
||||
data.append(UInt8(neighborsData.count))
|
||||
data.append(neighborsData)
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -44,6 +56,7 @@ struct AnnouncementPacket {
|
||||
var nickname: String?
|
||||
var noisePublicKey: Data?
|
||||
var signingPublicKey: Data?
|
||||
var directNeighbors: [Data]?
|
||||
|
||||
while offset + 2 <= data.count {
|
||||
let typeRaw = data[offset]
|
||||
@@ -63,6 +76,17 @@ struct AnnouncementPacket {
|
||||
noisePublicKey = Data(value)
|
||||
case .signingPublicKey:
|
||||
signingPublicKey = Data(value)
|
||||
case .directNeighbors:
|
||||
if length > 0 && length % 8 == 0 {
|
||||
var neighbors = [Data]()
|
||||
let count = length / 8
|
||||
for i in 0..<count {
|
||||
let start = value.startIndex + i * 8
|
||||
let end = start + 8
|
||||
neighbors.append(Data(value[start..<end]))
|
||||
}
|
||||
directNeighbors = neighbors
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
||||
@@ -74,7 +98,8 @@ struct AnnouncementPacket {
|
||||
return AnnouncementPacket(
|
||||
nickname: nickname,
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: signingPublicKey
|
||||
signingPublicKey: signingPublicKey,
|
||||
directNeighbors: directNeighbors
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
||||
//
|
||||
// MimeType.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
// MARK: - Extensions for missing UTTypes
|
||||
|
||||
extension UTType {
|
||||
static let webP = UTType(importedAs: "image/webp")
|
||||
static let aac = UTType(importedAs: "audio/aac")
|
||||
static let m4a = UTType(importedAs: "audio/m4a")
|
||||
static let ogg = UTType(importedAs: "audio/ogg")
|
||||
}
|
||||
|
||||
// MARK: - MimeType Enum
|
||||
|
||||
enum MimeType: CaseIterable, Hashable {
|
||||
case jpeg
|
||||
case jpg
|
||||
case png
|
||||
case gif
|
||||
case webp
|
||||
case mp4Audio
|
||||
case m4a
|
||||
case aac
|
||||
case mpeg
|
||||
case mp3
|
||||
case wav
|
||||
case xWav
|
||||
case ogg
|
||||
case pdf
|
||||
case octetStream
|
||||
|
||||
var utType: UTType {
|
||||
switch self {
|
||||
case .jpeg, .jpg: .jpeg
|
||||
case .png: .png
|
||||
case .gif: .gif
|
||||
case .webp: .webP
|
||||
case .aac: .aac
|
||||
case .m4a: .m4a
|
||||
case .mp4Audio: .mpeg4Audio
|
||||
case .mp3, .mpeg: .mp3
|
||||
case .wav, .xWav: .wav
|
||||
case .ogg: .ogg
|
||||
case .pdf: .pdf
|
||||
case .octetStream: .data
|
||||
}
|
||||
}
|
||||
|
||||
var category: Category {
|
||||
switch self {
|
||||
case .jpeg, .jpg, .png, .gif, .webp:
|
||||
return .image
|
||||
case .aac, .m4a, .mp4Audio, .mpeg, .mp3, .wav, .xWav, .ogg:
|
||||
return .audio
|
||||
case .pdf, .octetStream:
|
||||
return .file
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var mimeString: String {
|
||||
switch self {
|
||||
case .jpeg, .jpg: "image/jpeg"
|
||||
case .png: "image/png"
|
||||
case .gif: "image/gif"
|
||||
case .webp: "image/webp"
|
||||
case .mp4Audio: "audio/mp4"
|
||||
case .m4a: "audio/m4a"
|
||||
case .aac: "audio/aac"
|
||||
case .mpeg: "audio/mpeg"
|
||||
case .mp3: "audio/mp3"
|
||||
case .wav: "audio/wav"
|
||||
case .xWav: "audio/x-wav"
|
||||
case .ogg: "audio/ogg"
|
||||
case .pdf: "application/pdf"
|
||||
case .octetStream: "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
var defaultExtension: String {
|
||||
switch self {
|
||||
case .jpeg, .jpg: "jpg"
|
||||
case .png: "png"
|
||||
case .webp: "webp"
|
||||
case .gif: "gif"
|
||||
case .mp4Audio, .m4a, .aac: "m4a"
|
||||
case .mpeg, .mp3: "mp3"
|
||||
case .wav, .xWav: "wav"
|
||||
case .ogg: "ogg"
|
||||
case .pdf: "pdf"
|
||||
case .octetStream: "bin"
|
||||
}
|
||||
}
|
||||
|
||||
static var allowed: Set<MimeType> = [
|
||||
.jpeg, .jpg, .png, .gif, .webp,
|
||||
.mp4Audio, .m4a, .aac, .mpeg, .mp3,
|
||||
.wav, .xWav, .ogg,
|
||||
.pdf, .octetStream
|
||||
]
|
||||
|
||||
var isAllowed: Bool {
|
||||
Self.allowed.contains(self)
|
||||
}
|
||||
|
||||
// MARK: - Byte signature validation
|
||||
func matches(data: Data) -> Bool {
|
||||
guard !data.isEmpty else { return false }
|
||||
|
||||
// Generic type → skip validation
|
||||
if self == .octetStream { return true }
|
||||
|
||||
switch self {
|
||||
case .jpeg, .jpg:
|
||||
return data.count >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF
|
||||
|
||||
case .png:
|
||||
return data.count >= 8 &&
|
||||
data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 &&
|
||||
data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A
|
||||
|
||||
case .gif:
|
||||
return data.count >= 6 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 &&
|
||||
data[3] == 0x38 && (data[4] == 0x37 || data[4] == 0x39) && data[5] == 0x61
|
||||
|
||||
case .webp:
|
||||
return data.count >= 12 &&
|
||||
data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
|
||||
data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50
|
||||
|
||||
case .m4a, .mp4Audio, .aac:
|
||||
// AVAudioRecorder output varies by platform - be lenient
|
||||
// Security: size already capped + sandboxed execution
|
||||
return data.count > 100
|
||||
|
||||
case .mpeg, .mp3:
|
||||
if data.count >= 3 && data[0] == 0x49 && data[1] == 0x44 && data[2] == 0x33 {
|
||||
return true // ID3 header
|
||||
}
|
||||
return data.count >= 2 && data[0] == 0xFF && (data[1] & 0xE0) == 0xE0
|
||||
|
||||
case .wav, .xWav:
|
||||
return data.count >= 12 &&
|
||||
data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
|
||||
data[8] == 0x57 && data[9] == 0x41 && data[10] == 0x56 && data[11] == 0x45
|
||||
|
||||
case .ogg:
|
||||
return data.count >= 4 &&
|
||||
data[0] == 0x4F && data[1] == 0x67 && data[2] == 0x67 && data[3] == 0x53
|
||||
|
||||
case .pdf:
|
||||
return data.count >= 4 &&
|
||||
data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience Initializers
|
||||
|
||||
init?(_ mimeString: String?) {
|
||||
guard let mimeString else { return nil }
|
||||
|
||||
let normalized = mimeString.lowercased()
|
||||
|
||||
// Direct match with our canonical list
|
||||
if let match = MimeType.allCases.first(where: { $0.mimeString == normalized }) {
|
||||
self = match
|
||||
return
|
||||
}
|
||||
|
||||
// Let UTType normalize aliases like "image/jpg", "audio/x-wav", etc.
|
||||
if let type = UTType(mimeType: normalized),
|
||||
let match = MimeType.allCases.first(where: { type.conforms(to: $0.utType) }) {
|
||||
self = match
|
||||
return
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension MimeType {
|
||||
enum Category: String {
|
||||
case audio, image, file
|
||||
}
|
||||
}
|
||||
@@ -15,18 +15,66 @@ enum CommandResult {
|
||||
case handled // Command handled, no message needed
|
||||
}
|
||||
|
||||
/// Simple struct for geo participant info used by CommandProcessor
|
||||
struct CommandGeoParticipant {
|
||||
let id: String // pubkey hex (lowercased)
|
||||
let displayName: String
|
||||
}
|
||||
|
||||
/// Protocol defining what CommandProcessor needs from its context.
|
||||
/// This breaks the circular dependency between CommandProcessor and ChatViewModel.
|
||||
@MainActor
|
||||
protocol CommandContextProvider: AnyObject {
|
||||
// MARK: - State Properties
|
||||
var nickname: String { get }
|
||||
var selectedPrivateChatPeer: PeerID? { get }
|
||||
var blockedUsers: Set<String> { get }
|
||||
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
||||
var idBridge: NostrIdentityBridge { get }
|
||||
|
||||
// MARK: - Peer Lookup
|
||||
func getPeerIDForNickname(_ nickname: String) -> PeerID?
|
||||
func getVisibleGeoParticipants() -> [CommandGeoParticipant]
|
||||
func nostrPubkeyForDisplayName(_ displayName: String) -> String?
|
||||
|
||||
// MARK: - Chat Actions
|
||||
func startPrivateChat(with peerID: PeerID)
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID)
|
||||
func clearCurrentPublicTimeline()
|
||||
func sendPublicRaw(_ content: String)
|
||||
|
||||
// MARK: - System Messages
|
||||
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
|
||||
func addPublicSystemMessage(_ content: String)
|
||||
|
||||
// MARK: - Favorites
|
||||
func toggleFavorite(peerID: PeerID)
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
||||
}
|
||||
|
||||
/// Processes chat commands in a focused, efficient way
|
||||
@MainActor
|
||||
final class CommandProcessor {
|
||||
weak var chatViewModel: ChatViewModel?
|
||||
weak var contextProvider: CommandContextProvider?
|
||||
weak var meshService: Transport?
|
||||
private let identityManager: SecureIdentityStateManagerProtocol
|
||||
|
||||
init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
|
||||
self.chatViewModel = chatViewModel
|
||||
|
||||
/// Backward-compatible property for existing code
|
||||
weak var chatViewModel: CommandContextProvider? {
|
||||
get { contextProvider }
|
||||
set { contextProvider = newValue }
|
||||
}
|
||||
|
||||
init(contextProvider: CommandContextProvider? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
|
||||
self.contextProvider = contextProvider
|
||||
self.meshService = meshService
|
||||
self.identityManager = identityManager
|
||||
}
|
||||
|
||||
/// Backward-compatible initializer
|
||||
convenience init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
|
||||
self.init(contextProvider: chatViewModel, meshService: meshService, identityManager: identityManager)
|
||||
}
|
||||
|
||||
/// Process a command string
|
||||
@MainActor
|
||||
@@ -42,7 +90,7 @@ final class CommandProcessor {
|
||||
case .location: return true
|
||||
}
|
||||
}()
|
||||
let inGeoDM = (chatViewModel?.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
|
||||
let inGeoDM = contextProvider?.selectedPrivateChatPeer?.isGeoDM == true
|
||||
|
||||
switch cmd {
|
||||
case "/m", "/msg":
|
||||
@@ -65,14 +113,11 @@ final class CommandProcessor {
|
||||
case "/unfav":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||
return handleFavorite(args, add: false)
|
||||
//
|
||||
case "/help", "/h":
|
||||
return .error(message: "unknown command: \(cmd)")
|
||||
default:
|
||||
return .error(message: "unknown command: \(cmd)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Command Handlers
|
||||
|
||||
private func handleMessage(_ args: String) -> CommandResult {
|
||||
@@ -84,15 +129,15 @@ final class CommandProcessor {
|
||||
let targetName = String(parts[0])
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname) else {
|
||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else {
|
||||
return .error(message: "'\(nickname)' not found")
|
||||
}
|
||||
|
||||
chatViewModel?.startPrivateChat(with: peerID)
|
||||
|
||||
|
||||
contextProvider?.startPrivateChat(with: peerID)
|
||||
|
||||
if parts.count > 1 {
|
||||
let message = String(parts[1])
|
||||
chatViewModel?.sendPrivateMessage(message, to: peerID)
|
||||
contextProvider?.sendPrivateMessage(message, to: peerID)
|
||||
}
|
||||
|
||||
return .success(message: "started private chat with \(nickname)")
|
||||
@@ -103,9 +148,9 @@ final class CommandProcessor {
|
||||
switch LocationChannelManager.shared.selectedChannel {
|
||||
case .location(let ch):
|
||||
// Geohash context: show visible geohash participants (exclude self)
|
||||
guard let vm = chatViewModel else { return .success(message: "nobody around") }
|
||||
let myHex = (try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
||||
let people = vm.visibleGeohashPeople().filter { person in
|
||||
guard let vm = contextProvider else { return .success(message: "nobody around") }
|
||||
let myHex = (try? vm.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
||||
let people = vm.getVisibleGeoParticipants().filter { person in
|
||||
if let me = myHex { return person.id.lowercased() != me }
|
||||
return true
|
||||
}
|
||||
@@ -123,10 +168,10 @@ final class CommandProcessor {
|
||||
}
|
||||
|
||||
private func handleClear() -> CommandResult {
|
||||
if let peerID = chatViewModel?.selectedPrivateChatPeer {
|
||||
chatViewModel?.privateChats[peerID]?.removeAll()
|
||||
if let peerID = contextProvider?.selectedPrivateChatPeer {
|
||||
contextProvider?.privateChats[peerID]?.removeAll()
|
||||
} else {
|
||||
chatViewModel?.clearCurrentPublicTimeline()
|
||||
contextProvider?.clearCurrentPublicTimeline()
|
||||
}
|
||||
return .handled
|
||||
}
|
||||
@@ -139,19 +184,19 @@ final class CommandProcessor {
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let targetPeerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
let myNickname = chatViewModel?.nickname else {
|
||||
guard let targetPeerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||
let myNickname = contextProvider?.nickname else {
|
||||
return .error(message: "cannot \(command) \(nickname): not found")
|
||||
}
|
||||
|
||||
let emoteContent = "* \(emoji) \(myNickname) \(action) \(nickname)\(suffix) *"
|
||||
|
||||
if chatViewModel?.selectedPrivateChatPeer != nil {
|
||||
if contextProvider?.selectedPrivateChatPeer != nil {
|
||||
// In private chat
|
||||
if let peerNickname = meshService?.peerNickname(peerID: targetPeerID) {
|
||||
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
|
||||
meshService?.sendPrivateMessage(personalMessage, to: targetPeerID,
|
||||
recipientNickname: peerNickname,
|
||||
meshService?.sendPrivateMessage(personalMessage, to: targetPeerID,
|
||||
recipientNickname: peerNickname,
|
||||
messageID: UUID().uuidString)
|
||||
// Also add a local system message so the sender sees a natural-language confirmation
|
||||
let pastAction: String = {
|
||||
@@ -162,13 +207,13 @@ final class CommandProcessor {
|
||||
}
|
||||
}()
|
||||
let localText = "\(emoji) you \(pastAction) \(nickname)\(suffix)"
|
||||
chatViewModel?.addLocalPrivateSystemMessage(localText, to: targetPeerID)
|
||||
contextProvider?.addLocalPrivateSystemMessage(localText, to: targetPeerID)
|
||||
}
|
||||
} else {
|
||||
// In public chat: send to active public channel (mesh or geohash)
|
||||
chatViewModel?.sendPublicRaw(emoteContent)
|
||||
contextProvider?.sendPublicRaw(emoteContent)
|
||||
let publicEcho = "\(emoji) \(myNickname) \(action) \(nickname)\(suffix)"
|
||||
chatViewModel?.addPublicSystemMessage(publicEcho)
|
||||
contextProvider?.addPublicSystemMessage(publicEcho)
|
||||
}
|
||||
|
||||
return .handled
|
||||
@@ -179,7 +224,7 @@ final class CommandProcessor {
|
||||
|
||||
if targetName.isEmpty {
|
||||
// List blocked users (mesh) and geohash (Nostr) blocks
|
||||
let meshBlocked = chatViewModel?.blockedUsers ?? []
|
||||
let meshBlocked = contextProvider?.blockedUsers ?? []
|
||||
var blockedNicknames: [String] = []
|
||||
if let peers = meshService?.getPeerNicknames() {
|
||||
for (peerID, nickname) in peers {
|
||||
@@ -193,8 +238,8 @@ final class CommandProcessor {
|
||||
// Geohash blocked names (prefer visible display names; fallback to #suffix)
|
||||
let geoBlocked = Array(identityManager.getBlockedNostrPubkeys())
|
||||
var geoNames: [String] = []
|
||||
if let vm = chatViewModel {
|
||||
let visible = vm.visibleGeohashPeople()
|
||||
if let vm = contextProvider {
|
||||
let visible = vm.getVisibleGeoParticipants()
|
||||
let visibleIndex = Dictionary(uniqueKeysWithValues: visible.map { ($0.id.lowercased(), $0.displayName) })
|
||||
for pk in geoBlocked {
|
||||
if let name = visibleIndex[pk.lowercased()] {
|
||||
@@ -213,7 +258,7 @@ final class CommandProcessor {
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
if let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||
if identityManager.isBlocked(fingerprint: fingerprint) {
|
||||
return .success(message: "\(nickname) is already blocked")
|
||||
@@ -238,7 +283,7 @@ final class CommandProcessor {
|
||||
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
|
||||
}
|
||||
// Mesh lookup failed; try geohash (Nostr) participant by display name
|
||||
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
|
||||
if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) {
|
||||
if identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
|
||||
return .success(message: "\(nickname) is already blocked")
|
||||
}
|
||||
@@ -257,7 +302,7 @@ final class CommandProcessor {
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
if let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||
if !identityManager.isBlocked(fingerprint: fingerprint) {
|
||||
return .success(message: "\(nickname) is not blocked")
|
||||
@@ -266,7 +311,7 @@ final class CommandProcessor {
|
||||
return .success(message: "unblocked \(nickname)")
|
||||
}
|
||||
// Try geohash unblock
|
||||
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
|
||||
if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) {
|
||||
if !identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
|
||||
return .success(message: "\(nickname) is not blocked")
|
||||
}
|
||||
@@ -284,8 +329,8 @@ final class CommandProcessor {
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
let noisePublicKey = Data(hexString: peerID) else {
|
||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||
let noisePublicKey = Data(hexString: peerID.id) else {
|
||||
return .error(message: "can't find peer: \(nickname)")
|
||||
}
|
||||
|
||||
@@ -297,33 +342,18 @@ final class CommandProcessor {
|
||||
peerNickname: nickname
|
||||
)
|
||||
|
||||
chatViewModel?.toggleFavorite(peerID: peerID)
|
||||
chatViewModel?.sendFavoriteNotification(to: peerID, isFavorite: true)
|
||||
contextProvider?.toggleFavorite(peerID: peerID)
|
||||
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: true)
|
||||
|
||||
return .success(message: "added \(nickname) to favorites")
|
||||
} else {
|
||||
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
|
||||
|
||||
chatViewModel?.toggleFavorite(peerID: peerID)
|
||||
chatViewModel?.sendFavoriteNotification(to: peerID, isFavorite: false)
|
||||
contextProvider?.toggleFavorite(peerID: peerID)
|
||||
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: false)
|
||||
|
||||
return .success(message: "removed \(nickname) from favorites")
|
||||
}
|
||||
}
|
||||
|
||||
private func handleHelp() -> CommandResult {
|
||||
let helpText = """
|
||||
commands:
|
||||
/msg @name - start private chat
|
||||
/who - list who's online
|
||||
/clear - clear messages
|
||||
/hug @name - send a hug
|
||||
/slap @name - slap with a trout
|
||||
/fav @name - add to favorites
|
||||
/unfav @name - remove from favorites
|
||||
/block @name - block
|
||||
/unblock @name - unblock
|
||||
"""
|
||||
return .success(message: helpText)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
|
||||
private static let storageKey = "chat.bitchat.favorites"
|
||||
private static let keychainService = "chat.bitchat.favorites"
|
||||
private let keychain: KeychainHelperProtocol
|
||||
|
||||
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
||||
@Published private(set) var mutualFavorites: Set<Data> = []
|
||||
@@ -35,7 +36,8 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
|
||||
static let shared = FavoritesPersistenceService()
|
||||
|
||||
private init() {
|
||||
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
|
||||
self.keychain = keychain
|
||||
loadFavorites()
|
||||
|
||||
// Update mutual favorites when favorites change
|
||||
@@ -179,125 +181,15 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
|
||||
/// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey)
|
||||
/// Falls back to scanning favorites and matching on derived peer ID.
|
||||
func getFavoriteStatus(forPeerID peerID: String) -> FavoriteRelationship? {
|
||||
func getFavoriteStatus(forPeerID peerID: PeerID) -> FavoriteRelationship? {
|
||||
// Quick sanity: peerID should be 16 hex chars (8 bytes)
|
||||
guard peerID.count == 16 else { return nil }
|
||||
for (pubkey, rel) in favorites {
|
||||
let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey)
|
||||
if derived == peerID { return rel }
|
||||
guard peerID.isShort else { return nil }
|
||||
for (pubkey, rel) in favorites where PeerID(publicKey: pubkey) == peerID {
|
||||
return rel
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Update Nostr public key for a peer
|
||||
func updateNostrPublicKey(for peerNoisePublicKey: Data, nostrPubkey: String) {
|
||||
guard let existing = favorites[peerNoisePublicKey] else { return }
|
||||
|
||||
let updated = FavoriteRelationship(
|
||||
peerNoisePublicKey: existing.peerNoisePublicKey,
|
||||
peerNostrPublicKey: nostrPubkey,
|
||||
peerNickname: existing.peerNickname,
|
||||
isFavorite: existing.isFavorite,
|
||||
theyFavoritedUs: existing.theyFavoritedUs,
|
||||
favoritedAt: existing.favoritedAt,
|
||||
lastUpdated: Date()
|
||||
)
|
||||
|
||||
favorites[peerNoisePublicKey] = updated
|
||||
saveFavorites()
|
||||
}
|
||||
|
||||
/// Update nickname for an existing favorite
|
||||
func updateNickname(for peerNoisePublicKey: Data, newNickname: String) {
|
||||
guard let existing = favorites[peerNoisePublicKey] else { return }
|
||||
|
||||
// Skip if nickname hasn't changed
|
||||
if existing.peerNickname == newNickname { return }
|
||||
|
||||
// Updating nickname for favorite
|
||||
|
||||
let updated = FavoriteRelationship(
|
||||
peerNoisePublicKey: existing.peerNoisePublicKey,
|
||||
peerNostrPublicKey: existing.peerNostrPublicKey,
|
||||
peerNickname: newNickname,
|
||||
isFavorite: existing.isFavorite,
|
||||
theyFavoritedUs: existing.theyFavoritedUs,
|
||||
favoritedAt: existing.favoritedAt,
|
||||
lastUpdated: Date()
|
||||
)
|
||||
|
||||
favorites[peerNoisePublicKey] = updated
|
||||
saveFavorites()
|
||||
|
||||
// Notify observers
|
||||
NotificationCenter.default.post(
|
||||
name: .favoriteStatusChanged,
|
||||
object: nil,
|
||||
userInfo: ["peerPublicKey": peerNoisePublicKey]
|
||||
)
|
||||
}
|
||||
|
||||
/// Update noise public key when peer reconnects with new ID
|
||||
func updateNoisePublicKey(from oldKey: Data, to newKey: Data, peerNickname: String) {
|
||||
guard let existing = favorites[oldKey] else {
|
||||
SecureLogger.warning("⚠️ Cannot update noise key - no favorite found for \(oldKey.hexEncodedString())", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we already have a favorite with the new key
|
||||
if favorites[newKey] != nil {
|
||||
SecureLogger.warning("⚠️ Favorite already exists with new key \(newKey.hexEncodedString()), removing old entry", category: .session)
|
||||
favorites.removeValue(forKey: oldKey)
|
||||
saveFavorites()
|
||||
return
|
||||
}
|
||||
|
||||
// Updating noise public key
|
||||
|
||||
// Remove old entry
|
||||
favorites.removeValue(forKey: oldKey)
|
||||
|
||||
// Add with new key
|
||||
let updated = FavoriteRelationship(
|
||||
peerNoisePublicKey: newKey,
|
||||
peerNostrPublicKey: existing.peerNostrPublicKey,
|
||||
peerNickname: peerNickname,
|
||||
isFavorite: existing.isFavorite,
|
||||
theyFavoritedUs: existing.theyFavoritedUs,
|
||||
favoritedAt: existing.favoritedAt,
|
||||
lastUpdated: Date()
|
||||
)
|
||||
|
||||
favorites[newKey] = updated
|
||||
saveFavorites()
|
||||
|
||||
// Notify observers with both old and new keys
|
||||
NotificationCenter.default.post(
|
||||
name: .favoriteStatusChanged,
|
||||
object: nil,
|
||||
userInfo: [
|
||||
"peerPublicKey": newKey,
|
||||
"oldPeerPublicKey": oldKey,
|
||||
"isKeyUpdate": true
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
/// Get all favorites (including non-mutual)
|
||||
func getAllFavorites() -> [FavoriteRelationship] {
|
||||
favorites.values.filter { $0.isFavorite }
|
||||
}
|
||||
|
||||
/// Get only mutual favorites
|
||||
func getMutualFavorites() -> [FavoriteRelationship] {
|
||||
favorites.values.filter { $0.isMutual }
|
||||
}
|
||||
|
||||
/// Get all favorite relationships (including where they favorited us)
|
||||
func getAllRelationships() -> [FavoriteRelationship] {
|
||||
Array(favorites.values)
|
||||
}
|
||||
|
||||
/// Clear all favorites - used for panic mode
|
||||
func clearAllFavorites() {
|
||||
SecureLogger.warning("🧹 Clearing all favorites (panic mode)", category: .session)
|
||||
@@ -306,7 +198,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
saveFavorites()
|
||||
|
||||
// Delete from keychain directly
|
||||
KeychainHelper.delete(
|
||||
keychain.delete(
|
||||
key: Self.storageKey,
|
||||
service: Self.keychainService
|
||||
)
|
||||
@@ -326,10 +218,11 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
let data = try encoder.encode(relationships)
|
||||
|
||||
// Store in keychain for security
|
||||
KeychainHelper.save(
|
||||
keychain.save(
|
||||
key: Self.storageKey,
|
||||
data: data,
|
||||
service: Self.keychainService
|
||||
service: Self.keychainService,
|
||||
accessible: nil
|
||||
)
|
||||
|
||||
// Successfully saved favorites
|
||||
@@ -341,7 +234,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
private func loadFavorites() {
|
||||
// Loading favorites from keychain
|
||||
|
||||
guard let data = KeychainHelper.load(
|
||||
guard let data = keychain.load(
|
||||
key: Self.storageKey,
|
||||
service: Self.keychainService
|
||||
) else {
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
#if os(iOS) || os(macOS)
|
||||
import CoreLocation
|
||||
#endif
|
||||
|
||||
/// Stores a user-maintained list of bookmarked geohash channels.
|
||||
/// - Persistence: UserDefaults (JSON string array)
|
||||
/// - Semantics: geohashes are normalized to lowercase base32 and de-duplicated
|
||||
final class GeohashBookmarksStore: ObservableObject {
|
||||
static let shared = GeohashBookmarksStore()
|
||||
|
||||
@Published private(set) var bookmarks: [String] = []
|
||||
@Published private(set) var bookmarkNames: [String: String] = [:] // geohash -> friendly name
|
||||
|
||||
private let storeKey = "locationChannel.bookmarks"
|
||||
private let namesStoreKey = "locationChannel.bookmarkNames"
|
||||
private var membership: Set<String> = []
|
||||
#if os(iOS) || os(macOS)
|
||||
private let geocoder = CLGeocoder()
|
||||
private var resolving: Set<String> = []
|
||||
#endif
|
||||
|
||||
private init() {
|
||||
load()
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
func isBookmarked(_ geohash: String) -> Bool {
|
||||
return membership.contains(Self.normalize(geohash))
|
||||
}
|
||||
|
||||
func toggle(_ geohash: String) {
|
||||
let gh = Self.normalize(geohash)
|
||||
if membership.contains(gh) {
|
||||
remove(gh)
|
||||
} else {
|
||||
add(gh)
|
||||
}
|
||||
}
|
||||
|
||||
func add(_ geohash: String) {
|
||||
let gh = Self.normalize(geohash)
|
||||
guard !gh.isEmpty else { return }
|
||||
guard !membership.contains(gh) else { return }
|
||||
bookmarks.insert(gh, at: 0)
|
||||
membership.insert(gh)
|
||||
persist()
|
||||
// Resolve and persist a friendly name once when added
|
||||
resolveNameIfNeeded(for: gh)
|
||||
}
|
||||
|
||||
func remove(_ geohash: String) {
|
||||
let gh = Self.normalize(geohash)
|
||||
guard membership.contains(gh) else { return }
|
||||
if let idx = bookmarks.firstIndex(of: gh) { bookmarks.remove(at: idx) }
|
||||
membership.remove(gh)
|
||||
// Clean up stored name to avoid stale cache growth
|
||||
if bookmarkNames.removeValue(forKey: gh) != nil {
|
||||
persistNames()
|
||||
}
|
||||
persist()
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
private func load() {
|
||||
guard let data = UserDefaults.standard.data(forKey: storeKey) else { return }
|
||||
if let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||
// Sanitize, normalize, dedupe while preserving order (first occurrence wins)
|
||||
var seen = Set<String>()
|
||||
var list: [String] = []
|
||||
for raw in arr {
|
||||
let gh = Self.normalize(raw)
|
||||
guard !gh.isEmpty else { continue }
|
||||
if !seen.contains(gh) {
|
||||
seen.insert(gh)
|
||||
list.append(gh)
|
||||
}
|
||||
}
|
||||
bookmarks = list
|
||||
membership = seen
|
||||
}
|
||||
// Load any saved names
|
||||
if let namesData = UserDefaults.standard.data(forKey: namesStoreKey),
|
||||
let dict = try? JSONDecoder().decode([String: String].self, from: namesData) {
|
||||
bookmarkNames = dict
|
||||
}
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
if let data = try? JSONEncoder().encode(bookmarks) {
|
||||
UserDefaults.standard.set(data, forKey: storeKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func persistNames() {
|
||||
if let data = try? JSONEncoder().encode(bookmarkNames) {
|
||||
UserDefaults.standard.set(data, forKey: namesStoreKey)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
private static func normalize(_ s: String) -> String {
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
return s
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: "#", with: "")
|
||||
.filter { allowed.contains($0) }
|
||||
}
|
||||
|
||||
// MARK: - Name Resolution
|
||||
/// Attempt to resolve and persist a friendly place name for a bookmarked geohash.
|
||||
func resolveNameIfNeeded(for geohash: String) {
|
||||
let gh = Self.normalize(geohash)
|
||||
guard !gh.isEmpty else { return }
|
||||
if bookmarkNames[gh] != nil { return }
|
||||
#if os(iOS) || os(macOS)
|
||||
if resolving.contains(gh) { return }
|
||||
resolving.insert(gh)
|
||||
// For very coarse geohashes, sample multiple points to capture multiple admin areas
|
||||
if gh.count <= 2 {
|
||||
let b = Geohash.decodeBounds(gh)
|
||||
let pts: [CLLocation] = [
|
||||
CLLocation(latitude: (b.latMin + b.latMax) / 2, longitude: (b.lonMin + b.lonMax) / 2), // center
|
||||
CLLocation(latitude: b.latMin, longitude: b.lonMin),
|
||||
CLLocation(latitude: b.latMin, longitude: b.lonMax),
|
||||
CLLocation(latitude: b.latMax, longitude: b.lonMin),
|
||||
CLLocation(latitude: b.latMax, longitude: b.lonMax)
|
||||
]
|
||||
resolveCompositeAdminName(geohash: gh, points: pts)
|
||||
} else {
|
||||
let center = Geohash.decodeCenter(gh)
|
||||
let loc = CLLocation(latitude: center.lat, longitude: center.lon)
|
||||
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
|
||||
guard let self = self else { return }
|
||||
defer { self.resolving.remove(gh) }
|
||||
if let pm = placemarks?.first {
|
||||
let name = Self.nameForGeohashLength(gh.count, from: pm)
|
||||
if let name = name, !name.isEmpty {
|
||||
DispatchQueue.main.async {
|
||||
self.bookmarkNames[gh] = name
|
||||
self.persistNames()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
|
||||
var uniqueAdmins = OrderedSet<String>()
|
||||
var idx = 0
|
||||
func step() {
|
||||
if idx >= points.count {
|
||||
// Compose up to 2 names joined by ' and '
|
||||
let finalName: String? = {
|
||||
let names = uniqueAdmins.array
|
||||
if names.count >= 2 { return names[0] + " and " + names[1] }
|
||||
return names.first
|
||||
}()
|
||||
if let finalName = finalName, !finalName.isEmpty {
|
||||
DispatchQueue.main.async {
|
||||
self.bookmarkNames[gh] = finalName
|
||||
self.persistNames()
|
||||
}
|
||||
}
|
||||
self.resolving.remove(gh)
|
||||
return
|
||||
}
|
||||
let loc = points[idx]
|
||||
idx += 1
|
||||
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
|
||||
guard self != nil else { return }
|
||||
if let pm = placemarks?.first {
|
||||
if let admin = pm.administrativeArea, !admin.isEmpty {
|
||||
uniqueAdmins.insert(admin)
|
||||
} else if let country = pm.country, !country.isEmpty {
|
||||
uniqueAdmins.insert(country)
|
||||
}
|
||||
}
|
||||
// Proceed to next point
|
||||
step()
|
||||
}
|
||||
}
|
||||
step()
|
||||
}
|
||||
|
||||
// Minimal ordered-set for stable joining
|
||||
private struct OrderedSet<Element: Hashable> {
|
||||
private var set: Set<Element> = []
|
||||
private(set) var array: [Element] = []
|
||||
mutating func insert(_ element: Element) {
|
||||
if set.insert(element).inserted { array.append(element) }
|
||||
}
|
||||
}
|
||||
|
||||
private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? {
|
||||
switch len {
|
||||
case 0...2:
|
||||
// Prefer administrative area if available at this coarse level
|
||||
return pm.administrativeArea ?? pm.country
|
||||
case 3...4:
|
||||
return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country
|
||||
case 5:
|
||||
return pm.locality ?? pm.subAdministrativeArea ?? pm.administrativeArea
|
||||
case 6...7:
|
||||
return pm.subLocality ?? pm.locality ?? pm.administrativeArea
|
||||
default:
|
||||
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if DEBUG
|
||||
/// Testing-only reset helper
|
||||
func _resetForTesting() {
|
||||
bookmarks.removeAll()
|
||||
membership.removeAll()
|
||||
bookmarkNames.removeAll()
|
||||
persist()
|
||||
persistNames()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//
|
||||
// GeohashParticipantTracker.swift
|
||||
// bitchat
|
||||
//
|
||||
// Tracks participants in geohash-based location channels.
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Represents a participant in a geohash channel
|
||||
public struct GeoPerson: Identifiable, Equatable, Sendable {
|
||||
public let id: String // pubkey hex (lowercased)
|
||||
public let displayName: String
|
||||
public let lastSeen: Date
|
||||
|
||||
public init(id: String, displayName: String, lastSeen: Date) {
|
||||
self.id = id
|
||||
self.displayName = displayName
|
||||
self.lastSeen = lastSeen
|
||||
}
|
||||
}
|
||||
|
||||
/// Protocol for resolving display names and checking block status
|
||||
@MainActor
|
||||
public protocol GeohashParticipantContext: AnyObject {
|
||||
/// Returns display name for a Nostr pubkey (e.g., "alice#a1b2" or "anon#c3d4")
|
||||
func displayNameForPubkey(_ pubkeyHex: String) -> String
|
||||
/// Returns true if the pubkey is blocked
|
||||
func isBlocked(_ pubkeyHexLowercased: String) -> Bool
|
||||
}
|
||||
|
||||
/// Tracks participants across multiple geohash channels
|
||||
@MainActor
|
||||
public final class GeohashParticipantTracker: ObservableObject {
|
||||
|
||||
/// Activity cutoff duration (defaults to 5 minutes)
|
||||
public let activityCutoff: TimeInterval
|
||||
|
||||
/// Per-geohash participant map: [geohash: [pubkeyHex: lastSeen]]
|
||||
private var participants: [String: [String: Date]] = [:]
|
||||
|
||||
/// Currently visible people for the active geohash
|
||||
@Published public private(set) var visiblePeople: [GeoPerson] = []
|
||||
|
||||
/// The currently active geohash (if any)
|
||||
private var activeGeohash: String?
|
||||
|
||||
/// Context for display name resolution and block checking
|
||||
private weak var context: GeohashParticipantContext?
|
||||
|
||||
/// Timer for periodic refresh
|
||||
private var refreshTimer: Timer?
|
||||
|
||||
public init(activityCutoff: TimeInterval = -300) { // default 5 minutes
|
||||
self.activityCutoff = activityCutoff
|
||||
}
|
||||
|
||||
/// Configure with a context provider
|
||||
public func configure(context: GeohashParticipantContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// Set the currently active geohash
|
||||
public func setActiveGeohash(_ geohash: String?) {
|
||||
activeGeohash = geohash
|
||||
if geohash == nil {
|
||||
visiblePeople = []
|
||||
} else {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
/// Record activity from a participant in the current active geohash
|
||||
public func recordParticipant(pubkeyHex: String) {
|
||||
guard let gh = activeGeohash else { return }
|
||||
recordParticipant(pubkeyHex: pubkeyHex, geohash: gh)
|
||||
}
|
||||
|
||||
/// Record activity from a participant in a specific geohash
|
||||
public func recordParticipant(pubkeyHex: String, geohash: String) {
|
||||
let key = pubkeyHex.lowercased()
|
||||
var map = participants[geohash] ?? [:]
|
||||
map[key] = Date()
|
||||
participants[geohash] = map
|
||||
|
||||
// Only refresh visible list if this geohash is currently active
|
||||
if activeGeohash == geohash {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a participant from all geohashes (used when blocking)
|
||||
public func removeParticipant(pubkeyHex: String) {
|
||||
let key = pubkeyHex.lowercased()
|
||||
for (gh, var map) in participants {
|
||||
map.removeValue(forKey: key)
|
||||
participants[gh] = map
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
|
||||
/// Get participant count for a specific geohash
|
||||
public func participantCount(for geohash: String) -> Int {
|
||||
let cutoff = Date().addingTimeInterval(activityCutoff)
|
||||
let map = participants[geohash] ?? [:]
|
||||
return map.values.filter { $0 >= cutoff }.count
|
||||
}
|
||||
|
||||
/// Get the visible people list for the active geohash (read-only query)
|
||||
public func getVisiblePeople() -> [GeoPerson] {
|
||||
guard let gh = activeGeohash, let context = context else { return [] }
|
||||
let cutoff = Date().addingTimeInterval(activityCutoff)
|
||||
let map = (participants[gh] ?? [:])
|
||||
.filter { $0.value >= cutoff }
|
||||
.filter { !context.isBlocked($0.key) }
|
||||
|
||||
return map
|
||||
.map { (pub, seen) in
|
||||
GeoPerson(id: pub, displayName: context.displayNameForPubkey(pub), lastSeen: seen)
|
||||
}
|
||||
.sorted { $0.lastSeen > $1.lastSeen }
|
||||
}
|
||||
|
||||
/// Refresh the visible people list
|
||||
public func refresh() {
|
||||
visiblePeople = getVisiblePeople()
|
||||
}
|
||||
|
||||
/// Start the periodic refresh timer
|
||||
public func startRefreshTimer(interval: TimeInterval = 30.0) {
|
||||
stopRefreshTimer()
|
||||
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the periodic refresh timer
|
||||
public func stopRefreshTimer() {
|
||||
refreshTimer?.invalidate()
|
||||
refreshTimer = nil
|
||||
}
|
||||
|
||||
/// Clear all participant data
|
||||
public func clear() {
|
||||
participants.removeAll()
|
||||
visiblePeople = []
|
||||
}
|
||||
|
||||
/// Clear participant data for a specific geohash
|
||||
public func clear(geohash: String) {
|
||||
participants.removeValue(forKey: geohash)
|
||||
if activeGeohash == geohash {
|
||||
visiblePeople = []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,34 +27,6 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
private let service = BitchatApp.bundleID
|
||||
private let appGroup = "group.\(BitchatApp.bundleID)"
|
||||
|
||||
private func isSandboxed() -> Bool {
|
||||
#if os(macOS)
|
||||
// More robust sandbox detection using multiple methods
|
||||
|
||||
// Method 1: Check environment variable (can be spoofed)
|
||||
let environment = ProcessInfo.processInfo.environment
|
||||
let hasEnvVar = environment["APP_SANDBOX_CONTAINER_ID"] != nil
|
||||
|
||||
// Method 2: Check if we can access a path outside sandbox
|
||||
let homeDir = FileManager.default.homeDirectoryForCurrentUser
|
||||
let testPath = homeDir.appendingPathComponent("../../../tmp/bitchat_sandbox_test_\(UUID().uuidString)")
|
||||
let canWriteOutsideSandbox = FileManager.default.createFile(atPath: testPath.path, contents: nil, attributes: nil)
|
||||
if canWriteOutsideSandbox {
|
||||
try? FileManager.default.removeItem(at: testPath)
|
||||
}
|
||||
|
||||
// Method 3: Check container path
|
||||
let containerPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first?.path ?? ""
|
||||
let hasContainerPath = containerPath.contains("/Containers/")
|
||||
|
||||
// If any method indicates sandbox, we consider it sandboxed
|
||||
return hasEnvVar || !canWriteOutsideSandbox || hasContainerPath
|
||||
#else
|
||||
// iOS is always sandboxed
|
||||
return true
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Identity Keys
|
||||
|
||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
import CoreLocation
|
||||
|
||||
/// Manages location permissions, one-shot location retrieval, and computing geohash channels.
|
||||
/// Not main-actor isolated to satisfy CLLocationManagerDelegate in Swift 6; state updates hop to MainActor.
|
||||
final class LocationChannelManager: NSObject, CLLocationManagerDelegate, ObservableObject {
|
||||
static let shared = LocationChannelManager()
|
||||
|
||||
enum PermissionState: Equatable {
|
||||
case notDetermined
|
||||
case denied
|
||||
case restricted
|
||||
case authorized
|
||||
}
|
||||
|
||||
private let cl = CLLocationManager()
|
||||
private let geocoder = CLGeocoder()
|
||||
private var lastLocation: CLLocation?
|
||||
private var refreshTimer: Timer?
|
||||
private let userDefaultsKey = "locationChannel.selected"
|
||||
private let teleportedStoreKey = "locationChannel.teleportedSet"
|
||||
private var isGeocoding: Bool = false
|
||||
|
||||
// Published state for UI bindings
|
||||
@Published private(set) var permissionState: PermissionState = .notDetermined
|
||||
@Published private(set) var availableChannels: [GeohashChannel] = []
|
||||
@Published private(set) var selectedChannel: ChannelID = .mesh
|
||||
// True when the current location channel was selected via manual teleport
|
||||
@Published var teleported: Bool = false
|
||||
@Published private(set) var locationNames: [GeohashChannelLevel: String] = [:]
|
||||
|
||||
// Persisted set of geohashes that were selected via teleport
|
||||
private var teleportedSet: Set<String> = []
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
cl.delegate = self
|
||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters // meters; we're not tracking continuously
|
||||
// Load selection
|
||||
if let data = UserDefaults.standard.data(forKey: userDefaultsKey),
|
||||
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {
|
||||
selectedChannel = channel
|
||||
}
|
||||
// Load persisted teleported set
|
||||
if let data = UserDefaults.standard.data(forKey: teleportedStoreKey),
|
||||
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||
teleportedSet = Set(arr)
|
||||
}
|
||||
// Do not eagerly mark teleported on startup; wait for location to compute regional set.
|
||||
// This avoids showing teleported for in-region channels during cold start.
|
||||
let status: CLAuthorizationStatus
|
||||
if #available(iOS 14.0, macOS 11.0, *) {
|
||||
status = cl.authorizationStatus
|
||||
} else {
|
||||
status = CLLocationManager.authorizationStatus()
|
||||
}
|
||||
updatePermissionState(from: status)
|
||||
// If we don't have location authorization at startup, fall back to persisted teleport state
|
||||
switch status {
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||
break // will compute from location
|
||||
default:
|
||||
if case .location(let ch) = selectedChannel {
|
||||
teleported = teleportedSet.contains(ch.geohash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
func enableLocationChannels() {
|
||||
let status: CLAuthorizationStatus
|
||||
if #available(iOS 14.0, macOS 11.0, *) {
|
||||
status = cl.authorizationStatus
|
||||
} else {
|
||||
status = CLLocationManager.authorizationStatus()
|
||||
}
|
||||
switch status {
|
||||
case .notDetermined:
|
||||
cl.requestWhenInUseAuthorization()
|
||||
case .restricted:
|
||||
Task { @MainActor in self.permissionState = .restricted }
|
||||
case .denied:
|
||||
Task { @MainActor in self.permissionState = .denied }
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||
Task { @MainActor in self.permissionState = .authorized }
|
||||
requestOneShotLocation()
|
||||
@unknown default:
|
||||
Task { @MainActor in self.permissionState = .restricted }
|
||||
}
|
||||
}
|
||||
|
||||
func refreshChannels() {
|
||||
if permissionState == .authorized {
|
||||
requestOneShotLocation()
|
||||
}
|
||||
}
|
||||
|
||||
/// Begin continuous, distance-filtered updates while the channel sheet is visible.
|
||||
/// Uses a 21m filter (configurable) to only refresh on meaningful movement.
|
||||
func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
|
||||
guard permissionState == .authorized else { return }
|
||||
// Stop any previous polling timer
|
||||
refreshTimer?.invalidate()
|
||||
refreshTimer = nil
|
||||
// Tighten accuracy and distance filter for live view
|
||||
cl.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
|
||||
cl.distanceFilter = TransportConfig.locationDistanceFilterLiveMeters
|
||||
// Start continuous updates
|
||||
cl.startUpdatingLocation()
|
||||
// Request an immediate fix to populate UI without waiting for movement
|
||||
requestOneShotLocation()
|
||||
}
|
||||
|
||||
/// Stop continuous refreshes when selector UI is dismissed.
|
||||
func endLiveRefresh() {
|
||||
refreshTimer?.invalidate()
|
||||
refreshTimer = nil
|
||||
cl.stopUpdatingLocation()
|
||||
// Restore more relaxed defaults for background/idle state
|
||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
|
||||
}
|
||||
|
||||
func select(_ channel: ChannelID) {
|
||||
Task { @MainActor in
|
||||
self.selectedChannel = channel
|
||||
if let data = try? JSONEncoder().encode(channel) {
|
||||
UserDefaults.standard.set(data, forKey: self.userDefaultsKey)
|
||||
}
|
||||
// Update teleported flag based on persisted state for immediate UI behavior
|
||||
switch channel {
|
||||
case .mesh:
|
||||
self.teleported = false
|
||||
case .location(let ch):
|
||||
// If this geohash is in our current regional set, do NOT mark teleported.
|
||||
let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash }
|
||||
if inRegional {
|
||||
self.teleported = false
|
||||
// Clear persisted teleport for this geohash to keep future selections clean
|
||||
if self.teleportedSet.contains(ch.geohash) {
|
||||
self.teleportedSet.remove(ch.geohash)
|
||||
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
|
||||
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fall back to persisted mark (set by deep link or manual teleport)
|
||||
self.teleported = self.teleportedSet.contains(ch.geohash)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark or unmark a geohash as teleported in persistence and update current flag if relevant
|
||||
func markTeleported(for geohash: String, _ flag: Bool) {
|
||||
if flag { teleportedSet.insert(geohash) } else { teleportedSet.remove(geohash) }
|
||||
if let data = try? JSONEncoder().encode(Array(teleportedSet)) {
|
||||
UserDefaults.standard.set(data, forKey: teleportedStoreKey)
|
||||
}
|
||||
if case .location(let ch) = selectedChannel, ch.geohash == geohash {
|
||||
Task { @MainActor in self.teleported = flag }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CoreLocation
|
||||
private func requestOneShotLocation() {
|
||||
cl.requestLocation()
|
||||
}
|
||||
|
||||
// iOS < 14
|
||||
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
|
||||
updatePermissionState(from: status)
|
||||
if case .authorized = permissionState {
|
||||
requestOneShotLocation()
|
||||
}
|
||||
}
|
||||
|
||||
// iOS 14+ / macOS 11+
|
||||
@available(iOS 14.0, macOS 11.0, *)
|
||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
updatePermissionState(from: manager.authorizationStatus)
|
||||
if case .authorized = permissionState {
|
||||
requestOneShotLocation()
|
||||
}
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard let loc = locations.last else { return }
|
||||
lastLocation = loc
|
||||
computeChannels(from: loc.coordinate)
|
||||
reverseGeocodeIfNeeded(location: loc)
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
// Surface as denied/restricted if relevant; otherwise keep previous state
|
||||
SecureLogger.error("LocationChannelManager: location error: \(error.localizedDescription)", category: .session)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
private func updatePermissionState(from status: CLAuthorizationStatus) {
|
||||
let newState: PermissionState
|
||||
switch status {
|
||||
case .notDetermined: newState = .notDetermined
|
||||
case .restricted: newState = .restricted
|
||||
case .denied: newState = .denied
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized
|
||||
@unknown default: newState = .restricted
|
||||
}
|
||||
Task { @MainActor in self.permissionState = newState }
|
||||
}
|
||||
|
||||
private func computeChannels(from coord: CLLocationCoordinate2D) {
|
||||
let levels = GeohashChannelLevel.allCases
|
||||
var result: [GeohashChannel] = []
|
||||
for level in levels {
|
||||
let gh = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: level.precision)
|
||||
result.append(GeohashChannel(level: level, geohash: gh))
|
||||
}
|
||||
Task { @MainActor in
|
||||
self.availableChannels = result
|
||||
// Recompute teleported status based on whether the selected geohash is in our regional set
|
||||
switch self.selectedChannel {
|
||||
case .mesh:
|
||||
self.teleported = false
|
||||
case .location(let ch):
|
||||
// Membership check using freshly computed regional channels; avoids precision/rename drift
|
||||
let inRegional = result.contains { $0.geohash == ch.geohash }
|
||||
if inRegional {
|
||||
self.teleported = false
|
||||
// Clear persisted teleport flag if present
|
||||
if self.teleportedSet.contains(ch.geohash) {
|
||||
self.teleportedSet.remove(ch.geohash)
|
||||
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
|
||||
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.teleported = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reverseGeocodeIfNeeded(location: CLLocation) {
|
||||
// Always cancel previous to keep latest fresh while user moves
|
||||
geocoder.cancelGeocode()
|
||||
isGeocoding = true
|
||||
geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, error in
|
||||
guard let self = self else { return }
|
||||
self.isGeocoding = false
|
||||
if let pm = placemarks?.first {
|
||||
let names = self.namesByLevel(from: pm)
|
||||
Task { @MainActor in self.locationNames = names }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func namesByLevel(from pm: CLPlacemark) -> [GeohashChannelLevel: String] {
|
||||
var dict: [GeohashChannelLevel: String] = [:]
|
||||
// Region (country)
|
||||
if let country = pm.country, !country.isEmpty {
|
||||
dict[.region] = country
|
||||
}
|
||||
// Province (state/province or county)
|
||||
if let admin = pm.administrativeArea, !admin.isEmpty {
|
||||
dict[.province] = admin
|
||||
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
|
||||
dict[.province] = subAdmin
|
||||
}
|
||||
// City (locality)
|
||||
if let locality = pm.locality, !locality.isEmpty {
|
||||
dict[.city] = locality
|
||||
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
|
||||
dict[.city] = subAdmin
|
||||
} else if let admin = pm.administrativeArea, !admin.isEmpty {
|
||||
dict[.city] = admin
|
||||
}
|
||||
// Neighborhood
|
||||
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
||||
dict[.neighborhood] = subLocality
|
||||
} else if let locality = pm.locality, !locality.isEmpty {
|
||||
dict[.neighborhood] = locality
|
||||
}
|
||||
// Block: reuse neighborhood/locality granularity
|
||||
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
||||
dict[.block] = subLocality
|
||||
} else if let locality = pm.locality, !locality.isEmpty {
|
||||
dict[.block] = locality
|
||||
}
|
||||
// Building: prefer place name/street/venue when available
|
||||
if let name = pm.name, !name.isEmpty {
|
||||
dict[.building] = name
|
||||
} else if let thoroughfare = pm.thoroughfare, !thoroughfare.isEmpty {
|
||||
dict[.building] = thoroughfare
|
||||
}
|
||||
return dict
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,99 +0,0 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
struct LocationNotesCounterDependencies {
|
||||
typealias RelayLookup = @MainActor (_ geohash: String, _ count: Int) -> [String]
|
||||
typealias Subscribe = @MainActor (_ filter: NostrFilter, _ id: String, _ relays: [String], _ handler: @escaping (NostrEvent) -> Void, _ onEOSE: (() -> Void)?) -> Void
|
||||
typealias Unsubscribe = @MainActor (_ id: String) -> Void
|
||||
|
||||
var relayLookup: RelayLookup
|
||||
var subscribe: Subscribe
|
||||
var unsubscribe: Unsubscribe
|
||||
|
||||
static let live = LocationNotesCounterDependencies(
|
||||
relayLookup: { geohash, count in
|
||||
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
|
||||
},
|
||||
subscribe: { filter, id, relays, handler, onEOSE in
|
||||
NostrRelayManager.shared.subscribe(
|
||||
filter: filter,
|
||||
id: id,
|
||||
relayUrls: relays,
|
||||
handler: handler,
|
||||
onEOSE: onEOSE
|
||||
)
|
||||
},
|
||||
unsubscribe: { id in
|
||||
NostrRelayManager.shared.unsubscribe(id: id)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Lightweight background counter for location notes (kind 1) at building-level geohash (8 chars).
|
||||
@MainActor
|
||||
final class LocationNotesCounter: ObservableObject {
|
||||
static let shared = LocationNotesCounter()
|
||||
|
||||
@Published private(set) var geohash: String? = nil
|
||||
@Published private(set) var count: Int? = 0
|
||||
@Published private(set) var initialLoadComplete: Bool = false
|
||||
@Published private(set) var relayAvailable: Bool = true
|
||||
|
||||
private var subscriptionID: String? = nil
|
||||
private var noteIDs = Set<String>()
|
||||
private let dependencies: LocationNotesCounterDependencies
|
||||
|
||||
private init(dependencies: LocationNotesCounterDependencies = .live) {
|
||||
self.dependencies = dependencies
|
||||
}
|
||||
|
||||
init(testDependencies: LocationNotesCounterDependencies) {
|
||||
self.dependencies = testDependencies
|
||||
}
|
||||
|
||||
func subscribe(geohash gh: String) {
|
||||
let norm = gh.lowercased()
|
||||
if geohash == norm, subscriptionID != nil { return }
|
||||
// Unsubscribe previous without clearing count to avoid flicker
|
||||
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
||||
subscriptionID = nil
|
||||
geohash = norm
|
||||
noteIDs.removeAll()
|
||||
initialLoadComplete = false
|
||||
relayAvailable = true
|
||||
|
||||
// Subscribe only to the building geohash (precision 8)
|
||||
let subID = "locnotes-count-\(norm)-\(UUID().uuidString.prefix(6))"
|
||||
let relays = dependencies.relayLookup(norm, TransportConfig.nostrGeoRelayCount)
|
||||
guard !relays.isEmpty else {
|
||||
relayAvailable = false
|
||||
initialLoadComplete = true
|
||||
count = 0
|
||||
SecureLogger.warning("LocationNotesCounter: no geo relays for geohash=\(norm)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
subscriptionID = subID
|
||||
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 500)
|
||||
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
||||
guard let self = self else { return }
|
||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == norm }) else { return }
|
||||
if !self.noteIDs.contains(event.id) {
|
||||
self.noteIDs.insert(event.id)
|
||||
self.count = self.noteIDs.count
|
||||
}
|
||||
}, { [weak self] in
|
||||
self?.initialLoadComplete = true
|
||||
})
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
||||
subscriptionID = nil
|
||||
geohash = nil
|
||||
count = 0
|
||||
noteIDs.removeAll()
|
||||
relayAvailable = true
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ struct LocationNotesDependencies {
|
||||
var sendEvent: SendEvent
|
||||
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
|
||||
var now: () -> Date
|
||||
|
||||
private static let idBridge = NostrIdentityBridge()
|
||||
|
||||
static let live = LocationNotesDependencies(
|
||||
relayLookup: { geohash, count in
|
||||
@@ -35,7 +37,7 @@ struct LocationNotesDependencies {
|
||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
||||
},
|
||||
deriveIdentity: { geohash in
|
||||
try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
|
||||
try idBridge.deriveIdentity(forGeohash: geohash)
|
||||
},
|
||||
now: { Date() }
|
||||
)
|
||||
@@ -74,38 +76,52 @@ final class LocationNotesManager: ObservableObject {
|
||||
@Published private(set) var state: State = .loading
|
||||
@Published private(set) var errorMessage: String?
|
||||
private var subscriptionID: String?
|
||||
private var noteIDs = Set<String>() // O(1) duplicate detection
|
||||
private let dependencies: LocationNotesDependencies
|
||||
private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200)
|
||||
|
||||
private enum Strings {
|
||||
static let noRelays = NSLocalizedString(
|
||||
"location_notes.error.no_relays",
|
||||
comment: "Shown when no geo relays are available near the selected location"
|
||||
)
|
||||
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 {
|
||||
let format = NSLocalizedString(
|
||||
"location_notes.error.failed_to_send",
|
||||
comment: "Shown when a location note fails to send"
|
||||
String(
|
||||
format: String(localized: "location_notes.error.failed_to_send", comment: "Shown when a location note fails to send"),
|
||||
locale: .current,
|
||||
detail
|
||||
)
|
||||
return String(format: format, detail)
|
||||
}
|
||||
}
|
||||
|
||||
init(geohash: String, dependencies: LocationNotesDependencies = .live) {
|
||||
self.geohash = geohash.lowercased()
|
||||
let norm = geohash.lowercased()
|
||||
self.geohash = norm
|
||||
self.dependencies = dependencies
|
||||
// Validate geohash (building-level precision: 8 chars)
|
||||
if !Geohash.isValidBuildingGeohash(norm) {
|
||||
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||
}
|
||||
subscribe()
|
||||
}
|
||||
|
||||
func setGeohash(_ newGeohash: String) {
|
||||
let norm = newGeohash.lowercased()
|
||||
guard norm != geohash else { return }
|
||||
// Validate geohash (building-level precision: 8 chars)
|
||||
guard Geohash.isValidBuildingGeohash(norm) else {
|
||||
SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||
return
|
||||
}
|
||||
if let sub = subscriptionID {
|
||||
dependencies.unsubscribe(sub)
|
||||
subscriptionID = nil
|
||||
}
|
||||
// Set loading state before clearing to prevent empty state flicker
|
||||
state = .loading
|
||||
initialLoadComplete = false
|
||||
errorMessage = nil
|
||||
geohash = norm
|
||||
notes.removeAll()
|
||||
noteIDs.removeAll()
|
||||
subscribe()
|
||||
}
|
||||
|
||||
@@ -114,7 +130,12 @@ final class LocationNotesManager: ObservableObject {
|
||||
dependencies.unsubscribe(sub)
|
||||
subscriptionID = nil
|
||||
}
|
||||
// Set loading state before clearing to prevent empty state flicker
|
||||
state = .loading
|
||||
initialLoadComplete = false
|
||||
errorMessage = nil
|
||||
notes.removeAll()
|
||||
noteIDs.removeAll()
|
||||
subscribe()
|
||||
}
|
||||
|
||||
@@ -142,20 +163,30 @@ final class LocationNotesManager: ObservableObject {
|
||||
|
||||
subscriptionID = subID
|
||||
initialLoadComplete = false
|
||||
// For persistent notes, allow relays to return recent history without an aggressive time cutoff
|
||||
let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200)
|
||||
|
||||
// Subscribe to center + 8 neighbors (± 1 grid)
|
||||
let neighbors = Geohash.neighbors(of: geohash)
|
||||
let allGeohashes = [geohash] + neighbors
|
||||
let filter = NostrFilter.geohashNotes(allGeohashes, since: nil, limit: 200)
|
||||
|
||||
// Build a set of valid geohashes for tag matching (includes all 9 cells)
|
||||
let validGeohashes = Set(allGeohashes.map { $0.lowercased() })
|
||||
|
||||
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
||||
guard let self = self else { return }
|
||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||
// Ensure matching tag
|
||||
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) else { return }
|
||||
if self.notes.contains(where: { $0.id == event.id }) { return }
|
||||
// Ensure matching tag - accept any of our 9 geohashes
|
||||
guard event.tags.contains(where: { tag in
|
||||
tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased())
|
||||
}) else { return }
|
||||
guard !self.noteIDs.contains(event.id) else { return }
|
||||
self.noteIDs.insert(event.id)
|
||||
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
|
||||
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
|
||||
self.notes.append(note)
|
||||
self.notes.sort { $0.createdAt > $1.createdAt }
|
||||
self.enforceMemoryCap()
|
||||
self.state = .ready
|
||||
}, { [weak self] in
|
||||
guard let self = self else { return }
|
||||
@@ -191,10 +222,12 @@ final class LocationNotesManager: ObservableObject {
|
||||
id: event.id,
|
||||
pubkey: id.publicKeyHex,
|
||||
content: trimmed,
|
||||
createdAt: dependencies.now(),
|
||||
createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
|
||||
nickname: nickname
|
||||
)
|
||||
self.noteIDs.insert(event.id)
|
||||
self.notes.insert(echo, at: 0)
|
||||
self.enforceMemoryCap()
|
||||
self.state = .ready
|
||||
self.errorMessage = nil
|
||||
} catch {
|
||||
@@ -203,6 +236,15 @@ final class LocationNotesManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicitly cancel subscription and release resources.
|
||||
func cancel() {
|
||||
if let sub = subscriptionID {
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
import CoreLocation
|
||||
|
||||
/// Unified manager for location-based channel state including:
|
||||
/// - CoreLocation permissions and one-shot location retrieval
|
||||
/// - Geohash channel computation from coordinates
|
||||
/// - Channel selection and teleport state
|
||||
/// - Bookmark persistence and friendly name resolution
|
||||
///
|
||||
/// Consolidates LocationChannelManager + GeohashBookmarksStore into a single source of truth.
|
||||
final class LocationStateManager: NSObject, CLLocationManagerDelegate, ObservableObject {
|
||||
static let shared = LocationStateManager()
|
||||
|
||||
// MARK: - Permission State
|
||||
|
||||
enum PermissionState: Equatable {
|
||||
case notDetermined
|
||||
case denied
|
||||
case restricted
|
||||
case authorized
|
||||
}
|
||||
|
||||
// MARK: - Private Properties (CoreLocation)
|
||||
|
||||
private let cl = CLLocationManager()
|
||||
private let geocoder = CLGeocoder()
|
||||
private var lastLocation: CLLocation?
|
||||
private var refreshTimer: Timer?
|
||||
private var isGeocoding: Bool = false
|
||||
|
||||
// MARK: - Persistence Keys
|
||||
|
||||
private let selectedChannelKey = "locationChannel.selected"
|
||||
private let teleportedStoreKey = "locationChannel.teleportedSet"
|
||||
private let bookmarksKey = "locationChannel.bookmarks"
|
||||
private let bookmarkNamesKey = "locationChannel.bookmarkNames"
|
||||
|
||||
// MARK: - Published State (Channel)
|
||||
|
||||
@Published private(set) var permissionState: PermissionState = .notDetermined
|
||||
@Published private(set) var availableChannels: [GeohashChannel] = []
|
||||
@Published private(set) var selectedChannel: ChannelID = .mesh
|
||||
@Published var teleported: Bool = false
|
||||
@Published private(set) var locationNames: [GeohashChannelLevel: String] = [:]
|
||||
|
||||
// MARK: - Published State (Bookmarks)
|
||||
|
||||
@Published private(set) var bookmarks: [String] = []
|
||||
@Published private(set) var bookmarkNames: [String: String] = [:]
|
||||
|
||||
// MARK: - Private State
|
||||
|
||||
private var teleportedSet: Set<String> = []
|
||||
private var bookmarkMembership: Set<String> = []
|
||||
private var resolvingNames: Set<String> = []
|
||||
private let storage: UserDefaults
|
||||
|
||||
/// Returns true if running in test environment
|
||||
private static var isRunningTests: Bool {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
return NSClassFromString("XCTestCase") != nil ||
|
||||
env["XCTestConfigurationFilePath"] != nil ||
|
||||
env["XCTestBundlePath"] != nil ||
|
||||
env["GITHUB_ACTIONS"] != nil ||
|
||||
env["CI"] != nil
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
private override init() {
|
||||
self.storage = .standard
|
||||
super.init()
|
||||
|
||||
// Skip CoreLocation setup in test environments
|
||||
guard !Self.isRunningTests else {
|
||||
loadPersistedState()
|
||||
return
|
||||
}
|
||||
|
||||
cl.delegate = self
|
||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
|
||||
|
||||
loadPersistedState()
|
||||
initializePermissionState()
|
||||
}
|
||||
|
||||
/// Internal initializer for testing with custom storage
|
||||
init(storage: UserDefaults) {
|
||||
self.storage = storage
|
||||
super.init()
|
||||
loadPersistedState()
|
||||
}
|
||||
|
||||
private func loadPersistedState() {
|
||||
// Load selected channel
|
||||
if let data = storage.data(forKey: selectedChannelKey),
|
||||
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {
|
||||
selectedChannel = channel
|
||||
}
|
||||
|
||||
// Load teleported set
|
||||
if let data = storage.data(forKey: teleportedStoreKey),
|
||||
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||
teleportedSet = Set(arr)
|
||||
}
|
||||
|
||||
// Load bookmarks
|
||||
if let data = storage.data(forKey: bookmarksKey),
|
||||
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||
var seen = Set<String>()
|
||||
var list: [String] = []
|
||||
for raw in arr {
|
||||
let gh = Self.normalizeGeohash(raw)
|
||||
guard !gh.isEmpty, !seen.contains(gh) else { continue }
|
||||
seen.insert(gh)
|
||||
list.append(gh)
|
||||
}
|
||||
bookmarks = list
|
||||
bookmarkMembership = seen
|
||||
}
|
||||
|
||||
// Load bookmark names
|
||||
if let data = storage.data(forKey: bookmarkNamesKey),
|
||||
let dict = try? JSONDecoder().decode([String: String].self, from: data) {
|
||||
bookmarkNames = dict
|
||||
}
|
||||
}
|
||||
|
||||
private func initializePermissionState() {
|
||||
let status: CLAuthorizationStatus
|
||||
if #available(iOS 14.0, macOS 11.0, *) {
|
||||
status = cl.authorizationStatus
|
||||
} else {
|
||||
status = CLLocationManager.authorizationStatus()
|
||||
}
|
||||
updatePermissionState(from: status)
|
||||
|
||||
// Fall back to persisted teleport state if no location authorization
|
||||
switch status {
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||
break
|
||||
case .notDetermined, .restricted, .denied:
|
||||
fallthrough
|
||||
@unknown default:
|
||||
if case .location(let ch) = selectedChannel {
|
||||
teleported = teleportedSet.contains(ch.geohash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public API (Permissions & Location)
|
||||
|
||||
func enableLocationChannels() {
|
||||
let status: CLAuthorizationStatus
|
||||
if #available(iOS 14.0, macOS 11.0, *) {
|
||||
status = cl.authorizationStatus
|
||||
} else {
|
||||
status = CLLocationManager.authorizationStatus()
|
||||
}
|
||||
switch status {
|
||||
case .notDetermined:
|
||||
cl.requestWhenInUseAuthorization()
|
||||
case .restricted:
|
||||
Task { @MainActor in self.permissionState = .restricted }
|
||||
case .denied:
|
||||
Task { @MainActor in self.permissionState = .denied }
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||
Task { @MainActor in self.permissionState = .authorized }
|
||||
requestOneShotLocation()
|
||||
@unknown default:
|
||||
Task { @MainActor in self.permissionState = .restricted }
|
||||
}
|
||||
}
|
||||
|
||||
func refreshChannels() {
|
||||
if permissionState == .authorized {
|
||||
requestOneShotLocation()
|
||||
}
|
||||
}
|
||||
|
||||
func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
|
||||
guard permissionState == .authorized else { return }
|
||||
refreshTimer?.invalidate()
|
||||
refreshTimer = nil
|
||||
cl.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
|
||||
cl.distanceFilter = TransportConfig.locationDistanceFilterLiveMeters
|
||||
cl.startUpdatingLocation()
|
||||
requestOneShotLocation()
|
||||
}
|
||||
|
||||
func endLiveRefresh() {
|
||||
refreshTimer?.invalidate()
|
||||
refreshTimer = nil
|
||||
cl.stopUpdatingLocation()
|
||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
|
||||
}
|
||||
|
||||
// MARK: - Public API (Channel Selection)
|
||||
|
||||
func select(_ channel: ChannelID) {
|
||||
Task { @MainActor in
|
||||
self.selectedChannel = channel
|
||||
if let data = try? JSONEncoder().encode(channel) {
|
||||
self.storage.set(data, forKey: self.selectedChannelKey)
|
||||
}
|
||||
|
||||
switch channel {
|
||||
case .mesh:
|
||||
self.teleported = false
|
||||
case .location(let ch):
|
||||
let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash }
|
||||
if inRegional {
|
||||
self.teleported = false
|
||||
if self.teleportedSet.contains(ch.geohash) {
|
||||
self.teleportedSet.remove(ch.geohash)
|
||||
self.persistTeleportedSet()
|
||||
}
|
||||
} else {
|
||||
self.teleported = self.teleportedSet.contains(ch.geohash)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func markTeleported(for geohash: String, _ flag: Bool) {
|
||||
if flag {
|
||||
teleportedSet.insert(geohash)
|
||||
} else {
|
||||
teleportedSet.remove(geohash)
|
||||
}
|
||||
persistTeleportedSet()
|
||||
if case .location(let ch) = selectedChannel, ch.geohash == geohash {
|
||||
Task { @MainActor in self.teleported = flag }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public API (Bookmarks)
|
||||
|
||||
func isBookmarked(_ geohash: String) -> Bool {
|
||||
bookmarkMembership.contains(Self.normalizeGeohash(geohash))
|
||||
}
|
||||
|
||||
func toggleBookmark(_ geohash: String) {
|
||||
let gh = Self.normalizeGeohash(geohash)
|
||||
if bookmarkMembership.contains(gh) {
|
||||
removeBookmark(gh)
|
||||
} else {
|
||||
addBookmark(gh)
|
||||
}
|
||||
}
|
||||
|
||||
func addBookmark(_ geohash: String) {
|
||||
let gh = Self.normalizeGeohash(geohash)
|
||||
guard !gh.isEmpty, !bookmarkMembership.contains(gh) else { return }
|
||||
bookmarks.insert(gh, at: 0)
|
||||
bookmarkMembership.insert(gh)
|
||||
persistBookmarks()
|
||||
resolveBookmarkNameIfNeeded(for: gh)
|
||||
}
|
||||
|
||||
func removeBookmark(_ geohash: String) {
|
||||
let gh = Self.normalizeGeohash(geohash)
|
||||
guard bookmarkMembership.contains(gh) else { return }
|
||||
if let idx = bookmarks.firstIndex(of: gh) {
|
||||
bookmarks.remove(at: idx)
|
||||
}
|
||||
bookmarkMembership.remove(gh)
|
||||
if bookmarkNames.removeValue(forKey: gh) != nil {
|
||||
persistBookmarkNames()
|
||||
}
|
||||
persistBookmarks()
|
||||
}
|
||||
|
||||
// MARK: - CLLocationManagerDelegate
|
||||
|
||||
private func requestOneShotLocation() {
|
||||
cl.requestLocation()
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
|
||||
updatePermissionState(from: status)
|
||||
if case .authorized = permissionState {
|
||||
requestOneShotLocation()
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14.0, macOS 11.0, *)
|
||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
updatePermissionState(from: manager.authorizationStatus)
|
||||
if case .authorized = permissionState {
|
||||
requestOneShotLocation()
|
||||
}
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard let loc = locations.last else { return }
|
||||
lastLocation = loc
|
||||
computeChannels(from: loc.coordinate)
|
||||
reverseGeocodeLocation(loc)
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
SecureLogger.error("LocationStateManager: location error: \(error.localizedDescription)", category: .session)
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers (Permission)
|
||||
|
||||
private func updatePermissionState(from status: CLAuthorizationStatus) {
|
||||
let newState: PermissionState
|
||||
switch status {
|
||||
case .notDetermined: newState = .notDetermined
|
||||
case .restricted: newState = .restricted
|
||||
case .denied: newState = .denied
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized
|
||||
@unknown default: newState = .restricted
|
||||
}
|
||||
Task { @MainActor in self.permissionState = newState }
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers (Channel Computation)
|
||||
|
||||
private func computeChannels(from coord: CLLocationCoordinate2D) {
|
||||
let levels = GeohashChannelLevel.allCases
|
||||
var result: [GeohashChannel] = []
|
||||
for level in levels {
|
||||
let gh = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: level.precision)
|
||||
result.append(GeohashChannel(level: level, geohash: gh))
|
||||
}
|
||||
Task { @MainActor in
|
||||
self.availableChannels = result
|
||||
switch self.selectedChannel {
|
||||
case .mesh:
|
||||
self.teleported = false
|
||||
case .location(let ch):
|
||||
let inRegional = result.contains { $0.geohash == ch.geohash }
|
||||
if inRegional {
|
||||
self.teleported = false
|
||||
if self.teleportedSet.contains(ch.geohash) {
|
||||
self.teleportedSet.remove(ch.geohash)
|
||||
self.persistTeleportedSet()
|
||||
}
|
||||
} else {
|
||||
self.teleported = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers (Geocoding)
|
||||
|
||||
private func reverseGeocodeLocation(_ location: CLLocation) {
|
||||
geocoder.cancelGeocode()
|
||||
isGeocoding = true
|
||||
geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, _ in
|
||||
guard let self = self else { return }
|
||||
self.isGeocoding = false
|
||||
if let pm = placemarks?.first {
|
||||
let names = self.locationNamesByLevel(from: pm)
|
||||
Task { @MainActor in self.locationNames = names }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func locationNamesByLevel(from pm: CLPlacemark) -> [GeohashChannelLevel: String] {
|
||||
var dict: [GeohashChannelLevel: String] = [:]
|
||||
if let country = pm.country, !country.isEmpty {
|
||||
dict[.region] = country
|
||||
}
|
||||
if let admin = pm.administrativeArea, !admin.isEmpty {
|
||||
dict[.province] = admin
|
||||
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
|
||||
dict[.province] = subAdmin
|
||||
}
|
||||
if let locality = pm.locality, !locality.isEmpty {
|
||||
dict[.city] = locality
|
||||
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
|
||||
dict[.city] = subAdmin
|
||||
} else if let admin = pm.administrativeArea, !admin.isEmpty {
|
||||
dict[.city] = admin
|
||||
}
|
||||
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
||||
dict[.neighborhood] = subLocality
|
||||
} else if let locality = pm.locality, !locality.isEmpty {
|
||||
dict[.neighborhood] = locality
|
||||
}
|
||||
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
||||
dict[.block] = subLocality
|
||||
} else if let locality = pm.locality, !locality.isEmpty {
|
||||
dict[.block] = locality
|
||||
}
|
||||
if let name = pm.name, !name.isEmpty {
|
||||
dict[.building] = name
|
||||
} else if let thoroughfare = pm.thoroughfare, !thoroughfare.isEmpty {
|
||||
dict[.building] = thoroughfare
|
||||
}
|
||||
return dict
|
||||
}
|
||||
|
||||
func resolveBookmarkNameIfNeeded(for geohash: String) {
|
||||
let gh = Self.normalizeGeohash(geohash)
|
||||
guard !gh.isEmpty, bookmarkNames[gh] == nil, !resolvingNames.contains(gh) else { return }
|
||||
resolvingNames.insert(gh)
|
||||
|
||||
if gh.count <= 2 {
|
||||
let b = Geohash.decodeBounds(gh)
|
||||
let pts: [CLLocation] = [
|
||||
CLLocation(latitude: (b.latMin + b.latMax) / 2, longitude: (b.lonMin + b.lonMax) / 2),
|
||||
CLLocation(latitude: b.latMin, longitude: b.lonMin),
|
||||
CLLocation(latitude: b.latMin, longitude: b.lonMax),
|
||||
CLLocation(latitude: b.latMax, longitude: b.lonMin),
|
||||
CLLocation(latitude: b.latMax, longitude: b.lonMax)
|
||||
]
|
||||
resolveCompositeAdminName(geohash: gh, points: pts)
|
||||
} else {
|
||||
let center = Geohash.decodeCenter(gh)
|
||||
let loc = CLLocation(latitude: center.lat, longitude: center.lon)
|
||||
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
|
||||
guard let self = self else { return }
|
||||
defer { self.resolvingNames.remove(gh) }
|
||||
if let pm = placemarks?.first,
|
||||
let name = Self.nameForGeohashLength(gh.count, from: pm),
|
||||
!name.isEmpty {
|
||||
DispatchQueue.main.async {
|
||||
self.bookmarkNames[gh] = name
|
||||
self.persistBookmarkNames()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
|
||||
var uniqueAdmins: [String] = []
|
||||
var seenAdmins = Set<String>()
|
||||
var idx = 0
|
||||
|
||||
func step() {
|
||||
if idx >= points.count {
|
||||
let finalName: String? = {
|
||||
if uniqueAdmins.count >= 2 { return uniqueAdmins[0] + " and " + uniqueAdmins[1] }
|
||||
return uniqueAdmins.first
|
||||
}()
|
||||
if let finalName = finalName, !finalName.isEmpty {
|
||||
DispatchQueue.main.async {
|
||||
self.bookmarkNames[gh] = finalName
|
||||
self.persistBookmarkNames()
|
||||
}
|
||||
}
|
||||
self.resolvingNames.remove(gh)
|
||||
return
|
||||
}
|
||||
let loc = points[idx]
|
||||
idx += 1
|
||||
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
|
||||
guard self != nil else { return }
|
||||
if let pm = placemarks?.first {
|
||||
if let admin = pm.administrativeArea, !admin.isEmpty, !seenAdmins.contains(admin) {
|
||||
seenAdmins.insert(admin)
|
||||
uniqueAdmins.append(admin)
|
||||
} else if let country = pm.country, !country.isEmpty, !seenAdmins.contains(country) {
|
||||
seenAdmins.insert(country)
|
||||
uniqueAdmins.append(country)
|
||||
}
|
||||
}
|
||||
step()
|
||||
}
|
||||
}
|
||||
step()
|
||||
}
|
||||
|
||||
private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? {
|
||||
switch len {
|
||||
case 0...2:
|
||||
return pm.administrativeArea ?? pm.country
|
||||
case 3...4:
|
||||
return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country
|
||||
case 5:
|
||||
return pm.locality ?? pm.subAdministrativeArea ?? pm.administrativeArea
|
||||
case 6...7:
|
||||
return pm.subLocality ?? pm.locality ?? pm.administrativeArea
|
||||
default:
|
||||
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers (Persistence)
|
||||
|
||||
private func persistTeleportedSet() {
|
||||
if let data = try? JSONEncoder().encode(Array(teleportedSet)) {
|
||||
storage.set(data, forKey: teleportedStoreKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func persistBookmarks() {
|
||||
if let data = try? JSONEncoder().encode(bookmarks) {
|
||||
storage.set(data, forKey: bookmarksKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func persistBookmarkNames() {
|
||||
if let data = try? JSONEncoder().encode(bookmarkNames) {
|
||||
storage.set(data, forKey: bookmarkNamesKey)
|
||||
}
|
||||
}
|
||||
|
||||
private static func normalizeGeohash(_ s: String) -> String {
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
return s
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: "#", with: "")
|
||||
.filter { allowed.contains($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Backward Compatibility Typealiases
|
||||
|
||||
typealias LocationChannelManager = LocationStateManager
|
||||
typealias GeohashBookmarksStore = LocationStateManager
|
||||
|
||||
// MARK: - Backward Compatibility Extensions
|
||||
|
||||
extension LocationStateManager {
|
||||
/// Backward compatibility: toggle bookmark (was GeohashBookmarksStore.toggle)
|
||||
func toggle(_ geohash: String) {
|
||||
toggleBookmark(geohash)
|
||||
}
|
||||
|
||||
/// Backward compatibility: add bookmark (was GeohashBookmarksStore.add)
|
||||
func add(_ geohash: String) {
|
||||
addBookmark(geohash)
|
||||
}
|
||||
|
||||
/// Backward compatibility: remove bookmark (was GeohashBookmarksStore.remove)
|
||||
func remove(_ geohash: String) {
|
||||
removeBookmark(geohash)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,120 @@
|
||||
import Foundation
|
||||
|
||||
/// Tracks observed mesh topology and computes hop-by-hop routes.
|
||||
final class MeshTopologyTracker {
|
||||
private typealias RoutingID = Data
|
||||
|
||||
private let queue = DispatchQueue(label: "mesh.topology", attributes: .concurrent)
|
||||
private let hopSize = 8
|
||||
private var adjacency: [RoutingID: Set<RoutingID>] = [:]
|
||||
|
||||
func reset() {
|
||||
queue.sync(flags: .barrier) {
|
||||
self.adjacency.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
func recordDirectLink(between a: Data?, and b: Data?) {
|
||||
guard let left = sanitize(a), let right = sanitize(b), left != right else { return }
|
||||
queue.sync(flags: .barrier) {
|
||||
var setA = self.adjacency[left] ?? []
|
||||
setA.insert(right)
|
||||
self.adjacency[left] = setA
|
||||
|
||||
var setB = self.adjacency[right] ?? []
|
||||
setB.insert(left)
|
||||
self.adjacency[right] = setB
|
||||
}
|
||||
}
|
||||
|
||||
func removeDirectLink(between a: Data?, and b: Data?) {
|
||||
guard let left = sanitize(a), let right = sanitize(b), left != right else { return }
|
||||
queue.sync(flags: .barrier) {
|
||||
if var setA = self.adjacency[left] {
|
||||
setA.remove(right)
|
||||
self.adjacency[left] = setA.isEmpty ? nil : setA
|
||||
}
|
||||
if var setB = self.adjacency[right] {
|
||||
setB.remove(left)
|
||||
self.adjacency[right] = setB.isEmpty ? nil : setB
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removePeer(_ data: Data?) {
|
||||
guard let peer = sanitize(data) else { return }
|
||||
queue.sync(flags: .barrier) {
|
||||
guard let neighbors = self.adjacency.removeValue(forKey: peer) else { return }
|
||||
for neighbor in neighbors {
|
||||
if var set = self.adjacency[neighbor] {
|
||||
set.remove(peer)
|
||||
self.adjacency[neighbor] = set.isEmpty ? nil : set
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func recordRoute(_ hops: [Data]) {
|
||||
let sanitized = hops.compactMap { sanitize($0) }
|
||||
guard sanitized.count >= 2 else { return }
|
||||
queue.sync(flags: .barrier) {
|
||||
for idx in 0..<(sanitized.count - 1) {
|
||||
let left = sanitized[idx]
|
||||
let right = sanitized[idx + 1]
|
||||
guard left != right else { continue }
|
||||
|
||||
var setA = self.adjacency[left] ?? []
|
||||
setA.insert(right)
|
||||
self.adjacency[left] = setA
|
||||
|
||||
var setB = self.adjacency[right] ?? []
|
||||
setB.insert(left)
|
||||
self.adjacency[right] = setB
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 255) -> [Data]? {
|
||||
guard let source = sanitize(start), let target = sanitize(goal) else { return nil }
|
||||
if source == target { return [source] }
|
||||
|
||||
let graph = queue.sync { adjacency }
|
||||
guard graph[source] != nil, graph[target] != nil else { return nil }
|
||||
|
||||
var visited: Set<RoutingID> = [source]
|
||||
var queuePaths: [[RoutingID]] = [[source]]
|
||||
var index = 0
|
||||
|
||||
while index < queuePaths.count {
|
||||
let path = queuePaths[index]
|
||||
index += 1
|
||||
guard path.count <= maxHops else { continue }
|
||||
guard let last = path.last, let neighbors = graph[last] else { continue }
|
||||
|
||||
for neighbor in neighbors {
|
||||
if visited.contains(neighbor) { continue }
|
||||
var nextPath = path
|
||||
nextPath.append(neighbor)
|
||||
if neighbor == target { return nextPath }
|
||||
if nextPath.count <= maxHops {
|
||||
queuePaths.append(nextPath)
|
||||
}
|
||||
visited.insert(neighbor)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func sanitize(_ data: Data?) -> Data? {
|
||||
guard var value = data, !value.isEmpty else { return nil }
|
||||
if value.count > hopSize {
|
||||
value = Data(value.prefix(hopSize))
|
||||
} else if value.count < hopSize {
|
||||
value.append(Data(repeating: 0, count: hopSize - value.count))
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//
|
||||
// MessageDeduplicationService.swift
|
||||
// bitchat
|
||||
//
|
||||
// Handles message deduplication using LRU caches.
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - LRU Deduplication Cache
|
||||
|
||||
/// Generic LRU (Least Recently Used) cache for deduplication.
|
||||
/// Uses an efficient O(1) lookup with periodic compaction.
|
||||
final class LRUDeduplicationCache<Value> {
|
||||
private var map: [String: Value] = [:]
|
||||
private var order: [String] = []
|
||||
private var head: Int = 0
|
||||
private let capacity: Int
|
||||
|
||||
/// Creates a new LRU cache with the specified capacity.
|
||||
/// - Parameter capacity: Maximum number of entries before eviction
|
||||
init(capacity: Int) {
|
||||
precondition(capacity > 0, "LRU cache capacity must be positive")
|
||||
self.capacity = capacity
|
||||
}
|
||||
|
||||
/// Number of active entries in the cache
|
||||
var count: Int {
|
||||
order.count - head
|
||||
}
|
||||
|
||||
/// Checks if a key exists in the cache
|
||||
func contains(_ key: String) -> Bool {
|
||||
map[key] != nil
|
||||
}
|
||||
|
||||
/// Gets the value for a key, or nil if not present
|
||||
func value(for key: String) -> Value? {
|
||||
map[key]
|
||||
}
|
||||
|
||||
/// Records a key-value pair, updating if exists or inserting if new
|
||||
func record(_ key: String, value: Value) {
|
||||
if map[key] == nil {
|
||||
order.append(key)
|
||||
}
|
||||
map[key] = value
|
||||
trimIfNeeded()
|
||||
}
|
||||
|
||||
/// Removes a specific key from the cache
|
||||
func remove(_ key: String) {
|
||||
map.removeValue(forKey: key)
|
||||
// Note: key remains in order array but will be skipped during eviction
|
||||
}
|
||||
|
||||
/// Clears all entries from the cache
|
||||
func clear() {
|
||||
map.removeAll()
|
||||
order.removeAll()
|
||||
head = 0
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func trimIfNeeded() {
|
||||
let activeCount = order.count - head
|
||||
guard activeCount > capacity else { return }
|
||||
|
||||
let overflow = activeCount - capacity
|
||||
for _ in 0..<overflow {
|
||||
guard let victim = popOldest() else { break }
|
||||
map.removeValue(forKey: victim)
|
||||
}
|
||||
}
|
||||
|
||||
private func popOldest() -> String? {
|
||||
// Skip keys that were already removed from map
|
||||
while head < order.count {
|
||||
let key = order[head]
|
||||
head += 1
|
||||
|
||||
// Periodically compact the backing storage
|
||||
if head >= 32 && head * 2 >= order.count {
|
||||
order.removeFirst(head)
|
||||
head = 0
|
||||
}
|
||||
|
||||
// Only return if key is still in map
|
||||
if map[key] != nil {
|
||||
return key
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content Normalizer
|
||||
|
||||
/// Normalizes message content for near-duplicate detection.
|
||||
enum ContentNormalizer {
|
||||
|
||||
/// Regex to simplify HTTP URLs by stripping query strings and fragments
|
||||
private static let simplifyHTTPURL: NSRegularExpression = {
|
||||
try! NSRegularExpression(
|
||||
pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?",
|
||||
options: [.caseInsensitive]
|
||||
)
|
||||
}()
|
||||
|
||||
/// Normalizes content for deduplication comparison.
|
||||
/// - Parameters:
|
||||
/// - content: The raw message content
|
||||
/// - prefixLength: Maximum characters to consider (default from TransportConfig)
|
||||
/// - Returns: A hash-based key for comparison
|
||||
static func normalizedKey(
|
||||
_ content: String,
|
||||
prefixLength: Int = TransportConfig.contentKeyPrefixLength
|
||||
) -> String {
|
||||
// Lowercase for case-insensitive comparison
|
||||
let lowered = content.lowercased()
|
||||
let ns = lowered as NSString
|
||||
let range = NSRange(location: 0, length: ns.length)
|
||||
|
||||
// Simplify URLs by stripping query/fragment
|
||||
var simplified = ""
|
||||
var last = 0
|
||||
for match in simplifyHTTPURL.matches(in: lowered, options: [], range: range) {
|
||||
if match.range.location > last {
|
||||
simplified += ns.substring(with: NSRange(location: last, length: match.range.location - last))
|
||||
}
|
||||
let url = ns.substring(with: match.range)
|
||||
if let queryIndex = url.firstIndex(where: { $0 == "?" || $0 == "#" }) {
|
||||
simplified += String(url[..<queryIndex])
|
||||
} else {
|
||||
simplified += url
|
||||
}
|
||||
last = match.range.location + match.range.length
|
||||
}
|
||||
if last < ns.length {
|
||||
simplified += ns.substring(with: NSRange(location: last, length: ns.length - last))
|
||||
}
|
||||
|
||||
// Trim and collapse whitespace
|
||||
let trimmed = simplified.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||
|
||||
// Take prefix and hash
|
||||
let prefix = String(collapsed.prefix(prefixLength))
|
||||
let hash = prefix.djb2()
|
||||
return String(format: "h:%016llx", hash)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Message Deduplication Service
|
||||
|
||||
/// Service that manages message deduplication using LRU caches.
|
||||
/// Provides separate caches for content-based dedup and Nostr event ID dedup.
|
||||
final class MessageDeduplicationService {
|
||||
|
||||
/// Cache for content-based near-duplicate detection
|
||||
private let contentCache: LRUDeduplicationCache<Date>
|
||||
|
||||
/// Cache for Nostr event ID deduplication
|
||||
private let nostrEventCache: LRUDeduplicationCache<Bool>
|
||||
|
||||
/// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format)
|
||||
private let nostrAckCache: LRUDeduplicationCache<Bool>
|
||||
|
||||
/// Creates a new deduplication service with specified capacities.
|
||||
/// - Parameters:
|
||||
/// - contentCapacity: Max entries for content cache
|
||||
/// - nostrEventCapacity: Max entries for Nostr event cache
|
||||
init(
|
||||
contentCapacity: Int = TransportConfig.contentLRUCap,
|
||||
nostrEventCapacity: Int = TransportConfig.uiProcessedNostrEventsCap
|
||||
) {
|
||||
self.contentCache = LRUDeduplicationCache(capacity: contentCapacity)
|
||||
self.nostrEventCache = LRUDeduplicationCache(capacity: nostrEventCapacity)
|
||||
self.nostrAckCache = LRUDeduplicationCache(capacity: nostrEventCapacity)
|
||||
}
|
||||
|
||||
// MARK: - Content Deduplication
|
||||
|
||||
/// Records content with its timestamp for near-duplicate detection.
|
||||
/// - Parameters:
|
||||
/// - content: The message content
|
||||
/// - timestamp: When the content was received
|
||||
func recordContent(_ content: String, timestamp: Date) {
|
||||
let key = ContentNormalizer.normalizedKey(content)
|
||||
contentCache.record(key, value: timestamp)
|
||||
}
|
||||
|
||||
/// Records a pre-normalized content key with its timestamp.
|
||||
/// - Parameters:
|
||||
/// - key: The normalized content key
|
||||
/// - timestamp: When the content was received
|
||||
func recordContentKey(_ key: String, timestamp: Date) {
|
||||
contentCache.record(key, value: timestamp)
|
||||
}
|
||||
|
||||
/// Gets the timestamp for previously seen content.
|
||||
/// - Parameter content: The message content
|
||||
/// - Returns: The timestamp when first seen, or nil if not seen
|
||||
func contentTimestamp(for content: String) -> Date? {
|
||||
let key = ContentNormalizer.normalizedKey(content)
|
||||
return contentCache.value(for: key)
|
||||
}
|
||||
|
||||
/// Gets the timestamp for a pre-normalized content key.
|
||||
/// - Parameter key: The normalized content key
|
||||
/// - Returns: The timestamp when first seen, or nil if not seen
|
||||
func contentTimestamp(forKey key: String) -> Date? {
|
||||
contentCache.value(for: key)
|
||||
}
|
||||
|
||||
/// Normalizes content to a deduplication key.
|
||||
/// - Parameter content: The raw content
|
||||
/// - Returns: A normalized hash key
|
||||
func normalizedContentKey(_ content: String) -> String {
|
||||
ContentNormalizer.normalizedKey(content)
|
||||
}
|
||||
|
||||
// MARK: - Nostr Event Deduplication
|
||||
|
||||
/// Checks if a Nostr event has already been processed.
|
||||
/// - Parameter eventId: The event ID
|
||||
/// - Returns: true if already processed
|
||||
func hasProcessedNostrEvent(_ eventId: String) -> Bool {
|
||||
nostrEventCache.contains(eventId)
|
||||
}
|
||||
|
||||
/// Records a Nostr event as processed.
|
||||
/// - Parameter eventId: The event ID
|
||||
func recordNostrEvent(_ eventId: String) {
|
||||
nostrEventCache.record(eventId, value: true)
|
||||
}
|
||||
|
||||
// MARK: - Nostr ACK Deduplication
|
||||
|
||||
/// Checks if a Nostr ACK has already been processed.
|
||||
/// - Parameter ackKey: The ACK key in format "messageId:ackType:senderPubkey"
|
||||
/// - Returns: true if already processed
|
||||
func hasProcessedNostrAck(_ ackKey: String) -> Bool {
|
||||
nostrAckCache.contains(ackKey)
|
||||
}
|
||||
|
||||
/// Records a Nostr ACK as processed.
|
||||
/// - Parameter ackKey: The ACK key in format "messageId:ackType:senderPubkey"
|
||||
func recordNostrAck(_ ackKey: String) {
|
||||
nostrAckCache.record(ackKey, value: true)
|
||||
}
|
||||
|
||||
/// Creates an ACK key from components.
|
||||
static func ackKey(messageId: String, ackType: String, senderPubkey: String) -> String {
|
||||
"\(messageId):\(ackType):\(senderPubkey)"
|
||||
}
|
||||
|
||||
// MARK: - Clear
|
||||
|
||||
/// Clears all caches
|
||||
func clearAll() {
|
||||
contentCache.clear()
|
||||
nostrEventCache.clear()
|
||||
nostrAckCache.clear()
|
||||
}
|
||||
|
||||
/// Clears only the Nostr caches (events and ACKs)
|
||||
func clearNostrCaches() {
|
||||
nostrEventCache.clear()
|
||||
nostrAckCache.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
//
|
||||
// MessageFormattingEngine.swift
|
||||
// bitchat
|
||||
//
|
||||
// Handles message text formatting, including mentions, hashtags, URLs, and tokens.
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Formatting Context Protocol
|
||||
|
||||
/// Protocol defining the context needed for message formatting.
|
||||
/// Implemented by ChatViewModel to provide runtime state.
|
||||
@MainActor
|
||||
protocol MessageFormattingContext: AnyObject {
|
||||
/// The user's current nickname
|
||||
var nickname: String { get }
|
||||
|
||||
/// Determines if a message was sent by the current user
|
||||
func isSelfMessage(_ message: BitchatMessage) -> Bool
|
||||
|
||||
/// Gets the color for a message's sender
|
||||
func senderColor(for message: BitchatMessage, isDark: Bool) -> Color
|
||||
|
||||
/// Resolves a peer ID to a clickable URL
|
||||
func peerURL(for peerID: PeerID) -> URL?
|
||||
}
|
||||
|
||||
// MARK: - Formatting Engine
|
||||
|
||||
/// Handles rich text formatting for chat messages.
|
||||
/// Extracts mentions, hashtags, URLs, Lightning invoices, and Cashu tokens.
|
||||
final class MessageFormattingEngine {
|
||||
|
||||
// MARK: - Precompiled Regexes
|
||||
|
||||
/// Precompiled regex patterns for message content parsing
|
||||
enum Patterns {
|
||||
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: [])
|
||||
}()
|
||||
|
||||
static let simplifyHTTPURL: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?", options: [.caseInsensitive])
|
||||
}()
|
||||
}
|
||||
|
||||
// MARK: - Match Types
|
||||
|
||||
/// Types of matches found in message content
|
||||
enum MatchType: String {
|
||||
case hashtag
|
||||
case mention
|
||||
case url
|
||||
case cashu
|
||||
case lightning
|
||||
case bolt11
|
||||
case lnurl
|
||||
}
|
||||
|
||||
/// A match found in message content
|
||||
struct ContentMatch {
|
||||
let range: NSRange
|
||||
let type: MatchType
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Formats a message with rich text styling
|
||||
@MainActor
|
||||
static func formatMessage(
|
||||
_ message: BitchatMessage,
|
||||
context: MessageFormattingContext,
|
||||
colorScheme: ColorScheme
|
||||
) -> AttributedString {
|
||||
let isDark = colorScheme == .dark
|
||||
let isSelf = context.isSelfMessage(message)
|
||||
|
||||
// Check cache first
|
||||
if let cached = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf) {
|
||||
return cached
|
||||
}
|
||||
|
||||
var result = AttributedString()
|
||||
let baseColor: Color = isSelf ? .orange : context.senderColor(for: message, isDark: isDark)
|
||||
|
||||
// Format system messages differently
|
||||
if message.sender == "system" {
|
||||
result = formatSystemMessage(message, isDark: isDark)
|
||||
} else {
|
||||
// Format sender header
|
||||
result = formatSenderHeader(
|
||||
message: message,
|
||||
baseColor: baseColor,
|
||||
isSelf: isSelf,
|
||||
context: context
|
||||
)
|
||||
|
||||
// Format content
|
||||
let contentResult = formatContent(
|
||||
message.content,
|
||||
baseColor: baseColor,
|
||||
isSelf: isSelf,
|
||||
isMentioned: message.mentions?.contains(context.nickname) ?? false
|
||||
)
|
||||
result.append(contentResult)
|
||||
|
||||
// Add timestamp
|
||||
result.append(formatTimestamp(message.formattedTimestamp))
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Formats just the message header (sender portion)
|
||||
@MainActor
|
||||
static func formatHeader(
|
||||
_ message: BitchatMessage,
|
||||
context: MessageFormattingContext,
|
||||
colorScheme: ColorScheme
|
||||
) -> AttributedString {
|
||||
let isDark = colorScheme == .dark
|
||||
let isSelf = context.isSelfMessage(message)
|
||||
let baseColor: Color = isSelf ? .orange : context.senderColor(for: message, isDark: isDark)
|
||||
|
||||
if message.sender == "system" {
|
||||
var style = AttributeContainer()
|
||||
style.foregroundColor = baseColor
|
||||
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
|
||||
return AttributedString(message.sender).mergingAttributes(style)
|
||||
}
|
||||
|
||||
return formatSenderHeader(
|
||||
message: message,
|
||||
baseColor: baseColor,
|
||||
isSelf: isSelf,
|
||||
context: context
|
||||
)
|
||||
}
|
||||
|
||||
/// Extracts mentions from message content
|
||||
static func extractMentions(from content: String) -> [String] {
|
||||
let nsContent = content as NSString
|
||||
let range = NSRange(location: 0, length: nsContent.length)
|
||||
let matches = Patterns.mention.matches(in: content, options: [], range: range)
|
||||
|
||||
return matches.compactMap { match -> String? in
|
||||
guard match.numberOfRanges > 1 else { return nil }
|
||||
let captureRange = match.range(at: 1)
|
||||
guard let swiftRange = Range(captureRange, in: content) else { return nil }
|
||||
return String(content[swiftRange])
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if content contains a Cashu token
|
||||
static func containsCashuToken(_ content: String) -> Bool {
|
||||
let nsContent = content as NSString
|
||||
let range = NSRange(location: 0, length: nsContent.length)
|
||||
return Patterns.quickCashuPresence.numberOfMatches(in: content, options: [], range: range) > 0
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private static func formatSystemMessage(_ message: BitchatMessage, isDark: Bool) -> AttributedString {
|
||||
var result = AttributedString()
|
||||
|
||||
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))
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func formatSenderHeader(
|
||||
message: BitchatMessage,
|
||||
baseColor: Color,
|
||||
isSelf: Bool,
|
||||
context: MessageFormattingContext
|
||||
) -> AttributedString {
|
||||
var result = AttributedString()
|
||||
|
||||
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
|
||||
if let spid = message.senderPeerID, let url = context.peerURL(for: spid) {
|
||||
senderStyle.link = url
|
||||
}
|
||||
|
||||
// Build: "<@baseName#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))
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static func formatContent(
|
||||
_ content: String,
|
||||
baseColor: Color,
|
||||
isSelf: Bool,
|
||||
isMentioned: Bool
|
||||
) -> AttributedString {
|
||||
// For very long content without special tokens, use plain formatting
|
||||
let containsCashu = containsCashuToken(content)
|
||||
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu {
|
||||
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
|
||||
}
|
||||
|
||||
// Find all matches
|
||||
let matches = findAllMatches(in: content)
|
||||
|
||||
// Build formatted content
|
||||
var result = AttributedString()
|
||||
var lastEnd = content.startIndex
|
||||
|
||||
for match in matches {
|
||||
guard let swiftRange = Range(match.range, in: content) else { continue }
|
||||
|
||||
// Add text before match
|
||||
if lastEnd < swiftRange.lowerBound {
|
||||
let beforeText = String(content[lastEnd..<swiftRange.lowerBound])
|
||||
result.append(formatPlainText(beforeText, baseColor: baseColor, isSelf: isSelf, isMentioned: isMentioned))
|
||||
}
|
||||
|
||||
// Add styled match
|
||||
let matchText = String(content[swiftRange])
|
||||
result.append(formatMatch(matchText, type: match.type, baseColor: baseColor, isSelf: isSelf))
|
||||
|
||||
lastEnd = swiftRange.upperBound
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if lastEnd < content.endIndex {
|
||||
let remainingText = String(content[lastEnd...])
|
||||
result.append(formatPlainText(remainingText, baseColor: baseColor, isSelf: isSelf, isMentioned: isMentioned))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static func findAllMatches(in content: String) -> [ContentMatch] {
|
||||
let nsContent = content as NSString
|
||||
let nsLen = nsContent.length
|
||||
let fullRange = NSRange(location: 0, length: nsLen)
|
||||
|
||||
// Quick hints to avoid unnecessary regex work
|
||||
let hasMentions = content.contains("@")
|
||||
let hasHashtags = content.contains("#")
|
||||
let hasURLs = content.contains("://") || content.contains("www.") || content.contains("http")
|
||||
let hasLightning = content.lowercased().contains("ln") || content.lowercased().contains("lightning:")
|
||||
let hasCashu = content.lowercased().contains("cashu")
|
||||
|
||||
// Collect matches
|
||||
let mentionMatches = hasMentions ? Patterns.mention.matches(in: content, options: [], range: fullRange) : []
|
||||
let hashtagMatches = hasHashtags ? Patterns.hashtag.matches(in: content, options: [], range: fullRange) : []
|
||||
let urlMatches = hasURLs ? (Patterns.linkDetector?.matches(in: content, options: [], range: fullRange) ?? []) : []
|
||||
let cashuMatches = hasCashu ? Patterns.cashu.matches(in: content, options: [], range: fullRange) : []
|
||||
let lightningMatches = hasLightning ? Patterns.lightningScheme.matches(in: content, options: [], range: fullRange) : []
|
||||
let bolt11Matches = hasLightning ? Patterns.bolt11.matches(in: content, options: [], range: fullRange) : []
|
||||
let lnurlMatches = hasLightning ? Patterns.lnurl.matches(in: content, options: [], range: fullRange) : []
|
||||
|
||||
// Build mention ranges for overlap checking
|
||||
let mentionRanges = mentionMatches.map { $0.range(at: 0) }
|
||||
|
||||
func overlapsMention(_ r: NSRange) -> Bool {
|
||||
mentionRanges.contains { NSIntersectionRange(r, $0).length > 0 }
|
||||
}
|
||||
|
||||
func isStandaloneHashtag(_ r: NSRange) -> Bool {
|
||||
guard let swiftRange = Range(r, in: content) else { return false }
|
||||
if swiftRange.lowerBound == content.startIndex { return true }
|
||||
let prev = content.index(before: swiftRange.lowerBound)
|
||||
return content[prev].isWhitespace || content[prev].isNewline
|
||||
}
|
||||
|
||||
func attachedToMention(_ r: NSRange) -> Bool {
|
||||
guard let swiftRange = Range(r, in: content), swiftRange.lowerBound > content.startIndex else { return false }
|
||||
var i = content.index(before: swiftRange.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
|
||||
}
|
||||
|
||||
var allMatches: [ContentMatch] = []
|
||||
|
||||
// Add hashtags (excluding those attached to mentions)
|
||||
for match in hashtagMatches {
|
||||
let range = match.range(at: 0)
|
||||
if !overlapsMention(range) && !attachedToMention(range) && isStandaloneHashtag(range) {
|
||||
allMatches.append(ContentMatch(range: range, type: .hashtag))
|
||||
}
|
||||
}
|
||||
|
||||
// Add mentions
|
||||
for match in mentionMatches {
|
||||
allMatches.append(ContentMatch(range: match.range(at: 0), type: .mention))
|
||||
}
|
||||
|
||||
// Add URLs
|
||||
for match in urlMatches where !overlapsMention(match.range) {
|
||||
allMatches.append(ContentMatch(range: match.range, type: .url))
|
||||
}
|
||||
|
||||
// Add Cashu tokens
|
||||
for match in cashuMatches where !overlapsMention(match.range(at: 0)) {
|
||||
allMatches.append(ContentMatch(range: match.range(at: 0), type: .cashu))
|
||||
}
|
||||
|
||||
// Add Lightning scheme URLs
|
||||
for match in lightningMatches where !overlapsMention(match.range(at: 0)) {
|
||||
allMatches.append(ContentMatch(range: match.range(at: 0), type: .lightning))
|
||||
}
|
||||
|
||||
// Add bolt11/lnurl (avoiding overlaps with lightning scheme and URLs)
|
||||
let occupied = urlMatches.map { $0.range } + lightningMatches.map { $0.range(at: 0) }
|
||||
func overlapsOccupied(_ r: NSRange) -> Bool {
|
||||
occupied.contains { NSIntersectionRange(r, $0).length > 0 }
|
||||
}
|
||||
|
||||
for match in bolt11Matches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
|
||||
allMatches.append(ContentMatch(range: match.range(at: 0), type: .bolt11))
|
||||
}
|
||||
|
||||
for match in lnurlMatches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
|
||||
allMatches.append(ContentMatch(range: match.range(at: 0), type: .lnurl))
|
||||
}
|
||||
|
||||
// Sort by position
|
||||
return allMatches.sorted { $0.range.location < $1.range.location }
|
||||
}
|
||||
|
||||
private static func formatPlainContent(_ content: String, baseColor: Color, isSelf: Bool) -> AttributedString {
|
||||
var style = AttributeContainer()
|
||||
style.foregroundColor = baseColor
|
||||
style.font = isSelf
|
||||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||
: .bitchatSystem(size: 14, design: .monospaced)
|
||||
return AttributedString(content).mergingAttributes(style)
|
||||
}
|
||||
|
||||
private static func formatPlainText(_ text: String, baseColor: Color, isSelf: Bool, isMentioned: Bool) -> AttributedString {
|
||||
guard !text.isEmpty else { return AttributedString() }
|
||||
|
||||
var style = AttributeContainer()
|
||||
style.foregroundColor = baseColor
|
||||
style.font = isSelf
|
||||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||
: .bitchatSystem(size: 14, design: .monospaced)
|
||||
|
||||
if isMentioned {
|
||||
style.font = style.font?.bold()
|
||||
}
|
||||
|
||||
return AttributedString(text).mergingAttributes(style)
|
||||
}
|
||||
|
||||
private static func formatMatch(_ text: String, type: MatchType, baseColor: Color, isSelf: Bool) -> AttributedString {
|
||||
var style = AttributeContainer()
|
||||
|
||||
switch type {
|
||||
case .mention:
|
||||
// Split optional '#abcd' suffix
|
||||
let (baseName, suffix) = text.splitSuffix()
|
||||
var result = AttributedString()
|
||||
|
||||
var mentionStyle = AttributeContainer()
|
||||
mentionStyle.foregroundColor = .blue
|
||||
mentionStyle.font = .bitchatSystem(size: 14, weight: .semibold, design: .monospaced)
|
||||
result.append(AttributedString(baseName).mergingAttributes(mentionStyle))
|
||||
|
||||
if !suffix.isEmpty {
|
||||
var suffixStyle = mentionStyle
|
||||
suffixStyle.foregroundColor = Color.gray.opacity(0.7)
|
||||
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
case .hashtag:
|
||||
style.foregroundColor = .purple
|
||||
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
|
||||
|
||||
case .url:
|
||||
style.foregroundColor = .blue
|
||||
style.font = .bitchatSystem(size: 14, design: .monospaced)
|
||||
style.underlineStyle = .single
|
||||
if let url = URL(string: text) {
|
||||
style.link = url
|
||||
}
|
||||
|
||||
case .cashu:
|
||||
style.foregroundColor = .green
|
||||
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
|
||||
style.backgroundColor = Color.green.opacity(0.1)
|
||||
|
||||
case .lightning, .bolt11, .lnurl:
|
||||
style.foregroundColor = .yellow
|
||||
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
|
||||
style.backgroundColor = Color.yellow.opacity(0.1)
|
||||
}
|
||||
|
||||
return AttributedString(text).mergingAttributes(style)
|
||||
}
|
||||
|
||||
private static func formatTimestamp(_ timestamp: String) -> AttributedString {
|
||||
let text = AttributedString(" [\(timestamp)]")
|
||||
var style = AttributeContainer()
|
||||
style.foregroundColor = Color.gray.opacity(0.5)
|
||||
style.font = .bitchatSystem(size: 10, design: .monospaced)
|
||||
return text.mergingAttributes(style)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// Routes messages between BLE and Nostr transports
|
||||
/// Routes messages using available transports (Mesh, Nostr, etc.)
|
||||
@MainActor
|
||||
final class MessageRouter {
|
||||
private let mesh: Transport
|
||||
private let nostr: NostrTransport
|
||||
private var outbox: [String: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
|
||||
private let transports: [Transport]
|
||||
private var outbox: [PeerID: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
|
||||
|
||||
init(mesh: Transport, nostr: NostrTransport) {
|
||||
self.mesh = mesh
|
||||
self.nostr = nostr
|
||||
self.nostr.senderPeerID = mesh.myPeerID
|
||||
init(transports: [Transport]) {
|
||||
self.transports = transports
|
||||
|
||||
// Observe favorites changes to learn Nostr mapping and flush queued messages
|
||||
NotificationCenter.default.addObserver(
|
||||
@@ -21,7 +18,7 @@ final class MessageRouter {
|
||||
) { [weak self] note in
|
||||
guard let self = self else { return }
|
||||
if let data = note.userInfo?["peerPublicKey"] as? Data {
|
||||
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: data)
|
||||
let peerID = PeerID(publicKey: data)
|
||||
Task { @MainActor in
|
||||
self.flushOutbox(for: peerID)
|
||||
}
|
||||
@@ -29,7 +26,7 @@ final class MessageRouter {
|
||||
// Handle key updates
|
||||
if let newKey = note.userInfo?["peerPublicKey"] as? Data,
|
||||
let _ = note.userInfo?["isKeyUpdate"] as? Bool {
|
||||
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: newKey)
|
||||
let peerID = PeerID(publicKey: newKey)
|
||||
Task { @MainActor in
|
||||
self.flushOutbox(for: peerID)
|
||||
}
|
||||
@@ -37,89 +34,71 @@ final class MessageRouter {
|
||||
}
|
||||
}
|
||||
|
||||
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
||||
let reachableMesh = mesh.isPeerReachable(peerID)
|
||||
if reachableMesh {
|
||||
SecureLogger.debug("Routing PM via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
// BLEService will initiate a handshake if needed and queue the message
|
||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
} else if canSendViaNostr(peerID: peerID) {
|
||||
SecureLogger.debug("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
// Try to find a reachable transport
|
||||
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
||||
SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
} else {
|
||||
// Queue for later (when mesh connects or Nostr mapping appears)
|
||||
// Queue for later
|
||||
if outbox[peerID] == nil { outbox[peerID] = [] }
|
||||
outbox[peerID]?.append((content, recipientNickname, messageID))
|
||||
SecureLogger.debug("Queued PM for \(peerID.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))…", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
||||
// Prefer mesh for reachable peers; BLE will queue if handshake is needed
|
||||
if mesh.isPeerReachable(peerID) {
|
||||
SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||
mesh.sendReadReceipt(receipt, to: peerID)
|
||||
} else {
|
||||
SecureLogger.debug("Routing READ ack via Nostr to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||
nostr.sendReadReceipt(receipt, to: peerID)
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
||||
SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||
transport.sendReadReceipt(receipt, to: peerID)
|
||||
} else if !transports.isEmpty {
|
||||
// Fallback to last transport (usually Nostr) if neither is explicitly reachable?
|
||||
// Or better: just try the first one that supports it?
|
||||
// Existing logic preferred mesh, then nostr.
|
||||
// If neither reachable, existing logic queued it (via mesh usually) or sent via nostr.
|
||||
// Let's stick to "try reachable". If none, maybe pick the first one to queue?
|
||||
// Actually, for READ receipts, we might want to just fire-and-forget on the "best effort" transport.
|
||||
// But let's stick to the reachable check.
|
||||
SecureLogger.debug("No reachable transport for READ ack to \(peerID.id.prefix(8))…", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
func sendDeliveryAck(_ messageID: String, to peerID: String) {
|
||||
if mesh.isPeerReachable(peerID) {
|
||||
SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
mesh.sendDeliveryAck(for: messageID, to: peerID)
|
||||
} else {
|
||||
nostr.sendDeliveryAck(for: messageID, to: peerID)
|
||||
func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
|
||||
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
||||
SecureLogger.debug("Routing DELIVERED ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
transport.sendDeliveryAck(for: messageID, to: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
||||
// Route via mesh when connected; else use Nostr
|
||||
if mesh.isPeerConnected(peerID) {
|
||||
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||
if let transport = transports.first(where: { $0.isPeerConnected(peerID) }) {
|
||||
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
} else if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
||||
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
} else {
|
||||
nostr.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
// Fallback: try all? or just the last one?
|
||||
// Old logic: if mesh connected, mesh. Else nostr.
|
||||
// Note: NostrTransport.isPeerReachable now returns true if mapped.
|
||||
// If not mapped, we can't send via Nostr anyway.
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Outbox Management
|
||||
private func canSendViaNostr(peerID: String) -> Bool {
|
||||
// Two forms are supported:
|
||||
// - 64-hex Noise public key (32 bytes)
|
||||
// - 16-hex short peer ID (derived from Noise pubkey)
|
||||
if peerID.count == 64, let noiseKey = Data(hexString: peerID) {
|
||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
fav.peerNostrPublicKey != nil {
|
||||
return true
|
||||
}
|
||||
} else if peerID.count == 16 {
|
||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||
fav.peerNostrPublicKey != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func flushOutbox(for peerID: String) {
|
||||
func flushOutbox(for peerID: PeerID) {
|
||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
||||
SecureLogger.debug("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)", category: .session)
|
||||
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
||||
var remaining: [(content: String, nickname: String, messageID: String)] = []
|
||||
// Prefer mesh if connected; else try Nostr if mapping exists
|
||||
|
||||
for (content, nickname, messageID) in queued {
|
||||
if mesh.isPeerReachable(peerID) {
|
||||
SecureLogger.debug("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||
} else if canSendViaNostr(peerID: peerID) {
|
||||
SecureLogger.debug("Outbox -> Nostr for \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
||||
SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||
} else {
|
||||
// Keep unsent items queued
|
||||
remaining.append((content, nickname, messageID))
|
||||
}
|
||||
}
|
||||
// Persist only items we could not send
|
||||
|
||||
if remaining.isEmpty {
|
||||
outbox.removeValue(forKey: peerID)
|
||||
} else {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user