mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 23:45:20 +00:00
Compare commits
75
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d487e5bdb | ||
|
|
3d914dcf46 | ||
|
|
4acb2cf540 | ||
|
|
b3ec5eeda0 | ||
|
|
47d75ab9d8 | ||
|
|
3479c7d5df | ||
|
|
8a0727fcf7 | ||
|
|
6588861e34 | ||
|
|
a1647901e5 | ||
|
|
23249f3e41 | ||
|
|
ad4103bacc | ||
|
|
e3149fa098 | ||
|
|
987ba2e694 | ||
|
|
615273a63e | ||
|
|
4563c22d2d | ||
|
|
9f74266527 | ||
|
|
239064e4eb | ||
|
|
d371131ad5 | ||
|
|
d994ccf012 | ||
|
|
302c741d58 | ||
|
|
7ec84857eb | ||
|
|
68482c67d7 | ||
|
|
551a843691 | ||
|
|
fbc15ea08f | ||
|
|
0f23ed0a99 | ||
|
|
c583949031 | ||
|
|
d75700fa2b | ||
|
|
7149182c56 | ||
|
|
13b58aeaa9 | ||
|
|
b5b05977fb | ||
|
|
af6703cf24 | ||
|
|
eb13eec4c5 | ||
|
|
81c90b2924 | ||
|
|
8c368233c4 | ||
|
|
13ebaad42f | ||
|
|
10e3f574ab | ||
|
|
5f44c56a90 | ||
|
|
01ec4573f8 | ||
|
|
f86ae5e2ea | ||
|
|
2673d28686 | ||
|
|
03c357f048 | ||
|
|
64f91bb1d6 | ||
|
|
9ecda048e7 | ||
|
|
bdc31d3be3 | ||
|
|
6846b19d83 | ||
|
|
524a7e916b | ||
|
|
f2473f857b | ||
|
|
8cfee095d3 | ||
|
|
e4ec2ef3fe | ||
|
|
bd11940151 | ||
|
|
90273cee2d | ||
|
|
faae625e53 | ||
|
|
3a35b3acc2 | ||
|
|
787386c66d | ||
|
|
aeec15f054 | ||
|
|
f004f3e7ea | ||
|
|
8d938e8518 | ||
|
|
ea72212f66 | ||
|
|
3183703a63 | ||
|
|
2ffa709cca | ||
|
|
bf712a0610 | ||
|
|
78fb3f1bf6 | ||
|
|
f5af00be88 | ||
|
|
b2214e30f5 | ||
|
|
85063e9359 | ||
|
|
f67f728d79 | ||
|
|
b873b19104 | ||
|
|
0f7bcf17ff | ||
|
|
da2a12296e | ||
|
|
56bd100944 | ||
|
|
eb5bc96a3c | ||
|
|
d01538a2ea | ||
|
|
9abfab3248 | ||
|
|
0ae09f73d8 | ||
|
|
56dd12f3b1 |
@@ -24,4 +24,4 @@ jobs:
|
||||
run: swift build
|
||||
|
||||
- name: Run Tests
|
||||
run: swift test --parallel --disable-swift-testing # so it only runs xctests: https://github.com/swiftlang/swift-package-manager/issues/8529#issuecomment-2815711345
|
||||
run: swift test --parallel
|
||||
|
||||
+5
-4
@@ -9,9 +9,6 @@ plans/
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
|
||||
## User settings
|
||||
xcuserdata/
|
||||
|
||||
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
||||
*.xcscmblueprint
|
||||
*.xccheckout
|
||||
@@ -57,7 +54,8 @@ iOSInjectionProject/
|
||||
|
||||
## Xcode project
|
||||
*.xcodeproj/project.xcworkspace/
|
||||
*.xcodeproj/xcshareddata/
|
||||
## Xcode User settings
|
||||
xcuserdata/
|
||||
|
||||
## Python
|
||||
__pycache__/
|
||||
@@ -68,6 +66,9 @@ __pycache__/
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
## Cache
|
||||
.cache/
|
||||
|
||||
# Local build results
|
||||
.Result*/
|
||||
.Result*.xcresult/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
MARKETING_VERSION = 1.4.3
|
||||
MARKETING_VERSION = 1.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
+32
-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 */;
|
||||
@@ -315,6 +312,7 @@
|
||||
ne,
|
||||
"pt-BR",
|
||||
ru,
|
||||
tr,
|
||||
uk,
|
||||
"zh-Hans",
|
||||
);
|
||||
@@ -323,6 +321,7 @@
|
||||
packageReferences = (
|
||||
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
||||
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 90;
|
||||
projectDirPath = "";
|
||||
@@ -880,6 +879,10 @@
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = localPackages/BitLogger;
|
||||
};
|
||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = localPackages/Tor;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
@@ -907,6 +910,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>
|
||||
@@ -13,7 +13,6 @@
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "image-1024 1.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
@@ -25,7 +24,6 @@
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"filename" : "image-1024 2.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 11 KiB |
@@ -6,6 +6,7 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Tor
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
|
||||
@@ -25,11 +26,15 @@ struct BitchatApp: App {
|
||||
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
||||
#endif
|
||||
|
||||
private let idBridge = NostrIdentityBridge()
|
||||
|
||||
init() {
|
||||
let keychain = KeychainManager()
|
||||
let idBridge = self.idBridge
|
||||
_chatViewModel = StateObject(
|
||||
wrappedValue: ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: SecureIdentityStateManager(keychain)
|
||||
)
|
||||
)
|
||||
@@ -49,7 +54,7 @@ struct BitchatApp: App {
|
||||
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
||||
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
|
||||
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
||||
}
|
||||
#if os(iOS)
|
||||
@@ -216,7 +221,7 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
// Get peer ID from userInfo
|
||||
if let peerID = userInfo["peerID"] as? String {
|
||||
DispatchQueue.main.async {
|
||||
self.chatViewModel?.startPrivateChat(with: peerID)
|
||||
self.chatViewModel?.startPrivateChat(with: PeerID(str: peerID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,217 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
|
||||
/// Returns true if the `bare` id is all hex
|
||||
var isHex: Bool {
|
||||
bare.allSatisfy { $0.isHexDigit }
|
||||
}
|
||||
|
||||
/// Short routing IDs (exact 16-hex)
|
||||
var isShort: Bool {
|
||||
bare.count == Constants.hexIDLength && isHex
|
||||
}
|
||||
|
||||
/// Full Noise key hex (exact 64-hex)
|
||||
var isNoiseKeyHex: Bool {
|
||||
noiseKey != nil
|
||||
}
|
||||
|
||||
/// Full Noise key (exact 64-hex) as Data
|
||||
var noiseKey: Data? {
|
||||
guard bare.count == Constants.maxIDLength else { return nil }
|
||||
return Data(hexString: bare)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Comparable
|
||||
|
||||
extension PeerID: Comparable {
|
||||
static func < (lhs: PeerID, rhs: PeerID) -> Bool {
|
||||
lhs.id < rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 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) {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
//
|
||||
// NoiseRateLimiter.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
final class NoiseRateLimiter {
|
||||
private var handshakeTimestamps: [PeerID: [Date]] = [:]
|
||||
private var messageTimestamps: [PeerID: [Date]] = [:]
|
||||
|
||||
// Global rate limiting
|
||||
private var globalHandshakeTimestamps: [Date] = []
|
||||
private var globalMessageTimestamps: [Date] = []
|
||||
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
||||
|
||||
func allowHandshake(from peerID: PeerID) -> Bool {
|
||||
return queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
let oneMinuteAgo = now.addingTimeInterval(-60)
|
||||
|
||||
// Check global rate limit first
|
||||
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
|
||||
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
|
||||
SecureLogger.warning("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check per-peer rate limit
|
||||
var timestamps = handshakeTimestamps[peerID] ?? []
|
||||
timestamps = timestamps.filter { $0 > oneMinuteAgo }
|
||||
|
||||
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
|
||||
SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Record new handshake
|
||||
timestamps.append(now)
|
||||
handshakeTimestamps[peerID] = timestamps
|
||||
globalHandshakeTimestamps.append(now)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func allowMessage(from peerID: PeerID) -> Bool {
|
||||
return queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
let oneSecondAgo = now.addingTimeInterval(-1)
|
||||
|
||||
// Check global rate limit first
|
||||
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
|
||||
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
|
||||
SecureLogger.warning("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check per-peer rate limit
|
||||
var timestamps = messageTimestamps[peerID] ?? []
|
||||
timestamps = timestamps.filter { $0 > oneSecondAgo }
|
||||
|
||||
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
|
||||
SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Record new message
|
||||
timestamps.append(now)
|
||||
messageTimestamps[peerID] = timestamps
|
||||
globalMessageTimestamps.append(now)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func reset(for peerID: PeerID) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.handshakeTimestamps.removeValue(forKey: peerID)
|
||||
self.messageTimestamps.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func resetAll() {
|
||||
queue.async(flags: .barrier) {
|
||||
self.handshakeTimestamps.removeAll()
|
||||
self.messageTimestamps.removeAll()
|
||||
self.globalHandshakeTimestamps.removeAll()
|
||||
self.globalMessageTimestamps.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
//
|
||||
// NoiseSecurityConsiderations.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Security Constants
|
||||
|
||||
enum NoiseSecurityConstants {
|
||||
// Maximum message size to prevent memory exhaustion
|
||||
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
||||
|
||||
// Maximum handshake message size
|
||||
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
||||
|
||||
// Session timeout - sessions older than this should be renegotiated
|
||||
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
||||
|
||||
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
|
||||
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
|
||||
|
||||
// Handshake timeout - abandon incomplete handshakes
|
||||
static let handshakeTimeout: TimeInterval = 60 // 1 minute
|
||||
|
||||
// Maximum concurrent sessions per peer
|
||||
static let maxSessionsPerPeer = 3
|
||||
|
||||
// Rate limiting
|
||||
static let maxHandshakesPerMinute = 10
|
||||
static let maxMessagesPerSecond = 100
|
||||
|
||||
// Global rate limiting (across all peers)
|
||||
static let maxGlobalHandshakesPerMinute = 30
|
||||
static let maxGlobalMessagesPerSecond = 500
|
||||
}
|
||||
|
||||
// MARK: - Security Validations
|
||||
|
||||
struct NoiseSecurityValidator {
|
||||
|
||||
/// Validate message size
|
||||
static func validateMessageSize(_ data: Data) -> Bool {
|
||||
return data.count <= NoiseSecurityConstants.maxMessageSize
|
||||
}
|
||||
|
||||
/// Validate handshake message size
|
||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
||||
}
|
||||
|
||||
/// Validate peer ID format using unified validator
|
||||
static func validatePeerID(_ peerID: String) -> Bool {
|
||||
return InputValidator.validatePeerID(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Enhanced Noise Session with Security
|
||||
|
||||
final class SecureNoiseSession: NoiseSession {
|
||||
private(set) var messageCount: UInt64 = 0
|
||||
private let sessionStartTime = Date()
|
||||
private(set) var lastActivityTime = Date()
|
||||
|
||||
override func encrypt(_ plaintext: Data) throws -> Data {
|
||||
// Check session age
|
||||
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
||||
throw NoiseSecurityError.sessionExpired
|
||||
}
|
||||
|
||||
// Check message count
|
||||
if messageCount >= NoiseSecurityConstants.maxMessagesPerSession {
|
||||
throw NoiseSecurityError.sessionExhausted
|
||||
}
|
||||
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
let encrypted = try super.encrypt(plaintext)
|
||||
messageCount += 1
|
||||
lastActivityTime = Date()
|
||||
|
||||
return encrypted
|
||||
}
|
||||
|
||||
override func decrypt(_ ciphertext: Data) throws -> Data {
|
||||
// Check session age
|
||||
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
||||
throw NoiseSecurityError.sessionExpired
|
||||
}
|
||||
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
let decrypted = try super.decrypt(ciphertext)
|
||||
lastActivityTime = Date()
|
||||
|
||||
return decrypted
|
||||
}
|
||||
|
||||
func needsRenegotiation() -> Bool {
|
||||
// Check if we've used more than 90% of message limit
|
||||
let messageThreshold = UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
|
||||
if messageCount >= messageThreshold {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if last activity was more than 30 minutes ago
|
||||
if Date().timeIntervalSince(lastActivityTime) > NoiseSecurityConstants.sessionTimeout {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Testing Support
|
||||
#if DEBUG
|
||||
func setLastActivityTimeForTesting(_ date: Date) {
|
||||
lastActivityTime = date
|
||||
}
|
||||
|
||||
func setMessageCountForTesting(_ count: UInt64) {
|
||||
messageCount = count
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Rate Limiter
|
||||
|
||||
final class NoiseRateLimiter {
|
||||
private var handshakeTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
||||
private var messageTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
||||
|
||||
// Global rate limiting
|
||||
private var globalHandshakeTimestamps: [Date] = []
|
||||
private var globalMessageTimestamps: [Date] = []
|
||||
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
||||
|
||||
func allowHandshake(from peerID: String) -> Bool {
|
||||
return queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
let oneMinuteAgo = now.addingTimeInterval(-60)
|
||||
|
||||
// Check global rate limit first
|
||||
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
|
||||
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
|
||||
SecureLogger.warning("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check per-peer rate limit
|
||||
var timestamps = handshakeTimestamps[peerID] ?? []
|
||||
timestamps = timestamps.filter { $0 > oneMinuteAgo }
|
||||
|
||||
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
|
||||
SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Record new handshake
|
||||
timestamps.append(now)
|
||||
handshakeTimestamps[peerID] = timestamps
|
||||
globalHandshakeTimestamps.append(now)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func allowMessage(from peerID: String) -> Bool {
|
||||
return queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
let oneSecondAgo = now.addingTimeInterval(-1)
|
||||
|
||||
// Check global rate limit first
|
||||
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
|
||||
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
|
||||
SecureLogger.warning("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check per-peer rate limit
|
||||
var timestamps = messageTimestamps[peerID] ?? []
|
||||
timestamps = timestamps.filter { $0 > oneSecondAgo }
|
||||
|
||||
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
|
||||
SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Record new message
|
||||
timestamps.append(now)
|
||||
messageTimestamps[peerID] = timestamps
|
||||
globalMessageTimestamps.append(now)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func reset(for peerID: String) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.handshakeTimestamps.removeValue(forKey: peerID)
|
||||
self.messageTimestamps.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func resetAll() {
|
||||
queue.async(flags: .barrier) {
|
||||
self.handshakeTimestamps.removeAll()
|
||||
self.messageTimestamps.removeAll()
|
||||
self.globalHandshakeTimestamps.removeAll()
|
||||
self.globalMessageTimestamps.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Security Errors
|
||||
|
||||
enum NoiseSecurityError: Error {
|
||||
case sessionExpired
|
||||
case sessionExhausted
|
||||
case messageTooLarge
|
||||
case invalidPeerID
|
||||
case rateLimitExceeded
|
||||
case handshakeTimeout
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// NoiseSecurityConstants.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum NoiseSecurityConstants {
|
||||
// Maximum message size to prevent memory exhaustion
|
||||
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
||||
|
||||
// Maximum handshake message size
|
||||
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
||||
|
||||
// Session timeout - sessions older than this should be renegotiated
|
||||
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
||||
|
||||
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
|
||||
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
|
||||
|
||||
// Handshake timeout - abandon incomplete handshakes
|
||||
static let handshakeTimeout: TimeInterval = 60 // 1 minute
|
||||
|
||||
// Maximum concurrent sessions per peer
|
||||
static let maxSessionsPerPeer = 3
|
||||
|
||||
// Rate limiting
|
||||
static let maxHandshakesPerMinute = 10
|
||||
static let maxMessagesPerSecond = 100
|
||||
|
||||
// Global rate limiting (across all peers)
|
||||
static let maxGlobalHandshakesPerMinute = 30
|
||||
static let maxGlobalMessagesPerSecond = 500
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// NoiseSecurityError.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum NoiseSecurityError: Error {
|
||||
case sessionExpired
|
||||
case sessionExhausted
|
||||
case messageTooLarge
|
||||
case invalidPeerID
|
||||
case rateLimitExceeded
|
||||
case handshakeTimeout
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// NoiseSecurityValidator.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct NoiseSecurityValidator {
|
||||
|
||||
/// Validate message size
|
||||
static func validateMessageSize(_ data: Data) -> Bool {
|
||||
return data.count <= NoiseSecurityConstants.maxMessageSize
|
||||
}
|
||||
|
||||
/// Validate handshake message size
|
||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
||||
}
|
||||
}
|
||||
@@ -10,32 +10,8 @@ import BitLogger
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Noise Session State
|
||||
|
||||
enum NoiseSessionState: Equatable {
|
||||
case uninitialized
|
||||
case handshaking
|
||||
case established
|
||||
case failed(Error)
|
||||
|
||||
static func == (lhs: NoiseSessionState, rhs: NoiseSessionState) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.uninitialized, .uninitialized),
|
||||
(.handshaking, .handshaking),
|
||||
(.established, .established):
|
||||
return true
|
||||
case (.failed, .failed):
|
||||
return true // We don't compare the errors
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Noise Session
|
||||
|
||||
class NoiseSession {
|
||||
let peerID: String
|
||||
let peerID: PeerID
|
||||
let role: NoiseRole
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private var state: NoiseSessionState = .uninitialized
|
||||
@@ -55,7 +31,7 @@ class NoiseSession {
|
||||
private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent)
|
||||
|
||||
init(
|
||||
peerID: String,
|
||||
peerID: PeerID,
|
||||
role: NoiseRole,
|
||||
keychain: KeychainManagerProtocol,
|
||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
||||
@@ -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
|
||||
@@ -220,12 +196,6 @@ class NoiseSession {
|
||||
}
|
||||
}
|
||||
|
||||
func getHandshakeHash() -> Data? {
|
||||
return sessionQueue.sync {
|
||||
return handshakeHash
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
sessionQueue.sync(flags: .barrier) {
|
||||
let wasEstablished = state == .established
|
||||
@@ -252,249 +222,8 @@ class NoiseSession {
|
||||
handshakeHash = nil
|
||||
|
||||
if wasEstablished {
|
||||
SecureLogger.info(.sessionExpired(peerID: peerID))
|
||||
SecureLogger.info(.sessionExpired(peerID: peerID.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session Manager
|
||||
|
||||
final class NoiseSessionManager {
|
||||
private var sessions: [String: NoiseSession] = [:]
|
||||
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||
|
||||
// Callbacks
|
||||
var onSessionEstablished: ((String, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
||||
var onSessionFailed: ((String, Error) -> Void)?
|
||||
|
||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||
self.localStaticKey = localStaticKey
|
||||
self.keychain = keychain
|
||||
}
|
||||
|
||||
// MARK: - Session Management
|
||||
|
||||
func createSession(for peerID: String, role: NoiseRole) -> NoiseSession {
|
||||
return managerQueue.sync(flags: .barrier) {
|
||||
let session = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: role,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = session
|
||||
return session
|
||||
}
|
||||
}
|
||||
|
||||
func getSession(for peerID: String) -> NoiseSession? {
|
||||
return managerQueue.sync {
|
||||
return sessions[peerID]
|
||||
}
|
||||
}
|
||||
|
||||
func removeSession(for peerID: String) {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
if let session = sessions[peerID] {
|
||||
if session.isEstablished() {
|
||||
SecureLogger.info(.sessionExpired(peerID: peerID))
|
||||
}
|
||||
// Clear sensitive data before removing
|
||||
session.reset()
|
||||
}
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func removeAllSessions() {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
for (_, session) in sessions {
|
||||
session.reset()
|
||||
}
|
||||
sessions.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
func getEstablishedSessions() -> [String: NoiseSession] {
|
||||
return managerQueue.sync {
|
||||
return sessions.filter { $0.value.isEstablished() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Handshake Helpers
|
||||
|
||||
func initiateHandshake(with peerID: String) throws -> Data {
|
||||
return try managerQueue.sync(flags: .barrier) {
|
||||
// Check if we already have an established session
|
||||
if let existingSession = sessions[peerID], existingSession.isEstablished() {
|
||||
// Session already established, don't recreate
|
||||
throw NoiseSessionError.alreadyEstablished
|
||||
}
|
||||
|
||||
// Remove any existing non-established session
|
||||
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
}
|
||||
|
||||
// Create new initiator session
|
||||
let session = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: .initiator,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = session
|
||||
|
||||
do {
|
||||
let handshakeData = try session.startHandshake()
|
||||
return handshakeData
|
||||
} catch {
|
||||
// Clean up failed session
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
SecureLogger.error(.handshakeFailed(peerID: peerID, error: error.localizedDescription))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleIncomingHandshake(from peerID: String, message: Data) throws -> Data? {
|
||||
// Process everything within the synchronized block to prevent race conditions
|
||||
return try managerQueue.sync(flags: .barrier) {
|
||||
var shouldCreateNew = false
|
||||
var existingSession: NoiseSession? = nil
|
||||
|
||||
if let existing = sessions[peerID] {
|
||||
// If we have an established session, the peer must have cleared their session
|
||||
// for a good reason (e.g., decryption failure, restart, etc.)
|
||||
// We should accept the new handshake to re-establish encryption
|
||||
if existing.isEstablished() {
|
||||
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
shouldCreateNew = true
|
||||
} else {
|
||||
// If we're in the middle of a handshake and receive a new initiation,
|
||||
// reset and start fresh (the other side may have restarted)
|
||||
if existing.getState() == .handshaking && message.count == 32 {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
shouldCreateNew = true
|
||||
} else {
|
||||
existingSession = existing
|
||||
}
|
||||
}
|
||||
} else {
|
||||
shouldCreateNew = true
|
||||
}
|
||||
|
||||
// Get or create session
|
||||
let session: NoiseSession
|
||||
if shouldCreateNew {
|
||||
let newSession = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: .responder,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = newSession
|
||||
session = newSession
|
||||
} else {
|
||||
session = existingSession!
|
||||
}
|
||||
|
||||
// Process the handshake message within the synchronized block
|
||||
do {
|
||||
let response = try session.processHandshakeMessage(message)
|
||||
|
||||
// Check if session is established after processing
|
||||
if session.isEstablished() {
|
||||
if let remoteKey = session.getRemoteStaticPublicKey() {
|
||||
// Schedule callback outside the synchronized block to prevent deadlock
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.onSessionEstablished?(peerID, remoteKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
} catch {
|
||||
// Reset the session on handshake failure so next attempt can start fresh
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
|
||||
// Schedule callback outside the synchronized block to prevent deadlock
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.onSessionFailed?(peerID, error)
|
||||
}
|
||||
|
||||
SecureLogger.error(.handshakeFailed(peerID: peerID, error: error.localizedDescription))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Encryption/Decryption
|
||||
|
||||
func encrypt(_ plaintext: Data, for peerID: String) throws -> Data {
|
||||
guard let session = getSession(for: peerID) else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
}
|
||||
|
||||
return try session.encrypt(plaintext)
|
||||
}
|
||||
|
||||
func decrypt(_ ciphertext: Data, from peerID: String) throws -> Data {
|
||||
guard let session = getSession(for: peerID) else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
}
|
||||
|
||||
return try session.decrypt(ciphertext)
|
||||
}
|
||||
|
||||
// MARK: - Key Management
|
||||
|
||||
func getRemoteStaticKey(for peerID: String) -> Curve25519.KeyAgreement.PublicKey? {
|
||||
return getSession(for: peerID)?.getRemoteStaticPublicKey()
|
||||
}
|
||||
|
||||
func getHandshakeHash(for peerID: String) -> Data? {
|
||||
return getSession(for: peerID)?.getHandshakeHash()
|
||||
}
|
||||
|
||||
// MARK: - Session Rekeying
|
||||
|
||||
func getSessionsNeedingRekey() -> [(peerID: String, needsRekey: Bool)] {
|
||||
return managerQueue.sync {
|
||||
var needingRekey: [(peerID: String, needsRekey: Bool)] = []
|
||||
|
||||
for (peerID, session) in sessions {
|
||||
if let secureSession = session as? SecureNoiseSession,
|
||||
secureSession.isEstablished(),
|
||||
secureSession.needsRenegotiation() {
|
||||
needingRekey.append((peerID: peerID, needsRekey: true))
|
||||
}
|
||||
}
|
||||
|
||||
return needingRekey
|
||||
}
|
||||
}
|
||||
|
||||
func initiateRekey(for peerID: String) throws {
|
||||
// Remove old session
|
||||
removeSession(for: peerID)
|
||||
|
||||
// Initiate new handshake
|
||||
_ = try initiateHandshake(with: peerID)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum NoiseSessionError: Error {
|
||||
case invalidState
|
||||
case notEstablished
|
||||
case sessionNotFound
|
||||
case handshakeFailed(Error)
|
||||
case alreadyEstablished
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// NoiseSessionError.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
enum NoiseSessionError: Error, Equatable {
|
||||
case invalidState
|
||||
case notEstablished
|
||||
case sessionNotFound
|
||||
case alreadyEstablished
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
//
|
||||
// NoiseSessionManager.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
final class NoiseSessionManager {
|
||||
private var sessions: [PeerID: NoiseSession] = [:]
|
||||
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||
|
||||
// Callbacks
|
||||
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
||||
var onSessionFailed: ((PeerID, Error) -> Void)?
|
||||
|
||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||
self.localStaticKey = localStaticKey
|
||||
self.keychain = keychain
|
||||
}
|
||||
|
||||
// MARK: - Session Management
|
||||
|
||||
func getSession(for peerID: PeerID) -> NoiseSession? {
|
||||
return managerQueue.sync {
|
||||
return sessions[peerID]
|
||||
}
|
||||
}
|
||||
|
||||
func removeSession(for peerID: PeerID) {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
if let session = sessions.removeValue(forKey: peerID) {
|
||||
session.reset() // Clear sensitive data before removing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeAllSessions() {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
for (_, session) in sessions {
|
||||
session.reset()
|
||||
}
|
||||
sessions.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Handshake Helpers
|
||||
|
||||
func initiateHandshake(with peerID: PeerID) throws -> Data {
|
||||
return try managerQueue.sync(flags: .barrier) {
|
||||
// Check if we already have an established session
|
||||
if let existingSession = sessions[peerID], existingSession.isEstablished() {
|
||||
// Session already established, don't recreate
|
||||
throw NoiseSessionError.alreadyEstablished
|
||||
}
|
||||
|
||||
let session: NoiseSession
|
||||
|
||||
if let existingSession = sessions[peerID] {
|
||||
session = existingSession
|
||||
session.reset()
|
||||
} else {
|
||||
// Create new initiator session
|
||||
session = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: .initiator,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = session
|
||||
}
|
||||
|
||||
do {
|
||||
return try session.startHandshake()
|
||||
} catch {
|
||||
// Clean up failed session
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
||||
// Process everything within the synchronized block to prevent race conditions
|
||||
return try managerQueue.sync(flags: .barrier) {
|
||||
var shouldCreateNew = false
|
||||
var existingSession: NoiseSession? = nil
|
||||
|
||||
if let existing = sessions[peerID] {
|
||||
// If we have an established session, the peer must have cleared their session
|
||||
// for a good reason (e.g., decryption failure, restart, etc.)
|
||||
// We should accept the new handshake to re-establish encryption
|
||||
if existing.isEstablished() {
|
||||
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
shouldCreateNew = true
|
||||
} else {
|
||||
// If we're in the middle of a handshake and receive a new initiation,
|
||||
// reset and start fresh (the other side may have restarted)
|
||||
if existing.getState() == .handshaking && message.count == 32 {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
shouldCreateNew = true
|
||||
} else {
|
||||
existingSession = existing
|
||||
}
|
||||
}
|
||||
} else {
|
||||
shouldCreateNew = true
|
||||
}
|
||||
|
||||
// Get or create session
|
||||
let session: NoiseSession
|
||||
if shouldCreateNew {
|
||||
let newSession = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: .responder,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = newSession
|
||||
session = newSession
|
||||
} else {
|
||||
session = existingSession!
|
||||
}
|
||||
|
||||
// Process the handshake message within the synchronized block
|
||||
do {
|
||||
let response = try session.processHandshakeMessage(message)
|
||||
|
||||
// Check if session is established after processing
|
||||
if session.isEstablished() {
|
||||
if let remoteKey = session.getRemoteStaticPublicKey() {
|
||||
// Schedule callback outside the synchronized block to prevent deadlock
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.onSessionEstablished?(peerID, remoteKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
} catch {
|
||||
// Reset the session on handshake failure so next attempt can start fresh
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
|
||||
// Schedule callback outside the synchronized block to prevent deadlock
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.onSessionFailed?(peerID, error)
|
||||
}
|
||||
|
||||
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Encryption/Decryption
|
||||
|
||||
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
|
||||
guard let session = getSession(for: peerID) else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
}
|
||||
|
||||
return try session.encrypt(plaintext)
|
||||
}
|
||||
|
||||
func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
|
||||
guard let session = getSession(for: peerID) else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
}
|
||||
|
||||
return try session.decrypt(ciphertext)
|
||||
}
|
||||
|
||||
// MARK: - Key Management
|
||||
|
||||
func getRemoteStaticKey(for peerID: PeerID) -> Curve25519.KeyAgreement.PublicKey? {
|
||||
return getSession(for: peerID)?.getRemoteStaticPublicKey()
|
||||
}
|
||||
|
||||
// MARK: - Session Rekeying
|
||||
|
||||
func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
|
||||
return managerQueue.sync {
|
||||
var needingRekey: [(peerID: PeerID, needsRekey: Bool)] = []
|
||||
|
||||
for (peerID, session) in sessions {
|
||||
if let secureSession = session as? SecureNoiseSession,
|
||||
secureSession.isEstablished(),
|
||||
secureSession.needsRenegotiation() {
|
||||
needingRekey.append((peerID: peerID, needsRekey: true))
|
||||
}
|
||||
}
|
||||
|
||||
return needingRekey
|
||||
}
|
||||
}
|
||||
|
||||
func initiateRekey(for peerID: PeerID) throws {
|
||||
// Remove old session
|
||||
removeSession(for: peerID)
|
||||
|
||||
// Initiate new handshake
|
||||
_ = try initiateHandshake(with: peerID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//
|
||||
// NoiseSessionState.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
enum NoiseSessionState: Equatable {
|
||||
case uninitialized
|
||||
case handshaking
|
||||
case established
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//
|
||||
// SecureNoiseSession.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
final class SecureNoiseSession: NoiseSession {
|
||||
private(set) var messageCount: UInt64 = 0
|
||||
private let sessionStartTime = Date()
|
||||
private(set) var lastActivityTime = Date()
|
||||
|
||||
override func encrypt(_ plaintext: Data) throws -> Data {
|
||||
// Check session age
|
||||
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
||||
throw NoiseSecurityError.sessionExpired
|
||||
}
|
||||
|
||||
// Check message count
|
||||
if messageCount >= NoiseSecurityConstants.maxMessagesPerSession {
|
||||
throw NoiseSecurityError.sessionExhausted
|
||||
}
|
||||
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
let encrypted = try super.encrypt(plaintext)
|
||||
messageCount += 1
|
||||
lastActivityTime = Date()
|
||||
|
||||
return encrypted
|
||||
}
|
||||
|
||||
override func decrypt(_ ciphertext: Data) throws -> Data {
|
||||
// Check session age
|
||||
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
||||
throw NoiseSecurityError.sessionExpired
|
||||
}
|
||||
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
let decrypted = try super.decrypt(ciphertext)
|
||||
lastActivityTime = Date()
|
||||
|
||||
return decrypted
|
||||
}
|
||||
|
||||
func needsRenegotiation() -> Bool {
|
||||
// Check if we've used more than 90% of message limit
|
||||
let messageThreshold = UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
|
||||
if messageCount >= messageThreshold {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if last activity was more than 30 minutes ago
|
||||
if Date().timeIntervalSince(lastActivityTime) > NoiseSecurityConstants.sessionTimeout {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Testing Support
|
||||
#if DEBUG
|
||||
func setLastActivityTimeForTesting(_ date: Date) {
|
||||
lastActivityTime = date
|
||||
}
|
||||
|
||||
func setMessageCountForTesting(_ count: UInt64) {
|
||||
messageCount = count
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import Foundation
|
||||
|
||||
/// Bech32 encoding for Nostr (minimal implementation)
|
||||
enum Bech32 {
|
||||
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
||||
|
||||
static func encode(hrp: String, data: Data) throws -> String {
|
||||
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
|
||||
let checksum = createChecksum(hrp: hrp, values: values)
|
||||
let combined = values + checksum
|
||||
|
||||
return hrp + "1" + combined.map {
|
||||
let index = charset.index(charset.startIndex, offsetBy: Int($0))
|
||||
return String(charset[index])
|
||||
}.joined()
|
||||
}
|
||||
|
||||
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
|
||||
// Find the last occurrence of '1'
|
||||
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
|
||||
throw Bech32Error.invalidFormat
|
||||
}
|
||||
|
||||
let hrp = String(bech32String[..<separatorIndex])
|
||||
|
||||
// Validate HRP contains only ASCII characters
|
||||
for char in hrp {
|
||||
guard char.asciiValue != nil else {
|
||||
throw Bech32Error.invalidCharacter
|
||||
}
|
||||
}
|
||||
|
||||
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
|
||||
|
||||
// Convert characters to values
|
||||
var values = [UInt8]()
|
||||
for char in dataString {
|
||||
guard let index = charset.firstIndex(of: char) else {
|
||||
throw Bech32Error.invalidCharacter
|
||||
}
|
||||
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
|
||||
}
|
||||
|
||||
// Verify checksum
|
||||
guard values.count >= 6 else {
|
||||
throw Bech32Error.invalidChecksum
|
||||
}
|
||||
|
||||
let payloadValues = Array(values.dropLast(6))
|
||||
let checksum = Array(values.suffix(6))
|
||||
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
|
||||
|
||||
guard checksum == expectedChecksum else {
|
||||
throw Bech32Error.invalidChecksum
|
||||
}
|
||||
|
||||
// Convert back to bytes
|
||||
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
|
||||
return (hrp: hrp, data: Data(bytes))
|
||||
}
|
||||
|
||||
enum Bech32Error: Error {
|
||||
case invalidFormat
|
||||
case invalidCharacter
|
||||
case invalidChecksum
|
||||
}
|
||||
|
||||
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
|
||||
var acc = 0
|
||||
var bits = 0
|
||||
var result = [UInt8]()
|
||||
let maxv = (1 << to) - 1
|
||||
|
||||
for value in data {
|
||||
acc = (acc << from) | Int(value)
|
||||
bits += from
|
||||
|
||||
while bits >= to {
|
||||
bits -= to
|
||||
result.append(UInt8((acc >> bits) & maxv))
|
||||
}
|
||||
}
|
||||
|
||||
if pad && bits > 0 {
|
||||
result.append(UInt8((acc << (to - bits)) & maxv))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
|
||||
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
|
||||
let polymod = polymod(checksumValues) ^ 1
|
||||
var checksum = [UInt8]()
|
||||
|
||||
for i in 0..<6 {
|
||||
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
|
||||
}
|
||||
|
||||
return checksum
|
||||
}
|
||||
|
||||
private static func hrpExpand(_ hrp: String) -> [UInt8] {
|
||||
var result = [UInt8]()
|
||||
for c in hrp {
|
||||
guard let asciiValue = c.asciiValue else {
|
||||
return [] // Return empty array for invalid input
|
||||
}
|
||||
result.append(UInt8(asciiValue >> 5))
|
||||
}
|
||||
result.append(0)
|
||||
for c in hrp {
|
||||
guard let asciiValue = c.asciiValue else {
|
||||
return [] // Return empty array for invalid input
|
||||
}
|
||||
result.append(UInt8(asciiValue & 31))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func polymod(_ values: [UInt8]) -> Int {
|
||||
var chk = 1
|
||||
for value in values {
|
||||
let b = chk >> 25
|
||||
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
|
||||
for i in 0..<5 {
|
||||
if (b >> i) & 1 == 1 {
|
||||
chk ^= generator[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return chk
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import Tor
|
||||
|
||||
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
||||
@MainActor
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
|
||||
protocol KeychainHelperProtocol {
|
||||
func save(key: String, data: Data, service: String, accessible: CFString?)
|
||||
func load(key: String, service: String) -> Data?
|
||||
func delete(key: String, service: String)
|
||||
}
|
||||
|
||||
/// Keychain helper for secure storage
|
||||
struct KeychainHelper: KeychainHelperProtocol {
|
||||
func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data
|
||||
]
|
||||
if let accessible = accessible {
|
||||
query[kSecAttrAccessible as String] = accessible
|
||||
}
|
||||
|
||||
SecItemDelete(query as CFDictionary)
|
||||
SecItemAdd(query as CFDictionary, nil)
|
||||
}
|
||||
|
||||
func load(key: String, service: String) -> Data? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecReturnData as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status == errSecSuccess else { return nil }
|
||||
return result as? Data
|
||||
}
|
||||
|
||||
func delete(key: String, service: String) {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key
|
||||
]
|
||||
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,50 +1,5 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import P256K
|
||||
import Security
|
||||
|
||||
// Keychain helper for secure storage
|
||||
struct KeychainHelper {
|
||||
static func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data
|
||||
]
|
||||
if let accessible = accessible {
|
||||
query[kSecAttrAccessible as String] = accessible
|
||||
}
|
||||
|
||||
SecItemDelete(query as CFDictionary)
|
||||
SecItemAdd(query as CFDictionary, nil)
|
||||
}
|
||||
|
||||
static func load(key: String, service: String) -> Data? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecReturnData as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status == errSecSuccess else { return nil }
|
||||
return result as? Data
|
||||
}
|
||||
|
||||
static func delete(key: String, service: String) {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key
|
||||
]
|
||||
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
|
||||
struct NostrIdentity: Codable {
|
||||
@@ -103,269 +58,3 @@ struct NostrIdentity: Codable {
|
||||
return publicKey.hexEncodedString()
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridge between Noise and Nostr identities
|
||||
struct NostrIdentityBridge {
|
||||
private static let keychainService = "chat.bitchat.nostr"
|
||||
private static let currentIdentityKey = "nostr-current-identity"
|
||||
private static let deviceSeedKey = "nostr-device-seed"
|
||||
// In-memory cache to avoid transient keychain access issues
|
||||
private static var deviceSeedCache: Data?
|
||||
|
||||
/// Get or create the current Nostr identity
|
||||
static func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
||||
// Check if we already have a Nostr identity
|
||||
if let existingData = KeychainHelper.load(key: currentIdentityKey, service: keychainService),
|
||||
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
|
||||
return identity
|
||||
}
|
||||
|
||||
// Generate new Nostr identity
|
||||
let nostrIdentity = try NostrIdentity.generate()
|
||||
|
||||
// Store it
|
||||
let data = try JSONEncoder().encode(nostrIdentity)
|
||||
KeychainHelper.save(key: currentIdentityKey, data: data, service: keychainService)
|
||||
|
||||
return nostrIdentity
|
||||
}
|
||||
|
||||
/// Associate a Nostr identity with a Noise public key (for favorites)
|
||||
static func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||
if let data = nostrPubkey.data(using: .utf8) {
|
||||
KeychainHelper.save(key: key, data: data, service: keychainService)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get Nostr public key associated with a Noise public key
|
||||
static func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||
guard let data = KeychainHelper.load(key: key, service: keychainService),
|
||||
let pubkey = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return pubkey
|
||||
}
|
||||
|
||||
/// Clear all Nostr identity associations and current identity
|
||||
static func clearAllAssociations() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService,
|
||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||
kSecReturnAttributes as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
||||
for item in items {
|
||||
var deleteQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService
|
||||
]
|
||||
if let account = item[kSecAttrAccount as String] as? String {
|
||||
deleteQuery[kSecAttrAccount as String] = account
|
||||
}
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
}
|
||||
} else if status == errSecItemNotFound {
|
||||
// nothing persisted; no action needed
|
||||
}
|
||||
|
||||
deviceSeedCache = nil
|
||||
}
|
||||
|
||||
// MARK: - Per-Geohash Identities (Location Channels)
|
||||
|
||||
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
|
||||
/// Stored only on device keychain.
|
||||
private static func getOrCreateDeviceSeed() -> Data {
|
||||
if let cached = deviceSeedCache { return cached }
|
||||
if let existing = KeychainHelper.load(key: deviceSeedKey, service: keychainService) {
|
||||
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
|
||||
KeychainHelper.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||
deviceSeedCache = existing
|
||||
return existing
|
||||
}
|
||||
var seed = Data(count: 32)
|
||||
_ = seed.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||
}
|
||||
// Ensure availability after first unlock to prevent unintended rotation when locked
|
||||
KeychainHelper.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||
deviceSeedCache = seed
|
||||
return seed
|
||||
}
|
||||
|
||||
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
||||
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
||||
/// if the candidate is not a valid secp256k1 private key.
|
||||
static func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
||||
let seed = getOrCreateDeviceSeed()
|
||||
guard let msg = geohash.data(using: .utf8) else {
|
||||
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
|
||||
}
|
||||
|
||||
func candidateKey(iteration: UInt32) -> Data {
|
||||
var input = Data(msg)
|
||||
var iterBE = iteration.bigEndian
|
||||
withUnsafeBytes(of: &iterBE) { bytes in
|
||||
input.append(contentsOf: bytes)
|
||||
}
|
||||
let code = CryptoKit.HMAC<CryptoKit.SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
|
||||
return Data(code)
|
||||
}
|
||||
|
||||
// Try a few iterations to ensure a valid key can be formed
|
||||
for i in 0..<10 {
|
||||
let keyData = candidateKey(iteration: UInt32(i))
|
||||
if let identity = try? NostrIdentity(privateKeyData: keyData) {
|
||||
return identity
|
||||
}
|
||||
}
|
||||
// As a final fallback, hash the seed+msg and try again
|
||||
var combined = Data()
|
||||
combined.append(seed)
|
||||
combined.append(msg)
|
||||
let fallback = Data(CryptoKit.SHA256.hash(data: combined))
|
||||
return try NostrIdentity(privateKeyData: fallback)
|
||||
}
|
||||
}
|
||||
|
||||
// Bech32 encoding for Nostr (minimal implementation)
|
||||
enum Bech32 {
|
||||
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
||||
|
||||
static func encode(hrp: String, data: Data) throws -> String {
|
||||
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
|
||||
let checksum = createChecksum(hrp: hrp, values: values)
|
||||
let combined = values + checksum
|
||||
|
||||
return hrp + "1" + combined.map {
|
||||
let index = charset.index(charset.startIndex, offsetBy: Int($0))
|
||||
return String(charset[index])
|
||||
}.joined()
|
||||
}
|
||||
|
||||
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
|
||||
// Find the last occurrence of '1'
|
||||
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
|
||||
throw Bech32Error.invalidFormat
|
||||
}
|
||||
|
||||
let hrp = String(bech32String[..<separatorIndex])
|
||||
|
||||
// Validate HRP contains only ASCII characters
|
||||
for char in hrp {
|
||||
guard char.asciiValue != nil else {
|
||||
throw Bech32Error.invalidCharacter
|
||||
}
|
||||
}
|
||||
|
||||
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
|
||||
|
||||
// Convert characters to values
|
||||
var values = [UInt8]()
|
||||
for char in dataString {
|
||||
guard let index = charset.firstIndex(of: char) else {
|
||||
throw Bech32Error.invalidCharacter
|
||||
}
|
||||
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
|
||||
}
|
||||
|
||||
// Verify checksum
|
||||
guard values.count >= 6 else {
|
||||
throw Bech32Error.invalidChecksum
|
||||
}
|
||||
|
||||
let payloadValues = Array(values.dropLast(6))
|
||||
let checksum = Array(values.suffix(6))
|
||||
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
|
||||
|
||||
guard checksum == expectedChecksum else {
|
||||
throw Bech32Error.invalidChecksum
|
||||
}
|
||||
|
||||
// Convert back to bytes
|
||||
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
|
||||
return (hrp: hrp, data: Data(bytes))
|
||||
}
|
||||
|
||||
enum Bech32Error: Error {
|
||||
case invalidFormat
|
||||
case invalidCharacter
|
||||
case invalidChecksum
|
||||
}
|
||||
|
||||
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
|
||||
var acc = 0
|
||||
var bits = 0
|
||||
var result = [UInt8]()
|
||||
let maxv = (1 << to) - 1
|
||||
|
||||
for value in data {
|
||||
acc = (acc << from) | Int(value)
|
||||
bits += from
|
||||
|
||||
while bits >= to {
|
||||
bits -= to
|
||||
result.append(UInt8((acc >> bits) & maxv))
|
||||
}
|
||||
}
|
||||
|
||||
if pad && bits > 0 {
|
||||
result.append(UInt8((acc << (to - bits)) & maxv))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
|
||||
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
|
||||
let polymod = polymod(checksumValues) ^ 1
|
||||
var checksum = [UInt8]()
|
||||
|
||||
for i in 0..<6 {
|
||||
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
|
||||
}
|
||||
|
||||
return checksum
|
||||
}
|
||||
|
||||
private static func hrpExpand(_ hrp: String) -> [UInt8] {
|
||||
var result = [UInt8]()
|
||||
for c in hrp {
|
||||
guard let asciiValue = c.asciiValue else {
|
||||
return [] // Return empty array for invalid input
|
||||
}
|
||||
result.append(UInt8(asciiValue >> 5))
|
||||
}
|
||||
result.append(0)
|
||||
for c in hrp {
|
||||
guard let asciiValue = c.asciiValue else {
|
||||
return [] // Return empty array for invalid input
|
||||
}
|
||||
result.append(UInt8(asciiValue & 31))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func polymod(_ values: [UInt8]) -> Int {
|
||||
var chk = 1
|
||||
for value in values {
|
||||
let b = chk >> 25
|
||||
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
|
||||
for i in 0..<5 {
|
||||
if (b >> i) & 1 == 1 {
|
||||
chk ^= generator[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return chk
|
||||
}
|
||||
}
|
||||
|
||||
// Data hex encoding extension moved to BinaryEncodingUtils.swift to avoid duplication
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Bridge between Noise and Nostr identities
|
||||
final class NostrIdentityBridge {
|
||||
private let keychainService = "chat.bitchat.nostr"
|
||||
private let currentIdentityKey = "nostr-current-identity"
|
||||
private let deviceSeedKey = "nostr-device-seed"
|
||||
// In-memory cache to avoid transient keychain access issues
|
||||
private var deviceSeedCache: Data?
|
||||
|
||||
private let keychain: KeychainHelperProtocol
|
||||
|
||||
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
|
||||
self.keychain = keychain
|
||||
}
|
||||
|
||||
/// Get or create the current Nostr identity
|
||||
func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
||||
// Check if we already have a Nostr identity
|
||||
if let existingData = keychain.load(key: currentIdentityKey, service: keychainService),
|
||||
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
|
||||
return identity
|
||||
}
|
||||
|
||||
// Generate new Nostr identity
|
||||
let nostrIdentity = try NostrIdentity.generate()
|
||||
|
||||
// Store it
|
||||
let data = try JSONEncoder().encode(nostrIdentity)
|
||||
keychain.save(key: currentIdentityKey, data: data, service: keychainService, accessible: nil)
|
||||
|
||||
return nostrIdentity
|
||||
}
|
||||
|
||||
/// Associate a Nostr identity with a Noise public key (for favorites)
|
||||
func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||
if let data = nostrPubkey.data(using: .utf8) {
|
||||
keychain.save(key: key, data: data, service: keychainService, accessible: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get Nostr public key associated with a Noise public key
|
||||
func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||
guard let data = keychain.load(key: key, service: keychainService),
|
||||
let pubkey = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return pubkey
|
||||
}
|
||||
|
||||
/// Clear all Nostr identity associations and current identity
|
||||
func clearAllAssociations() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService,
|
||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||
kSecReturnAttributes as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
||||
for item in items {
|
||||
var deleteQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService
|
||||
]
|
||||
if let account = item[kSecAttrAccount as String] as? String {
|
||||
deleteQuery[kSecAttrAccount as String] = account
|
||||
}
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
}
|
||||
} else if status == errSecItemNotFound {
|
||||
// nothing persisted; no action needed
|
||||
}
|
||||
|
||||
deviceSeedCache = nil
|
||||
}
|
||||
|
||||
// MARK: - Per-Geohash Identities (Location Channels)
|
||||
|
||||
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
|
||||
/// Stored only on device keychain.
|
||||
private func getOrCreateDeviceSeed() -> Data {
|
||||
if let cached = deviceSeedCache { return cached }
|
||||
if let existing = keychain.load(key: deviceSeedKey, service: keychainService) {
|
||||
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
|
||||
keychain.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||
deviceSeedCache = existing
|
||||
return existing
|
||||
}
|
||||
var seed = Data(count: 32)
|
||||
_ = seed.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||
}
|
||||
// Ensure availability after first unlock to prevent unintended rotation when locked
|
||||
keychain.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||
deviceSeedCache = seed
|
||||
return seed
|
||||
}
|
||||
|
||||
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
||||
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
||||
/// if the candidate is not a valid secp256k1 private key.
|
||||
func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
||||
let seed = getOrCreateDeviceSeed()
|
||||
guard let msg = geohash.data(using: .utf8) else {
|
||||
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
|
||||
}
|
||||
|
||||
func candidateKey(iteration: UInt32) -> Data {
|
||||
var input = Data(msg)
|
||||
var iterBE = iteration.bigEndian
|
||||
withUnsafeBytes(of: &iterBE) { bytes in
|
||||
input.append(contentsOf: bytes)
|
||||
}
|
||||
let code = HMAC<SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
|
||||
return Data(code)
|
||||
}
|
||||
|
||||
// Try a few iterations to ensure a valid key can be formed
|
||||
for i in 0..<10 {
|
||||
let keyData = candidateKey(iteration: UInt32(i))
|
||||
if let identity = try? NostrIdentity(privateKeyData: keyData) {
|
||||
return identity
|
||||
}
|
||||
}
|
||||
// As a final fallback, hash the seed+msg and try again
|
||||
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
|
||||
@@ -161,18 +162,21 @@ enum DeliveryStatus: Codable, Equatable {
|
||||
|
||||
protocol BitchatDelegate: AnyObject {
|
||||
func didReceiveMessage(_ message: BitchatMessage)
|
||||
func didConnectToPeer(_ peerID: String)
|
||||
func didDisconnectFromPeer(_ peerID: String)
|
||||
func didUpdatePeerList(_ peers: [String])
|
||||
|
||||
func didConnectToPeer(_ peerID: PeerID)
|
||||
func didDisconnectFromPeer(_ peerID: PeerID)
|
||||
func didUpdatePeerList(_ peers: [PeerID])
|
||||
|
||||
// Optional method to check if a fingerprint belongs to a favorite peer
|
||||
func isFavorite(fingerprint: String) -> Bool
|
||||
|
||||
|
||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
|
||||
|
||||
// Low-level events for better separation of concerns
|
||||
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date)
|
||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||
|
||||
// Bluetooth state updates for user notifications
|
||||
func didUpdateBluetoothState(_ state: CBManagerState)
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date)
|
||||
}
|
||||
|
||||
// Provide default implementation to make it effectively optional
|
||||
@@ -185,11 +189,11 @@ extension BitchatDelegate {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
|
||||
// Default empty implementation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,14 @@ enum Geohash {
|
||||
return map
|
||||
}()
|
||||
|
||||
/// Validates a geohash string for building-level precision (8 characters).
|
||||
/// - Parameter geohash: The geohash string to validate
|
||||
/// - Returns: true if valid 8-character base32 geohash, false otherwise
|
||||
static func isValidBuildingGeohash(_ geohash: String) -> Bool {
|
||||
guard geohash.count == 8 else { return false }
|
||||
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
|
||||
}
|
||||
|
||||
/// Encodes the provided coordinates into a geohash string.
|
||||
/// - Parameters:
|
||||
/// - latitude: Latitude in degrees (-90...90)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+2038
-2072
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,7 @@ final class CommandProcessor {
|
||||
case .location: return true
|
||||
}
|
||||
}()
|
||||
let inGeoDM = (chatViewModel?.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
|
||||
let inGeoDM = chatViewModel?.selectedPrivateChatPeer?.isGeoDM == true
|
||||
|
||||
switch cmd {
|
||||
case "/m", "/msg":
|
||||
@@ -104,7 +104,7 @@ final class CommandProcessor {
|
||||
case .location(let ch):
|
||||
// Geohash context: show visible geohash participants (exclude self)
|
||||
guard let vm = chatViewModel else { return .success(message: "nobody around") }
|
||||
let myHex = (try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
||||
let myHex = (try? chatViewModel?.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
||||
let people = vm.visibleGeohashPeople().filter { person in
|
||||
if let me = myHex { return person.id.lowercased() != me }
|
||||
return true
|
||||
@@ -150,8 +150,8 @@ final class CommandProcessor {
|
||||
// In private chat
|
||||
if let peerNickname = meshService?.peerNickname(peerID: targetPeerID) {
|
||||
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
|
||||
meshService?.sendPrivateMessage(personalMessage, to: targetPeerID,
|
||||
recipientNickname: peerNickname,
|
||||
meshService?.sendPrivateMessage(personalMessage, to: targetPeerID,
|
||||
recipientNickname: peerNickname,
|
||||
messageID: UUID().uuidString)
|
||||
// Also add a local system message so the sender sees a natural-language confirmation
|
||||
let pastAction: String = {
|
||||
@@ -285,7 +285,7 @@ final class CommandProcessor {
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
let noisePublicKey = Data(hexString: peerID) else {
|
||||
let noisePublicKey = Data(hexString: peerID.id) else {
|
||||
return .error(message: "can't find peer: \(nickname)")
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
|
||||
private static let storageKey = "chat.bitchat.favorites"
|
||||
private static let keychainService = "chat.bitchat.favorites"
|
||||
private let keychain: KeychainHelperProtocol
|
||||
|
||||
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
||||
@Published private(set) var mutualFavorites: Set<Data> = []
|
||||
@@ -35,7 +36,8 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
|
||||
static let shared = FavoritesPersistenceService()
|
||||
|
||||
private init() {
|
||||
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
|
||||
self.keychain = keychain
|
||||
loadFavorites()
|
||||
|
||||
// Update mutual favorites when favorites change
|
||||
@@ -179,125 +181,15 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
|
||||
/// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey)
|
||||
/// Falls back to scanning favorites and matching on derived peer ID.
|
||||
func getFavoriteStatus(forPeerID peerID: String) -> FavoriteRelationship? {
|
||||
func getFavoriteStatus(forPeerID peerID: PeerID) -> FavoriteRelationship? {
|
||||
// Quick sanity: peerID should be 16 hex chars (8 bytes)
|
||||
guard peerID.count == 16 else { return nil }
|
||||
for (pubkey, rel) in favorites {
|
||||
let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey)
|
||||
if derived == peerID { return rel }
|
||||
guard peerID.isShort else { return nil }
|
||||
for (pubkey, rel) in favorites where PeerID(publicKey: pubkey) == peerID {
|
||||
return rel
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Update Nostr public key for a peer
|
||||
func updateNostrPublicKey(for peerNoisePublicKey: Data, nostrPubkey: String) {
|
||||
guard let existing = favorites[peerNoisePublicKey] else { return }
|
||||
|
||||
let updated = FavoriteRelationship(
|
||||
peerNoisePublicKey: existing.peerNoisePublicKey,
|
||||
peerNostrPublicKey: nostrPubkey,
|
||||
peerNickname: existing.peerNickname,
|
||||
isFavorite: existing.isFavorite,
|
||||
theyFavoritedUs: existing.theyFavoritedUs,
|
||||
favoritedAt: existing.favoritedAt,
|
||||
lastUpdated: Date()
|
||||
)
|
||||
|
||||
favorites[peerNoisePublicKey] = updated
|
||||
saveFavorites()
|
||||
}
|
||||
|
||||
/// Update nickname for an existing favorite
|
||||
func updateNickname(for peerNoisePublicKey: Data, newNickname: String) {
|
||||
guard let existing = favorites[peerNoisePublicKey] else { return }
|
||||
|
||||
// Skip if nickname hasn't changed
|
||||
if existing.peerNickname == newNickname { return }
|
||||
|
||||
// Updating nickname for favorite
|
||||
|
||||
let updated = FavoriteRelationship(
|
||||
peerNoisePublicKey: existing.peerNoisePublicKey,
|
||||
peerNostrPublicKey: existing.peerNostrPublicKey,
|
||||
peerNickname: newNickname,
|
||||
isFavorite: existing.isFavorite,
|
||||
theyFavoritedUs: existing.theyFavoritedUs,
|
||||
favoritedAt: existing.favoritedAt,
|
||||
lastUpdated: Date()
|
||||
)
|
||||
|
||||
favorites[peerNoisePublicKey] = updated
|
||||
saveFavorites()
|
||||
|
||||
// Notify observers
|
||||
NotificationCenter.default.post(
|
||||
name: .favoriteStatusChanged,
|
||||
object: nil,
|
||||
userInfo: ["peerPublicKey": peerNoisePublicKey]
|
||||
)
|
||||
}
|
||||
|
||||
/// Update noise public key when peer reconnects with new ID
|
||||
func updateNoisePublicKey(from oldKey: Data, to newKey: Data, peerNickname: String) {
|
||||
guard let existing = favorites[oldKey] else {
|
||||
SecureLogger.warning("⚠️ Cannot update noise key - no favorite found for \(oldKey.hexEncodedString())", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we already have a favorite with the new key
|
||||
if favorites[newKey] != nil {
|
||||
SecureLogger.warning("⚠️ Favorite already exists with new key \(newKey.hexEncodedString()), removing old entry", category: .session)
|
||||
favorites.removeValue(forKey: oldKey)
|
||||
saveFavorites()
|
||||
return
|
||||
}
|
||||
|
||||
// Updating noise public key
|
||||
|
||||
// Remove old entry
|
||||
favorites.removeValue(forKey: oldKey)
|
||||
|
||||
// Add with new key
|
||||
let updated = FavoriteRelationship(
|
||||
peerNoisePublicKey: newKey,
|
||||
peerNostrPublicKey: existing.peerNostrPublicKey,
|
||||
peerNickname: peerNickname,
|
||||
isFavorite: existing.isFavorite,
|
||||
theyFavoritedUs: existing.theyFavoritedUs,
|
||||
favoritedAt: existing.favoritedAt,
|
||||
lastUpdated: Date()
|
||||
)
|
||||
|
||||
favorites[newKey] = updated
|
||||
saveFavorites()
|
||||
|
||||
// Notify observers with both old and new keys
|
||||
NotificationCenter.default.post(
|
||||
name: .favoriteStatusChanged,
|
||||
object: nil,
|
||||
userInfo: [
|
||||
"peerPublicKey": newKey,
|
||||
"oldPeerPublicKey": oldKey,
|
||||
"isKeyUpdate": true
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
/// Get all favorites (including non-mutual)
|
||||
func getAllFavorites() -> [FavoriteRelationship] {
|
||||
favorites.values.filter { $0.isFavorite }
|
||||
}
|
||||
|
||||
/// Get only mutual favorites
|
||||
func getMutualFavorites() -> [FavoriteRelationship] {
|
||||
favorites.values.filter { $0.isMutual }
|
||||
}
|
||||
|
||||
/// Get all favorite relationships (including where they favorited us)
|
||||
func getAllRelationships() -> [FavoriteRelationship] {
|
||||
Array(favorites.values)
|
||||
}
|
||||
|
||||
/// Clear all favorites - used for panic mode
|
||||
func clearAllFavorites() {
|
||||
SecureLogger.warning("🧹 Clearing all favorites (panic mode)", category: .session)
|
||||
@@ -306,7 +198,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
saveFavorites()
|
||||
|
||||
// Delete from keychain directly
|
||||
KeychainHelper.delete(
|
||||
keychain.delete(
|
||||
key: Self.storageKey,
|
||||
service: Self.keychainService
|
||||
)
|
||||
@@ -326,10 +218,11 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
let data = try encoder.encode(relationships)
|
||||
|
||||
// Store in keychain for security
|
||||
KeychainHelper.save(
|
||||
keychain.save(
|
||||
key: Self.storageKey,
|
||||
data: data,
|
||||
service: Self.keychainService
|
||||
service: Self.keychainService,
|
||||
accessible: nil
|
||||
)
|
||||
|
||||
// Successfully saved favorites
|
||||
@@ -341,7 +234,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
private func loadFavorites() {
|
||||
// Loading favorites from keychain
|
||||
|
||||
guard let data = KeychainHelper.load(
|
||||
guard let data = keychain.load(
|
||||
key: Self.storageKey,
|
||||
service: Self.keychainService
|
||||
) else {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,15 +216,4 @@ final class GeohashBookmarksStore: ObservableObject {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if DEBUG
|
||||
/// Testing-only reset helper
|
||||
func _resetForTesting() {
|
||||
bookmarks.removeAll()
|
||||
membership.removeAll()
|
||||
bookmarkNames.removeAll()
|
||||
persist()
|
||||
persistNames()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -14,6 +14,8 @@ struct LocationNotesDependencies {
|
||||
var sendEvent: SendEvent
|
||||
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
|
||||
var now: () -> Date
|
||||
|
||||
private static let idBridge = NostrIdentityBridge()
|
||||
|
||||
static let live = LocationNotesDependencies(
|
||||
relayLookup: { geohash, count in
|
||||
@@ -35,7 +37,7 @@ struct LocationNotesDependencies {
|
||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
||||
},
|
||||
deriveIdentity: { geohash in
|
||||
try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
|
||||
try idBridge.deriveIdentity(forGeohash: geohash)
|
||||
},
|
||||
now: { Date() }
|
||||
)
|
||||
@@ -74,38 +76,52 @@ final class LocationNotesManager: ObservableObject {
|
||||
@Published private(set) var state: State = .loading
|
||||
@Published private(set) var errorMessage: String?
|
||||
private var subscriptionID: String?
|
||||
private var noteIDs = Set<String>() // O(1) duplicate detection
|
||||
private let dependencies: LocationNotesDependencies
|
||||
private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200)
|
||||
|
||||
private enum Strings {
|
||||
static let noRelays = NSLocalizedString(
|
||||
"location_notes.error.no_relays",
|
||||
comment: "Shown when no geo relays are available near the selected location"
|
||||
)
|
||||
static let noRelays = String(localized: "location_notes.error.no_relays", comment: "Shown when no geo relays are available near the selected location")
|
||||
|
||||
static func failedToSend(_ detail: String) -> String {
|
||||
let format = NSLocalizedString(
|
||||
"location_notes.error.failed_to_send",
|
||||
comment: "Shown when a location note fails to send"
|
||||
String(
|
||||
format: String(localized: "location_notes.error.failed_to_send", comment: "Shown when a location note fails to send"),
|
||||
locale: .current,
|
||||
detail
|
||||
)
|
||||
return String(format: format, detail)
|
||||
}
|
||||
}
|
||||
|
||||
init(geohash: String, dependencies: LocationNotesDependencies = .live) {
|
||||
self.geohash = geohash.lowercased()
|
||||
let norm = geohash.lowercased()
|
||||
self.geohash = norm
|
||||
self.dependencies = dependencies
|
||||
// Validate geohash (building-level precision: 8 chars)
|
||||
if !Geohash.isValidBuildingGeohash(norm) {
|
||||
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||
}
|
||||
subscribe()
|
||||
}
|
||||
|
||||
func setGeohash(_ newGeohash: String) {
|
||||
let norm = newGeohash.lowercased()
|
||||
guard norm != geohash else { return }
|
||||
// Validate geohash (building-level precision: 8 chars)
|
||||
guard Geohash.isValidBuildingGeohash(norm) else {
|
||||
SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||
return
|
||||
}
|
||||
if let sub = subscriptionID {
|
||||
dependencies.unsubscribe(sub)
|
||||
subscriptionID = nil
|
||||
}
|
||||
// Set loading state before clearing to prevent empty state flicker
|
||||
state = .loading
|
||||
initialLoadComplete = false
|
||||
errorMessage = nil
|
||||
geohash = norm
|
||||
notes.removeAll()
|
||||
noteIDs.removeAll()
|
||||
subscribe()
|
||||
}
|
||||
|
||||
@@ -114,7 +130,12 @@ final class LocationNotesManager: ObservableObject {
|
||||
dependencies.unsubscribe(sub)
|
||||
subscriptionID = nil
|
||||
}
|
||||
// Set loading state before clearing to prevent empty state flicker
|
||||
state = .loading
|
||||
initialLoadComplete = false
|
||||
errorMessage = nil
|
||||
notes.removeAll()
|
||||
noteIDs.removeAll()
|
||||
subscribe()
|
||||
}
|
||||
|
||||
@@ -150,12 +171,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 +214,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 +228,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,46 +4,51 @@ 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
|
||||
private let idBridge: NostrIdentityBridge
|
||||
|
||||
var myPeerID: String { senderPeerID }
|
||||
init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) {
|
||||
self.keychain = keychain
|
||||
self.idBridge = idBridge
|
||||
}
|
||||
|
||||
// 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 */ }
|
||||
|
||||
init(keychain: KeychainManagerProtocol) {
|
||||
self.keychain = keychain
|
||||
}
|
||||
|
||||
func startServices() { /* no-op */ }
|
||||
func stopServices() { /* no-op */ }
|
||||
func emergencyDisconnectAll() { /* no-op */ }
|
||||
|
||||
func isPeerConnected(_ peerID: String) -> Bool { false }
|
||||
func isPeerReachable(_ peerID: String) -> Bool { false }
|
||||
func peerNickname(peerID: String) -> String? { nil }
|
||||
func getPeerNicknames() -> [String : String] { [:] }
|
||||
func isPeerConnected(_ peerID: PeerID) -> Bool { false }
|
||||
func isPeerReachable(_ peerID: PeerID) -> Bool { false }
|
||||
func peerNickname(peerID: PeerID) -> String? { nil }
|
||||
func getPeerNicknames() -> [PeerID : String] { [:] }
|
||||
|
||||
func getFingerprint(for peerID: String) -> String? { nil }
|
||||
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState { .none }
|
||||
func triggerHandshake(with peerID: String) { /* no-op */ }
|
||||
func getFingerprint(for peerID: PeerID) -> String? { nil }
|
||||
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
|
||||
func triggerHandshake(with peerID: PeerID) { /* no-op */ }
|
||||
|
||||
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
|
||||
private static var cachedNoiseService: NoiseEncryptionService?
|
||||
@@ -59,11 +64,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)
|
||||
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
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 +82,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 +95,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? idBridge.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? idBridge.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 }
|
||||
@@ -108,7 +214,7 @@ final class NostrTransport: Transport {
|
||||
let item = readQueue.removeFirst()
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
|
||||
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
|
||||
SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||
// Convert recipient npub -> hex
|
||||
let recipientHex: String
|
||||
@@ -117,7 +223,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 +245,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)
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,9 @@ import SwiftUI
|
||||
|
||||
/// Manages all private chat functionality
|
||||
final class PrivateChatManager: ObservableObject {
|
||||
@Published var privateChats: [String: [BitchatMessage]] = [:]
|
||||
@Published var selectedPeer: String? = nil
|
||||
@Published var unreadMessages: Set<String> = []
|
||||
@Published var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||
@Published var selectedPeer: PeerID? = nil
|
||||
@Published var unreadMessages: Set<PeerID> = []
|
||||
|
||||
private var selectedPeerFingerprint: String? = nil
|
||||
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
||||
@@ -31,7 +31,7 @@ final class PrivateChatManager: ObservableObject {
|
||||
private let privateChatCap = TransportConfig.privateChatCap
|
||||
|
||||
/// Start a private chat with a peer
|
||||
func startChat(with peerID: String) {
|
||||
func startChat(with peerID: PeerID) {
|
||||
selectedPeer = peerID
|
||||
|
||||
// Store fingerprint for persistence across reconnections
|
||||
@@ -53,109 +53,33 @@ 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) {
|
||||
func sanitizeChat(for peerID: PeerID) {
|
||||
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
|
||||
}
|
||||
|
||||
/// Mark messages from a peer as read
|
||||
func markAsRead(from peerID: String) {
|
||||
func markAsRead(from peerID: PeerID) {
|
||||
unreadMessages.remove(peerID)
|
||||
|
||||
// Send read receipts for unread messages that haven't been sent yet
|
||||
@@ -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,15 +18,16 @@ 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 idBridge: NostrIdentityBridge
|
||||
private let identityManager: SecureIdentityStateManagerProtocol
|
||||
weak var messageRouter: MessageRouter?
|
||||
private let favoritesService = FavoritesPersistenceService.shared
|
||||
@@ -35,8 +35,13 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(meshService: Transport, identityManager: SecureIdentityStateManagerProtocol) {
|
||||
init(
|
||||
meshService: Transport,
|
||||
idBridge: NostrIdentityBridge,
|
||||
identityManager: SecureIdentityStateManagerProtocol
|
||||
) {
|
||||
self.meshService = meshService
|
||||
self.idBridge = idBridge
|
||||
self.identityManager = identityManager
|
||||
|
||||
// Subscribe to changes from both services
|
||||
@@ -78,12 +83,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 +109,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 +143,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 +190,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 +210,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 +230,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? {
|
||||
func getPeerID(for nickname: String) -> PeerID? {
|
||||
for peer in peers {
|
||||
if peer.displayName == nickname || peer.nickname == nickname {
|
||||
return peer.id
|
||||
return peer.peerID
|
||||
}
|
||||
}
|
||||
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 +258,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
|
||||
}
|
||||
@@ -291,7 +291,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
var peerNostrKey = peer.nostrPublicKey
|
||||
if peerNostrKey == nil {
|
||||
// Try to get from NostrIdentityBridge association
|
||||
peerNostrKey = NostrIdentityBridge.getNostrPublicKey(for: peer.noisePublicKey)
|
||||
peerNostrKey = idBridge.getNostrPublicKey(for: peer.noisePublicKey)
|
||||
}
|
||||
|
||||
// Add favorite
|
||||
@@ -322,39 +322,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 +347,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: Set<PeerID> { 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,13 @@ 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
|
||||
var maintenanceIntervalSeconds: TimeInterval = 30.0
|
||||
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
||||
var stalePeerTimeoutSeconds: TimeInterval = 60.0
|
||||
}
|
||||
|
||||
private let myPeerID: String
|
||||
private let myPeerID: PeerID
|
||||
private let config: Config
|
||||
weak var delegate: Delegate?
|
||||
|
||||
@@ -26,8 +30,9 @@ final class GossipSyncManager {
|
||||
// Timer
|
||||
private var periodicTimer: DispatchSourceTimer?
|
||||
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
||||
private var lastStalePeerCleanup: Date = .distantPast
|
||||
|
||||
init(myPeerID: String, config: Config = Config()) {
|
||||
init(myPeerID: PeerID, config: Config = Config()) {
|
||||
self.myPeerID = myPeerID
|
||||
self.config = config
|
||||
}
|
||||
@@ -35,8 +40,11 @@ final class GossipSyncManager {
|
||||
func start() {
|
||||
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() }
|
||||
let interval = max(0.1, config.maintenanceIntervalSeconds)
|
||||
timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(1))
|
||||
timer.setEventHandler { [weak self] in
|
||||
self?.performPeriodicMaintenance()
|
||||
}
|
||||
timer.resume()
|
||||
periodicTimer = timer
|
||||
}
|
||||
@@ -45,7 +53,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 +65,27 @@ 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 isAnnouncementFresh(_ packet: BitchatPacket) -> Bool {
|
||||
guard config.stalePeerTimeoutSeconds > 0 else { return true }
|
||||
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
let timeoutMs = UInt64(config.stalePeerTimeoutSeconds * 1000)
|
||||
guard nowMs >= timeoutMs else { return true }
|
||||
let cutoffMs = nowMs - timeoutMs
|
||||
return packet.timestamp >= cutoffMs
|
||||
}
|
||||
|
||||
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
||||
let mt = MessageType(rawValue: packet.type)
|
||||
let isBroadcastRecipient: Bool = {
|
||||
@@ -67,6 +96,17 @@ 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 }
|
||||
|
||||
if isAnnounce {
|
||||
guard isAnnouncementFresh(packet) else {
|
||||
let sender = packet.senderID.hexEncodedString().lowercased()
|
||||
removeState(forNormalizedPeerID: sender)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
|
||||
if isBroadcastMessage {
|
||||
@@ -81,7 +121,7 @@ final class GossipSyncManager {
|
||||
}
|
||||
}
|
||||
} else if isAnnounce {
|
||||
let sender = packet.senderID.hexEncodedString()
|
||||
let sender = packet.senderID.hexEncodedString().lowercased()
|
||||
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
|
||||
}
|
||||
}
|
||||
@@ -90,7 +130,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 +141,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 +152,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 +163,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 +177,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,17 +234,65 @@ 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 }
|
||||
}
|
||||
}
|
||||
|
||||
private func performPeriodicMaintenance(now: Date = Date()) {
|
||||
cleanupExpiredMessages()
|
||||
cleanupStaleAnnouncementsIfNeeded(now: now)
|
||||
sendRequestSync()
|
||||
}
|
||||
|
||||
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
||||
guard now.timeIntervalSince(lastStalePeerCleanup) >= config.stalePeerCleanupIntervalSeconds else {
|
||||
return
|
||||
}
|
||||
lastStalePeerCleanup = now
|
||||
cleanupStaleAnnouncements(now: now)
|
||||
}
|
||||
|
||||
private func cleanupStaleAnnouncements(now: Date) {
|
||||
let timeoutMs = UInt64(config.stalePeerTimeoutSeconds * 1000)
|
||||
let nowMs = UInt64(now.timeIntervalSince1970 * 1000)
|
||||
guard nowMs >= timeoutMs else { return }
|
||||
let cutoff = nowMs - timeoutMs
|
||||
let stalePeerIDs = latestAnnouncementByPeer.compactMap { (peerHex, pair) -> String? in
|
||||
pair.packet.timestamp < cutoff ? peerHex.lowercased() : nil
|
||||
}
|
||||
guard !stalePeerIDs.isEmpty else { return }
|
||||
for peerKey in stalePeerIDs {
|
||||
removeState(forNormalizedPeerID: peerKey)
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
|
||||
private func _removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||
let normalizedPeerID = peerID.id.lowercased()
|
||||
removeState(forNormalizedPeerID: normalizedPeerID)
|
||||
}
|
||||
|
||||
private func removeState(forNormalizedPeerID normalizedPeerID: String) {
|
||||
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
|
||||
// Remove messages from this peer
|
||||
// Collect IDs to remove first to avoid concurrent modification
|
||||
let messageIdsToRemove = messages.compactMap { (id, message) -> String? in
|
||||
@@ -208,3 +306,25 @@ final class GossipSyncManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension GossipSyncManager {
|
||||
func _performMaintenanceSynchronously(now: Date = Date()) {
|
||||
queue.sync {
|
||||
performPeriodicMaintenance(now: now)
|
||||
}
|
||||
}
|
||||
|
||||
func _hasAnnouncement(for peerID: PeerID) -> Bool {
|
||||
queue.sync {
|
||||
latestAnnouncementByPeer[peerID.id.lowercased()] != nil
|
||||
}
|
||||
}
|
||||
|
||||
func _messageCount(for peerID: PeerID) -> Int {
|
||||
queue.sync {
|
||||
messages.values.filter { $0.senderID.hexEncodedString().lowercased() == peerID.id.lowercased() }.count
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -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,98 @@
|
||||
//
|
||||
// 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,
|
||||
idBridge: NostrIdentityBridge(),
|
||||
identityManager: SecureIdentityStateManager(keychain)
|
||||
)
|
||||
)
|
||||
}
|
||||
+443
-769
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user