mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 12:25:19 +00:00
Compare commits
59
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e868e3bbfb | ||
|
|
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 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,12 @@
|
||||
---
|
||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/Swift.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||
dependencies:
|
||||
- mtime: 1757258659000000000
|
||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/Swift.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||
size: 14166264
|
||||
- mtime: 1754189697000000000
|
||||
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||
size: 2261306
|
||||
sdk_relative: true
|
||||
version: 1
|
||||
...
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||
dependencies:
|
||||
- mtime: 1757258662000000000
|
||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||
size: 18068
|
||||
- mtime: 1754189697000000000
|
||||
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||
size: 2261306
|
||||
sdk_relative: true
|
||||
- mtime: 1754191141000000000
|
||||
path: 'usr/lib/swift/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||
size: 1224
|
||||
sdk_relative: true
|
||||
version: 1
|
||||
...
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_Concurrency.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||
dependencies:
|
||||
- mtime: 1757258669000000000
|
||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_Concurrency.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||
size: 699544
|
||||
- mtime: 1754189697000000000
|
||||
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||
size: 2261306
|
||||
sdk_relative: true
|
||||
- mtime: 1754192470000000000
|
||||
path: 'usr/lib/swift/_Concurrency.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||
size: 364219
|
||||
sdk_relative: true
|
||||
version: 1
|
||||
...
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||
dependencies:
|
||||
- mtime: 1757258664000000000
|
||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||
size: 83568
|
||||
- mtime: 1754189697000000000
|
||||
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||
size: 2261306
|
||||
sdk_relative: true
|
||||
- mtime: 1754192532000000000
|
||||
path: 'usr/lib/swift/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||
size: 24507
|
||||
sdk_relative: true
|
||||
version: 1
|
||||
...
|
||||
@@ -24,4 +24,4 @@ jobs:
|
||||
run: swift build
|
||||
|
||||
- name: Run Tests
|
||||
run: swift test --parallel --disable-swift-testing # so it only runs xctests: https://github.com/swiftlang/swift-package-manager/issues/8529#issuecomment-2815711345
|
||||
run: swift test --parallel
|
||||
|
||||
+2
-4
@@ -9,9 +9,6 @@ plans/
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
|
||||
## User settings
|
||||
xcuserdata/
|
||||
|
||||
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
||||
*.xcscmblueprint
|
||||
*.xccheckout
|
||||
@@ -57,7 +54,8 @@ iOSInjectionProject/
|
||||
|
||||
## Xcode project
|
||||
*.xcodeproj/project.xcworkspace/
|
||||
*.xcodeproj/xcshareddata/
|
||||
## Xcode User settings
|
||||
xcuserdata/
|
||||
|
||||
## Python
|
||||
__pycache__/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
MARKETING_VERSION = 1.4.3
|
||||
MARKETING_VERSION = 1.4.4
|
||||
CURRENT_PROJECT_VERSION = 1
|
||||
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||
|
||||
+9
-14
@@ -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: [
|
||||
@@ -33,21 +34,12 @@ let package = Package(
|
||||
"Assets.xcassets",
|
||||
"bitchat.entitlements",
|
||||
"bitchat-macOS.entitlements",
|
||||
"LaunchScreen.storyboard",
|
||||
"Services/Tor/C/"
|
||||
"LaunchScreen.storyboard"
|
||||
],
|
||||
linkerSettings: [
|
||||
.linkedLibrary("z")
|
||||
resources: [
|
||||
.process("Localizable.xcstrings")
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "TorC",
|
||||
path: "bitchat/Services/Tor/C"
|
||||
),
|
||||
.binaryTarget(
|
||||
name: "tor-nolzma",
|
||||
path: "Frameworks/tor-nolzma.xcframework"
|
||||
),
|
||||
.testTarget(
|
||||
name: "bitchatTests",
|
||||
dependencies: ["bitchat"],
|
||||
@@ -55,6 +47,9 @@ let package = Package(
|
||||
exclude: [
|
||||
"Info.plist",
|
||||
"README.md"
|
||||
],
|
||||
resources: [
|
||||
.process("Localization")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
Generated
+31
-21
@@ -7,15 +7,14 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; };
|
||||
0481A3A02E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */; };
|
||||
0481A3A12E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */; };
|
||||
048A88812E76FD18000FBCDD /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; };
|
||||
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };
|
||||
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
|
||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
|
||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; };
|
||||
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; };
|
||||
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA802E7706A80032EA8A /* Tor */; };
|
||||
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */; };
|
||||
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||
/* End PBXBuildFile section */
|
||||
@@ -58,11 +57,10 @@
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
0481A35C2E6DA18600FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; };
|
||||
0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = "tor-nolzma.xcframework"; sourceTree = "<group>"; };
|
||||
61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = bitchatShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
96D0D41CA19EE5A772AA8434 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = "tor-nolzma.xcframework"; path = "localPackages/Tor/Frameworks/tor-nolzma.xcframework"; sourceTree = "<group>"; };
|
||||
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = relays/online_relays_gps.csv; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
@@ -87,7 +85,6 @@
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
LaunchScreen.storyboard,
|
||||
Services/Tor/C/include/.gitkeep,
|
||||
);
|
||||
target = 0576A29205865664C0937536 /* bitchat_macOS */;
|
||||
};
|
||||
@@ -136,32 +133,22 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
files = (
|
||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
|
||||
048A88812E76FD18000FBCDD /* libz.tbd in Frameworks */,
|
||||
0481A3A02E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */,
|
||||
3EE336D150427F736F32B56C /* P256K in Frameworks */,
|
||||
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
|
||||
);
|
||||
};
|
||||
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
files = (
|
||||
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */,
|
||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
||||
0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */,
|
||||
0481A3A12E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */,
|
||||
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
||||
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
|
||||
);
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
0481A3542E6D877600FC845E /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */,
|
||||
0481A35C2E6DA18600FC845E /* libz.tbd */,
|
||||
);
|
||||
path = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
18198ED912AAF495D8AF7763 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -170,8 +157,8 @@
|
||||
A6E32D212E762EAB0032EA8A /* bitchatShareExtension */,
|
||||
A6E32D412E762EAE0032EA8A /* bitchatTests */,
|
||||
A6E367C92E76469E0032EA8A /* Configs */,
|
||||
0481A3542E6D877600FC845E /* Frameworks */,
|
||||
9F37F9F2C353B58AC809E93B /* Products */,
|
||||
A6F183FB2E948783006A9046 /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -187,6 +174,14 @@
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A6F183FB2E948783006A9046 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -207,6 +202,7 @@
|
||||
packageProductDependencies = (
|
||||
B1D9136AA0083366353BFA2F /* P256K */,
|
||||
A6E3E5712E7703760032EA8A /* BitLogger */,
|
||||
A6E3EA802E7706A80032EA8A /* Tor */,
|
||||
);
|
||||
productName = bitchat_macOS;
|
||||
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
||||
@@ -284,6 +280,7 @@
|
||||
packageProductDependencies = (
|
||||
4EB6BA1B8464F1EA38F4E286 /* P256K */,
|
||||
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
||||
A6E3EA7E2E7706720032EA8A /* Tor */,
|
||||
);
|
||||
productName = bitchat_iOS;
|
||||
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
||||
@@ -323,6 +320,7 @@
|
||||
packageReferences = (
|
||||
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
||||
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 90;
|
||||
projectDirPath = "";
|
||||
@@ -880,6 +878,10 @@
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = localPackages/BitLogger;
|
||||
};
|
||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = localPackages/Tor;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
@@ -907,6 +909,14 @@
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = BitLogger;
|
||||
};
|
||||
A6E3EA7E2E7706720032EA8A /* Tor */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Tor;
|
||||
};
|
||||
A6E3EA802E7706A80032EA8A /* Tor */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Tor;
|
||||
};
|
||||
B1D9136AA0083366353BFA2F /* P256K */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1640"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "57CA17A36A2532A6CFF367BB"
|
||||
BuildableName = "bitchatShareExtension.appex"
|
||||
BlueprintName = "bitchatShareExtension"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
codeCoverageEnabled = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<CodeCoverageTargets>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</CodeCoverageTargets>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES"
|
||||
testExecutionOrdering = "random">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "6CB97DF2EA57234CB3E563B8"
|
||||
BuildableName = "bitchatTests_iOS.xctest"
|
||||
BlueprintName = "bitchatTests_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "-DBITCHAT_DEV_ALLOW_CLEARNET"
|
||||
value = ""
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,105 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1640"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_macOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_macOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "47FF23248747DD7CB666CB91"
|
||||
BuildableName = "bitchatTests_macOS.xctest"
|
||||
BlueprintName = "bitchatTests_macOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_macOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "BITCHAT_LOG_LEVEL"
|
||||
value = "debug"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_macOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
+21
-19
@@ -6,6 +6,7 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Tor
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
|
||||
@@ -48,9 +49,10 @@ 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)
|
||||
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
|
||||
}
|
||||
#if os(iOS)
|
||||
appDelegate.chatViewModel = chatViewModel
|
||||
@@ -236,29 +238,29 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
let identifier = notification.request.identifier
|
||||
let userInfo = notification.request.content.userInfo
|
||||
|
||||
// Check if this is a private message notification
|
||||
if identifier.hasPrefix("private-") {
|
||||
// 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
|
||||
}
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self = self else {
|
||||
completionHandler([.banner, .sound])
|
||||
return
|
||||
}
|
||||
}
|
||||
// Suppress geohash activity notification if we're already in that geohash channel
|
||||
if identifier.hasPrefix("geo-activity-"),
|
||||
let deep = userInfo["deeplink"] as? String,
|
||||
let gh = deep.components(separatedBy: "/").last {
|
||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh {
|
||||
// Check if this is a private message notification and chat already open
|
||||
if identifier.hasPrefix("private-"),
|
||||
let peerID = userInfo["peerID"] as? String,
|
||||
self.chatViewModel?.selectedPrivateChatPeer == peerID {
|
||||
completionHandler([])
|
||||
return
|
||||
}
|
||||
// Suppress geohash activity notification if we're already in that geohash channel
|
||||
if identifier.hasPrefix("geo-activity-"),
|
||||
let deep = userInfo["deeplink"] as? String,
|
||||
let gh = deep.components(separatedBy: "/").last,
|
||||
case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
||||
ch.geohash == gh {
|
||||
completionHandler([])
|
||||
return
|
||||
}
|
||||
completionHandler([.banner, .sound])
|
||||
}
|
||||
|
||||
// Show notification in all other cases
|
||||
completionHandler([.banner, .sound])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ import Foundation
|
||||
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
|
||||
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
|
||||
struct EphemeralIdentity {
|
||||
let peerID: String // 8 random bytes
|
||||
let peerID: PeerID // 8 random bytes
|
||||
let sessionStart: Date
|
||||
var handshakeState: HandshakeState
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ protocol SecureIdentityStateManagerProtocol {
|
||||
|
||||
// MARK: Cryptographic Identities
|
||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?)
|
||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity]
|
||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity]
|
||||
func updateSocialIdentity(_ identity: SocialIdentity)
|
||||
|
||||
// MARK: Favorites Management
|
||||
@@ -121,12 +121,12 @@ protocol SecureIdentityStateManagerProtocol {
|
||||
func getBlockedNostrPubkeys() -> Set<String>
|
||||
|
||||
// MARK: Ephemeral Session Management
|
||||
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState)
|
||||
func updateHandshakeState(peerID: String, state: HandshakeState)
|
||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState)
|
||||
func updateHandshakeState(peerID: PeerID, state: HandshakeState)
|
||||
|
||||
// MARK: Cleanup
|
||||
func clearAllIdentityData()
|
||||
func removeEphemeralSession(peerID: String)
|
||||
func removeEphemeralSession(peerID: PeerID)
|
||||
|
||||
// MARK: Verification
|
||||
func setVerified(fingerprint: String, verified: Bool)
|
||||
@@ -143,7 +143,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
private let encryptionKeyName = "identityCacheEncryptionKey"
|
||||
|
||||
// In-memory state
|
||||
private var ephemeralSessions: [String: EphemeralIdentity] = [:]
|
||||
private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
|
||||
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
||||
private var cache: IdentityCache = IdentityCache()
|
||||
|
||||
@@ -321,11 +321,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
/// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID
|
||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
|
||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity] {
|
||||
queue.sync {
|
||||
// Defensive: ensure hex and correct length
|
||||
guard peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] }
|
||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
|
||||
guard peerID.isShort else { return [] }
|
||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,7 +455,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
// MARK: - Ephemeral Session Management
|
||||
|
||||
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState = .none) {
|
||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
||||
peerID: peerID,
|
||||
@@ -465,7 +465,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
func updateHandshakeState(peerID: String, state: HandshakeState) {
|
||||
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.ephemeralSessions[peerID]?.handshakeState = state
|
||||
|
||||
@@ -493,7 +493,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
func removeEphemeralSession(peerID: String) {
|
||||
func removeEphemeralSession(peerID: PeerID) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.ephemeralSessions.removeValue(forKey: peerID)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,12 +34,12 @@ struct BitchatPacket: Codable {
|
||||
}
|
||||
|
||||
// Convenience initializer for new binary format
|
||||
init(type: UInt8, ttl: UInt8, senderID: String, payload: Data) {
|
||||
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data) {
|
||||
self.version = 1
|
||||
self.type = type
|
||||
// Convert hex string peer ID to binary data (8 bytes)
|
||||
var senderData = Data()
|
||||
var tempID = senderID
|
||||
var tempID = senderID.id
|
||||
while tempID.count >= 2 {
|
||||
let hexByte = String(tempID.prefix(2))
|
||||
if let byte = UInt8(hexByte, radix: 16) {
|
||||
|
||||
@@ -2,8 +2,8 @@ import Foundation
|
||||
import CoreBluetooth
|
||||
|
||||
/// Represents a peer in the BitChat network with all associated metadata
|
||||
struct BitchatPeer: Identifiable, Equatable {
|
||||
let id: String // Hex-encoded peer ID
|
||||
struct BitchatPeer: Equatable {
|
||||
let peerID: PeerID // Hex-encoded peer ID
|
||||
let noisePublicKey: Data
|
||||
let nickname: String
|
||||
let lastSeen: Date
|
||||
@@ -51,7 +51,7 @@ struct BitchatPeer: Identifiable, Equatable {
|
||||
|
||||
// Display helpers
|
||||
var displayName: String {
|
||||
nickname.isEmpty ? String(id.prefix(8)) : nickname
|
||||
nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname
|
||||
}
|
||||
|
||||
var statusIcon: String {
|
||||
@@ -73,14 +73,14 @@ struct BitchatPeer: Identifiable, Equatable {
|
||||
|
||||
// Initialize from mesh service data
|
||||
init(
|
||||
id: String,
|
||||
peerID: PeerID,
|
||||
noisePublicKey: Data,
|
||||
nickname: String,
|
||||
lastSeen: Date = Date(),
|
||||
isConnected: Bool = false,
|
||||
isReachable: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.peerID = peerID
|
||||
self.noisePublicKey = noisePublicKey
|
||||
self.nickname = nickname
|
||||
self.lastSeen = lastSeen
|
||||
@@ -93,8 +93,6 @@ struct BitchatPeer: Identifiable, Equatable {
|
||||
}
|
||||
|
||||
static func == (lhs: BitchatPeer, rhs: BitchatPeer) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
lhs.peerID == rhs.peerID
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
//
|
||||
// PeerID.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct PeerID: Equatable, Hashable {
|
||||
enum Prefix: String, CaseIterable {
|
||||
/// When no prefix is provided
|
||||
case empty = ""
|
||||
/// `"mesh:"`
|
||||
case mesh = "mesh:"
|
||||
/// `"name:"`
|
||||
case name = "name:"
|
||||
/// `"noise:"` (+ 64 characters hex)
|
||||
case noise = "noise:"
|
||||
/// `"nostr_"` (+ 16 characters hex)
|
||||
case geoDM = "nostr_"
|
||||
/// `"nostr:"` (+ 8 characters hex)
|
||||
case geoChat = "nostr:"
|
||||
}
|
||||
|
||||
let prefix: Prefix
|
||||
|
||||
/// Returns the actual value without any prefix
|
||||
let bare: String
|
||||
|
||||
/// Returns the full `id` value by combining `(prefix + bare)`
|
||||
var id: String { prefix.rawValue + bare }
|
||||
|
||||
// Private so the callers have to go through a convenience init
|
||||
private init(prefix: Prefix, bare: any StringProtocol) {
|
||||
self.prefix = prefix
|
||||
self.bare = String(bare)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience Inits
|
||||
|
||||
extension PeerID {
|
||||
/// Convenience init to create GeoDM PeerID by appending `"nostr_"` to the first 16 characters of `pubKey`
|
||||
init(nostr_ pubKey: String) {
|
||||
self.init(prefix: .geoDM, bare: pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))
|
||||
}
|
||||
|
||||
/// Convenience init to create GeoChat PeerID by appending `"nostr:"` to the first 8 characters of `pubKey`
|
||||
init(nostr pubKey: String) {
|
||||
self.init(prefix: .geoChat, bare: pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))
|
||||
}
|
||||
|
||||
/// Convenience init to create PeerID from String/Substring by splitting it into prefix and bare parts
|
||||
init(str: any StringProtocol) {
|
||||
if let prefix = Prefix.allCases.first(where: { $0 != .empty && str.hasPrefix($0.rawValue) }) {
|
||||
self.init(prefix: prefix, bare: String(str).dropFirst(prefix.rawValue.count))
|
||||
} else {
|
||||
self.init(prefix: .empty, bare: str)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience init to handle `Optional<String>`
|
||||
init?(str: (any StringProtocol)?) {
|
||||
guard let str else { return nil }
|
||||
self.init(str: str)
|
||||
}
|
||||
|
||||
/// Convenience init to create PeerID by converting Data to String
|
||||
init?(data: Data) {
|
||||
self.init(str: String(data: data, encoding: .utf8))
|
||||
}
|
||||
|
||||
/// Convenience init to "hide" hex-encoding implementation detail
|
||||
init(hexData: Data) {
|
||||
self.init(str: hexData.hexEncodedString())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Noise Public Key Helpers
|
||||
|
||||
extension PeerID {
|
||||
/// Derive the stable 16-hex peer ID from a Noise static public key
|
||||
init(publicKey: Data) {
|
||||
self.init(str: publicKey.sha256Fingerprint().prefix(16))
|
||||
}
|
||||
|
||||
/// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed
|
||||
func toShort() -> PeerID {
|
||||
if let noiseKey {
|
||||
return PeerID(publicKey: noiseKey)
|
||||
}
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Codable
|
||||
|
||||
extension PeerID: Codable {
|
||||
init(from decoder: any Decoder) throws {
|
||||
self.init(str: try decoder.singleValueContainer().decode(String.self))
|
||||
}
|
||||
|
||||
func encode(to encoder: any Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
try container.encode(id)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
extension PeerID {
|
||||
var isEmpty: Bool {
|
||||
id.isEmpty
|
||||
}
|
||||
|
||||
/// Returns true if `id` starts with "`nostr:`"
|
||||
var isGeoChat: Bool {
|
||||
prefix == .geoChat
|
||||
}
|
||||
|
||||
/// Returns true if `id` starts with "`nostr_`"
|
||||
var isGeoDM: Bool {
|
||||
prefix == .geoDM
|
||||
}
|
||||
|
||||
func toPercentEncoded() -> String {
|
||||
id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Validation
|
||||
|
||||
extension PeerID {
|
||||
private enum Constants {
|
||||
static let maxIDLength = 64
|
||||
static let hexIDLength = 16 // 8 bytes = 16 hex chars
|
||||
}
|
||||
|
||||
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
|
||||
var isValid: Bool {
|
||||
if prefix != .empty {
|
||||
return PeerID(str: bare).isValid
|
||||
}
|
||||
|
||||
// Accept short routing IDs (exact 16-hex) or Full Noise key hex (exact 64-hex)
|
||||
if isShort || isNoiseKeyHex {
|
||||
return true
|
||||
}
|
||||
|
||||
// If length equals short or full but isn't valid hex, reject
|
||||
if id.count == Constants.hexIDLength || id.count == Constants.maxIDLength {
|
||||
return false
|
||||
}
|
||||
|
||||
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
|
||||
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
||||
return !id.isEmpty &&
|
||||
id.count < Constants.maxIDLength &&
|
||||
id.rangeOfCharacter(from: validCharset.inverted) == nil
|
||||
}
|
||||
|
||||
/// Short routing IDs (exact 16-hex)
|
||||
var isShort: Bool {
|
||||
bare.count == Constants.hexIDLength && Data(hexString: bare) != nil
|
||||
}
|
||||
|
||||
/// Full Noise key hex (exact 64-hex)
|
||||
var isNoiseKeyHex: Bool {
|
||||
noiseKey != nil
|
||||
}
|
||||
|
||||
/// Full Noise key (exact 64-hex) as Data
|
||||
var noiseKey: Data? {
|
||||
guard bare.count == Constants.maxIDLength else { return nil }
|
||||
return Data(hexString: bare)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Comparable
|
||||
|
||||
extension PeerID: Comparable {
|
||||
static func < (lhs: PeerID, rhs: PeerID) -> Bool {
|
||||
lhs.id < rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - String Interop Helpers
|
||||
|
||||
// MARK: CustomStringConvertible
|
||||
|
||||
extension PeerID: CustomStringConvertible {
|
||||
/// So it returns the actual `id` like before even inside another String
|
||||
var description: String {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Custom Equatable w/ String & Optionality
|
||||
|
||||
// PeerID <> String
|
||||
extension Optional where Wrapped == PeerID {
|
||||
static func ==(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id == rhs }
|
||||
static func !=(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id != rhs }
|
||||
}
|
||||
|
||||
// String <> PeerID
|
||||
extension Optional where Wrapped == String {
|
||||
static func ==(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs == rhs?.id }
|
||||
static func !=(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs != rhs?.id }
|
||||
}
|
||||
@@ -79,7 +79,7 @@ struct ReadReceipt: Codable {
|
||||
|
||||
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
||||
let readerID = readerIDData.hexEncodedString()
|
||||
guard InputValidator.validatePeerID(readerID) else { return nil }
|
||||
guard PeerID(str: readerID).isValid else { return nil }
|
||||
|
||||
guard let timestamp = dataCopy.readDate(at: &offset),
|
||||
InputValidator.validateTimestamp(timestamp),
|
||||
|
||||
@@ -397,7 +397,7 @@ final class NoiseSymmetricState {
|
||||
if nameData.count <= 32 {
|
||||
self.hash = nameData + Data(repeating: 0, count: 32 - nameData.count)
|
||||
} else {
|
||||
self.hash = Data(SHA256.hash(data: nameData))
|
||||
self.hash = nameData.sha256Hash()
|
||||
}
|
||||
self.chainingKey = self.hash
|
||||
}
|
||||
@@ -410,7 +410,7 @@ final class NoiseSymmetricState {
|
||||
}
|
||||
|
||||
func mixHash(_ data: Data) {
|
||||
hash = Data(SHA256.hash(data: hash + data))
|
||||
hash = (hash + data).sha256Hash()
|
||||
}
|
||||
|
||||
func mixKeyAndHash(_ inputKeyMaterial: Data) {
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Security Constants
|
||||
|
||||
@@ -53,11 +52,6 @@ struct NoiseSecurityValidator {
|
||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
||||
}
|
||||
|
||||
/// Validate peer ID format using unified validator
|
||||
static func validatePeerID(_ peerID: String) -> Bool {
|
||||
return InputValidator.validatePeerID(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Enhanced Noise Session with Security
|
||||
@@ -137,8 +131,8 @@ final class SecureNoiseSession: NoiseSession {
|
||||
// MARK: - Rate Limiter
|
||||
|
||||
final class NoiseRateLimiter {
|
||||
private var handshakeTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
||||
private var messageTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
||||
private var handshakeTimestamps: [PeerID: [Date]] = [:]
|
||||
private var messageTimestamps: [PeerID: [Date]] = [:]
|
||||
|
||||
// Global rate limiting
|
||||
private var globalHandshakeTimestamps: [Date] = []
|
||||
@@ -146,7 +140,7 @@ final class NoiseRateLimiter {
|
||||
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
||||
|
||||
func allowHandshake(from peerID: String) -> Bool {
|
||||
func allowHandshake(from peerID: PeerID) -> Bool {
|
||||
return queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
let oneMinuteAgo = now.addingTimeInterval(-60)
|
||||
@@ -175,7 +169,7 @@ final class NoiseRateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
func allowMessage(from peerID: String) -> Bool {
|
||||
func allowMessage(from peerID: PeerID) -> Bool {
|
||||
return queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
let oneSecondAgo = now.addingTimeInterval(-1)
|
||||
@@ -204,7 +198,7 @@ final class NoiseRateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
func reset(for peerID: String) {
|
||||
func reset(for peerID: PeerID) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.handshakeTimestamps.removeValue(forKey: peerID)
|
||||
self.messageTimestamps.removeValue(forKey: peerID)
|
||||
|
||||
@@ -10,32 +10,8 @@ import BitLogger
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Noise Session State
|
||||
|
||||
enum NoiseSessionState: Equatable {
|
||||
case uninitialized
|
||||
case handshaking
|
||||
case established
|
||||
case failed(Error)
|
||||
|
||||
static func == (lhs: NoiseSessionState, rhs: NoiseSessionState) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.uninitialized, .uninitialized),
|
||||
(.handshaking, .handshaking),
|
||||
(.established, .established):
|
||||
return true
|
||||
case (.failed, .failed):
|
||||
return true // We don't compare the errors
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Noise Session
|
||||
|
||||
class NoiseSession {
|
||||
let peerID: String
|
||||
let peerID: PeerID
|
||||
let role: NoiseRole
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private var state: NoiseSessionState = .uninitialized
|
||||
@@ -55,7 +31,7 @@ class NoiseSession {
|
||||
private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent)
|
||||
|
||||
init(
|
||||
peerID: String,
|
||||
peerID: PeerID,
|
||||
role: NoiseRole,
|
||||
keychain: KeychainManagerProtocol,
|
||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
||||
@@ -141,7 +117,7 @@ class NoiseSession {
|
||||
handshakeState = nil // Clear handshake state
|
||||
|
||||
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established")
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID))
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||
|
||||
return nil
|
||||
} else {
|
||||
@@ -167,7 +143,7 @@ class NoiseSession {
|
||||
handshakeState = nil // Clear handshake state
|
||||
|
||||
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established")
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID))
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||
}
|
||||
|
||||
return response
|
||||
@@ -252,249 +228,8 @@ class NoiseSession {
|
||||
handshakeHash = nil
|
||||
|
||||
if wasEstablished {
|
||||
SecureLogger.info(.sessionExpired(peerID: peerID))
|
||||
SecureLogger.info(.sessionExpired(peerID: peerID.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session Manager
|
||||
|
||||
final class NoiseSessionManager {
|
||||
private var sessions: [String: NoiseSession] = [:]
|
||||
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||
|
||||
// Callbacks
|
||||
var onSessionEstablished: ((String, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
||||
var onSessionFailed: ((String, Error) -> Void)?
|
||||
|
||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||
self.localStaticKey = localStaticKey
|
||||
self.keychain = keychain
|
||||
}
|
||||
|
||||
// MARK: - Session Management
|
||||
|
||||
func createSession(for peerID: String, role: NoiseRole) -> NoiseSession {
|
||||
return managerQueue.sync(flags: .barrier) {
|
||||
let session = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: role,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = session
|
||||
return session
|
||||
}
|
||||
}
|
||||
|
||||
func getSession(for peerID: String) -> NoiseSession? {
|
||||
return managerQueue.sync {
|
||||
return sessions[peerID]
|
||||
}
|
||||
}
|
||||
|
||||
func removeSession(for peerID: String) {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
if let session = sessions[peerID] {
|
||||
if session.isEstablished() {
|
||||
SecureLogger.info(.sessionExpired(peerID: peerID))
|
||||
}
|
||||
// Clear sensitive data before removing
|
||||
session.reset()
|
||||
}
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func removeAllSessions() {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
for (_, session) in sessions {
|
||||
session.reset()
|
||||
}
|
||||
sessions.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
func getEstablishedSessions() -> [String: NoiseSession] {
|
||||
return managerQueue.sync {
|
||||
return sessions.filter { $0.value.isEstablished() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Handshake Helpers
|
||||
|
||||
func initiateHandshake(with peerID: String) throws -> Data {
|
||||
return try managerQueue.sync(flags: .barrier) {
|
||||
// Check if we already have an established session
|
||||
if let existingSession = sessions[peerID], existingSession.isEstablished() {
|
||||
// Session already established, don't recreate
|
||||
throw NoiseSessionError.alreadyEstablished
|
||||
}
|
||||
|
||||
// Remove any existing non-established session
|
||||
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
}
|
||||
|
||||
// Create new initiator session
|
||||
let session = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: .initiator,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = session
|
||||
|
||||
do {
|
||||
let handshakeData = try session.startHandshake()
|
||||
return handshakeData
|
||||
} catch {
|
||||
// Clean up failed session
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
SecureLogger.error(.handshakeFailed(peerID: peerID, error: error.localizedDescription))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleIncomingHandshake(from peerID: String, message: Data) throws -> Data? {
|
||||
// Process everything within the synchronized block to prevent race conditions
|
||||
return try managerQueue.sync(flags: .barrier) {
|
||||
var shouldCreateNew = false
|
||||
var existingSession: NoiseSession? = nil
|
||||
|
||||
if let existing = sessions[peerID] {
|
||||
// If we have an established session, the peer must have cleared their session
|
||||
// for a good reason (e.g., decryption failure, restart, etc.)
|
||||
// We should accept the new handshake to re-establish encryption
|
||||
if existing.isEstablished() {
|
||||
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
shouldCreateNew = true
|
||||
} else {
|
||||
// If we're in the middle of a handshake and receive a new initiation,
|
||||
// reset and start fresh (the other side may have restarted)
|
||||
if existing.getState() == .handshaking && message.count == 32 {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
shouldCreateNew = true
|
||||
} else {
|
||||
existingSession = existing
|
||||
}
|
||||
}
|
||||
} else {
|
||||
shouldCreateNew = true
|
||||
}
|
||||
|
||||
// Get or create session
|
||||
let session: NoiseSession
|
||||
if shouldCreateNew {
|
||||
let newSession = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: .responder,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = newSession
|
||||
session = newSession
|
||||
} else {
|
||||
session = existingSession!
|
||||
}
|
||||
|
||||
// Process the handshake message within the synchronized block
|
||||
do {
|
||||
let response = try session.processHandshakeMessage(message)
|
||||
|
||||
// Check if session is established after processing
|
||||
if session.isEstablished() {
|
||||
if let remoteKey = session.getRemoteStaticPublicKey() {
|
||||
// Schedule callback outside the synchronized block to prevent deadlock
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.onSessionEstablished?(peerID, remoteKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
} catch {
|
||||
// Reset the session on handshake failure so next attempt can start fresh
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
|
||||
// Schedule callback outside the synchronized block to prevent deadlock
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.onSessionFailed?(peerID, error)
|
||||
}
|
||||
|
||||
SecureLogger.error(.handshakeFailed(peerID: peerID, error: error.localizedDescription))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Encryption/Decryption
|
||||
|
||||
func encrypt(_ plaintext: Data, for peerID: String) throws -> Data {
|
||||
guard let session = getSession(for: peerID) else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
}
|
||||
|
||||
return try session.encrypt(plaintext)
|
||||
}
|
||||
|
||||
func decrypt(_ ciphertext: Data, from peerID: String) throws -> Data {
|
||||
guard let session = getSession(for: peerID) else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
}
|
||||
|
||||
return try session.decrypt(ciphertext)
|
||||
}
|
||||
|
||||
// MARK: - Key Management
|
||||
|
||||
func getRemoteStaticKey(for peerID: String) -> Curve25519.KeyAgreement.PublicKey? {
|
||||
return getSession(for: peerID)?.getRemoteStaticPublicKey()
|
||||
}
|
||||
|
||||
func getHandshakeHash(for peerID: String) -> Data? {
|
||||
return getSession(for: peerID)?.getHandshakeHash()
|
||||
}
|
||||
|
||||
// MARK: - Session Rekeying
|
||||
|
||||
func getSessionsNeedingRekey() -> [(peerID: String, needsRekey: Bool)] {
|
||||
return managerQueue.sync {
|
||||
var needingRekey: [(peerID: String, needsRekey: Bool)] = []
|
||||
|
||||
for (peerID, session) in sessions {
|
||||
if let secureSession = session as? SecureNoiseSession,
|
||||
secureSession.isEstablished(),
|
||||
secureSession.needsRenegotiation() {
|
||||
needingRekey.append((peerID: peerID, needsRekey: true))
|
||||
}
|
||||
}
|
||||
|
||||
return needingRekey
|
||||
}
|
||||
}
|
||||
|
||||
func initiateRekey(for peerID: String) throws {
|
||||
// Remove old session
|
||||
removeSession(for: peerID)
|
||||
|
||||
// Initiate new handshake
|
||||
_ = try initiateHandshake(with: peerID)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum NoiseSessionError: Error {
|
||||
case invalidState
|
||||
case notEstablished
|
||||
case sessionNotFound
|
||||
case handshakeFailed(Error)
|
||||
case alreadyEstablished
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// NoiseSessionError.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
enum NoiseSessionError: Error {
|
||||
case invalidState
|
||||
case notEstablished
|
||||
case sessionNotFound
|
||||
case handshakeFailed(Error)
|
||||
case alreadyEstablished
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//
|
||||
// NoiseSessionManager.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
final class NoiseSessionManager {
|
||||
private var sessions: [PeerID: NoiseSession] = [:]
|
||||
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||
|
||||
// Callbacks
|
||||
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
||||
var onSessionFailed: ((PeerID, Error) -> Void)?
|
||||
|
||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||
self.localStaticKey = localStaticKey
|
||||
self.keychain = keychain
|
||||
}
|
||||
|
||||
// MARK: - Session Management
|
||||
|
||||
func createSession(for peerID: PeerID, role: NoiseRole) -> NoiseSession {
|
||||
return managerQueue.sync(flags: .barrier) {
|
||||
let session = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: role,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = session
|
||||
return session
|
||||
}
|
||||
}
|
||||
|
||||
func getSession(for peerID: PeerID) -> NoiseSession? {
|
||||
return managerQueue.sync {
|
||||
return sessions[peerID]
|
||||
}
|
||||
}
|
||||
|
||||
func removeSession(for peerID: PeerID) {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
if let session = sessions[peerID] {
|
||||
if session.isEstablished() {
|
||||
SecureLogger.info(.sessionExpired(peerID: peerID.id))
|
||||
}
|
||||
// Clear sensitive data before removing
|
||||
session.reset()
|
||||
}
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func removeAllSessions() {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
for (_, session) in sessions {
|
||||
session.reset()
|
||||
}
|
||||
sessions.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
func getEstablishedSessions() -> [PeerID: NoiseSession] {
|
||||
return managerQueue.sync {
|
||||
return sessions.filter { $0.value.isEstablished() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Handshake Helpers
|
||||
|
||||
func initiateHandshake(with peerID: PeerID) throws -> Data {
|
||||
return try managerQueue.sync(flags: .barrier) {
|
||||
// Check if we already have an established session
|
||||
if let existingSession = sessions[peerID], existingSession.isEstablished() {
|
||||
// Session already established, don't recreate
|
||||
throw NoiseSessionError.alreadyEstablished
|
||||
}
|
||||
|
||||
// Remove any existing non-established session
|
||||
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
}
|
||||
|
||||
// Create new initiator session
|
||||
let session = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: .initiator,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = session
|
||||
|
||||
do {
|
||||
let handshakeData = try session.startHandshake()
|
||||
return handshakeData
|
||||
} catch {
|
||||
// Clean up failed session
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
||||
// Process everything within the synchronized block to prevent race conditions
|
||||
return try managerQueue.sync(flags: .barrier) {
|
||||
var shouldCreateNew = false
|
||||
var existingSession: NoiseSession? = nil
|
||||
|
||||
if let existing = sessions[peerID] {
|
||||
// If we have an established session, the peer must have cleared their session
|
||||
// for a good reason (e.g., decryption failure, restart, etc.)
|
||||
// We should accept the new handshake to re-establish encryption
|
||||
if existing.isEstablished() {
|
||||
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
shouldCreateNew = true
|
||||
} else {
|
||||
// If we're in the middle of a handshake and receive a new initiation,
|
||||
// reset and start fresh (the other side may have restarted)
|
||||
if existing.getState() == .handshaking && message.count == 32 {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
shouldCreateNew = true
|
||||
} else {
|
||||
existingSession = existing
|
||||
}
|
||||
}
|
||||
} else {
|
||||
shouldCreateNew = true
|
||||
}
|
||||
|
||||
// Get or create session
|
||||
let session: NoiseSession
|
||||
if shouldCreateNew {
|
||||
let newSession = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: .responder,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = newSession
|
||||
session = newSession
|
||||
} else {
|
||||
session = existingSession!
|
||||
}
|
||||
|
||||
// Process the handshake message within the synchronized block
|
||||
do {
|
||||
let response = try session.processHandshakeMessage(message)
|
||||
|
||||
// Check if session is established after processing
|
||||
if session.isEstablished() {
|
||||
if let remoteKey = session.getRemoteStaticPublicKey() {
|
||||
// Schedule callback outside the synchronized block to prevent deadlock
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.onSessionEstablished?(peerID, remoteKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
} catch {
|
||||
// Reset the session on handshake failure so next attempt can start fresh
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
|
||||
// Schedule callback outside the synchronized block to prevent deadlock
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.onSessionFailed?(peerID, error)
|
||||
}
|
||||
|
||||
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Encryption/Decryption
|
||||
|
||||
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
|
||||
guard let session = getSession(for: peerID) else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
}
|
||||
|
||||
return try session.encrypt(plaintext)
|
||||
}
|
||||
|
||||
func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
|
||||
guard let session = getSession(for: peerID) else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
}
|
||||
|
||||
return try session.decrypt(ciphertext)
|
||||
}
|
||||
|
||||
// MARK: - Key Management
|
||||
|
||||
func getRemoteStaticKey(for peerID: PeerID) -> Curve25519.KeyAgreement.PublicKey? {
|
||||
return getSession(for: peerID)?.getRemoteStaticPublicKey()
|
||||
}
|
||||
|
||||
func getHandshakeHash(for peerID: PeerID) -> Data? {
|
||||
return getSession(for: peerID)?.getHandshakeHash()
|
||||
}
|
||||
|
||||
// MARK: - Session Rekeying
|
||||
|
||||
func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
|
||||
return managerQueue.sync {
|
||||
var needingRekey: [(peerID: PeerID, needsRekey: Bool)] = []
|
||||
|
||||
for (peerID, session) in sessions {
|
||||
if let secureSession = session as? SecureNoiseSession,
|
||||
secureSession.isEstablished(),
|
||||
secureSession.needsRenegotiation() {
|
||||
needingRekey.append((peerID: peerID, needsRekey: true))
|
||||
}
|
||||
}
|
||||
|
||||
return needingRekey
|
||||
}
|
||||
}
|
||||
|
||||
func initiateRekey(for peerID: PeerID) throws {
|
||||
// Remove old session
|
||||
removeSession(for: peerID)
|
||||
|
||||
// Initiate new handshake
|
||||
_ = try initiateHandshake(with: peerID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//
|
||||
// NoiseSessionState.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
enum NoiseSessionState: Equatable {
|
||||
case uninitialized
|
||||
case handshaking
|
||||
case established
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import Tor
|
||||
|
||||
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
||||
@MainActor
|
||||
|
||||
@@ -100,7 +100,7 @@ struct NostrEmbeddedBitChat {
|
||||
if let maybeData = Data(hexString: recipientPeerID) {
|
||||
if maybeData.count == 32 {
|
||||
// Treat as Noise static public key; derive peerID from fingerprint
|
||||
return PeerIDUtils.derivePeerID(fromPublicKey: maybeData)
|
||||
return PeerID(publicKey: maybeData).id
|
||||
} else if maybeData.count == 8 {
|
||||
// Already an 8-byte peer ID
|
||||
return recipientPeerID
|
||||
|
||||
@@ -226,10 +226,7 @@ struct NostrIdentityBridge {
|
||||
}
|
||||
}
|
||||
// As a final fallback, hash the seed+msg and try again
|
||||
var combined = Data()
|
||||
combined.append(seed)
|
||||
combined.append(msg)
|
||||
let fallback = Data(CryptoKit.SHA256.hash(data: combined))
|
||||
let fallback = (seed + msg).sha256Hash()
|
||||
return try NostrIdentity(privateKeyData: fallback)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,10 +521,7 @@ struct NostrEvent: Codable {
|
||||
] as [Any]
|
||||
|
||||
let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
||||
let hash = CryptoKit.SHA256.hash(data: data)
|
||||
let hashData = Data(hash)
|
||||
let hashHex = hash.compactMap { String(format: "%02x", $0) }.joined()
|
||||
return (hashHex, hashData)
|
||||
return (data.sha256Fingerprint(), data.sha256Hash())
|
||||
}
|
||||
|
||||
func jsonString() throws -> String {
|
||||
|
||||
@@ -2,6 +2,7 @@ import BitLogger
|
||||
import Foundation
|
||||
import Network
|
||||
import Combine
|
||||
import Tor
|
||||
|
||||
/// Manages WebSocket connections to Nostr relays
|
||||
@MainActor
|
||||
|
||||
@@ -197,7 +197,7 @@ extension Data {
|
||||
offset += 16
|
||||
|
||||
// Convert 16 bytes to UUID string format
|
||||
let uuid = uuidData.map { String(format: "%02x", $0) }.joined()
|
||||
let uuid = uuidData.hexEncodedString()
|
||||
|
||||
// Insert hyphens at proper positions: 8-4-4-4-12
|
||||
var result = ""
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
///
|
||||
|
||||
import Foundation
|
||||
import CoreBluetooth
|
||||
|
||||
// MARK: - Message Types
|
||||
|
||||
@@ -131,7 +132,7 @@ enum LazyHandshakeState {
|
||||
// MARK: - Delivery Status
|
||||
|
||||
// Delivery status for messages
|
||||
enum DeliveryStatus: Codable, Equatable {
|
||||
enum DeliveryStatus: Codable, Equatable, Hashable {
|
||||
case sending
|
||||
case sent // Left our device
|
||||
case delivered(to: String, at: Date) // Confirmed by recipient
|
||||
@@ -159,23 +160,28 @@ enum DeliveryStatus: Codable, Equatable {
|
||||
|
||||
// MARK: - Delegate Protocol
|
||||
|
||||
@MainActor
|
||||
protocol BitchatDelegate: AnyObject {
|
||||
func didReceiveMessage(_ message: BitchatMessage)
|
||||
func didConnectToPeer(_ peerID: String)
|
||||
func didDisconnectFromPeer(_ peerID: String)
|
||||
func didUpdatePeerList(_ peers: [String])
|
||||
|
||||
func didConnectToPeer(_ peerID: PeerID)
|
||||
func didDisconnectFromPeer(_ peerID: PeerID)
|
||||
func didUpdatePeerList(_ peers: [PeerID])
|
||||
|
||||
// Optional method to check if a fingerprint belongs to a favorite peer
|
||||
func isFavorite(fingerprint: String) -> Bool
|
||||
|
||||
|
||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
|
||||
|
||||
// Low-level events for better separation of concerns
|
||||
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date)
|
||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||
|
||||
// Bluetooth state updates for user notifications
|
||||
func didUpdateBluetoothState(_ state: CBManagerState)
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date)
|
||||
}
|
||||
|
||||
// Provide default implementation to make it effectively optional
|
||||
@MainActor
|
||||
extension BitchatDelegate {
|
||||
func isFavorite(fingerprint: String) -> Bool {
|
||||
return false
|
||||
@@ -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) {
|
||||
// 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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+2033
-2081
File diff suppressed because it is too large
Load Diff
@@ -148,10 +148,10 @@ final class CommandProcessor {
|
||||
|
||||
if chatViewModel?.selectedPrivateChatPeer != nil {
|
||||
// In private chat
|
||||
if let peerNickname = meshService?.peerNickname(peerID: targetPeerID) {
|
||||
if let peerNickname = meshService?.peerNickname(peerID: PeerID(str: targetPeerID)) {
|
||||
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
|
||||
meshService?.sendPrivateMessage(personalMessage, to: targetPeerID,
|
||||
recipientNickname: peerNickname,
|
||||
meshService?.sendPrivateMessage(personalMessage, to: PeerID(str: targetPeerID),
|
||||
recipientNickname: peerNickname,
|
||||
messageID: UUID().uuidString)
|
||||
// Also add a local system message so the sender sees a natural-language confirmation
|
||||
let pastAction: String = {
|
||||
@@ -214,7 +214,7 @@ final class CommandProcessor {
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||
let fingerprint = meshService?.getFingerprint(for: PeerID(str: peerID)) {
|
||||
if identityManager.isBlocked(fingerprint: fingerprint) {
|
||||
return .success(message: "\(nickname) is already blocked")
|
||||
}
|
||||
@@ -258,7 +258,7 @@ final class CommandProcessor {
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||
let fingerprint = meshService?.getFingerprint(for: PeerID(str: peerID)) {
|
||||
if !identityManager.isBlocked(fingerprint: fingerprint) {
|
||||
return .success(message: "\(nickname) is not blocked")
|
||||
}
|
||||
|
||||
@@ -179,125 +179,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)
|
||||
|
||||
@@ -20,8 +20,11 @@ final class GeohashBookmarksStore: ObservableObject {
|
||||
private let geocoder = CLGeocoder()
|
||||
private var resolving: Set<String> = []
|
||||
#endif
|
||||
|
||||
private let storage: UserDefaults
|
||||
|
||||
private init() {
|
||||
init(storage: UserDefaults = .standard) {
|
||||
self.storage = storage
|
||||
load()
|
||||
}
|
||||
|
||||
@@ -64,7 +67,7 @@ final class GeohashBookmarksStore: ObservableObject {
|
||||
|
||||
// MARK: - Persistence
|
||||
private func load() {
|
||||
guard let data = UserDefaults.standard.data(forKey: storeKey) else { return }
|
||||
guard let data = storage.data(forKey: storeKey) else { return }
|
||||
if let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||
// Sanitize, normalize, dedupe while preserving order (first occurrence wins)
|
||||
var seen = Set<String>()
|
||||
@@ -81,7 +84,7 @@ final class GeohashBookmarksStore: ObservableObject {
|
||||
membership = seen
|
||||
}
|
||||
// Load any saved names
|
||||
if let namesData = UserDefaults.standard.data(forKey: namesStoreKey),
|
||||
if let namesData = storage.data(forKey: namesStoreKey),
|
||||
let dict = try? JSONDecoder().decode([String: String].self, from: namesData) {
|
||||
bookmarkNames = dict
|
||||
}
|
||||
@@ -89,13 +92,13 @@ final class GeohashBookmarksStore: ObservableObject {
|
||||
|
||||
private func persist() {
|
||||
if let data = try? JSONEncoder().encode(bookmarks) {
|
||||
UserDefaults.standard.set(data, forKey: storeKey)
|
||||
storage.set(data, forKey: storeKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func persistNames() {
|
||||
if let data = try? JSONEncoder().encode(bookmarkNames) {
|
||||
UserDefaults.standard.set(data, forKey: namesStoreKey)
|
||||
storage.set(data, forKey: namesStoreKey)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,11 @@ final class LocationNotesCounter: ObservableObject {
|
||||
func subscribe(geohash gh: String) {
|
||||
let norm = gh.lowercased()
|
||||
if geohash == norm, subscriptionID != nil { return }
|
||||
// Validate geohash (building-level precision: 8 chars)
|
||||
guard Geohash.isValidBuildingGeohash(norm) else {
|
||||
SecureLogger.warning("LocationNotesCounter: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||
return
|
||||
}
|
||||
// Unsubscribe previous without clearing count to avoid flicker
|
||||
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
||||
subscriptionID = nil
|
||||
@@ -74,7 +79,7 @@ final class LocationNotesCounter: ObservableObject {
|
||||
}
|
||||
|
||||
subscriptionID = subID
|
||||
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 500)
|
||||
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 200)
|
||||
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
||||
guard let self = self else { return }
|
||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||
|
||||
@@ -74,38 +74,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 +128,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()
|
||||
}
|
||||
|
||||
@@ -150,12 +169,14 @@ final class LocationNotesManager: ObservableObject {
|
||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||
// Ensure matching tag
|
||||
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) else { return }
|
||||
if self.notes.contains(where: { $0.id == event.id }) { return }
|
||||
guard !self.noteIDs.contains(event.id) else { return }
|
||||
self.noteIDs.insert(event.id)
|
||||
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
|
||||
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
|
||||
self.notes.append(note)
|
||||
self.notes.sort { $0.createdAt > $1.createdAt }
|
||||
self.enforceMemoryCap()
|
||||
self.state = .ready
|
||||
}, { [weak self] in
|
||||
guard let self = self else { return }
|
||||
@@ -191,10 +212,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 +226,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 {
|
||||
|
||||
@@ -6,7 +6,7 @@ import Foundation
|
||||
final class MessageRouter {
|
||||
private let mesh: Transport
|
||||
private let nostr: NostrTransport
|
||||
private var outbox: [String: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
|
||||
private var outbox: [PeerID: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
|
||||
|
||||
init(mesh: Transport, nostr: NostrTransport) {
|
||||
self.mesh = mesh
|
||||
@@ -21,7 +21,7 @@ final class MessageRouter {
|
||||
) { [weak self] note in
|
||||
guard let self = self else { return }
|
||||
if let data = note.userInfo?["peerPublicKey"] as? Data {
|
||||
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: data)
|
||||
let peerID = PeerID(publicKey: data)
|
||||
Task { @MainActor in
|
||||
self.flushOutbox(for: peerID)
|
||||
}
|
||||
@@ -29,7 +29,7 @@ final class MessageRouter {
|
||||
// Handle key updates
|
||||
if let newKey = note.userInfo?["peerPublicKey"] as? Data,
|
||||
let _ = note.userInfo?["isKeyUpdate"] as? Bool {
|
||||
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: newKey)
|
||||
let peerID = PeerID(publicKey: newKey)
|
||||
Task { @MainActor in
|
||||
self.flushOutbox(for: peerID)
|
||||
}
|
||||
@@ -37,44 +37,44 @@ final class MessageRouter {
|
||||
}
|
||||
}
|
||||
|
||||
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
||||
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
let reachableMesh = mesh.isPeerReachable(peerID)
|
||||
if reachableMesh {
|
||||
SecureLogger.debug("Routing PM via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("Routing PM via mesh (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
// BLEService will initiate a handshake if needed and queue the message
|
||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
} else if canSendViaNostr(peerID: peerID) {
|
||||
SecureLogger.debug("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("Routing PM via Nostr to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
} else {
|
||||
// Queue for later (when mesh connects or Nostr mapping appears)
|
||||
if outbox[peerID] == nil { outbox[peerID] = [] }
|
||||
outbox[peerID]?.append((content, recipientNickname, messageID))
|
||||
SecureLogger.debug("Queued PM for \(peerID.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))…", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
// Prefer mesh for reachable peers; BLE will queue if handshake is needed
|
||||
if mesh.isPeerReachable(peerID) {
|
||||
SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||
mesh.sendReadReceipt(receipt, to: peerID)
|
||||
} else {
|
||||
SecureLogger.debug("Routing READ ack via Nostr to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("Routing READ ack via Nostr to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||
nostr.sendReadReceipt(receipt, to: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func sendDeliveryAck(_ messageID: String, to peerID: String) {
|
||||
func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
|
||||
if mesh.isPeerReachable(peerID) {
|
||||
SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
mesh.sendDeliveryAck(for: messageID, to: peerID)
|
||||
} else {
|
||||
nostr.sendDeliveryAck(for: messageID, to: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||
// Route via mesh when connected; else use Nostr
|
||||
if mesh.isPeerConnected(peerID) {
|
||||
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
@@ -84,16 +84,16 @@ final class MessageRouter {
|
||||
}
|
||||
|
||||
// MARK: - Outbox Management
|
||||
private func canSendViaNostr(peerID: String) -> Bool {
|
||||
private func canSendViaNostr(peerID: PeerID) -> Bool {
|
||||
// Two forms are supported:
|
||||
// - 64-hex Noise public key (32 bytes)
|
||||
// - 16-hex short peer ID (derived from Noise pubkey)
|
||||
if peerID.count == 64, let noiseKey = Data(hexString: peerID) {
|
||||
if let noiseKey = peerID.noiseKey {
|
||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
fav.peerNostrPublicKey != nil {
|
||||
return true
|
||||
}
|
||||
} else if peerID.count == 16 {
|
||||
} else if peerID.isShort {
|
||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||
fav.peerNostrPublicKey != nil {
|
||||
return true
|
||||
@@ -102,17 +102,17 @@ final class MessageRouter {
|
||||
return false
|
||||
}
|
||||
|
||||
func flushOutbox(for peerID: String) {
|
||||
func flushOutbox(for peerID: PeerID) {
|
||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
||||
SecureLogger.debug("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)", category: .session)
|
||||
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
||||
var remaining: [(content: String, nickname: String, messageID: String)] = []
|
||||
// Prefer mesh if connected; else try Nostr if mapping exists
|
||||
for (content, nickname, messageID) in queued {
|
||||
if mesh.isPeerReachable(peerID) {
|
||||
SecureLogger.debug("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("Outbox -> mesh for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||
} else if canSendViaNostr(peerID: peerID) {
|
||||
SecureLogger.debug("Outbox -> Nostr for \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("Outbox -> Nostr for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||
} else {
|
||||
// Keep unsent items queued
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
import BitLogger
|
||||
import Combine
|
||||
import Tor
|
||||
|
||||
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
|
||||
/// Policy: permit start when either location permissions are authorized OR
|
||||
|
||||
@@ -115,60 +115,30 @@ enum EncryptionStatus: Equatable {
|
||||
var description: String {
|
||||
switch self {
|
||||
case .none:
|
||||
return L10n.string(
|
||||
"encryption.status.failed",
|
||||
comment: "Status text when encryption failed"
|
||||
)
|
||||
return String(localized: "encryption.status.failed", comment: "Status text when encryption failed")
|
||||
case .noHandshake:
|
||||
return L10n.string(
|
||||
"encryption.status.not_encrypted",
|
||||
comment: "Status text when no encryption handshake happened"
|
||||
)
|
||||
return String(localized: "encryption.status.not_encrypted", comment: "Status text when no encryption handshake happened")
|
||||
case .noiseHandshaking:
|
||||
return L10n.string(
|
||||
"encryption.status.establishing",
|
||||
comment: "Status text when encryption is being established"
|
||||
)
|
||||
return String(localized: "encryption.status.establishing", comment: "Status text when encryption is being established")
|
||||
case .noiseSecured:
|
||||
return L10n.string(
|
||||
"encryption.status.secured",
|
||||
comment: "Status text when encryption is secured but not verified"
|
||||
)
|
||||
return String(localized: "encryption.status.secured", comment: "Status text when encryption is secured but not verified")
|
||||
case .noiseVerified:
|
||||
return L10n.string(
|
||||
"encryption.status.verified",
|
||||
comment: "Status text when encryption is verified"
|
||||
)
|
||||
return String(localized: "encryption.status.verified", comment: "Status text when encryption is verified")
|
||||
}
|
||||
}
|
||||
|
||||
var accessibilityDescription: String {
|
||||
switch self {
|
||||
case .none:
|
||||
return L10n.string(
|
||||
"encryption.accessibility.failed",
|
||||
comment: "Accessibility text when encryption failed"
|
||||
)
|
||||
return String(localized: "encryption.accessibility.failed", comment: "Accessibility text when encryption failed")
|
||||
case .noHandshake:
|
||||
return L10n.string(
|
||||
"encryption.accessibility.not_encrypted",
|
||||
comment: "Accessibility text when encryption is not established"
|
||||
)
|
||||
return String(localized: "encryption.accessibility.not_encrypted", comment: "Accessibility text when encryption is not established")
|
||||
case .noiseHandshaking:
|
||||
return L10n.string(
|
||||
"encryption.accessibility.establishing",
|
||||
comment: "Accessibility text when encryption is being established"
|
||||
)
|
||||
return String(localized: "encryption.accessibility.establishing", comment: "Accessibility text when encryption is being established")
|
||||
case .noiseSecured:
|
||||
return L10n.string(
|
||||
"encryption.accessibility.secured",
|
||||
comment: "Accessibility text when encryption is secured"
|
||||
)
|
||||
return String(localized: "encryption.accessibility.secured", comment: "Accessibility text when encryption is secured")
|
||||
case .noiseVerified:
|
||||
return L10n.string(
|
||||
"encryption.accessibility.verified",
|
||||
comment: "Accessibility text when encryption is verified"
|
||||
)
|
||||
return String(localized: "encryption.accessibility.verified", comment: "Accessibility text when encryption is verified")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,8 +162,8 @@ final class NoiseEncryptionService {
|
||||
private let sessionManager: NoiseSessionManager
|
||||
|
||||
// Peer fingerprints (SHA256 hash of static public key)
|
||||
private var peerFingerprints: [String: String] = [:] // peerID -> fingerprint
|
||||
private var fingerprintToPeerID: [String: String] = [:] // fingerprint -> peerID
|
||||
private var peerFingerprints: [PeerID: String] = [:]
|
||||
private var fingerprintToPeerID: [String: PeerID] = [:]
|
||||
|
||||
// Thread safety
|
||||
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
|
||||
@@ -208,7 +178,7 @@ final class NoiseEncryptionService {
|
||||
|
||||
// Callbacks
|
||||
private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication
|
||||
var onHandshakeRequired: ((String) -> Void)? // peerID needs handshake
|
||||
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
||||
|
||||
// Add a handler for peer authentication
|
||||
func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) {
|
||||
@@ -302,12 +272,11 @@ final class NoiseEncryptionService {
|
||||
|
||||
/// Get our identity fingerprint
|
||||
func getIdentityFingerprint() -> String {
|
||||
let hash = SHA256.hash(data: staticIdentityPublicKey.rawRepresentation)
|
||||
return hash.map { String(format: "%02x", $0) }.joined()
|
||||
staticIdentityPublicKey.rawRepresentation.sha256Fingerprint()
|
||||
}
|
||||
|
||||
/// Get peer's public key data
|
||||
func getPeerPublicKeyData(_ peerID: String) -> Data? {
|
||||
func getPeerPublicKeyData(_ peerID: PeerID) -> Data? {
|
||||
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
|
||||
}
|
||||
|
||||
@@ -435,11 +404,11 @@ final class NoiseEncryptionService {
|
||||
// MARK: - Handshake Management
|
||||
|
||||
/// Initiate a Noise handshake with a peer
|
||||
func initiateHandshake(with peerID: String) throws -> Data {
|
||||
func initiateHandshake(with peerID: PeerID) throws -> Data {
|
||||
|
||||
// Validate peer ID
|
||||
guard NoiseSecurityValidator.validatePeerID(peerID) else {
|
||||
SecureLogger.warning(.authenticationFailed(peerID: peerID))
|
||||
guard peerID.isValid else {
|
||||
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||
throw NoiseSecurityError.invalidPeerID
|
||||
}
|
||||
|
||||
@@ -449,7 +418,7 @@ final class NoiseEncryptionService {
|
||||
throw NoiseSecurityError.rateLimitExceeded
|
||||
}
|
||||
|
||||
SecureLogger.info(.handshakeStarted(peerID: peerID))
|
||||
SecureLogger.info(.handshakeStarted(peerID: peerID.id))
|
||||
|
||||
// Return raw handshake data without wrapper
|
||||
// The Noise protocol handles its own message format
|
||||
@@ -458,17 +427,17 @@ final class NoiseEncryptionService {
|
||||
}
|
||||
|
||||
/// Process an incoming handshake message
|
||||
func processHandshakeMessage(from peerID: String, message: Data) throws -> Data? {
|
||||
func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
|
||||
|
||||
// Validate peer ID
|
||||
guard NoiseSecurityValidator.validatePeerID(peerID) else {
|
||||
SecureLogger.warning(.authenticationFailed(peerID: peerID))
|
||||
guard peerID.isValid else {
|
||||
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||
throw NoiseSecurityError.invalidPeerID
|
||||
}
|
||||
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else {
|
||||
SecureLogger.warning(.handshakeFailed(peerID: peerID, error: "Message too large"))
|
||||
SecureLogger.warning(.handshakeFailed(peerID: peerID.id, error: "Message too large"))
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
@@ -488,19 +457,19 @@ final class NoiseEncryptionService {
|
||||
}
|
||||
|
||||
/// Check if we have an established session with a peer
|
||||
func hasEstablishedSession(with peerID: String) -> Bool {
|
||||
func hasEstablishedSession(with peerID: PeerID) -> Bool {
|
||||
return sessionManager.getSession(for: peerID)?.isEstablished() ?? false
|
||||
}
|
||||
|
||||
/// Check if we have a session (established or handshaking) with a peer
|
||||
func hasSession(with peerID: String) -> Bool {
|
||||
func hasSession(with peerID: PeerID) -> Bool {
|
||||
return sessionManager.getSession(for: peerID) != nil
|
||||
}
|
||||
|
||||
// MARK: - Encryption/Decryption
|
||||
|
||||
/// Encrypt data for a specific peer
|
||||
func encrypt(_ data: Data, for peerID: String) throws -> Data {
|
||||
func encrypt(_ data: Data, for peerID: PeerID) throws -> Data {
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
@@ -522,7 +491,7 @@ final class NoiseEncryptionService {
|
||||
}
|
||||
|
||||
/// Decrypt data from a specific peer
|
||||
func decrypt(_ data: Data, from peerID: String) throws -> Data {
|
||||
func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
@@ -544,32 +513,11 @@ final class NoiseEncryptionService {
|
||||
// MARK: - Peer Management
|
||||
|
||||
/// Get fingerprint for a peer
|
||||
func getPeerFingerprint(_ peerID: String) -> String? {
|
||||
func getPeerFingerprint(_ peerID: PeerID) -> String? {
|
||||
return serviceQueue.sync {
|
||||
return peerFingerprints[peerID]
|
||||
}
|
||||
}
|
||||
|
||||
/// Get peer ID for a fingerprint
|
||||
func getPeerID(for fingerprint: String) -> String? {
|
||||
return serviceQueue.sync {
|
||||
return fingerprintToPeerID[fingerprint]
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a peer session
|
||||
func removePeer(_ peerID: String) {
|
||||
sessionManager.removeSession(for: peerID)
|
||||
|
||||
serviceQueue.sync(flags: .barrier) {
|
||||
if let fingerprint = peerFingerprints[peerID] {
|
||||
fingerprintToPeerID.removeValue(forKey: fingerprint)
|
||||
}
|
||||
peerFingerprints.removeValue(forKey: peerID)
|
||||
}
|
||||
|
||||
SecureLogger.info(.sessionExpired(peerID: peerID))
|
||||
}
|
||||
|
||||
func clearEphemeralStateForPanic() {
|
||||
sessionManager.removeAllSessions()
|
||||
@@ -582,9 +530,9 @@ final class NoiseEncryptionService {
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func handleSessionEstablished(peerID: String, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
|
||||
private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
|
||||
// Calculate fingerprint
|
||||
let fingerprint = calculateFingerprint(for: remoteStaticKey)
|
||||
let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint()
|
||||
|
||||
// Store fingerprint mapping
|
||||
serviceQueue.sync(flags: .barrier) {
|
||||
@@ -593,20 +541,15 @@ final class NoiseEncryptionService {
|
||||
}
|
||||
|
||||
// Log security event
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID))
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||
|
||||
// Notify all handlers about authentication
|
||||
serviceQueue.async { [weak self] in
|
||||
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
||||
handler(peerID, fingerprint)
|
||||
handler(peerID.id, fingerprint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func calculateFingerprint(for publicKey: Curve25519.KeyAgreement.PublicKey) -> String {
|
||||
let hash = SHA256.hash(data: publicKey.rawRepresentation)
|
||||
return hash.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
// MARK: - Session Maintenance
|
||||
|
||||
|
||||
@@ -4,53 +4,57 @@ import Combine
|
||||
|
||||
// Minimal Nostr transport conforming to Transport for offline sending
|
||||
final class NostrTransport: Transport {
|
||||
weak var delegate: BitchatDelegate?
|
||||
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||
Just([]).eraseToAnyPublisher()
|
||||
}
|
||||
func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] }
|
||||
|
||||
// Provide BLE short peer ID for BitChat embedding
|
||||
var senderPeerID: String = ""
|
||||
var senderPeerID = PeerID(str: "")
|
||||
|
||||
// Throttle READ receipts to avoid relay rate limits
|
||||
private struct QueuedRead {
|
||||
let receipt: ReadReceipt
|
||||
let peerID: String
|
||||
let peerID: PeerID
|
||||
}
|
||||
private var readQueue: [QueuedRead] = []
|
||||
private var isSendingReadAcks = false
|
||||
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
||||
private let keychain: KeychainManagerProtocol
|
||||
|
||||
var myPeerID: String { senderPeerID }
|
||||
var myNickname: String { "" }
|
||||
func setNickname(_ nickname: String) { /* not used for Nostr */ }
|
||||
|
||||
init(keychain: KeychainManagerProtocol) {
|
||||
self.keychain = keychain
|
||||
}
|
||||
|
||||
// MARK: - Transport Protocol Conformance
|
||||
|
||||
weak var delegate: BitchatDelegate?
|
||||
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||
|
||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||
Just([]).eraseToAnyPublisher()
|
||||
}
|
||||
func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] }
|
||||
|
||||
var myPeerID: PeerID { senderPeerID }
|
||||
var myNickname: String { "" }
|
||||
func setNickname(_ nickname: String) { /* not used for Nostr */ }
|
||||
|
||||
func startServices() { /* no-op */ }
|
||||
func stopServices() { /* no-op */ }
|
||||
func emergencyDisconnectAll() { /* no-op */ }
|
||||
|
||||
func isPeerConnected(_ peerID: String) -> Bool { false }
|
||||
func isPeerReachable(_ peerID: String) -> Bool { false }
|
||||
func peerNickname(peerID: String) -> String? { nil }
|
||||
func getPeerNicknames() -> [String : String] { [:] }
|
||||
func isPeerConnected(_ peerID: PeerID) -> Bool { false }
|
||||
func isPeerReachable(_ peerID: PeerID) -> Bool { false }
|
||||
func peerNickname(peerID: PeerID) -> String? { nil }
|
||||
func getPeerNicknames() -> [PeerID : String] { [:] }
|
||||
|
||||
func getFingerprint(for peerID: String) -> String? { nil }
|
||||
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState { .none }
|
||||
func triggerHandshake(with peerID: String) { /* no-op */ }
|
||||
func getFingerprint(for peerID: PeerID) -> String? { nil }
|
||||
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
|
||||
func triggerHandshake(with peerID: PeerID) { /* no-op */ }
|
||||
|
||||
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
|
||||
private static var cachedNoiseService: NoiseEncryptionService?
|
||||
private static let noiseServiceLock = NSLock()
|
||||
func getNoiseService() -> NoiseEncryptionService {
|
||||
if let noiseService = Self.cachedNoiseService {
|
||||
return noiseService
|
||||
}
|
||||
Self.noiseServiceLock.lock()
|
||||
defer { Self.noiseServiceLock.unlock() }
|
||||
if let noiseService = Self.cachedNoiseService { return noiseService }
|
||||
let noiseService = NoiseEncryptionService(keychain: keychain)
|
||||
Self.cachedNoiseService = noiseService
|
||||
return noiseService
|
||||
@@ -59,11 +63,11 @@ final class NostrTransport: Transport {
|
||||
// Public broadcast not supported over Nostr here
|
||||
func sendMessage(_ content: String, mentions: [String]) { /* no-op */ }
|
||||
|
||||
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
// Convert recipient npub -> hex (x-only)
|
||||
let recipientHex: String
|
||||
do {
|
||||
@@ -77,7 +81,7 @@ final class NostrTransport: Transport {
|
||||
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
||||
return
|
||||
}
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||
return
|
||||
}
|
||||
@@ -90,12 +94,113 @@ final class NostrTransport: Transport {
|
||||
}
|
||||
}
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
// Enqueue and process with throttling to avoid relay rate limits
|
||||
readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
|
||||
processReadQueueIfNeeded()
|
||||
}
|
||||
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
||||
// Convert recipient npub -> hex
|
||||
let recipientHex: String
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||
guard hrp == "npub" else { return }
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch { return }
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event for favorite notification", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||
let recipientHex: String
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||
guard hrp == "npub" else { return }
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch { return }
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event for DELIVERED ack", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geohash Helpers
|
||||
|
||||
extension NostrTransport {
|
||||
|
||||
// MARK: Geohash ACK helpers
|
||||
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
Task { @MainActor in
|
||||
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID.id) else { return }
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
Task { @MainActor in
|
||||
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID.id) else { return }
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Geohash DMs (per-geohash identity)
|
||||
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
||||
Task { @MainActor in
|
||||
guard !recipientHex.isEmpty else { return }
|
||||
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||
// Build embedded BitChat packet without recipient peer ID
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID.id) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event for geohash PM", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
extension NostrTransport {
|
||||
private func processReadQueueIfNeeded() {
|
||||
guard !isSendingReadAcks else { return }
|
||||
guard !readQueue.isEmpty else { return }
|
||||
@@ -117,7 +222,7 @@ final class NostrTransport: Transport {
|
||||
guard hrp == "npub" else { scheduleNextReadAck(); return }
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch { scheduleNextReadAck(); return }
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID.id, senderPeerID: senderPeerID.id) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||
scheduleNextReadAck(); return
|
||||
}
|
||||
@@ -139,111 +244,18 @@ final class NostrTransport: Transport {
|
||||
}
|
||||
}
|
||||
|
||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
||||
// Convert recipient npub -> hex
|
||||
let recipientHex: String
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||
guard hrp == "npub" else { return }
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch { return }
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event for favorite notification", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
@MainActor
|
||||
private func resolveRecipientNpub(for peerID: String) -> String? {
|
||||
if let noiseKey = Data(hexString: peerID),
|
||||
private func resolveRecipientNpub(for peerID: PeerID) -> String? {
|
||||
if let noiseKey = Data(hexString: peerID.id),
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
let npub = fav.peerNostrPublicKey {
|
||||
return npub
|
||||
}
|
||||
if peerID.count == 16,
|
||||
if peerID.id.count == 16,
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||
let npub = fav.peerNostrPublicKey {
|
||||
return npub
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
||||
func sendDeliveryAck(for messageID: String, to peerID: String) {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||
let recipientHex: String
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||
guard hrp == "npub" else { return }
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch { return }
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event for DELIVERED ack", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geohash ACK helpers
|
||||
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
Task { @MainActor in
|
||||
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
Task { @MainActor in
|
||||
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geohash DMs (per-geohash identity)
|
||||
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
||||
Task { @MainActor in
|
||||
guard !recipientHex.isEmpty else { return }
|
||||
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||
// Build embedded BitChat packet without recipient peer ID
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event for geohash PM", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,26 +70,6 @@ final class NotificationService {
|
||||
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
|
||||
}
|
||||
|
||||
func sendFavoriteOnlineNotification(nickname: String) {
|
||||
// Send directly without checking app state for favorites
|
||||
DispatchQueue.main.async {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "⭐ \(nickname) is online!"
|
||||
content.body = "wanna get in there?"
|
||||
content.sound = .default
|
||||
|
||||
let request = UNNotificationRequest(
|
||||
identifier: "favorite-online-\(UUID().uuidString)",
|
||||
content: content,
|
||||
trigger: nil
|
||||
)
|
||||
|
||||
UNUserNotificationCenter.current().add(request) { _ in
|
||||
// Notification added
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Geohash public chat notification with deep link to a specific geohash
|
||||
func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) {
|
||||
let title = "\(titlePrefix)\(geohash)"
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//
|
||||
// NotificationStreamAssembler.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct NotificationStreamAssembler {
|
||||
private var buffer = Data()
|
||||
|
||||
mutating func append(_ chunk: Data) -> (frames: [Data], droppedPrefixes: [UInt8], reset: Bool) {
|
||||
guard !chunk.isEmpty else { return ([], [], false) }
|
||||
|
||||
buffer.append(chunk)
|
||||
|
||||
var frames: [Data] = []
|
||||
var dropped: [UInt8] = []
|
||||
var reset = false
|
||||
let maxFrameLength = TransportConfig.blePendingWriteBufferCapBytes
|
||||
|
||||
let minHeaderBytes = 14 // version + type + ttl + timestamp(8) + flags + length(2)
|
||||
let minFramePrefix = minHeaderBytes + BinaryProtocol.senderIDSize
|
||||
|
||||
while buffer.count >= minFramePrefix {
|
||||
guard let first = buffer.first else { break }
|
||||
if first != 1 {
|
||||
dropped.append(buffer.removeFirst())
|
||||
continue
|
||||
}
|
||||
|
||||
guard buffer.count >= minHeaderBytes else { break }
|
||||
|
||||
let headerBytes = Array(buffer.prefix(minFramePrefix))
|
||||
guard headerBytes.count == minFramePrefix else { break }
|
||||
|
||||
let flags = headerBytes[11]
|
||||
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
|
||||
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
|
||||
let payloadLen = (Int(headerBytes[12]) << 8) | Int(headerBytes[13])
|
||||
|
||||
var frameLength = minFramePrefix + payloadLen
|
||||
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
|
||||
if hasSignature { frameLength += BinaryProtocol.signatureSize }
|
||||
|
||||
guard frameLength > 0, frameLength <= maxFrameLength else {
|
||||
buffer.removeAll()
|
||||
reset = true
|
||||
break
|
||||
}
|
||||
|
||||
if buffer.count < frameLength {
|
||||
// Check if a new frame start exists within the incomplete buffer; if so, drop leading partial bytes.
|
||||
if let nextStart = buffer.dropFirst().firstIndex(of: 1) {
|
||||
let dropCount = buffer.distance(from: buffer.startIndex, to: nextStart)
|
||||
if dropCount > 0 {
|
||||
buffer.removeFirst(dropCount)
|
||||
dropped.append(1) // treat as dropped partial start
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
let frame = Data(buffer.prefix(frameLength))
|
||||
frames.append(frame)
|
||||
buffer.removeFirst(frameLength)
|
||||
}
|
||||
|
||||
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
|
||||
buffer.removeAll(keepingCapacity: false)
|
||||
}
|
||||
|
||||
return (frames, dropped, reset)
|
||||
}
|
||||
|
||||
mutating func reset() {
|
||||
buffer.removeAll(keepingCapacity: false)
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ final class PrivateChatManager: ObservableObject {
|
||||
selectedPeer = peerID
|
||||
|
||||
// Store fingerprint for persistence across reconnections
|
||||
if let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||
if let fingerprint = meshService?.getFingerprint(for: PeerID(str: peerID)) {
|
||||
selectedPeerFingerprint = fingerprint
|
||||
}
|
||||
|
||||
@@ -53,104 +53,28 @@ final class PrivateChatManager: ObservableObject {
|
||||
selectedPeer = nil
|
||||
selectedPeerFingerprint = nil
|
||||
}
|
||||
|
||||
/// Send a private message
|
||||
func sendMessage(_ content: String, to peerID: String) {
|
||||
guard let meshService = meshService,
|
||||
let peerNickname = meshService.peerNickname(peerID: peerID) else {
|
||||
return
|
||||
}
|
||||
|
||||
let messageID = UUID().uuidString
|
||||
|
||||
// Create local message
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: meshService.myNickname,
|
||||
content: content,
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: peerNickname,
|
||||
senderPeerID: meshService.myPeerID,
|
||||
mentions: nil,
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
|
||||
// Add to chat
|
||||
if privateChats[peerID] == nil { privateChats[peerID] = [] }
|
||||
privateChats[peerID]?.append(message)
|
||||
// Enforce per-chat cap on local append
|
||||
if var arr = privateChats[peerID], arr.count > privateChatCap {
|
||||
let remove = arr.count - privateChatCap
|
||||
arr.removeFirst(remove)
|
||||
privateChats[peerID] = arr
|
||||
}
|
||||
|
||||
// Send via mesh service
|
||||
meshService.sendPrivateMessage(content, to: peerID, recipientNickname: peerNickname, messageID: messageID)
|
||||
}
|
||||
|
||||
/// Handle incoming private message
|
||||
func handleIncomingMessage(_ message: BitchatMessage) {
|
||||
guard let senderPeerID = message.senderPeerID else { return }
|
||||
|
||||
// Initialize chat if needed
|
||||
if privateChats[senderPeerID] == nil {
|
||||
privateChats[senderPeerID] = []
|
||||
}
|
||||
|
||||
// Deduplicate by ID: replace existing message if present, else append
|
||||
if let idx = privateChats[senderPeerID]?.firstIndex(where: { $0.id == message.id }) {
|
||||
privateChats[senderPeerID]?[idx] = message
|
||||
} else {
|
||||
privateChats[senderPeerID]?.append(message)
|
||||
}
|
||||
|
||||
// Sanitize chat to avoid duplicate IDs and sort by timestamp
|
||||
sanitizeChat(for: senderPeerID)
|
||||
// Enforce cap after sanitize
|
||||
if var arr = privateChats[senderPeerID], arr.count > privateChatCap {
|
||||
let remove = arr.count - privateChatCap
|
||||
arr.removeFirst(remove)
|
||||
privateChats[senderPeerID] = arr
|
||||
}
|
||||
|
||||
// Mark as unread if not in this chat
|
||||
if selectedPeer != senderPeerID {
|
||||
unreadMessages.insert(senderPeerID)
|
||||
|
||||
// Avoid notifying for messages already marked as read (dup/resubscribe cases)
|
||||
if !sentReadReceipts.contains(message.id) {
|
||||
NotificationService.shared.sendPrivateMessageNotification(
|
||||
from: message.sender,
|
||||
message: message.content,
|
||||
peerID: senderPeerID
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Send read receipt if viewing this chat
|
||||
sendReadReceipt(for: message)
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove duplicate messages by ID and keep chronological order
|
||||
func sanitizeChat(for peerID: String) {
|
||||
guard let arr = privateChats[peerID] else { return }
|
||||
var seen = Set<String>()
|
||||
if arr.count <= 1 {
|
||||
return
|
||||
}
|
||||
|
||||
var indexByID: [String: Int] = [:]
|
||||
indexByID.reserveCapacity(arr.count)
|
||||
var deduped: [BitchatMessage] = []
|
||||
deduped.reserveCapacity(arr.count)
|
||||
|
||||
for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
|
||||
if !seen.contains(msg.id) {
|
||||
seen.insert(msg.id)
|
||||
deduped.append(msg)
|
||||
if let existing = indexByID[msg.id] {
|
||||
deduped[existing] = msg
|
||||
} else {
|
||||
// Replace previous with the latest occurrence (which is later in sort)
|
||||
if let index = deduped.firstIndex(where: { $0.id == msg.id }) {
|
||||
deduped[index] = msg
|
||||
}
|
||||
indexByID[msg.id] = deduped.count
|
||||
deduped.append(msg)
|
||||
}
|
||||
}
|
||||
|
||||
privateChats[peerID] = deduped
|
||||
}
|
||||
|
||||
@@ -168,48 +92,6 @@ final class PrivateChatManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the selected peer if fingerprint matches (for reconnections)
|
||||
func updateSelectedPeer(peers: [String: String]) {
|
||||
guard let fingerprint = selectedPeerFingerprint else { return }
|
||||
|
||||
// Find peer with matching fingerprint
|
||||
for (peerID, _) in peers {
|
||||
if meshService?.getFingerprint(for: peerID) == fingerprint {
|
||||
selectedPeer = peerID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get chat messages for current context
|
||||
func getCurrentMessages() -> [BitchatMessage] {
|
||||
guard let peer = selectedPeer else { return [] }
|
||||
return privateChats[peer] ?? []
|
||||
}
|
||||
|
||||
/// Clear a private chat
|
||||
func clearChat(with peerID: String) {
|
||||
privateChats[peerID]?.removeAll()
|
||||
}
|
||||
|
||||
/// Handle delivery acknowledgment
|
||||
func handleDeliveryAck(messageID: String, from peerID: String) {
|
||||
guard privateChats[peerID] != nil else { return }
|
||||
|
||||
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
privateChats[peerID]?[index].deliveryStatus = .delivered(to: "recipient", at: Date())
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle read receipt
|
||||
func handleReadReceipt(messageID: String, from peerID: String) {
|
||||
guard privateChats[peerID] != nil else { return }
|
||||
|
||||
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
privateChats[peerID]?[index].deliveryStatus = .read(by: "recipient", at: Date())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func sendReadReceipt(for message: BitchatMessage) {
|
||||
@@ -223,13 +105,13 @@ final class PrivateChatManager: ObservableObject {
|
||||
// Create read receipt using the simplified method
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: message.id,
|
||||
readerID: meshService?.myPeerID ?? "",
|
||||
readerID: meshService?.myPeerID.id ?? "",
|
||||
readerNickname: meshService?.myNickname ?? ""
|
||||
)
|
||||
|
||||
// Route via MessageRouter to avoid handshakeRequired spam when session isn't established
|
||||
if let router = messageRouter {
|
||||
SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.prefix(8))… via router", category: .session)
|
||||
SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.id.prefix(8))… via router", category: .session)
|
||||
Task { @MainActor in
|
||||
router.sendReadReceipt(receipt, to: senderPeerID)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import Combine
|
||||
/// Abstract transport interface used by ChatViewModel and services.
|
||||
/// BLEService implements this protocol; a future Nostr transport can too.
|
||||
struct TransportPeerSnapshot: Equatable, Hashable {
|
||||
let id: String
|
||||
let peerID: PeerID
|
||||
let nickname: String
|
||||
let isConnected: Bool
|
||||
let noisePublicKey: Data?
|
||||
@@ -12,13 +12,17 @@ struct TransportPeerSnapshot: Equatable, Hashable {
|
||||
}
|
||||
|
||||
protocol Transport: AnyObject {
|
||||
// Peer events (preferred over publishers for UI)
|
||||
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
|
||||
// Event sink
|
||||
var delegate: BitchatDelegate? { get set }
|
||||
// Peer events (preferred over publishers for UI)
|
||||
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
|
||||
|
||||
// Peer snapshots (for non-UI services)
|
||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
|
||||
func currentPeerSnapshots() -> [TransportPeerSnapshot]
|
||||
|
||||
// Identity
|
||||
var myPeerID: String { get }
|
||||
var myPeerID: PeerID { get }
|
||||
var myNickname: String { get }
|
||||
func setNickname(_ nickname: String)
|
||||
|
||||
@@ -28,37 +32,33 @@ protocol Transport: AnyObject {
|
||||
func emergencyDisconnectAll()
|
||||
|
||||
// Connectivity and peers
|
||||
func isPeerConnected(_ peerID: String) -> Bool
|
||||
func isPeerReachable(_ peerID: String) -> Bool
|
||||
func peerNickname(peerID: String) -> String?
|
||||
func getPeerNicknames() -> [String: String]
|
||||
func isPeerConnected(_ peerID: PeerID) -> Bool
|
||||
func isPeerReachable(_ peerID: PeerID) -> Bool
|
||||
func peerNickname(peerID: PeerID) -> String?
|
||||
func getPeerNicknames() -> [PeerID: String]
|
||||
|
||||
// Protocol utilities
|
||||
func getFingerprint(for peerID: String) -> String?
|
||||
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState
|
||||
func triggerHandshake(with peerID: String)
|
||||
func getFingerprint(for peerID: PeerID) -> String?
|
||||
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState
|
||||
func triggerHandshake(with peerID: PeerID)
|
||||
func getNoiseService() -> NoiseEncryptionService
|
||||
|
||||
// Messaging
|
||||
func sendMessage(_ content: String, mentions: [String])
|
||||
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String)
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String)
|
||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool)
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
||||
func sendBroadcastAnnounce()
|
||||
func sendDeliveryAck(for messageID: String, to peerID: String)
|
||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID)
|
||||
|
||||
// QR verification (optional for transports)
|
||||
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
||||
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
||||
|
||||
// Peer snapshots (for non-UI services)
|
||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
|
||||
func currentPeerSnapshots() -> [TransportPeerSnapshot]
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
}
|
||||
|
||||
extension Transport {
|
||||
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||
}
|
||||
|
||||
protocol TransportPeerEventsDelegate: AnyObject {
|
||||
|
||||
@@ -10,7 +10,6 @@ import BitLogger
|
||||
import Foundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
import CryptoKit
|
||||
|
||||
/// Single source of truth for peer state, combining mesh connectivity and favorites
|
||||
@MainActor
|
||||
@@ -19,14 +18,14 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
// MARK: - Published Properties
|
||||
|
||||
@Published private(set) var peers: [BitchatPeer] = []
|
||||
@Published private(set) var connectedPeerIDs: Set<String> = []
|
||||
@Published private(set) var connectedPeerIDs: Set<PeerID> = []
|
||||
@Published private(set) var favorites: [BitchatPeer] = []
|
||||
@Published private(set) var mutualFavorites: [BitchatPeer] = []
|
||||
|
||||
// MARK: - Private Properties
|
||||
|
||||
private var peerIndex: [String: BitchatPeer] = [:]
|
||||
private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint
|
||||
private var peerIndex: [PeerID: BitchatPeer] = [:]
|
||||
private var fingerprintCache: [PeerID: String] = [:]
|
||||
private let meshService: Transport
|
||||
private let identityManager: SecureIdentityStateManagerProtocol
|
||||
weak var messageRouter: MessageRouter?
|
||||
@@ -78,12 +77,12 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
let favorites = favoritesService.favorites
|
||||
|
||||
var enrichedPeers: [BitchatPeer] = []
|
||||
var connected: Set<String> = []
|
||||
var addedPeerIDs: Set<String> = []
|
||||
var connected: Set<PeerID> = []
|
||||
var addedPeerIDs: Set<PeerID> = []
|
||||
|
||||
// Phase 1: Add all mesh peers (connected and reachable)
|
||||
for peerInfo in meshPeers {
|
||||
let peerID = peerInfo.id
|
||||
let peerID = peerInfo.peerID
|
||||
guard peerID != meshService.myPeerID else { continue } // Never add self
|
||||
|
||||
let peer = buildPeerFromMesh(
|
||||
@@ -104,7 +103,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
|
||||
// Phase 2: Add offline favorites that we actively favorite
|
||||
for (favoriteKey, favorite) in favorites where favorite.isFavorite {
|
||||
let peerID = favoriteKey.hexEncodedString()
|
||||
let peerID = PeerID(hexData: favoriteKey)
|
||||
|
||||
// Skip if already added (connected peer)
|
||||
if addedPeerIDs.contains(peerID) { continue }
|
||||
@@ -138,10 +137,10 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
// Phase 4: Build subsets and indices
|
||||
var favoritesList: [BitchatPeer] = []
|
||||
var mutualsList: [BitchatPeer] = []
|
||||
var newIndex: [String: BitchatPeer] = [:]
|
||||
var newIndex: [PeerID: BitchatPeer] = [:]
|
||||
|
||||
for peer in enrichedPeers {
|
||||
newIndex[peer.id] = peer
|
||||
newIndex[peer.peerID] = peer
|
||||
|
||||
if peer.isFavorite {
|
||||
favoritesList.append(peer)
|
||||
@@ -185,7 +184,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached)
|
||||
|
||||
var peer = BitchatPeer(
|
||||
id: peerInfo.id,
|
||||
peerID: peerInfo.peerID,
|
||||
noisePublicKey: peerInfo.noisePublicKey ?? Data(),
|
||||
nickname: peerInfo.nickname,
|
||||
lastSeen: peerInfo.lastSeen,
|
||||
@@ -205,10 +204,10 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
|
||||
private func buildPeerFromFavorite(
|
||||
favorite: FavoritesPersistenceService.FavoriteRelationship,
|
||||
peerID: String
|
||||
peerID: PeerID
|
||||
) -> BitchatPeer {
|
||||
var peer = BitchatPeer(
|
||||
id: peerID,
|
||||
peerID: peerID,
|
||||
noisePublicKey: favorite.peerNoisePublicKey,
|
||||
nickname: favorite.peerNickname,
|
||||
lastSeen: favorite.lastUpdated,
|
||||
@@ -225,27 +224,22 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
// MARK: - Public Methods
|
||||
|
||||
/// Get peer by ID
|
||||
func getPeer(by id: String) -> BitchatPeer? {
|
||||
return peerIndex[id]
|
||||
func getPeer(by peerID: PeerID) -> BitchatPeer? {
|
||||
return peerIndex[peerID]
|
||||
}
|
||||
|
||||
/// Get peer ID for nickname
|
||||
func getPeerID(for nickname: String) -> String? {
|
||||
for peer in peers {
|
||||
if peer.displayName == nickname || peer.nickname == nickname {
|
||||
return peer.id
|
||||
return peer.peerID.id
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Check if peer is online
|
||||
func isOnline(_ peerID: String) -> Bool {
|
||||
return connectedPeerIDs.contains(peerID)
|
||||
}
|
||||
|
||||
/// Check if peer is blocked
|
||||
func isBlocked(_ peerID: String) -> Bool {
|
||||
func isBlocked(_ peerID: PeerID) -> Bool {
|
||||
// Get fingerprint
|
||||
guard let fingerprint = getFingerprint(for: peerID) else { return false }
|
||||
|
||||
@@ -258,8 +252,8 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
}
|
||||
|
||||
/// Toggle favorite status
|
||||
func toggleFavorite(_ peerID: String) {
|
||||
guard let peer = getPeer(by: peerID) else {
|
||||
func toggleFavorite(_ peerID: PeerID) {
|
||||
guard let peer = getPeer(by: peerID) else {
|
||||
SecureLogger.warning("⚠️ Cannot toggle favorite - peer not found: \(peerID)", category: .session)
|
||||
return
|
||||
}
|
||||
@@ -322,39 +316,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle blocked status
|
||||
func toggleBlocked(_ peerID: String) {
|
||||
guard let fingerprint = getFingerprint(for: peerID) else { return }
|
||||
|
||||
// Get or create social identity
|
||||
var identity = identityManager.getSocialIdentity(for: fingerprint)
|
||||
?? SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: nil,
|
||||
claimedNickname: getPeer(by: peerID)?.displayName ?? "Unknown",
|
||||
trustLevel: .unknown,
|
||||
isFavorite: false,
|
||||
isBlocked: false,
|
||||
notes: nil
|
||||
)
|
||||
|
||||
// Toggle blocked status
|
||||
identity.isBlocked = !identity.isBlocked
|
||||
|
||||
// Can't be both favorite and blocked
|
||||
if identity.isBlocked {
|
||||
identity.isFavorite = false
|
||||
// Also remove from favorites service
|
||||
if let peer = getPeer(by: peerID) {
|
||||
favoritesService.removeFavorite(peerNoisePublicKey: peer.noisePublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
identityManager.updateSocialIdentity(identity)
|
||||
}
|
||||
|
||||
/// Get fingerprint for peer ID
|
||||
func getFingerprint(for peerID: String) -> String? {
|
||||
func getFingerprint(for peerID: PeerID) -> String? {
|
||||
// Check cache first
|
||||
if let cached = fingerprintCache[peerID] {
|
||||
return cached
|
||||
@@ -379,23 +341,13 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
// MARK: - Compatibility Methods (for easy migration)
|
||||
|
||||
var allPeers: [BitchatPeer] { peers }
|
||||
var connectedPeers: [String] { Array(connectedPeerIDs) }
|
||||
var favoritePeers: Set<String> {
|
||||
Set(favorites.compactMap { getFingerprint(for: $0.id) })
|
||||
var connectedPeers: [PeerID] { Array(connectedPeerIDs) }
|
||||
var favoritePeers: Set<String> {
|
||||
Set(favorites.compactMap { getFingerprint(for: $0.peerID) })
|
||||
}
|
||||
var blockedUsers: Set<String> {
|
||||
Set(peers.compactMap { peer in
|
||||
isBlocked(peer.id) ? getFingerprint(for: peer.id) : nil
|
||||
isBlocked(peer.peerID) ? getFingerprint(for: peer.peerID) : nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Extensions
|
||||
|
||||
extension Data {
|
||||
func sha256Fingerprint() -> String {
|
||||
// Implementation matches existing fingerprint generation in NoiseEncryptionService
|
||||
let hash = SHA256.hash(data: self)
|
||||
return hash.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// QR verification scaffolding: schema, signing, and basic challenge/response helpers.
|
||||
final class VerificationService {
|
||||
@@ -95,7 +94,7 @@ final class VerificationService {
|
||||
nickname: payload.nickname,
|
||||
ts: payload.ts,
|
||||
nonceB64: payload.nonceB64,
|
||||
sigHex: sig.map { String(format: "%02x", $0) }.joined())
|
||||
sigHex: sig.hexEncodedString())
|
||||
let out = signed.toURLString()
|
||||
Cache.last = (nickname, npub, Date(), out)
|
||||
return out
|
||||
|
||||
@@ -4,7 +4,7 @@ import Foundation
|
||||
final class GossipSyncManager {
|
||||
protocol Delegate: AnyObject {
|
||||
func sendPacket(_ packet: BitchatPacket)
|
||||
func sendPacket(to peerID: String, packet: BitchatPacket)
|
||||
func sendPacket(to peerID: PeerID, packet: BitchatPacket)
|
||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
|
||||
}
|
||||
|
||||
@@ -12,9 +12,10 @@ final class GossipSyncManager {
|
||||
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
|
||||
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
||||
var gcsTargetFpr: Double = 0.01 // 1%
|
||||
var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages
|
||||
}
|
||||
|
||||
private let myPeerID: String
|
||||
private let myPeerID: PeerID
|
||||
private let config: Config
|
||||
weak var delegate: Delegate?
|
||||
|
||||
@@ -27,7 +28,7 @@ final class GossipSyncManager {
|
||||
private var periodicTimer: DispatchSourceTimer?
|
||||
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
||||
|
||||
init(myPeerID: String, config: Config = Config()) {
|
||||
init(myPeerID: PeerID, config: Config = Config()) {
|
||||
self.myPeerID = myPeerID
|
||||
self.config = config
|
||||
}
|
||||
@@ -36,7 +37,10 @@ final class GossipSyncManager {
|
||||
stop()
|
||||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||
timer.schedule(deadline: .now() + 30.0, repeating: 30.0, leeway: .seconds(1))
|
||||
timer.setEventHandler { [weak self] in self?.sendRequestSync() }
|
||||
timer.setEventHandler { [weak self] in
|
||||
self?.cleanupExpiredMessages()
|
||||
self?.sendRequestSync()
|
||||
}
|
||||
timer.resume()
|
||||
periodicTimer = timer
|
||||
}
|
||||
@@ -45,7 +49,7 @@ final class GossipSyncManager {
|
||||
periodicTimer?.cancel(); periodicTimer = nil
|
||||
}
|
||||
|
||||
func scheduleInitialSyncToPeer(_ peerID: String, delaySeconds: TimeInterval = 5.0) {
|
||||
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
|
||||
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
||||
self?.sendRequestSync(to: peerID)
|
||||
}
|
||||
@@ -57,6 +61,18 @@ final class GossipSyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to check if a packet is within the age threshold
|
||||
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
|
||||
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
let ageThresholdMs = UInt64(config.maxMessageAgeSeconds * 1000)
|
||||
|
||||
// If current time is less than threshold, accept all (handle clock issues gracefully)
|
||||
guard nowMs >= ageThresholdMs else { return true }
|
||||
|
||||
let cutoffMs = nowMs - ageThresholdMs
|
||||
return packet.timestamp >= cutoffMs
|
||||
}
|
||||
|
||||
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
||||
let mt = MessageType(rawValue: packet.type)
|
||||
let isBroadcastRecipient: Bool = {
|
||||
@@ -67,6 +83,9 @@ final class GossipSyncManager {
|
||||
let isAnnounce = (mt == .announce)
|
||||
guard isBroadcastMessage || isAnnounce else { return }
|
||||
|
||||
// Reject expired packets to prevent ghost peers and old messages
|
||||
guard isPacketFresh(packet) else { return }
|
||||
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
|
||||
if isBroadcastMessage {
|
||||
@@ -90,7 +109,7 @@ final class GossipSyncManager {
|
||||
let payload = buildGcsPayload()
|
||||
let pkt = BitchatPacket(
|
||||
type: MessageType.requestSync.rawValue,
|
||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||
senderID: Data(hexString: myPeerID.id) ?? Data(),
|
||||
recipientID: nil, // broadcast
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
@@ -101,10 +120,10 @@ final class GossipSyncManager {
|
||||
delegate?.sendPacket(signed)
|
||||
}
|
||||
|
||||
private func sendRequestSync(to peerID: String) {
|
||||
private func sendRequestSync(to peerID: PeerID) {
|
||||
let payload = buildGcsPayload()
|
||||
var recipient = Data()
|
||||
var temp = peerID
|
||||
var temp = peerID.id
|
||||
while temp.count >= 2 && recipient.count < 8 {
|
||||
let hexByte = String(temp.prefix(2))
|
||||
if let b = UInt8(hexByte, radix: 16) { recipient.append(b) }
|
||||
@@ -112,7 +131,7 @@ final class GossipSyncManager {
|
||||
}
|
||||
let pkt = BitchatPacket(
|
||||
type: MessageType.requestSync.rawValue,
|
||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||
senderID: Data(hexString: myPeerID.id) ?? Data(),
|
||||
recipientID: recipient,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
@@ -123,13 +142,13 @@ final class GossipSyncManager {
|
||||
delegate?.sendPacket(to: peerID, packet: signed)
|
||||
}
|
||||
|
||||
func handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
|
||||
func handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
|
||||
queue.async { [weak self] in
|
||||
self?._handleRequestSync(fromPeerID: fromPeerID, request: request)
|
||||
self?._handleRequestSync(from: peerID, request: request)
|
||||
}
|
||||
}
|
||||
|
||||
private func _handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
|
||||
private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
|
||||
// Decode GCS into sorted set and prepare membership checker
|
||||
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
||||
func mightContain(_ id: Data) -> Bool {
|
||||
@@ -137,36 +156,46 @@ final class GossipSyncManager {
|
||||
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
||||
}
|
||||
|
||||
// 1) Announcements: send latest per peer if requester lacks them
|
||||
// 1) Announcements: send latest per peer if requester lacks them (and not expired)
|
||||
for (_, pair) in latestAnnouncementByPeer {
|
||||
let (idHex, pkt) = pair
|
||||
guard isPacketFresh(pkt) else { continue }
|
||||
let idBytes = Data(hexString: idHex) ?? Data()
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
delegate?.sendPacket(to: fromPeerID, packet: toSend)
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Broadcast messages: send all missing
|
||||
// 2) Broadcast messages: send all missing (and not expired)
|
||||
let toSendMsgs = messageOrder.compactMap { messages[$0] }
|
||||
for pkt in toSendMsgs {
|
||||
guard isPacketFresh(pkt) else { continue }
|
||||
let idBytes = PacketIdUtil.computeId(pkt)
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
delegate?.sendPacket(to: fromPeerID, packet: toSend)
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build REQUEST_SYNC payload using current candidates and GCS params
|
||||
private func buildGcsPayload() -> Data {
|
||||
// Collect candidates: latest announce per peer + broadcast messages
|
||||
// Collect candidates: latest announce per peer + broadcast messages (only fresh)
|
||||
var candidates: [BitchatPacket] = []
|
||||
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count)
|
||||
for (_, pair) in latestAnnouncementByPeer { candidates.append(pair.packet) }
|
||||
for id in messageOrder { if let p = messages[id] { candidates.append(p) } }
|
||||
for (_, pair) in latestAnnouncementByPeer {
|
||||
if isPacketFresh(pair.packet) {
|
||||
candidates.append(pair.packet)
|
||||
}
|
||||
}
|
||||
for id in messageOrder {
|
||||
if let p = messages[id], isPacketFresh(p) {
|
||||
candidates.append(p)
|
||||
}
|
||||
}
|
||||
// Sort by timestamp desc
|
||||
candidates.sort { $0.timestamp > $1.timestamp }
|
||||
|
||||
@@ -184,15 +213,32 @@ final class GossipSyncManager {
|
||||
return req.encode()
|
||||
}
|
||||
|
||||
// Periodic cleanup of expired messages and announcements
|
||||
private func cleanupExpiredMessages() {
|
||||
// Remove expired announcements
|
||||
latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pair in
|
||||
isPacketFresh(pair.packet)
|
||||
}
|
||||
|
||||
// Remove expired messages
|
||||
let expiredMessageIds = messages.compactMap { id, pkt in
|
||||
isPacketFresh(pkt) ? nil : id
|
||||
}
|
||||
for id in expiredMessageIds {
|
||||
messages.removeValue(forKey: id)
|
||||
messageOrder.removeAll { $0 == id }
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit removal hook for LEAVE/stale peer
|
||||
func removeAnnouncementForPeer(_ peerID: String) {
|
||||
func removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||
queue.async { [weak self] in
|
||||
self?._removeAnnouncementForPeer(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
private func _removeAnnouncementForPeer(_ peerID: String) {
|
||||
let normalizedPeerID = peerID.lowercased()
|
||||
private func _removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||
let normalizedPeerID = peerID.id.lowercased()
|
||||
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
|
||||
|
||||
// Remove messages from this peer
|
||||
|
||||
@@ -14,8 +14,4 @@ enum PacketIdUtil {
|
||||
let digest = hasher.finalize()
|
||||
return Data(digest.prefix(16))
|
||||
}
|
||||
|
||||
static func computeIdHex(_ packet: BitchatPacket) -> String {
|
||||
return computeId(packet).hexEncodedString()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// Data+SHA256.swift
|
||||
// bitchat
|
||||
//
|
||||
// Created by Islam on 26/09/2025.
|
||||
//
|
||||
|
||||
import struct Foundation.Data
|
||||
import struct CryptoKit.SHA256
|
||||
|
||||
extension Data {
|
||||
/// Returns the hex representation of SHA256 hash
|
||||
func sha256Fingerprint() -> String {
|
||||
// Implementation matches existing fingerprint generation in NoiseEncryptionService
|
||||
sha256Hash().hexEncodedString()
|
||||
}
|
||||
|
||||
/// Returns the SHA256 hash wrapped in Data
|
||||
func sha256Hash() -> Data {
|
||||
Data(SHA256.hash(data: self))
|
||||
}
|
||||
}
|
||||
@@ -11,51 +11,24 @@ struct InputValidator {
|
||||
// BinaryProtocol caps payload length at UInt16.max (65_535). Leave headroom
|
||||
// for headers/padding by limiting user content to 60_000 bytes.
|
||||
static let maxMessageLength = 60_000
|
||||
static let maxReasonLength = 200
|
||||
static let maxPeerIDLength = 64
|
||||
static let hexPeerIDLength = 16 // 8 bytes = 16 hex chars
|
||||
}
|
||||
|
||||
// MARK: - Peer ID Validation
|
||||
|
||||
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
|
||||
static func validatePeerID(_ peerID: String) -> Bool {
|
||||
// Accept short routing IDs (exact 16-hex)
|
||||
if PeerIDResolver.isShortID(peerID) { return true }
|
||||
// If length equals short-hex length but isn't valid hex, reject
|
||||
if peerID.count == Limits.hexPeerIDLength { return false }
|
||||
// Accept full Noise key hex (exact 64-hex)
|
||||
if PeerIDResolver.isNoiseKeyHex(peerID) { return true }
|
||||
// If length equals full key length but isn't valid hex, reject
|
||||
if peerID.count == Limits.maxPeerIDLength { return false }
|
||||
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
|
||||
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
||||
return !peerID.isEmpty &&
|
||||
peerID.count < Limits.maxPeerIDLength &&
|
||||
peerID.rangeOfCharacter(from: validCharset.inverted) == nil
|
||||
}
|
||||
|
||||
// MARK: - String Content Validation
|
||||
|
||||
/// Validates and sanitizes user-provided strings (nicknames, messages)
|
||||
static func validateUserString(_ string: String, maxLength: Int, allowNewlines: Bool = false) -> String? {
|
||||
/// Validates and sanitizes user-provided strings used in UI
|
||||
static func validateUserString(_ string: String, maxLength: Int) -> String? {
|
||||
// Check empty
|
||||
guard !string.isEmpty else { return nil }
|
||||
|
||||
|
||||
// Trim whitespace
|
||||
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
|
||||
// Check length
|
||||
guard trimmed.count <= maxLength else { return nil }
|
||||
|
||||
// Remove control characters except allowed ones
|
||||
var allowedControlChars = CharacterSet()
|
||||
if allowNewlines {
|
||||
allowedControlChars.insert(charactersIn: "\n\r")
|
||||
}
|
||||
|
||||
let controlChars = CharacterSet.controlCharacters.subtracting(allowedControlChars)
|
||||
|
||||
// Remove control characters
|
||||
let controlChars = CharacterSet.controlCharacters
|
||||
let cleaned = trimmed.components(separatedBy: controlChars).joined()
|
||||
|
||||
// Ensure valid UTF-8 (should already be, but double-check)
|
||||
@@ -70,29 +43,14 @@ struct InputValidator {
|
||||
|
||||
/// Validates nickname
|
||||
static func validateNickname(_ nickname: String) -> String? {
|
||||
return validateUserString(nickname, maxLength: Limits.maxNicknameLength, allowNewlines: false)
|
||||
}
|
||||
|
||||
/// Validates message content
|
||||
static func validateMessageContent(_ content: String) -> String? {
|
||||
return validateUserString(content, maxLength: Limits.maxMessageLength, allowNewlines: true)
|
||||
}
|
||||
|
||||
/// Validates error/reason strings
|
||||
static func validateReasonString(_ reason: String) -> String? {
|
||||
return validateUserString(reason, maxLength: Limits.maxReasonLength, allowNewlines: false)
|
||||
return validateUserString(nickname, maxLength: Limits.maxNicknameLength)
|
||||
}
|
||||
|
||||
// MARK: - Protocol Field Validation
|
||||
|
||||
|
||||
// Note: Message type validation is performed closer to decoding using
|
||||
// MessageType/NoisePayloadType enums; keeping validator free of stale lists.
|
||||
|
||||
/// Validates hop count is reasonable
|
||||
static func validateHopCount(_ hopCount: UInt8) -> Bool {
|
||||
return hopCount <= 10 // Prevent excessive forwarding
|
||||
}
|
||||
|
||||
|
||||
/// Validates timestamp is reasonable (not too far in past or future)
|
||||
static func validateTimestamp(_ timestamp: Date) -> Bool {
|
||||
let now = Date()
|
||||
@@ -100,38 +58,5 @@ struct InputValidator {
|
||||
let oneHourFromNow = now.addingTimeInterval(3600)
|
||||
return timestamp >= oneHourAgo && timestamp <= oneHourFromNow
|
||||
}
|
||||
|
||||
/// Validates data size for different contexts
|
||||
static func validateDataSize(_ data: Data, maxSize: Int) -> Bool {
|
||||
return data.count > 0 && data.count <= maxSize
|
||||
}
|
||||
|
||||
// MARK: - Binary Data Validation
|
||||
|
||||
/// Validates UUID format
|
||||
static func validateUUID(_ uuid: String) -> Bool {
|
||||
// Remove dashes and validate hex
|
||||
let cleaned = uuid.replacingOccurrences(of: "-", with: "")
|
||||
return cleaned.count == 32 && cleaned.allSatisfy { $0.isHexDigit }
|
||||
}
|
||||
|
||||
/// Validates public key data
|
||||
static func validatePublicKey(_ keyData: Data) -> Bool {
|
||||
// Curve25519 public keys are 32 bytes
|
||||
return keyData.count == 32
|
||||
}
|
||||
|
||||
/// Validates signature data
|
||||
static func validateSignature(_ signature: Data) -> Bool {
|
||||
// Ed25519 signatures are 64 bytes
|
||||
return signature.count == 64
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Character Extensions
|
||||
|
||||
private extension Character {
|
||||
var isHexDigit: Bool {
|
||||
return "0123456789abcdefABCDEF".contains(self)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum L10n {
|
||||
static func string(_ key: String, comment: String) -> String {
|
||||
NSLocalizedString(key, comment: comment)
|
||||
}
|
||||
|
||||
static func format(_ key: String, comment: String, _ args: CVarArg...) -> String {
|
||||
let format = NSLocalizedString(key, comment: comment)
|
||||
return String(format: format, locale: Locale.current, arguments: args)
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import Foundation
|
||||
struct PeerDisplayNameResolver {
|
||||
/// Computes display names with a `#xxxx` suffix for connected peers when nickname collisions occur.
|
||||
/// - Parameters:
|
||||
/// - peers: Array of tuples (id, nickname, isConnected).
|
||||
/// - peers: Array of tuples (peerID, nickname, isConnected).
|
||||
/// - selfNickname: The local user's current nickname, included in collision counts to suffix remotes matching it.
|
||||
/// - Returns: Map of peerID -> displayName.
|
||||
static func resolve(_ peers: [(id: String, nickname: String, isConnected: Bool)], selfNickname: String) -> [String: String] {
|
||||
static func resolve(_ peers: [(peerID: PeerID, nickname: String, isConnected: Bool)], selfNickname: String) -> [PeerID: String] {
|
||||
// Count collisions among connected peers and include our own nickname
|
||||
var counts: [String: Int] = [:]
|
||||
for p in peers where p.isConnected {
|
||||
@@ -15,13 +15,13 @@ struct PeerDisplayNameResolver {
|
||||
}
|
||||
counts[selfNickname, default: 0] += 1
|
||||
|
||||
var result: [String: String] = [:]
|
||||
var result: [PeerID: String] = [:]
|
||||
for p in peers {
|
||||
var name = p.nickname
|
||||
if p.isConnected, (counts[p.nickname] ?? 0) > 1 {
|
||||
name += "#" + String(p.id.prefix(4))
|
||||
name += "#" + String(p.peerID.id.prefix(4))
|
||||
}
|
||||
result[p.id] = name
|
||||
result[p.peerID] = name
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct PeerIDResolver {
|
||||
/// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed
|
||||
static func toShortID(_ id: String) -> String {
|
||||
if id.count == 64, let data = Data(hexString: id) {
|
||||
return PeerIDUtils.derivePeerID(fromPublicKey: data)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
static func isShortID(_ id: String) -> Bool {
|
||||
return id.count == 16 && Data(hexString: id) != nil
|
||||
}
|
||||
|
||||
static func isNoiseKeyHex(_ id: String) -> Bool {
|
||||
return id.count == 64 && Data(hexString: id) != nil
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
||||
//
|
||||
// DeliveryStatusView.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct DeliveryStatusView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let status: DeliveryStatus
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
private var textColor: Color {
|
||||
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||
}
|
||||
|
||||
private var secondaryTextColor: Color {
|
||||
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
|
||||
}
|
||||
|
||||
private enum Strings {
|
||||
static func delivered(to nickname: String) -> String {
|
||||
String(
|
||||
format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
}
|
||||
|
||||
static func read(by nickname: String) -> String {
|
||||
String(
|
||||
format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
}
|
||||
|
||||
static func failed(_ reason: String) -> String {
|
||||
String(
|
||||
format: String(localized: "content.delivery.failed", comment: "Tooltip for failed message delivery"),
|
||||
locale: .current,
|
||||
reason
|
||||
)
|
||||
}
|
||||
|
||||
static func deliveredToMembers(_ reached: Int, _ total: Int) -> String {
|
||||
String(
|
||||
format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"),
|
||||
locale: .current,
|
||||
reached,
|
||||
total
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
switch status {
|
||||
case .sending:
|
||||
Image(systemName: "circle")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
|
||||
case .sent:
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
|
||||
case .delivered(let nickname, _):
|
||||
HStack(spacing: -2) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
}
|
||||
.foregroundColor(textColor.opacity(0.8))
|
||||
.help(Strings.delivered(to: nickname))
|
||||
|
||||
case .read(let nickname, _):
|
||||
HStack(spacing: -2) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10, weight: .bold))
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10, weight: .bold))
|
||||
}
|
||||
.foregroundColor(Color(red: 0.0, green: 0.478, blue: 1.0)) // Bright blue
|
||||
.help(Strings.read(by: nickname))
|
||||
|
||||
case .failed(let reason):
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(Color.red.opacity(0.8))
|
||||
.help(Strings.failed(reason))
|
||||
|
||||
case .partiallyDelivered(let reached, let total):
|
||||
HStack(spacing: 1) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
Text(verbatim: "\(reached)/\(total)")
|
||||
.font(.bitchatSystem(size: 10, design: .monospaced))
|
||||
}
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
.help(Strings.deliveredToMembers(reached, total))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let statuses: [DeliveryStatus] = [
|
||||
.sending,
|
||||
.sent,
|
||||
.delivered(to: "John Doe", at: Date()),
|
||||
.read(by: "Jane Doe", at: Date()),
|
||||
.failed(reason: "Offline"),
|
||||
.partiallyDelivered(reached: 2, total: 5)
|
||||
]
|
||||
|
||||
List {
|
||||
ForEach(statuses, id: \.self) { status in
|
||||
HStack {
|
||||
Text(status.displayText)
|
||||
Spacer()
|
||||
DeliveryStatusView(status: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
.environment(\.colorScheme, .light)
|
||||
|
||||
List {
|
||||
ForEach(statuses, id: \.self) { status in
|
||||
HStack {
|
||||
Text(status.displayText)
|
||||
Spacer()
|
||||
DeliveryStatusView(status: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//
|
||||
// PaymentChipView.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PaymentChipView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
enum PaymentType {
|
||||
case cashu(String)
|
||||
case lightning(String)
|
||||
|
||||
var url: URL? {
|
||||
switch self {
|
||||
case .cashu(let link), .lightning(let link):
|
||||
return URL(string: link)
|
||||
}
|
||||
}
|
||||
|
||||
var emoji: String {
|
||||
switch self {
|
||||
case .cashu: "🥜"
|
||||
case .lightning: "⚡"
|
||||
}
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .cashu:
|
||||
String(localized: "content.payment.cashu", comment: "Label for Cashu payment chip")
|
||||
case .lightning:
|
||||
String(localized: "content.payment.lightning", comment: "Label for Lightning payment chip")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let paymentType: PaymentType
|
||||
|
||||
private var fgColor: Color {
|
||||
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||
}
|
||||
private var bgColor: Color {
|
||||
colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12)
|
||||
}
|
||||
private var border: Color { fgColor.opacity(0.25) }
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
#if os(iOS)
|
||||
if let url = paymentType.url { openURL(url) }
|
||||
#else
|
||||
if let url = paymentType.url { NSWorkspace.shared.open(url) }
|
||||
#endif
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Text(paymentType.emoji)
|
||||
Text(paymentType.label)
|
||||
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(bgColor)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(border, lineWidth: 1)
|
||||
)
|
||||
.foregroundColor(fgColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let cashuLink = "https://example.com/cashu"
|
||||
let lightningLink = "https://example.com/lightning"
|
||||
|
||||
List {
|
||||
HStack {
|
||||
PaymentChipView(paymentType: .cashu(cashuLink))
|
||||
PaymentChipView(paymentType: .lightning(lightningLink))
|
||||
}
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(EmptyView())
|
||||
}
|
||||
.environment(\.colorScheme, .light)
|
||||
|
||||
List {
|
||||
HStack {
|
||||
PaymentChipView(paymentType: .cashu(cashuLink))
|
||||
PaymentChipView(paymentType: .lightning(lightningLink))
|
||||
}
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(EmptyView())
|
||||
}
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//
|
||||
// TextMessageView.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct TextMessageView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
||||
@EnvironmentObject private var viewModel: ChatViewModel
|
||||
|
||||
let message: BitchatMessage
|
||||
@Binding var expandedMessageIDs: Set<String>
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Precompute heavy token scans once per row
|
||||
let cashuLinks = message.content.extractCashuLinks()
|
||||
let lightningLinks = message.content.extractLightningLinks()
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
|
||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
// Delivery status indicator for private messages
|
||||
if message.isPrivate && message.sender == viewModel.nickname,
|
||||
let status = message.deliveryStatus {
|
||||
DeliveryStatusView(status: status)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// Expand/Collapse for very long messages
|
||||
if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty {
|
||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||
let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more")
|
||||
Button(labelKey) {
|
||||
if isExpanded { expandedMessageIDs.remove(message.id) }
|
||||
else { expandedMessageIDs.insert(message.id) }
|
||||
}
|
||||
.font(.bitchatSystem(size: 11, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(Color.blue)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
// Render payment chips (Lightning / Cashu) with rounded background
|
||||
if !lightningLinks.isEmpty || !cashuLinks.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(lightningLinks, id: \.self) { link in
|
||||
PaymentChipView(paymentType: .lightning(link))
|
||||
}
|
||||
ForEach(cashuLinks, id: \.self) { link in
|
||||
PaymentChipView(paymentType: .cashu(link))
|
||||
}
|
||||
}
|
||||
.padding(.top, 6)
|
||||
.padding(.leading, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 14, iOS 17, *)
|
||||
#Preview {
|
||||
@Previewable @State var ids: Set<String> = []
|
||||
let keychain = PreviewKeychainManager()
|
||||
|
||||
Group {
|
||||
List {
|
||||
TextMessageView(message: .preview, expandedMessageIDs: $ids)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(EmptyView())
|
||||
}
|
||||
.environment(\.colorScheme, .light)
|
||||
|
||||
List {
|
||||
TextMessageView(message: .preview, expandedMessageIDs: $ids)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(EmptyView())
|
||||
}
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
.environmentObject(
|
||||
ChatViewModel(
|
||||
keychain: keychain,
|
||||
identityManager: SecureIdentityStateManager(keychain)
|
||||
)
|
||||
)
|
||||
}
|
||||
+280
-534
File diff suppressed because it is too large
Load Diff
@@ -32,19 +32,16 @@ struct FingerprintView: View {
|
||||
static let notVerifiedBadge: LocalizedStringKey = "fingerprint.badge.not_verified"
|
||||
static let verifiedMessage: LocalizedStringKey = "fingerprint.message.verified"
|
||||
static func verifyHint(_ nickname: String) -> String {
|
||||
L10n.format(
|
||||
"fingerprint.message.verify_hint",
|
||||
comment: "Instruction to compare fingerprints with a named peer",
|
||||
String(
|
||||
format: String(localized: "fingerprint.message.verify_hint", comment: "Instruction to compare fingerprints with a named peer"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
}
|
||||
static let markVerified: LocalizedStringKey = "fingerprint.action.mark_verified"
|
||||
static let removeVerification: LocalizedStringKey = "fingerprint.action.remove_verification"
|
||||
static func unknownPeer() -> String {
|
||||
L10n.string(
|
||||
"common.unknown",
|
||||
comment: "Label for an unknown peer"
|
||||
)
|
||||
String(localized: "common.unknown", comment: "Label for an unknown peer")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +72,7 @@ struct FingerprintView: View {
|
||||
// Resolve a friendly name
|
||||
let peerNickname: String = {
|
||||
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
|
||||
if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name }
|
||||
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: statusPeerID)) { return name }
|
||||
if peerID.count == 64, let data = Data(hexString: peerID) {
|
||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
|
||||
let fp = data.sha256Fingerprint()
|
||||
|
||||
@@ -11,10 +11,7 @@ struct GeohashPeopleList: View {
|
||||
private enum Strings {
|
||||
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
|
||||
static let youSuffix: LocalizedStringKey = "geohash_people.you_suffix"
|
||||
static let blockedTooltip = L10n.string(
|
||||
"geohash_people.tooltip.blocked",
|
||||
comment: "Tooltip shown next to users blocked in geohash channels"
|
||||
)
|
||||
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to users blocked in geohash channels")
|
||||
static let unblock: LocalizedStringKey = "geohash_people.action.unblock"
|
||||
static let block: LocalizedStringKey = "geohash_people.action.block"
|
||||
}
|
||||
@@ -64,7 +61,7 @@ struct GeohashPeopleList: View {
|
||||
let rowColor: Color = isMe ? .orange : assignedColor
|
||||
Image(systemName: icon).font(.bitchatSystem(size: 12)).foregroundColor(rowColor)
|
||||
|
||||
let (base, suffix) = splitSuffix(from: person.displayName)
|
||||
let (base, suffix) = person.displayName.splitSuffix()
|
||||
HStack(spacing: 0) {
|
||||
Text(base)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
@@ -129,16 +126,3 @@ struct GeohashPeopleList: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to split a trailing #abcd suffix
|
||||
private func splitSuffix(from name: String) -> (String, String) {
|
||||
guard name.count >= 5 else { return (name, "") }
|
||||
let suffix = String(name.suffix(5))
|
||||
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
|
||||
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
|
||||
}) {
|
||||
let base = String(name.dropLast(5))
|
||||
return (base, suffix)
|
||||
}
|
||||
return (name, "")
|
||||
}
|
||||
|
||||
@@ -32,16 +32,10 @@ struct LocationChannelsSheet: View {
|
||||
static let toggleOn: LocalizedStringKey = "common.toggle.on"
|
||||
static let toggleOff: LocalizedStringKey = "common.toggle.off"
|
||||
|
||||
static let invalidGeohash = L10n.string(
|
||||
"location_channels.error.invalid_geohash",
|
||||
comment: "Error shown when a custom geohash is invalid"
|
||||
)
|
||||
static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid")
|
||||
|
||||
static func meshTitle(_ count: Int) -> String {
|
||||
let label = L10n.string(
|
||||
"location_channels.mesh_label",
|
||||
comment: "Label for the mesh channel row"
|
||||
)
|
||||
let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row")
|
||||
return rowTitle(label: label, count: count)
|
||||
}
|
||||
|
||||
@@ -54,30 +48,28 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
|
||||
static func subtitlePrefix(geohash: String, coverage: String) -> String {
|
||||
L10n.format(
|
||||
"location_channels.subtitle_prefix",
|
||||
comment: "Subtitle prefix showing geohash and coverage",
|
||||
geohash,
|
||||
coverage
|
||||
String(
|
||||
format: String(localized: "location_channels.subtitle_prefix", comment: "Subtitle prefix showing geohash and coverage"),
|
||||
locale: .current,
|
||||
geohash, coverage
|
||||
)
|
||||
}
|
||||
|
||||
static func subtitle(prefix: String, name: String?) -> String {
|
||||
guard let name, !name.isEmpty else { return prefix }
|
||||
return L10n.format(
|
||||
"location_channels.subtitle_with_name",
|
||||
comment: "Subtitle combining prefix and resolved location name",
|
||||
prefix,
|
||||
name
|
||||
return String(
|
||||
format: String(localized: "location_channels.subtitle_with_name", comment: "Subtitle combining prefix and resolved location name"),
|
||||
locale: .current,
|
||||
prefix, name
|
||||
)
|
||||
}
|
||||
|
||||
private static func rowTitle(label: String, count: Int) -> String {
|
||||
let format = NSLocalizedString(
|
||||
"location_channels.row_title",
|
||||
comment: "List row title with participant count"
|
||||
String(
|
||||
format: String(localized: "location_channels.row_title", comment: "List row title with participant count"),
|
||||
locale: .current,
|
||||
label, count
|
||||
)
|
||||
return NSString.localizedStringWithFormat(format as NSString, label, count) as String
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,7 +263,7 @@ struct LocationChannelsSheet: View {
|
||||
private var customTeleportSection: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 2) {
|
||||
Text("#")
|
||||
Text(verbatim: "#")
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
TextField("geohash", text: $customGeohash)
|
||||
@@ -427,7 +419,7 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
Spacer()
|
||||
if isSelected {
|
||||
Text("✔︎")
|
||||
Text(verbatim: "✔︎")
|
||||
.font(.bitchatSystem(size: 16, design: .monospaced))
|
||||
.foregroundColor(standardGreen)
|
||||
}
|
||||
@@ -451,7 +443,7 @@ struct LocationChannelsSheet: View {
|
||||
// Count mesh-connected OR mesh-reachable peers (exclude self)
|
||||
let myID = viewModel.meshService.myPeerID
|
||||
return viewModel.allPeers.reduce(0) { acc, peer in
|
||||
if peer.id != myID && (peer.isConnected || peer.isReachable) { return acc + 1 }
|
||||
if peer.peerID != myID && (peer.isConnected || peer.isReachable) { return acc + 1 }
|
||||
return acc
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user