mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 14:05:18 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
991def1deb | ||
|
|
ac7feb380e | ||
|
|
57c5d81981 | ||
|
|
fa04e801b6 | ||
|
|
3c21783eae | ||
|
|
b196a55c37 | ||
|
|
ade700e7f0 | ||
|
|
ea7b90c074 | ||
|
|
5486fc6a69 | ||
|
|
7415039183 | ||
|
|
7b73728f1d | ||
|
|
1ea90295ce | ||
|
|
c9fac83433 | ||
|
|
5b799e1a50 |
@@ -1,20 +0,0 @@
|
||||
# Prevent Github Languages stats skewing:
|
||||
|
||||
# Binaries and assets
|
||||
**/*.xcframework/** linguist-vendored
|
||||
**/*.xcassets/** linguist-vendored
|
||||
|
||||
# Generated files
|
||||
**/*.pbxproj linguist-generated
|
||||
**/*.storyboard linguist-generated
|
||||
Package.resolved linguist-generated
|
||||
|
||||
# Downloaded CSVs
|
||||
relays/online_relays_gps.csv linguist-vendored
|
||||
|
||||
# Docs
|
||||
**/*.md linguist-documentation
|
||||
|
||||
# Configs
|
||||
Configs/*.xcconfig linguist-documentation
|
||||
**/*.plist linguist-documentation
|
||||
@@ -1,4 +1,4 @@
|
||||
MARKETING_VERSION = 1.4.3
|
||||
MARKETING_VERSION = 1.4.0
|
||||
CURRENT_PROJECT_VERSION = 1
|
||||
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||
|
||||
@@ -14,6 +14,7 @@ default:
|
||||
# Check prerequisites
|
||||
check:
|
||||
@echo "Checking prerequisites..."
|
||||
@command -v xcodegen >/dev/null 2>&1 || (echo "❌ XcodeGen not found. Install with: brew install xcodegen" && exit 1)
|
||||
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ Xcode not found. Install Xcode from App Store" && exit 1)
|
||||
@security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
|
||||
@echo "✅ All prerequisites met"
|
||||
@@ -21,6 +22,8 @@ check:
|
||||
# Backup original files
|
||||
backup:
|
||||
@echo "Backing up original project configuration..."
|
||||
@cp project.yml project.yml.backup 2>/dev/null || true
|
||||
@# Backup other files that get modified by xcodegen
|
||||
@if [ -f bitchat.xcodeproj/project.pbxproj ]; then cp bitchat.xcodeproj/project.pbxproj bitchat.xcodeproj/project.pbxproj.backup; fi
|
||||
@if [ -f bitchat/Info.plist ]; then cp bitchat/Info.plist bitchat/Info.plist.backup; fi
|
||||
|
||||
@@ -41,10 +44,15 @@ patch-for-macos: backup
|
||||
@# Move iOS-specific files out of the way temporarily
|
||||
@if [ -f bitchat/LaunchScreen.storyboard ]; then mv bitchat/LaunchScreen.storyboard bitchat/LaunchScreen.storyboard.ios; fi
|
||||
|
||||
# Generate Xcode project with patches
|
||||
generate: patch-for-macos
|
||||
@echo "Generating Xcode project..."
|
||||
@xcodegen generate
|
||||
|
||||
# Build the macOS app
|
||||
build: #check generate
|
||||
build: check generate
|
||||
@echo "Building BitChat for macOS..."
|
||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||
|
||||
# Run the macOS app
|
||||
run: build
|
||||
@@ -67,7 +75,9 @@ clean: restore
|
||||
# Quick run without cleaning (for development)
|
||||
dev-run: check
|
||||
@echo "Quick development build..."
|
||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||
@if [ ! -f project.yml.backup ]; then just patch-for-macos; fi
|
||||
@xcodegen generate
|
||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
|
||||
|
||||
# Show app info
|
||||
@@ -96,9 +106,11 @@ nuke:
|
||||
@echo "🧨 Nuclear clean - removing all build artifacts and backups..."
|
||||
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true
|
||||
@rm -rf bitchat.xcodeproj 2>/dev/null || true
|
||||
@rm -f project.yml.backup 2>/dev/null || true
|
||||
@rm -f project-macos.yml 2>/dev/null || true
|
||||
@rm -f bitchat.xcodeproj/project.pbxproj.backup 2>/dev/null || true
|
||||
@rm -f bitchat/Info.plist.backup 2>/dev/null || true
|
||||
@# Restore iOS-specific files if they were moved
|
||||
@if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi
|
||||
@git checkout bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore"
|
||||
@git checkout -- project.yml bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore"
|
||||
@echo "✅ Nuclear clean complete"
|
||||
|
||||
@@ -9,7 +9,7 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
|
||||
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
|
||||
|
||||
> [!WARNING]
|
||||
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](https://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
|
||||
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](http://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
|
||||
|
||||
## License
|
||||
|
||||
@@ -22,7 +22,7 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file
|
||||
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
|
||||
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
||||
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
||||
- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, NIP-17 for Nostr
|
||||
- **Private Message End-to-End Encryption**: [Noise Protocol](http://noiseprotocol.org) for mesh, NIP-17 for Nostr
|
||||
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
|
||||
- **Universal App**: Native support for iOS and macOS
|
||||
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
||||
@@ -116,10 +116,3 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
|
||||
|
||||
Want to try this on macos: `just run` will set it up and run from source.
|
||||
Run `just clean` afterwards to restore things to original state for mobile app building and development.
|
||||
|
||||
## Localization
|
||||
|
||||
- Base app resources live under `bitchat/Localization/Base.lproj/`. Add new copy to `Localizable.strings` and plural rules to `Localizable.stringsdict`.
|
||||
- Share extension strings are separate in `bitchatShareExtension/Localization/Base.lproj/Localizable.strings`.
|
||||
- Prefer keys that describe intent (`app_info.features.offline.title`) and reuse existing ones where possible.
|
||||
- Run `xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build` to compile-check any localization updates.
|
||||
|
||||
@@ -184,28 +184,6 @@ To minimize bandwidth, `BitchatPacket`s are serialized into a compact binary for
|
||||
|
||||
**Padding:** All packets are padded to the next standard block size (256, 512, 1024, or 2048 bytes) using a PKCS#7-style scheme to obscure the true message length from network observers.
|
||||
|
||||
```mermaid
|
||||
---
|
||||
config:
|
||||
theme: dark
|
||||
---
|
||||
---
|
||||
title: "BitchatPacket"
|
||||
---
|
||||
packet
|
||||
+8: "Version"
|
||||
+8: "Type"
|
||||
+8: "TTL"
|
||||
+64: "Timestamp"
|
||||
+8: "Flags"
|
||||
+16: "Payload Length"
|
||||
+64: "Sender ID"
|
||||
+64: "Recipient ID (optional)"
|
||||
+48: "Payload (variable)"
|
||||
+64: "Signature (optional)"
|
||||
```
|
||||
_A representation of the sizes of the fields in `BitchatPacket`_
|
||||
|
||||
### 6.2. Application Message Format (`BitchatMessage`)
|
||||
|
||||
For packets of type `message`, the payload is a binary-serialized `BitchatMessage` containing the chat content.
|
||||
@@ -220,25 +198,6 @@ For packets of type `message`, the payload is a binary-serialized `BitchatMessag
|
||||
| Original Sender | 1 + len (opt)| Nickname of the original sender if the message is a relay. |
|
||||
| Recipient Nickname | 1 + len (opt)| Nickname of the recipient for private messages. |
|
||||
|
||||
```mermaid
|
||||
---
|
||||
config:
|
||||
theme: dark
|
||||
---
|
||||
---
|
||||
title: "BitchatMessage"
|
||||
---
|
||||
packet
|
||||
+8: "Flags"
|
||||
+64: "Timestamp"
|
||||
+24: "ID (variable)"
|
||||
+32: "Sender (variable)"
|
||||
+32: "Content (variable)"
|
||||
+32: "Original Sender (variable) (optional)"
|
||||
+32: "Recipient Nickname (variable) (optional)"
|
||||
```
|
||||
_A representation of the sizes of the fields in `BitchatMessage`_
|
||||
|
||||
---
|
||||
|
||||
## 7. Message Routing and Propagation
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
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 */; };
|
||||
@@ -28,13 +27,6 @@
|
||||
remoteGlobalIDString = AF077EA0474EDEDE2C72716C;
|
||||
remoteInfo = bitchat_iOS;
|
||||
};
|
||||
E35E7AF9854A2E72452DD34F /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 475D96681D0EA0AE57A4E06E /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 57CA17A36A2532A6CFF367BB;
|
||||
remoteInfo = bitchatShareExtension;
|
||||
};
|
||||
FF470234EF8C6BB8865B80B5 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 475D96681D0EA0AE57A4E06E /* Project object */;
|
||||
@@ -50,7 +42,6 @@
|
||||
dstPath = "";
|
||||
dstSubfolder = PlugIns;
|
||||
files = (
|
||||
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
};
|
||||
@@ -60,7 +51,6 @@
|
||||
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; };
|
||||
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -68,13 +58,6 @@
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Services/TransportConfig.swift,
|
||||
);
|
||||
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
||||
};
|
||||
A6E32D1C2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_iOS" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
@@ -91,20 +74,12 @@
|
||||
);
|
||||
target = 0576A29205865664C0937536 /* bitchat_macOS */;
|
||||
};
|
||||
A6E32D232E762EAB0032EA8A /* Exceptions for "bitchatShareExtension" folder in "bitchatShareExtension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
ShareViewController.swift,
|
||||
);
|
||||
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
A6E32C972E762EA70032EA8A /* bitchat */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */,
|
||||
A6E32D1C2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_iOS" target */,
|
||||
A6E32D1D2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_macOS" target */,
|
||||
);
|
||||
@@ -113,9 +88,6 @@
|
||||
};
|
||||
A6E32D212E762EAB0032EA8A /* bitchatShareExtension */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
A6E32D232E762EAB0032EA8A /* Exceptions for "bitchatShareExtension" folder in "bitchatShareExtension" target */,
|
||||
);
|
||||
path = bitchatShareExtension;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -180,7 +152,6 @@
|
||||
children = (
|
||||
96D0D41CA19EE5A772AA8434 /* bitchat.app */,
|
||||
8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */,
|
||||
61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */,
|
||||
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */,
|
||||
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */,
|
||||
);
|
||||
@@ -231,19 +202,6 @@
|
||||
productReference = 03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
57CA17A36A2532A6CFF367BB /* bitchatShareExtension */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = E4EA6DC648DF55FF84032EB5 /* Build configuration list for PBXNativeTarget "bitchatShareExtension" */;
|
||||
buildPhases = (
|
||||
0A08E70F08F55FD5BA8C7EF3 /* Sources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
name = bitchatShareExtension;
|
||||
productName = bitchatShareExtension;
|
||||
productReference = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget "bitchatTests_iOS" */;
|
||||
@@ -274,9 +232,6 @@
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
6EB655BA5DB11909C1DEC460 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
A6E32C972E762EA70032EA8A /* bitchat */,
|
||||
);
|
||||
@@ -304,19 +259,6 @@
|
||||
knownRegions = (
|
||||
Base,
|
||||
en,
|
||||
es,
|
||||
ar,
|
||||
de,
|
||||
fr,
|
||||
he,
|
||||
id,
|
||||
it,
|
||||
ja,
|
||||
ne,
|
||||
"pt-BR",
|
||||
ru,
|
||||
uk,
|
||||
"zh-Hans",
|
||||
);
|
||||
mainGroup = 18198ED912AAF495D8AF7763;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
@@ -328,7 +270,6 @@
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
57CA17A36A2532A6CFF367BB /* bitchatShareExtension */,
|
||||
6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */,
|
||||
47FF23248747DD7CB666CB91 /* bitchatTests_macOS */,
|
||||
AF077EA0474EDEDE2C72716C /* bitchat_iOS */,
|
||||
@@ -353,11 +294,6 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
0A08E70F08F55FD5BA8C7EF3 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
files = (
|
||||
);
|
||||
};
|
||||
137ABE739BF20ACDDF8CC605 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
files = (
|
||||
@@ -386,11 +322,6 @@
|
||||
target = 0576A29205865664C0937536 /* bitchat_macOS */;
|
||||
targetProxy = FF470234EF8C6BB8865B80B5 /* PBXContainerItemProxy */;
|
||||
};
|
||||
6EB655BA5DB11909C1DEC460 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
||||
targetProxy = E35E7AF9854A2E72452DD34F /* PBXContainerItemProxy */;
|
||||
};
|
||||
D8C09F21DB7DC06E8E672C21 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = AF077EA0474EDEDE2C72716C /* bitchat_iOS */;
|
||||
@@ -475,49 +406,18 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
3DCF45111852FB2AEBE05E31 /* Release configuration for PBXNativeTarget "bitchatShareExtension" */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
|
||||
baseConfigurationReferenceRelativePath = Release.xcconfig;
|
||||
buildSettings = {
|
||||
CODE_SIGNING_ALLOWED = YES;
|
||||
CODE_SIGNING_REQUIRED = YES;
|
||||
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
INFOPLIST_FILE = bitchatShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_VERSION = "$(SWIFT_VERSION)";
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
702E7395723CADA4B830F4A9 /* Debug configuration for PBXNativeTarget "bitchat_iOS" */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
|
||||
baseConfigurationReferenceRelativePath = Debug.xcconfig;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDebug;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||
CODE_SIGNING_ALLOWED = YES;
|
||||
CODE_SIGNING_REQUIRED = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
DEVELOPMENT_TEAM = 66W2RU3J5T;
|
||||
ENABLE_PREVIEWS = NO;
|
||||
INFOPLIST_FILE = bitchat/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||
@@ -529,6 +429,7 @@
|
||||
);
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
"PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = "chat.bitchat-local";
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
@@ -577,7 +478,7 @@
|
||||
CODE_SIGNING_REQUIRED = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
DEVELOPMENT_TEAM = 66W2RU3J5T;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = bitchat/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||
@@ -589,6 +490,7 @@
|
||||
);
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
"PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = "chat.bitchat-local";
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
@@ -613,11 +515,10 @@
|
||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
DEVELOPMENT_TEAM = 66W2RU3J5T;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = bitchat/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
@@ -625,6 +526,7 @@
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
"PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "chat.bitchat-local";
|
||||
PRODUCT_NAME = bitchat;
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SDKROOT = macosx;
|
||||
@@ -697,7 +599,7 @@
|
||||
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
|
||||
baseConfigurationReferenceRelativePath = Debug.xcconfig;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDebug;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||
CODE_SIGNING_ALLOWED = YES;
|
||||
CODE_SIGNING_REQUIRED = YES;
|
||||
@@ -705,11 +607,10 @@
|
||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
DEVELOPMENT_TEAM = 66W2RU3J5T;
|
||||
ENABLE_PREVIEWS = NO;
|
||||
INFOPLIST_FILE = bitchat/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
@@ -717,6 +618,7 @@
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
"PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "chat.bitchat-local";
|
||||
PRODUCT_NAME = bitchat;
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SDKROOT = macosx;
|
||||
@@ -791,37 +693,6 @@
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
DAC5E82049F8A97360BE63D6 /* Debug configuration for PBXNativeTarget "bitchatShareExtension" */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
|
||||
baseConfigurationReferenceRelativePath = Debug.xcconfig;
|
||||
buildSettings = {
|
||||
CODE_SIGNING_ALLOWED = YES;
|
||||
CODE_SIGNING_REQUIRED = YES;
|
||||
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
INFOPLIST_FILE = bitchatShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_VERSION = "$(SWIFT_VERSION)";
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -865,14 +736,6 @@
|
||||
);
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
E4EA6DC648DF55FF84032EB5 /* Build configuration list for PBXNativeTarget "bitchatShareExtension" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
DAC5E82049F8A97360BE63D6 /* Debug configuration for PBXNativeTarget "bitchatShareExtension" */,
|
||||
3DCF45111852FB2AEBE05E31 /* Release configuration for PBXNativeTarget "bitchatShareExtension" */,
|
||||
);
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "image-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "image-1024 1.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"filename" : "image-1024 2.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
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 |
@@ -3,4 +3,4 @@
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import Foundation
|
||||
|
||||
#if os(iOS)
|
||||
import AVFoundation
|
||||
import CoreGraphics
|
||||
|
||||
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
||||
private var recorder: AVAudioRecorder?
|
||||
private(set) var isRecording = false
|
||||
|
||||
func startRecording(to url: URL) throws {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker])
|
||||
try session.setActive(true)
|
||||
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 44100,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderBitRateKey: 32000
|
||||
]
|
||||
recorder = try AVAudioRecorder(url: url, settings: settings)
|
||||
recorder?.delegate = self
|
||||
recorder?.isMeteringEnabled = true
|
||||
guard recorder?.record() == true else { throw NSError(domain: "VoiceRecorder", code: -1) }
|
||||
isRecording = true
|
||||
}
|
||||
|
||||
func stopRecording(completion: @escaping () -> Void) {
|
||||
guard isRecording else { completion(); return }
|
||||
// Add 500ms padding to avoid clipping
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.recorder?.stop()
|
||||
self?.isRecording = false
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Live metrics for visualizer
|
||||
|
||||
/// Returns normalized amplitude [0,1] based on average power in dB.
|
||||
/// Call periodically while recording to drive a live waveform.
|
||||
func pollNormalizedAmplitude() -> CGFloat {
|
||||
guard let r = recorder, isRecording else { return 0 }
|
||||
r.updateMeters()
|
||||
// Use peak power for responsiveness, map dB [-160,0] -> linear [0,1]
|
||||
let db = r.peakPower(forChannel: 0)
|
||||
let linear = pow(10.0, db / 20.0) // 0..1
|
||||
if linear.isNaN || linear.isInfinite { return 0 }
|
||||
// Keep linear to avoid globally scaling up low amplitudes
|
||||
return CGFloat(max(0, min(1, linear)))
|
||||
}
|
||||
|
||||
/// Current elapsed recording time in milliseconds.
|
||||
func currentTimeMs() -> Int {
|
||||
guard let r = recorder, isRecording else { return 0 }
|
||||
return Int(r.currentTime * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
enum VoiceRecorderPaths {
|
||||
static func outgoingURL() throws -> URL {
|
||||
let fm = FileManager.default
|
||||
let base = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let folder = base.appendingPathComponent("bitchat_voicenotes/outgoing", isDirectory: true)
|
||||
if !fm.fileExists(atPath: folder.path) {
|
||||
try fm.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
}
|
||||
let ts = ISO8601DateFormatter()
|
||||
ts.formatOptions = [.withInternetDateTime, .withDashSeparatorInDate, .withColonSeparatorInTime]
|
||||
let name = "voice_\(Int(Date().timeIntervalSince1970)).m4a"
|
||||
return folder.appendingPathComponent(name)
|
||||
}
|
||||
}
|
||||
#else
|
||||
/// Minimal macOS-compatible stubs so the macOS target builds without AVFAudio.
|
||||
final class VoiceRecorder: NSObject {
|
||||
private(set) var isRecording = false
|
||||
func startRecording(to url: URL) throws {
|
||||
// Voice recording is iOS-only in this project
|
||||
throw NSError(domain: "VoiceRecorder", code: -1, userInfo: [NSLocalizedDescriptionKey: "Voice recording is not supported on macOS in this build."])
|
||||
}
|
||||
func stopRecording(completion: @escaping () -> Void) { completion() }
|
||||
}
|
||||
|
||||
enum VoiceRecorderPaths {
|
||||
static func outgoingURL() throws -> URL {
|
||||
throw NSError(domain: "VoiceRecorder", code: -2, userInfo: [NSLocalizedDescriptionKey: "Voice recording is not supported on macOS in this build."])
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,76 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import Accelerate
|
||||
|
||||
struct WaveformCache {
|
||||
static var shared = WaveformCache()
|
||||
private var cache: [String: [Float]] = [:]
|
||||
mutating func set(_ bins: [Float], for path: String) { cache[path] = bins }
|
||||
func get(_ path: String) -> [Float]? { cache[path] }
|
||||
}
|
||||
|
||||
enum WaveformExtractor {
|
||||
static func extractBins(url: URL, binCount: Int = 120) -> [Float] {
|
||||
if let cached = WaveformCache.shared.get(url.path) { return cached }
|
||||
let asset = AVURLAsset(url: url)
|
||||
guard let track = asset.tracks(withMediaType: .audio).first else { return [] }
|
||||
let reader: AVAssetReader
|
||||
do { reader = try AVAssetReader(asset: asset) } catch { return [] }
|
||||
let outputSettings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatLinearPCM,
|
||||
AVLinearPCMBitDepthKey: 16,
|
||||
AVLinearPCMIsBigEndianKey: false,
|
||||
AVLinearPCMIsFloatKey: false,
|
||||
AVLinearPCMIsNonInterleaved: false
|
||||
]
|
||||
let output = AVAssetReaderTrackOutput(track: track, outputSettings: outputSettings)
|
||||
output.alwaysCopiesSampleData = false
|
||||
if reader.canAdd(output) { reader.add(output) } else { return [] }
|
||||
guard reader.startReading() else { return [] }
|
||||
|
||||
var samples: [Float] = []
|
||||
while reader.status == .reading {
|
||||
guard let buffer = output.copyNextSampleBuffer() else { break }
|
||||
if let block = CMSampleBufferGetDataBuffer(buffer) {
|
||||
var length = 0
|
||||
var dataPointer: UnsafeMutablePointer<Int8>?
|
||||
if CMBlockBufferGetDataPointer(block, atOffset: 0, lengthAtOffsetOut: nil, totalLengthOut: &length, dataPointerOut: &dataPointer) == kCMBlockBufferNoErr,
|
||||
let base = dataPointer {
|
||||
// 16-bit signed little endian
|
||||
let count = length / 2
|
||||
var floats = [Float](repeating: 0, count: count)
|
||||
base.withMemoryRebound(to: Int16.self, capacity: count) { ptr in
|
||||
vDSP.convertElements(of: UnsafeBufferPointer(start: ptr, count: count), to: &floats)
|
||||
}
|
||||
// Normalize to [-1,1]
|
||||
var maxVal: Float = 32768.0
|
||||
vDSP.divide(floats, maxVal, result: &floats)
|
||||
samples += floats
|
||||
}
|
||||
}
|
||||
CMSampleBufferInvalidate(buffer)
|
||||
}
|
||||
|
||||
guard !samples.isEmpty, reader.status == .completed || reader.status == .reading else { return [] }
|
||||
// Reduce to bins by RMS per window
|
||||
let window = max(1, samples.count / binCount)
|
||||
var bins: [Float] = []
|
||||
bins.reserveCapacity(binCount)
|
||||
var i = 0
|
||||
while i < samples.count && bins.count < binCount {
|
||||
let end = min(i + window, samples.count)
|
||||
let slice = samples[i..<end]
|
||||
var sum: Float = 0
|
||||
vDSP_svesq(slice.map { $0 }, 1, &sum, vDSP_Length(slice.count))
|
||||
let rms = sqrtf(sum / Float(slice.count))
|
||||
bins.append(min(1.0, rms))
|
||||
i = end
|
||||
}
|
||||
if bins.count < binCount {
|
||||
bins += Array(repeating: 0, count: binCount - bins.count)
|
||||
}
|
||||
WaveformCache.shared.set(bins, for: url.path)
|
||||
return bins
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +158,23 @@ struct IdentityCache: Codable {
|
||||
var version: Int = 1
|
||||
}
|
||||
|
||||
// MARK: - Identity Resolution
|
||||
|
||||
enum IdentityHint {
|
||||
case unknown
|
||||
case likelyKnown(fingerprint: String)
|
||||
case ambiguous(candidates: Set<String>)
|
||||
case verified(fingerprint: String)
|
||||
}
|
||||
|
||||
// MARK: - Pending Actions
|
||||
|
||||
struct PendingActions {
|
||||
var toggleFavorite: Bool?
|
||||
var setTrustLevel: TrustLevel?
|
||||
var setPetname: String?
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
// MARK: - Migration Support
|
||||
|
||||
@@ -33,12 +33,18 @@
|
||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>bitchat saves received images to your photo library when you choose to save them.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>bitchat needs access to your photos to let you pick images to send.</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>bitchat uses the microphone to record and send short voice notes to nearby peers.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-central</string>
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,133 @@
|
||||
//
|
||||
// BitchatFilePacket.swift
|
||||
// bitchat
|
||||
//
|
||||
// TLV encoder/decoder for file transfer payloads (images, audio, and generic files)
|
||||
// v2 Spec: 0x01 FILE_NAME (utf8), 0x02 FILE_SIZE (4 bytes, BE), 0x03 MIME_TYPE (utf8), 0x04 CONTENT (4-byte len)
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
struct BitchatFilePacket {
|
||||
enum TLVType: UInt8 {
|
||||
case fileName = 0x01
|
||||
case fileSize = 0x02
|
||||
case mimeType = 0x03
|
||||
case content = 0x04
|
||||
}
|
||||
|
||||
let fileName: String
|
||||
let fileSize: UInt32
|
||||
let mimeType: String
|
||||
let content: Data
|
||||
|
||||
func encode() -> Data? {
|
||||
var out = Data()
|
||||
|
||||
// Standard TLV helper for FILE_NAME and MIME_TYPE (2-byte length)
|
||||
func appendStandardTLV(_ type: TLVType, _ value: Data) {
|
||||
out.append(type.rawValue)
|
||||
let len = UInt16(min(value.count, 0xFFFF))
|
||||
out.append(UInt8((len >> 8) & 0xFF))
|
||||
out.append(UInt8(len & 0xFF))
|
||||
out.append(value.prefix(Int(len)))
|
||||
}
|
||||
|
||||
// FILE_NAME
|
||||
if let nameData = fileName.data(using: .utf8) {
|
||||
appendStandardTLV(.fileName, nameData)
|
||||
}
|
||||
|
||||
// FILE_SIZE (4 bytes, UInt32 BE) - v2 spec
|
||||
out.append(TLVType.fileSize.rawValue)
|
||||
out.append(UInt8(0)) // Length high byte = 0
|
||||
out.append(UInt8(4)) // Length low byte = 4
|
||||
let size32 = UInt32(min(fileSize, UInt32.max))
|
||||
out.append(UInt8((size32 >> 24) & 0xFF))
|
||||
out.append(UInt8((size32 >> 16) & 0xFF))
|
||||
out.append(UInt8((size32 >> 8) & 0xFF))
|
||||
out.append(UInt8(size32 & 0xFF))
|
||||
|
||||
// MIME_TYPE
|
||||
if let mimeData = mimeType.data(using: .utf8) {
|
||||
appendStandardTLV(.mimeType, mimeData)
|
||||
}
|
||||
|
||||
// CONTENT (4-byte length) - v2 spec
|
||||
out.append(TLVType.content.rawValue)
|
||||
let contentLen = UInt32(content.count)
|
||||
out.append(UInt8((contentLen >> 24) & 0xFF))
|
||||
out.append(UInt8((contentLen >> 16) & 0xFF))
|
||||
out.append(UInt8((contentLen >> 8) & 0xFF))
|
||||
out.append(UInt8(contentLen & 0xFF))
|
||||
out.append(content)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
static func decode(from data: Data) -> BitchatFilePacket? {
|
||||
var idx = 0
|
||||
func read(_ n: Int) -> Data? {
|
||||
guard idx + n <= data.count else { return nil }
|
||||
defer { idx += n }
|
||||
return data.subdata(in: idx..<(idx+n))
|
||||
}
|
||||
func read8() -> UInt8? { read(1)?.first }
|
||||
func read16() -> UInt16? {
|
||||
guard let d = read(2) else { return nil }
|
||||
return (UInt16(d[0]) << 8) | UInt16(d[1])
|
||||
}
|
||||
func read32() -> UInt32? {
|
||||
guard let d = read(4) else { return nil }
|
||||
return (UInt32(d[0]) << 24) | (UInt32(d[1]) << 16) | (UInt32(d[2]) << 8) | UInt32(d[3])
|
||||
}
|
||||
|
||||
var name: String?
|
||||
var size: UInt32?
|
||||
var mime: String?
|
||||
var contentData = Data()
|
||||
|
||||
while idx < data.count {
|
||||
guard let t = read8(), let tlvType = TLVType(rawValue: t) else { return nil }
|
||||
|
||||
// CONTENT uses 4-byte length; others use 2-byte length
|
||||
let len: Int
|
||||
if tlvType == .content {
|
||||
guard let len32 = read32() else { return nil }
|
||||
len = Int(len32)
|
||||
} else {
|
||||
guard let len16 = read16() else { return nil }
|
||||
len = Int(len16)
|
||||
}
|
||||
|
||||
guard len >= 0, idx + len <= data.count else { return nil }
|
||||
let val = data.subdata(in: idx..<(idx+len))
|
||||
idx += len
|
||||
|
||||
switch tlvType {
|
||||
case .fileName:
|
||||
name = String(data: val, encoding: .utf8)
|
||||
case .fileSize:
|
||||
guard len == 4 else { return nil } // v2 spec: 4 bytes
|
||||
var s: UInt32 = 0
|
||||
for b in val { s = (s << 8) | UInt32(b) }
|
||||
size = s
|
||||
case .mimeType:
|
||||
mime = String(data: val, encoding: .utf8)
|
||||
case .content:
|
||||
// Expect single CONTENT TLV; if multiple, concatenate defensively
|
||||
contentData.append(val)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate
|
||||
guard !contentData.isEmpty else { return nil }
|
||||
let finalSize = size ?? UInt32(contentData.count)
|
||||
let finalName = name ?? "file"
|
||||
let finalMime = mime ?? "application/octet-stream"
|
||||
return BitchatFilePacket(fileName: finalName, fileSize: finalSize, mimeType: finalMime, content: contentData)
|
||||
}
|
||||
}
|
||||
|
||||
extension Data { func sha256Hex() -> String { SHA256.hash(data: self).map { String(format: "%02x", $0) }.joined() } }
|
||||
@@ -21,7 +21,20 @@ struct BitchatPacket: Codable {
|
||||
let payload: Data
|
||||
var signature: Data?
|
||||
var ttl: UInt8
|
||||
|
||||
|
||||
// Full initialization with explicit version
|
||||
init(version: UInt8, type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
|
||||
self.version = version
|
||||
self.type = type
|
||||
self.senderID = senderID
|
||||
self.recipientID = recipientID
|
||||
self.timestamp = timestamp
|
||||
self.payload = payload
|
||||
self.signature = signature
|
||||
self.ttl = ttl
|
||||
}
|
||||
|
||||
// Backward compatible constructor (defaults to v1)
|
||||
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
|
||||
self.version = 1
|
||||
self.type = type
|
||||
@@ -74,6 +87,7 @@ struct BitchatPacket: Codable {
|
||||
// Create a copy without signature and with fixed TTL for signing
|
||||
// TTL must be excluded because it changes during relay
|
||||
let unsignedPacket = BitchatPacket(
|
||||
version: version, // Preserve packet version for signing
|
||||
type: type,
|
||||
senderID: senderID,
|
||||
recipientID: recipientID,
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
//
|
||||
// NoiseHandshakeCoordinator.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// Coordinates Noise handshakes to prevent race conditions and ensure reliable encryption establishment
|
||||
final class NoiseHandshakeCoordinator {
|
||||
|
||||
// MARK: - Handshake State
|
||||
|
||||
enum HandshakeState: Equatable {
|
||||
case idle
|
||||
case waitingToInitiate(since: Date)
|
||||
case initiating(attempt: Int, lastAttempt: Date)
|
||||
case responding(since: Date)
|
||||
case waitingForResponse(messagesSent: [Data], timeout: Date)
|
||||
case established(since: Date)
|
||||
case failed(reason: String, canRetry: Bool, lastAttempt: Date)
|
||||
|
||||
var isActive: Bool {
|
||||
switch self {
|
||||
case .idle, .established, .failed:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private var handshakeStates: [String: HandshakeState] = [:]
|
||||
private var handshakeQueue = DispatchQueue(label: "chat.bitchat.noise.handshake", attributes: .concurrent)
|
||||
|
||||
// Configuration
|
||||
private let maxHandshakeAttempts = 3
|
||||
private let handshakeTimeout: TimeInterval = 10.0
|
||||
private let retryDelay: TimeInterval = 2.0
|
||||
private let minTimeBetweenHandshakes: TimeInterval = 1.0 // Reduced from 5.0 for faster recovery
|
||||
private let establishedSessionTTL: TimeInterval = 300.0 // 5 minutes - sessions older than this can be cleaned up
|
||||
private let maxEstablishedSessions = 50 // Limit total established sessions
|
||||
|
||||
// Track handshake messages to detect duplicates
|
||||
private var processedHandshakeMessages: Set<Data> = []
|
||||
private let messageHistoryLimit = 100
|
||||
|
||||
// MARK: - Role Determination
|
||||
|
||||
/// Deterministically determine who should initiate the handshake
|
||||
/// Lower peer ID becomes the initiator to prevent simultaneous attempts
|
||||
func determineHandshakeRole(myPeerID: String, remotePeerID: String) -> NoiseRole {
|
||||
// Use simple string comparison for deterministic ordering
|
||||
return myPeerID < remotePeerID ? .initiator : .responder
|
||||
}
|
||||
|
||||
/// Check if we should initiate handshake with a peer
|
||||
func shouldInitiateHandshake(myPeerID: String, remotePeerID: String, forceIfStale: Bool = false) -> Bool {
|
||||
return handshakeQueue.sync {
|
||||
// Check if we're already in an active handshake
|
||||
if let state = handshakeStates[remotePeerID], state.isActive {
|
||||
// Check if the handshake is stale and we should force a new one
|
||||
if forceIfStale {
|
||||
switch state {
|
||||
case .initiating(_, let lastAttempt):
|
||||
if Date().timeIntervalSince(lastAttempt) > handshakeTimeout {
|
||||
SecureLogger.warning("Forcing new handshake with \(remotePeerID) - previous stuck in initiating", category: .handshake)
|
||||
return true
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
SecureLogger.debug("Already in active handshake with \(remotePeerID), state: \(state)", category: .handshake)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check role
|
||||
let role = determineHandshakeRole(myPeerID: myPeerID, remotePeerID: remotePeerID)
|
||||
if role != .initiator {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if we've failed recently and can't retry yet
|
||||
if case .failed(_, let canRetry, let lastAttempt) = handshakeStates[remotePeerID] {
|
||||
if !canRetry {
|
||||
return false
|
||||
}
|
||||
if Date().timeIntervalSince(lastAttempt) < retryDelay {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Record that we're initiating a handshake
|
||||
func recordHandshakeInitiation(peerID: String) {
|
||||
handshakeQueue.async(flags: .barrier) {
|
||||
let attempt = self.getCurrentAttempt(for: peerID) + 1
|
||||
self.handshakeStates[peerID] = .initiating(attempt: attempt, lastAttempt: Date())
|
||||
SecureLogger.info("Recording handshake initiation with \(peerID), attempt \(attempt)", category: .handshake)
|
||||
}
|
||||
}
|
||||
|
||||
/// Record that we're responding to a handshake
|
||||
func recordHandshakeResponse(peerID: String) {
|
||||
handshakeQueue.async(flags: .barrier) {
|
||||
self.handshakeStates[peerID] = .responding(since: Date())
|
||||
SecureLogger.info("Recording handshake response to \(peerID)", category: .handshake)
|
||||
}
|
||||
}
|
||||
|
||||
/// Record successful handshake completion
|
||||
func recordHandshakeSuccess(peerID: String) {
|
||||
handshakeQueue.async(flags: .barrier) {
|
||||
self.handshakeStates[peerID] = .established(since: Date())
|
||||
SecureLogger.info("Handshake successfully established with \(peerID)", category: .handshake)
|
||||
}
|
||||
}
|
||||
|
||||
/// Record handshake failure
|
||||
func recordHandshakeFailure(peerID: String, reason: String) {
|
||||
handshakeQueue.async(flags: .barrier) {
|
||||
let attempts = self.getCurrentAttempt(for: peerID)
|
||||
let canRetry = attempts < self.maxHandshakeAttempts
|
||||
self.handshakeStates[peerID] = .failed(reason: reason, canRetry: canRetry, lastAttempt: Date())
|
||||
SecureLogger.warning("Handshake failed with \(peerID): \(reason), canRetry: \(canRetry)", category: .handshake)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if we should accept an incoming handshake initiation
|
||||
func shouldAcceptHandshakeInitiation(myPeerID: String, remotePeerID: String) -> Bool {
|
||||
return handshakeQueue.sync {
|
||||
// If we're already established, reject new handshakes
|
||||
if case .established = handshakeStates[remotePeerID] {
|
||||
SecureLogger.debug("Rejecting handshake from \(remotePeerID) - already established", category: .handshake)
|
||||
return false
|
||||
}
|
||||
|
||||
let role = determineHandshakeRole(myPeerID: myPeerID, remotePeerID: remotePeerID)
|
||||
|
||||
// If we're the initiator and already initiating, this is a race condition
|
||||
if role == .initiator {
|
||||
if case .initiating = handshakeStates[remotePeerID] {
|
||||
// They shouldn't be initiating, but accept it to recover from race condition
|
||||
SecureLogger.warning("Accepting handshake from \(remotePeerID) despite being initiator (race condition recovery)", category: .handshake)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// If we're the responder, we should accept
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is a duplicate handshake message
|
||||
func isDuplicateHandshakeMessage(_ data: Data) -> Bool {
|
||||
return handshakeQueue.sync {
|
||||
if processedHandshakeMessages.contains(data) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Add to processed messages with size limit
|
||||
if processedHandshakeMessages.count >= messageHistoryLimit {
|
||||
processedHandshakeMessages.removeAll()
|
||||
}
|
||||
processedHandshakeMessages.insert(data)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Get time to wait before next handshake attempt
|
||||
func getRetryDelay(for peerID: String) -> TimeInterval? {
|
||||
return handshakeQueue.sync {
|
||||
guard let state = handshakeStates[peerID] else { return nil }
|
||||
|
||||
switch state {
|
||||
case .failed(_, let canRetry, let lastAttempt):
|
||||
if !canRetry { return nil }
|
||||
let timeSinceFailure = Date().timeIntervalSince(lastAttempt)
|
||||
if timeSinceFailure >= retryDelay {
|
||||
return 0
|
||||
}
|
||||
return retryDelay - timeSinceFailure
|
||||
|
||||
case .initiating(_, let lastAttempt):
|
||||
let timeSinceAttempt = Date().timeIntervalSince(lastAttempt)
|
||||
if timeSinceAttempt >= minTimeBetweenHandshakes {
|
||||
return 0
|
||||
}
|
||||
return minTimeBetweenHandshakes - timeSinceAttempt
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset handshake state for a peer
|
||||
func resetHandshakeState(for peerID: String) {
|
||||
handshakeQueue.async(flags: .barrier) {
|
||||
self.handshakeStates.removeValue(forKey: peerID)
|
||||
SecureLogger.debug("Reset handshake state for \(peerID)", category: .handshake)
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up stale handshake states and old established sessions
|
||||
func cleanupStaleHandshakes(staleTimeout: TimeInterval = 30.0) -> [String] {
|
||||
return handshakeQueue.sync {
|
||||
let now = Date()
|
||||
var stalePeerIDs: [String] = []
|
||||
var establishedSessions: [(peerID: String, since: Date)] = []
|
||||
|
||||
for (peerID, state) in handshakeStates {
|
||||
var isStale = false
|
||||
|
||||
switch state {
|
||||
case .initiating(_, let lastAttempt):
|
||||
if now.timeIntervalSince(lastAttempt) > staleTimeout {
|
||||
isStale = true
|
||||
}
|
||||
case .responding(let since):
|
||||
if now.timeIntervalSince(since) > staleTimeout {
|
||||
isStale = true
|
||||
}
|
||||
case .waitingForResponse(_, let timeout):
|
||||
if now > timeout {
|
||||
isStale = true
|
||||
}
|
||||
case .established(let since):
|
||||
// Track established sessions for potential cleanup
|
||||
establishedSessions.append((peerID, since))
|
||||
// Clean up very old established sessions
|
||||
if now.timeIntervalSince(since) > establishedSessionTTL {
|
||||
isStale = true
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if isStale {
|
||||
stalePeerIDs.append(peerID)
|
||||
SecureLogger.warning("Found stale handshake state for \(peerID): \(state)", category: .handshake)
|
||||
}
|
||||
}
|
||||
|
||||
// If we have too many established sessions, clean up the oldest ones
|
||||
if establishedSessions.count > maxEstablishedSessions {
|
||||
// Sort by age (oldest first)
|
||||
let sortedSessions = establishedSessions.sorted { $0.since < $1.since }
|
||||
let sessionsToRemove = sortedSessions.count - maxEstablishedSessions
|
||||
|
||||
for i in 0..<sessionsToRemove {
|
||||
let peerID = sortedSessions[i].peerID
|
||||
stalePeerIDs.append(peerID)
|
||||
SecureLogger.info("Removing old established session for \(peerID) to maintain session limit", category: .handshake)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up stale states
|
||||
for peerID in stalePeerIDs {
|
||||
handshakeStates.removeValue(forKey: peerID)
|
||||
}
|
||||
|
||||
if !stalePeerIDs.isEmpty {
|
||||
SecureLogger.info("Cleaned up \(stalePeerIDs.count) stale handshake states", category: .handshake)
|
||||
}
|
||||
|
||||
return stalePeerIDs
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current handshake state
|
||||
func getHandshakeState(for peerID: String) -> HandshakeState {
|
||||
return handshakeQueue.sync {
|
||||
return handshakeStates[peerID] ?? .idle
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current retry count for a peer
|
||||
func getRetryCount(for peerID: String) -> Int {
|
||||
return handshakeQueue.sync {
|
||||
switch handshakeStates[peerID] {
|
||||
case .initiating(let attempt, _):
|
||||
return attempt - 1 // Attempts start at 1, retries start at 0
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Increment retry count for a peer
|
||||
func incrementRetryCount(for peerID: String) {
|
||||
handshakeQueue.async(flags: .barrier) {
|
||||
let currentAttempt = self.getCurrentAttempt(for: peerID)
|
||||
self.handshakeStates[peerID] = .initiating(attempt: currentAttempt + 1, lastAttempt: Date())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func getCurrentAttempt(for peerID: String) -> Int {
|
||||
switch handshakeStates[peerID] {
|
||||
case .initiating(let attempt, _):
|
||||
return attempt
|
||||
case .failed(_, _, _):
|
||||
// Count previous attempts
|
||||
return 1 // Simplified for now
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Log current handshake states for debugging
|
||||
func logHandshakeStates() {
|
||||
handshakeQueue.sync {
|
||||
SecureLogger.debug("=== Handshake States ===", category: .handshake)
|
||||
for (peerID, state) in handshakeStates {
|
||||
let stateDesc: String
|
||||
switch state {
|
||||
case .idle:
|
||||
stateDesc = "idle"
|
||||
case .waitingToInitiate(let since):
|
||||
stateDesc = "waiting to initiate (since \(since))"
|
||||
case .initiating(let attempt, let lastAttempt):
|
||||
stateDesc = "initiating (attempt \(attempt), last: \(lastAttempt))"
|
||||
case .responding(let since):
|
||||
stateDesc = "responding (since: \(since))"
|
||||
case .waitingForResponse(let messages, let timeout):
|
||||
stateDesc = "waiting for response (\(messages.count) messages, timeout: \(timeout))"
|
||||
case .established(let since):
|
||||
stateDesc = "established (since \(since))"
|
||||
case .failed(let reason, let canRetry, let lastAttempt):
|
||||
stateDesc = "failed: \(reason) (canRetry: \(canRetry), last: \(lastAttempt))"
|
||||
}
|
||||
SecureLogger.debug(" \(peerID): \(stateDesc)", category: .handshake)
|
||||
}
|
||||
SecureLogger.debug("========================", category: .handshake)
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all handshake states - used during panic mode
|
||||
func clearAllHandshakeStates() {
|
||||
handshakeQueue.async(flags: .barrier) {
|
||||
SecureLogger.warning("Clearing all handshake states for panic mode", category: .handshake)
|
||||
self.handshakeStates.removeAll()
|
||||
self.processedHandshakeMessages.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,15 +210,6 @@ final class NoiseRateLimiter {
|
||||
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
|
||||
|
||||
@@ -308,15 +308,6 @@ final class NoiseSessionManager {
|
||||
_ = 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 {
|
||||
|
||||
@@ -150,31 +150,13 @@ struct NostrIdentityBridge {
|
||||
|
||||
/// 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
|
||||
// Delete current Nostr identity
|
||||
KeychainHelper.delete(key: currentIdentityKey, service: keychainService)
|
||||
KeychainHelper.delete(key: deviceSeedKey, service: keychainService)
|
||||
|
||||
// Note: We can't efficiently delete all noise-nostr associations
|
||||
// without tracking them, but they'll be orphaned and eventually cleaned up
|
||||
// The important part is deleting the current identity so a new one is generated
|
||||
}
|
||||
|
||||
// MARK: - Per-Geohash Identities (Location Channels)
|
||||
|
||||
@@ -35,14 +35,10 @@ final class NostrRelayManager: ObservableObject {
|
||||
"wss://nostr21.com"
|
||||
// For local testing, you can add: "ws://localhost:8080"
|
||||
]
|
||||
private static let defaultRelaySet = Set(defaultRelays)
|
||||
|
||||
@Published private(set) var relays: [Relay] = []
|
||||
@Published private(set) var isConnected = false
|
||||
|
||||
private var allowDefaultRelays: Bool = false
|
||||
private var hasMutualFavorites: Bool = false
|
||||
private var hasLocationPermission: Bool = false
|
||||
private var connections: [String: URLSessionWebSocketTask] = [:]
|
||||
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
|
||||
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
|
||||
@@ -69,8 +65,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
private let messageQueueLock = NSLock()
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
private var networkService: NetworkActivationService { NetworkActivationService.shared }
|
||||
private var shouldUseTor: Bool { networkService.userTorEnabled }
|
||||
|
||||
// Exponential backoff configuration
|
||||
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
|
||||
@@ -84,54 +78,28 @@ final class NostrRelayManager: ObservableObject {
|
||||
private var connectionGeneration: Int = 0
|
||||
|
||||
init() {
|
||||
hasMutualFavorites = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
||||
hasLocationPermission = LocationChannelManager.shared.permissionState == .authorized
|
||||
applyDefaultRelayPolicy(force: true)
|
||||
// Initialize with default relays
|
||||
self.relays = Self.defaultRelays.map { Relay(url: $0) }
|
||||
// Deterministic JSON shape for outbound requests
|
||||
self.encoder.outputFormatting = .sortedKeys
|
||||
FavoritesPersistenceService.shared.$mutualFavorites
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] favorites in
|
||||
guard let self = self else { return }
|
||||
self.hasMutualFavorites = !favorites.isEmpty
|
||||
self.applyDefaultRelayPolicy()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
LocationChannelManager.shared.$permissionState
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] state in
|
||||
guard let self = self else { return }
|
||||
let authorized = (state == .authorized)
|
||||
if authorized == self.hasLocationPermission { return }
|
||||
self.hasLocationPermission = authorized
|
||||
self.applyDefaultRelayPolicy()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
/// Connect to all configured relays
|
||||
func connect() {
|
||||
// Global network policy gate
|
||||
guard networkService.activationAllowed else { return }
|
||||
if shouldUseTor {
|
||||
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
|
||||
Task.detached {
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
await MainActor.run {
|
||||
if !ready {
|
||||
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
|
||||
for relay in self.relays {
|
||||
self.connectToRelay(relay.url)
|
||||
}
|
||||
if !TorManager.shared.isAutoStartAllowed() { return }
|
||||
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
|
||||
Task.detached {
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
await MainActor.run {
|
||||
if !ready {
|
||||
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
|
||||
for relay in self.relays {
|
||||
self.connectToRelay(relay.url)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (direct)", category: .session)
|
||||
for relay in self.relays {
|
||||
connectToRelay(relay.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,10 +120,8 @@ final class NostrRelayManager: ObservableObject {
|
||||
/// Ensure connections exist to the given relay URLs (idempotent).
|
||||
func ensureConnections(to relayUrls: [String]) {
|
||||
// Global network policy gate
|
||||
guard networkService.activationAllowed else { return }
|
||||
let targets = allowedRelayList(from: relayUrls)
|
||||
guard !targets.isEmpty else { return }
|
||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
if !TorManager.shared.isAutoStartAllowed() { return }
|
||||
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
// Defer until Tor is fully ready; avoid queuing connection attempts early
|
||||
Task.detached { [weak self] in
|
||||
guard let self = self else { return }
|
||||
@@ -164,21 +130,22 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
return
|
||||
}
|
||||
var existing = Set(relays.map { $0.url })
|
||||
for url in targets where !existing.contains(url) {
|
||||
relays.append(Relay(url: url))
|
||||
existing.insert(url)
|
||||
}
|
||||
for url in targets where connections[url] == nil {
|
||||
connectToRelay(url)
|
||||
let existing = Set(relays.map { $0.url })
|
||||
for url in Set(relayUrls) {
|
||||
if !existing.contains(url) {
|
||||
relays.append(Relay(url: url))
|
||||
}
|
||||
if connections[url] == nil {
|
||||
connectToRelay(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send an event to specified relays (or all if none specified)
|
||||
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
||||
// Global network policy gate
|
||||
guard networkService.activationAllowed else { return }
|
||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
if !TorManager.shared.isAutoStartAllowed() { return }
|
||||
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
// Defer sends until Tor is ready to avoid premature queueing
|
||||
Task.detached { [weak self] in
|
||||
guard let self = self else { return }
|
||||
@@ -187,9 +154,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
return
|
||||
}
|
||||
let requestedRelays = relayUrls ?? Self.defaultRelays
|
||||
let targetRelays = allowedRelayList(from: requestedRelays)
|
||||
guard !targetRelays.isEmpty else { return }
|
||||
let targetRelays = relayUrls ?? Self.defaultRelays
|
||||
ensureConnections(to: targetRelays)
|
||||
|
||||
// Attempt immediate send to relays with active connections; queue the rest
|
||||
@@ -255,7 +220,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
onEOSE: (() -> Void)? = nil
|
||||
) {
|
||||
// Global network policy gate
|
||||
guard networkService.activationAllowed else { return }
|
||||
if !TorManager.shared.isAutoStartAllowed() { return }
|
||||
// Coalesce rapid duplicate subscribe requests only if a handler already exists
|
||||
let now = Date()
|
||||
if messageHandlers[id] != nil {
|
||||
@@ -264,7 +229,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
subscribeCoalesce[id] = now
|
||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
// Defer subscription setup until Tor is ready; avoid queuing subs early
|
||||
Task.detached { [weak self] in
|
||||
guard let self = self else { return }
|
||||
@@ -292,37 +257,32 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
// Target specific relays if provided; else default. Filter permanently failed relays.
|
||||
let baseUrls = relayUrls ?? Self.defaultRelays
|
||||
let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) }
|
||||
let urls = allowedRelayList(from: candidateUrls)
|
||||
let urls = baseUrls.filter { !isPermanentlyFailed($0) }
|
||||
// Always queue subscriptions; sending happens when a relay reports connected
|
||||
let existingSet = Set(relays.map { $0.url })
|
||||
for url in urls where !existingSet.contains(url) {
|
||||
relays.append(Relay(url: url))
|
||||
}
|
||||
for url in candidateUrls {
|
||||
for url in urls {
|
||||
var map = self.pendingSubscriptions[url] ?? [:]
|
||||
map[id] = messageString
|
||||
self.pendingSubscriptions[url] = map
|
||||
}
|
||||
// Initialize EOSE tracking if requested
|
||||
if let onEOSE = onEOSE {
|
||||
if urls.isEmpty {
|
||||
onEOSE()
|
||||
} else {
|
||||
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
|
||||
// Fallback timeout to avoid hanging if a relay never sends EOSE
|
||||
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
guard let self = self else { return }
|
||||
if let t = self.eoseTrackers[id] {
|
||||
t.timer?.invalidate()
|
||||
self.eoseTrackers.removeValue(forKey: id)
|
||||
onEOSE()
|
||||
}
|
||||
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
|
||||
// Fallback timeout to avoid hanging if a relay never sends EOSE
|
||||
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
guard let self = self else { return }
|
||||
if let t = self.eoseTrackers[id] {
|
||||
t.timer?.invalidate()
|
||||
self.eoseTrackers.removeValue(forKey: id)
|
||||
onEOSE()
|
||||
}
|
||||
}
|
||||
eoseTrackers[id] = tracker
|
||||
}
|
||||
eoseTrackers[id] = tracker
|
||||
}
|
||||
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
|
||||
// Ensure we actually have sockets opening to these relays so queued REQs can flush
|
||||
@@ -337,55 +297,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
SecureLogger.error("❌ Failed to encode subscription request: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyDefaultRelayPolicy(force: Bool = false) {
|
||||
let shouldAllow = hasMutualFavorites || hasLocationPermission
|
||||
if !force && shouldAllow == allowDefaultRelays { return }
|
||||
allowDefaultRelays = shouldAllow
|
||||
if shouldAllow {
|
||||
var existing = Set(relays.map { $0.url })
|
||||
for url in Self.defaultRelays where !existing.contains(url) {
|
||||
relays.append(Relay(url: url))
|
||||
existing.insert(url)
|
||||
}
|
||||
if networkService.activationAllowed {
|
||||
ensureConnections(to: Self.defaultRelays)
|
||||
}
|
||||
} else {
|
||||
for url in Self.defaultRelays {
|
||||
if let connection = connections[url] {
|
||||
connection.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
connections.removeValue(forKey: url)
|
||||
subscriptions.removeValue(forKey: url)
|
||||
}
|
||||
messageQueueLock.lock()
|
||||
for index in (0..<messageQueue.count).reversed() {
|
||||
var item = messageQueue[index]
|
||||
item.pendingRelays.subtract(Self.defaultRelaySet)
|
||||
if item.pendingRelays.isEmpty {
|
||||
messageQueue.remove(at: index)
|
||||
} else {
|
||||
messageQueue[index] = item
|
||||
}
|
||||
}
|
||||
messageQueueLock.unlock()
|
||||
relays.removeAll { Self.defaultRelaySet.contains($0.url) }
|
||||
updateConnectionStatus()
|
||||
}
|
||||
}
|
||||
|
||||
private func allowedRelayList(from urls: [String]) -> [String] {
|
||||
var seen = Set<String>()
|
||||
var result: [String] = []
|
||||
for url in urls {
|
||||
if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue }
|
||||
if seen.insert(url).inserted {
|
||||
result.append(url)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Unsubscribe from a subscription
|
||||
func unsubscribe(id: String) {
|
||||
@@ -415,14 +326,14 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
private func connectToRelay(_ urlString: String) {
|
||||
// Global network policy gate
|
||||
guard networkService.activationAllowed else { return }
|
||||
if !TorManager.shared.isAutoStartAllowed() { return }
|
||||
guard let url = URL(string: urlString) else {
|
||||
SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
// Avoid initiating connections while app is backgrounded; we'll reconnect on foreground
|
||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isForeground() {
|
||||
if TorManager.shared.torEnforced && !TorManager.shared.isForeground() {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -437,7 +348,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
// Attempting to connect to Nostr relay via the proxied session
|
||||
|
||||
// If Tor is enforced but not ready, delay connection until it is.
|
||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
Task.detached { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
@@ -623,7 +534,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
private func handleDisconnection(relayUrl: String, error: Error) {
|
||||
// If networking is disallowed, do not schedule reconnection
|
||||
if !networkService.activationAllowed {
|
||||
if !TorManager.shared.isAutoStartAllowed() {
|
||||
connections.removeValue(forKey: relayUrl)
|
||||
subscriptions.removeValue(forKey: relayUrl)
|
||||
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
||||
|
||||
@@ -22,11 +22,12 @@
|
||||
///
|
||||
/// ## Wire Format
|
||||
/// ```
|
||||
/// Header (Fixed 13 bytes):
|
||||
/// +--------+------+-----+-----------+-------+----------------+
|
||||
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
|
||||
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes |
|
||||
/// +--------+------+-----+-----------+-------+----------------+
|
||||
/// Header (13 bytes for v1, 15 bytes for v2):
|
||||
/// +--------+------+-----+-----------+-------+---------------+
|
||||
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
|
||||
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes (v1) |
|
||||
/// | | | | | | 4 bytes (v2) |
|
||||
/// +--------+------+-----+-----------+-------+---------------+
|
||||
///
|
||||
/// Variable sections:
|
||||
/// +----------+-------------+---------+------------+
|
||||
@@ -56,9 +57,10 @@
|
||||
/// - Bits 3-7: Reserved for future use
|
||||
///
|
||||
/// ## Size Constraints
|
||||
/// - Maximum packet size: 65,535 bytes (16-bit length field)
|
||||
/// - Maximum packet size: 4.2GB (32-bit length field for v2+)
|
||||
/// - Typical packet size: < 512 bytes (BLE MTU)
|
||||
/// - Minimum packet size: 21 bytes (header + sender ID)
|
||||
/// - Legacy v1 packets: max 65,535 bytes (16-bit length field)
|
||||
///
|
||||
/// ## Encoding Process
|
||||
/// 1. Construct header with fixed fields
|
||||
@@ -105,16 +107,22 @@ extension Data {
|
||||
/// their binary wire format representation.
|
||||
/// - Note: All multi-byte values use network byte order (big-endian)
|
||||
struct BinaryProtocol {
|
||||
static let headerSize = 13
|
||||
static let headerSizeV1 = 13
|
||||
static let headerSizeV2 = 15
|
||||
static let senderIDSize = 8
|
||||
static let recipientIDSize = 8
|
||||
static let signatureSize = 64
|
||||
|
||||
|
||||
struct Flags {
|
||||
static let hasRecipient: UInt8 = 0x01
|
||||
static let hasSignature: UInt8 = 0x02
|
||||
static let isCompressed: UInt8 = 0x04
|
||||
}
|
||||
|
||||
/// Get header size based on protocol version
|
||||
static func getHeaderSize(_ version: UInt8) -> Int {
|
||||
return version >= 2 ? headerSizeV2 : headerSizeV1
|
||||
}
|
||||
|
||||
// Encode BitchatPacket to binary format
|
||||
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
|
||||
@@ -138,9 +146,10 @@ struct BinaryProtocol {
|
||||
} else {
|
||||
}
|
||||
|
||||
// Header
|
||||
// Header with version-dependent size
|
||||
let headerSize = getHeaderSize(packet.version)
|
||||
// Reserve capacity to reduce reallocations. Estimate base size conservatively.
|
||||
// header(13) + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad
|
||||
// header + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad
|
||||
let estimatedPayload = payload.count + (isCompressed ? 2 : 0)
|
||||
let estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255
|
||||
data.reserveCapacity(estimated)
|
||||
@@ -166,13 +175,20 @@ struct BinaryProtocol {
|
||||
}
|
||||
data.append(flags)
|
||||
|
||||
// Payload length (2 bytes, big-endian) - includes original size if compressed
|
||||
// Payload length - version-dependent (2 bytes for v1, 4 bytes for v2)
|
||||
let payloadDataSize = payload.count + (isCompressed ? 2 : 0)
|
||||
let payloadLength = UInt16(payloadDataSize)
|
||||
|
||||
|
||||
data.append(UInt8((payloadLength >> 8) & 0xFF))
|
||||
data.append(UInt8(payloadLength & 0xFF))
|
||||
if packet.version >= 2 {
|
||||
// v2+: 4 bytes big-endian
|
||||
data.append(UInt8((payloadDataSize >> 24) & 0xFF))
|
||||
data.append(UInt8((payloadDataSize >> 16) & 0xFF))
|
||||
data.append(UInt8((payloadDataSize >> 8) & 0xFF))
|
||||
data.append(UInt8(payloadDataSize & 0xFF))
|
||||
} else {
|
||||
// v1: 2 bytes big-endian
|
||||
let payloadLength16 = UInt16(payloadDataSize)
|
||||
data.append(UInt8((payloadLength16 >> 8) & 0xFF))
|
||||
data.append(UInt8(payloadLength16 & 0xFF))
|
||||
}
|
||||
|
||||
// SenderID (exactly 8 bytes)
|
||||
let senderBytes = packet.senderID.prefix(senderIDSize)
|
||||
@@ -227,8 +243,8 @@ struct BinaryProtocol {
|
||||
|
||||
// Core decoding implementation used by decode(_:) with and without padding removal
|
||||
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
|
||||
// Minimum size: header + senderID
|
||||
guard raw.count >= headerSize + senderIDSize else { return nil }
|
||||
// Minimum size: smallest header (v1) + senderID
|
||||
guard raw.count >= headerSizeV1 + senderIDSize else { return nil }
|
||||
|
||||
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
|
||||
guard let base = buf.baseAddress else { return nil }
|
||||
@@ -249,6 +265,14 @@ struct BinaryProtocol {
|
||||
offset += 2
|
||||
return v
|
||||
}
|
||||
// Read big-endian 32-bit
|
||||
func read32() -> UInt32? {
|
||||
guard require(4) else { return nil }
|
||||
let p = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
|
||||
let v = (UInt32(p[0]) << 24) | (UInt32(p[1]) << 16) | (UInt32(p[2]) << 8) | UInt32(p[3])
|
||||
offset += 4
|
||||
return v
|
||||
}
|
||||
// Copy N bytes into Data
|
||||
func readData(_ n: Int) -> Data? {
|
||||
guard require(n) else { return nil }
|
||||
@@ -258,8 +282,8 @@ struct BinaryProtocol {
|
||||
return d
|
||||
}
|
||||
|
||||
// Version
|
||||
guard let version = read8(), version == 1 else { return nil }
|
||||
// Version - accept both v1 and v2+
|
||||
guard let version = read8(), version >= 1 else { return nil }
|
||||
guard let type = read8() else { return nil }
|
||||
guard let ttl = read8() else { return nil }
|
||||
|
||||
@@ -277,8 +301,15 @@ struct BinaryProtocol {
|
||||
let hasSignature = (flags & Flags.hasSignature) != 0
|
||||
let isCompressed = (flags & Flags.isCompressed) != 0
|
||||
|
||||
// Payload length
|
||||
guard let payloadLen = read16(), payloadLen <= 65535 else { return nil }
|
||||
// Payload length - version-dependent (2 bytes for v1, 4 bytes for v2+)
|
||||
let payloadLen: UInt32
|
||||
if version >= 2 {
|
||||
guard let len32 = read32(), len32 <= 4_294_967_295 else { return nil }
|
||||
payloadLen = len32
|
||||
} else {
|
||||
guard let len16 = read16(), len16 <= 65535 else { return nil }
|
||||
payloadLen = UInt32(len16)
|
||||
}
|
||||
|
||||
// SenderID
|
||||
guard let senderID = readData(senderIDSize) else { return nil }
|
||||
@@ -317,6 +348,7 @@ struct BinaryProtocol {
|
||||
guard offset <= buf.count else { return nil }
|
||||
|
||||
return BitchatPacket(
|
||||
version: version,
|
||||
type: type,
|
||||
senderID: senderID,
|
||||
recipientID: recipientID,
|
||||
|
||||
@@ -78,6 +78,7 @@ enum MessageType: UInt8 {
|
||||
|
||||
// Fragmentation (simplified)
|
||||
case fragment = 0x20 // Single fragment type for large messages
|
||||
case fileTransfer = 0x22 // File transfer (audio/images) TLV payload
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
@@ -88,6 +89,7 @@ enum MessageType: UInt8 {
|
||||
case .noiseHandshake: return "noiseHandshake"
|
||||
case .noiseEncrypted: return "noiseEncrypted"
|
||||
case .fragment: return "fragment"
|
||||
case .fileTransfer: return "fileTransfer"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,21 +23,15 @@ 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")
|
||||
case .block:
|
||||
return L10n.string("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")
|
||||
case .city:
|
||||
return L10n.string("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")
|
||||
case .region:
|
||||
return L10n.string("location_levels.region", comment: "Name for region-level location channel")
|
||||
}
|
||||
case .building: return "Building"
|
||||
case .block: return "Block"
|
||||
case .neighborhood: return "Neighborhood"
|
||||
case .city: return "City"
|
||||
case .province: return "Province"
|
||||
case .region: return "Region"
|
||||
}
|
||||
}
|
||||
}
|
||||
// Backward-compatible Codable for renamed cases
|
||||
extension GeohashChannelLevel {
|
||||
init(from decoder: Decoder) throws {
|
||||
|
||||
+249
-213
@@ -7,78 +7,6 @@ import CryptoKit
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// BLEService — Bluetooth Mesh Transport
|
||||
/// - Emits events exclusively via `BitchatDelegate` for UI.
|
||||
/// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`).
|
||||
@@ -88,7 +16,7 @@ final class BLEService: NSObject {
|
||||
// MARK: - Constants
|
||||
|
||||
#if DEBUG
|
||||
static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5A") // testnet
|
||||
static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") // testnet
|
||||
#else
|
||||
static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") // mainnet
|
||||
#endif
|
||||
@@ -112,7 +40,6 @@ final class BLEService: NSObject {
|
||||
var isConnecting: Bool = false
|
||||
var isConnected: Bool = false
|
||||
var lastConnectionAttempt: Date? = nil
|
||||
var assembler = NotificationStreamAssembler()
|
||||
}
|
||||
private var peripherals: [String: PeripheralState] = [:] // UUID -> PeripheralState
|
||||
private var peerToPeripheralUUID: [String: String] = [:] // PeerID -> Peripheral UUID
|
||||
@@ -162,9 +89,8 @@ final class BLEService: NSObject {
|
||||
|
||||
var myPeerID: String = ""
|
||||
var myNickname: String = "anon"
|
||||
private var noiseService: NoiseEncryptionService
|
||||
private let noiseService: NoiseEncryptionService
|
||||
private let identityManager: SecureIdentityStateManagerProtocol
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private var myPeerIDData: Data = Data()
|
||||
|
||||
// MARK: - Advertising Privacy
|
||||
@@ -264,9 +190,7 @@ final class BLEService: NSObject {
|
||||
// MARK: - Helpers: IDs, selection, and write backpressure
|
||||
private func makeMessageID(for packet: BitchatPacket) -> String {
|
||||
let senderID = packet.senderID.hexEncodedString()
|
||||
let digest = SHA256.hash(data: packet.payload)
|
||||
let digestPrefix = digest.prefix(4).map { String(format: "%02x", $0) }.joined()
|
||||
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
|
||||
return "\(senderID)-\(packet.timestamp)-\(packet.type)"
|
||||
}
|
||||
|
||||
private func subsetSizeForFanout(_ n: Int) -> Int {
|
||||
@@ -406,45 +330,34 @@ final class BLEService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
|
||||
service.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
||||
SecureLogger.debug("🔐 Noise session authenticated with \(peerID), fingerprint: \(fingerprint.prefix(16))...")
|
||||
self?.messageQueue.async { [weak self] in
|
||||
self?.sendPendingMessagesAfterHandshake(for: peerID)
|
||||
self?.sendPendingNoisePayloadsAfterHandshake(for: peerID)
|
||||
}
|
||||
self?.messageQueue.async { [weak self] in
|
||||
self?.sendAnnounce(forceSend: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshPeerIdentity() {
|
||||
let fingerprint = noiseService.getIdentityFingerprint()
|
||||
myPeerID = String(fingerprint.prefix(16))
|
||||
myPeerIDData = Data(hexString: myPeerID) ?? Data()
|
||||
}
|
||||
|
||||
private func restartGossipManager() {
|
||||
gossipSyncManager?.stop()
|
||||
let sync = GossipSyncManager(myPeerID: myPeerID)
|
||||
sync.delegate = self
|
||||
sync.start()
|
||||
gossipSyncManager = sync
|
||||
}
|
||||
|
||||
init(keychain: KeychainManagerProtocol, identityManager: SecureIdentityStateManagerProtocol) {
|
||||
self.keychain = keychain
|
||||
noiseService = NoiseEncryptionService(keychain: keychain)
|
||||
self.identityManager = identityManager
|
||||
super.init()
|
||||
|
||||
configureNoiseServiceCallbacks(for: noiseService)
|
||||
refreshPeerIdentity()
|
||||
// Derive stable peer ID from Noise static public key fingerprint (first 8 bytes → 16 hex chars)
|
||||
let fingerprint = noiseService.getIdentityFingerprint() // 64 hex chars
|
||||
self.myPeerID = String(fingerprint.prefix(16))
|
||||
self.myPeerIDData = Data(hexString: myPeerID) ?? Data()
|
||||
|
||||
// Set queue key for identification
|
||||
messageQueue.setSpecific(key: messageQueueKey, value: ())
|
||||
|
||||
// Set up Noise session establishment callback
|
||||
// This ensures we send pending messages only when session is truly established
|
||||
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
||||
SecureLogger.debug("🔐 Noise session authenticated with \(peerID), fingerprint: \(fingerprint.prefix(16))...")
|
||||
// Send any messages that were queued during handshake
|
||||
self?.messageQueue.async { [weak self] in
|
||||
self?.sendPendingMessagesAfterHandshake(for: peerID)
|
||||
self?.sendPendingNoisePayloadsAfterHandshake(for: peerID)
|
||||
}
|
||||
// Proactive presence nudge: announce immediately after handshake
|
||||
self?.messageQueue.async { [weak self] in
|
||||
self?.sendAnnounce(forceSend: true)
|
||||
}
|
||||
}
|
||||
|
||||
// Set up application state tracking (iOS only)
|
||||
#if os(iOS)
|
||||
// Check initial state on main thread
|
||||
@@ -494,7 +407,10 @@ final class BLEService: NSObject {
|
||||
requestPeerDataPublish()
|
||||
|
||||
// Initialize gossip sync manager
|
||||
restartGossipManager()
|
||||
let sync = GossipSyncManager(myPeerID: myPeerID)
|
||||
sync.delegate = self
|
||||
sync.start()
|
||||
self.gossipSyncManager = sync
|
||||
}
|
||||
|
||||
func setNickname(_ nickname: String) {
|
||||
@@ -822,46 +738,6 @@ final class BLEService: NSObject {
|
||||
subscribedCentrals.removeAll()
|
||||
centralToPeerID.removeAll()
|
||||
}
|
||||
|
||||
func resetIdentityForPanic(currentNickname: String) {
|
||||
messageQueue.sync(flags: .barrier) {
|
||||
pendingMessagesAfterHandshake.removeAll()
|
||||
pendingNoisePayloadsAfterHandshake.removeAll()
|
||||
}
|
||||
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
recentAnnounceBySender.removeAll()
|
||||
recentAnnounceOrder.removeAll()
|
||||
pendingPeripheralWrites.removeAll()
|
||||
pendingNotifications.removeAll()
|
||||
pendingDirectedRelays.removeAll()
|
||||
ingressByMessageID.removeAll()
|
||||
recentPacketTimestamps.removeAll()
|
||||
scheduledRelays.values.forEach { $0.cancel() }
|
||||
scheduledRelays.removeAll()
|
||||
}
|
||||
|
||||
bleQueue.sync {
|
||||
pendingWriteBuffers.removeAll()
|
||||
recentConnectTimeouts.removeAll()
|
||||
}
|
||||
recentDisconnectNotifies.removeAll()
|
||||
|
||||
noiseService.clearEphemeralStateForPanic()
|
||||
noiseService.clearPersistentIdentity()
|
||||
|
||||
let newNoise = NoiseEncryptionService(keychain: keychain)
|
||||
noiseService = newNoise
|
||||
configureNoiseServiceCallbacks(for: newNoise)
|
||||
refreshPeerIdentity()
|
||||
restartGossipManager()
|
||||
|
||||
setNickname(currentNickname)
|
||||
|
||||
messageDeduplicator.reset()
|
||||
requestPeerDataPublish()
|
||||
startServices()
|
||||
}
|
||||
|
||||
func getNoiseService() -> NoiseEncryptionService {
|
||||
return noiseService
|
||||
@@ -1185,13 +1061,6 @@ final class BLEService: NSObject {
|
||||
// Determine last-hop link for this message to avoid echoing back
|
||||
let messageID = makeMessageID(for: packet)
|
||||
let ingressLink: LinkID? = collectionsQueue.sync { ingressByMessageID[messageID]?.link }
|
||||
let directedPeerHint: String? = {
|
||||
if let explicit = directedOnlyPeer { return explicit }
|
||||
if let recipient = packet.recipientID?.hexEncodedString(), !recipient.isEmpty {
|
||||
return recipient
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
let states = snapshotPeripheralStates()
|
||||
var minCentralWriteLen: Int?
|
||||
@@ -1245,7 +1114,7 @@ final class BLEService: NSObject {
|
||||
// Special-case control/presence messages: do NOT subset to maximize immediate coverage
|
||||
var selectedPeripheralIDs = Set(allowedPeripheralIDs)
|
||||
var selectedCentralIDs = Set(allowedCentralIDs)
|
||||
if directedPeerHint == nil
|
||||
if directedOnlyPeer == nil
|
||||
&& packet.type != MessageType.fragment.rawValue
|
||||
&& packet.type != MessageType.announce.rawValue
|
||||
&& packet.type != MessageType.requestSync.rawValue {
|
||||
@@ -1256,7 +1125,7 @@ final class BLEService: NSObject {
|
||||
}
|
||||
|
||||
// If directed and we currently have no links to forward on, spool for a short window
|
||||
if let only = directedPeerHint,
|
||||
if let only = directedOnlyPeer,
|
||||
selectedPeripheralIDs.isEmpty && selectedCentralIDs.isEmpty,
|
||||
(packet.type == MessageType.noiseEncrypted.rawValue || packet.type == MessageType.noiseHandshake.rawValue) {
|
||||
spoolDirectedPacket(packet, recipientPeerID: only)
|
||||
@@ -1407,6 +1276,127 @@ final class BLEService: NSObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// File transfer with progress: send TLV payload inside a FILE_TRANSFER packet.
|
||||
// Reports .partiallyDelivered via delegate for the provided messageID during fragmentation.
|
||||
func sendFileTransferTLV(_ payload: Data, recipientPeerID: String?, transferId: String, messageID: String) {
|
||||
// Build packet
|
||||
let recipientData: Data? = recipientPeerID.flatMap { Data(hexString: $0) }
|
||||
var packet = BitchatPacket(
|
||||
version: 2, // FILE_TRANSFER uses v2 for 4-byte payload length to support large files
|
||||
type: MessageType.fileTransfer.rawValue,
|
||||
senderID: myPeerIDData,
|
||||
recipientID: recipientData,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
// Log debug info about the file TLV
|
||||
if let fp = BitchatFilePacket.decode(from: payload) {
|
||||
let sha = fp.content.sha256Hex()
|
||||
let scope = (recipientData == nil || recipientData == Data(repeating: 0xFF, count: 8)) ? "broadcast" : "private"
|
||||
SecureLogger.debug("📤 FILE_TRANSFER send (\(scope)) name=\(fp.fileName) mime=\(fp.mimeType) size=\(fp.fileSize) sha256=\(sha)", category: .session)
|
||||
} else {
|
||||
SecureLogger.warning("⚠️ FILE_TRANSFER send: failed to decode TLV for logging (payload=\(payload.count) bytes)", category: .session)
|
||||
}
|
||||
// For private sends, sign (integrity)
|
||||
if recipientData != nil {
|
||||
if let signed = noiseService.signPacket(packet) { packet = signed }
|
||||
}
|
||||
|
||||
// Encode once to decide if fragmentation is needed on current links
|
||||
let pad = padPolicy(for: packet.type)
|
||||
guard let data = packet.toBinaryData(padding: pad) else { return }
|
||||
|
||||
// Determine max link MTU across active links similar to sendOnAllLinks
|
||||
let states = snapshotPeripheralStates()
|
||||
var minCentralWriteLen: Int?
|
||||
for s in states where s.isConnected {
|
||||
let m = s.peripheral.maximumWriteValueLength(for: .withoutResponse)
|
||||
minCentralWriteLen = minCentralWriteLen.map { min($0, m) } ?? m
|
||||
}
|
||||
var minNotifyLen: Int?
|
||||
do {
|
||||
let (centrals, _) = snapshotSubscribedCentrals()
|
||||
if !centrals.isEmpty {
|
||||
minNotifyLen = centrals.map { $0.maximumUpdateValueLength }.min()
|
||||
}
|
||||
}
|
||||
|
||||
let needsFragment = {
|
||||
if let minLen = [minCentralWriteLen, minNotifyLen].compactMap({ $0 }).min() {
|
||||
return data.count > minLen
|
||||
}
|
||||
return false
|
||||
}()
|
||||
|
||||
if needsFragment {
|
||||
let overhead = 13 + 8 + 8 + 13
|
||||
let minLen = [minCentralWriteLen, minNotifyLen].compactMap({ $0 }).min() ?? defaultFragmentSize
|
||||
let chunk = max(64, minLen - overhead)
|
||||
|
||||
// Create fragments and emit progress per fragment
|
||||
guard let fullData = packet.toBinaryData(padding: pad) else { return }
|
||||
let fragmentID = Data((0..<8).map { _ in UInt8.random(in: 0...255) })
|
||||
let fragments = stride(from: 0, to: fullData.count, by: chunk).map { offset in
|
||||
Data(fullData[offset..<min(offset + chunk, fullData.count)])
|
||||
}
|
||||
let total = fragments.count
|
||||
|
||||
// Pause scanning for long trains as in sendFragmentedPacket
|
||||
if total > 4 {
|
||||
bleQueue.async { [weak self] in
|
||||
guard let self = self, let c = self.centralManager, c.state == .poweredOn else { return }
|
||||
if c.isScanning { c.stopScan() }
|
||||
let expectedMs = min(TransportConfig.bleExpectedWriteMaxMs, total * TransportConfig.bleExpectedWritePerFragmentMs)
|
||||
self.bleQueue.asyncAfter(deadline: .now() + .milliseconds(expectedMs)) { [weak self] in self?.startScanning() }
|
||||
}
|
||||
}
|
||||
|
||||
for (index, fragment) in fragments.enumerated() {
|
||||
var fragPayload = Data()
|
||||
fragPayload.append(fragmentID)
|
||||
fragPayload.append(contentsOf: withUnsafeBytes(of: UInt16(index).bigEndian) { Data($0) })
|
||||
fragPayload.append(contentsOf: withUnsafeBytes(of: UInt16(total).bigEndian) { Data($0) })
|
||||
fragPayload.append(packet.type)
|
||||
fragPayload.append(fragment)
|
||||
|
||||
let fragmentRecipient: Data? = recipientData
|
||||
let fragmentPacket = BitchatPacket(
|
||||
type: MessageType.fragment.rawValue,
|
||||
senderID: packet.senderID,
|
||||
recipientID: fragmentRecipient,
|
||||
timestamp: packet.timestamp,
|
||||
payload: fragPayload,
|
||||
signature: nil,
|
||||
ttl: packet.ttl
|
||||
)
|
||||
let perFragMs = (recipientData != nil) ? TransportConfig.bleFragmentSpacingDirectedMs : TransportConfig.bleFragmentSpacingMs
|
||||
let delayMs = index * perFragMs
|
||||
|
||||
// Emit progress to UI just before scheduling send
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: index, total: total))
|
||||
}
|
||||
|
||||
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in
|
||||
self?.broadcastPacket(fragmentPacket)
|
||||
if index == total - 1 {
|
||||
self?.notifyUI { [weak self] in
|
||||
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single packet path
|
||||
broadcastPacket(packet)
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleFragment(_ packet: BitchatPacket, from peerID: String) {
|
||||
// Don't process our own fragments
|
||||
@@ -1549,6 +1539,9 @@ final class BLEService: NSObject {
|
||||
|
||||
case .fragment:
|
||||
handleFragment(packet, from: senderID)
|
||||
|
||||
case .fileTransfer:
|
||||
handleFileTransfer(packet, from: senderID)
|
||||
|
||||
case .leave:
|
||||
handleLeave(packet, from: senderID)
|
||||
@@ -1591,6 +1584,72 @@ final class BLEService: NSObject {
|
||||
messageQueue.asyncAfter(deadline: .now() + .milliseconds(decision.delayMs), execute: work)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleFileTransfer(_ packet: BitchatPacket, from peerID: String) {
|
||||
// Decode TLV
|
||||
guard let file = BitchatFilePacket.decode(from: packet.payload) else {
|
||||
SecureLogger.error("❌ Failed to decode file TLV from \(peerID)", category: .session)
|
||||
return
|
||||
}
|
||||
let sha = file.content.sha256Hex()
|
||||
let scope = (packet.recipientID == nil || packet.recipientID == Data(repeating: 0xFF, count: 8)) ? "broadcast" : "private"
|
||||
SecureLogger.debug("📥 FILE_TRANSFER recv (\(scope)) from=\(peerID.prefix(8))… name=\(file.fileName) mime=\(file.mimeType) size=\(file.fileSize) sha256=\(sha)", category: .session)
|
||||
// Determine if this is a true direct message; Android may use 0xFF..FF to denote broadcast
|
||||
let isBroadcastRecipient: Bool = {
|
||||
guard let rid = packet.recipientID else { return true }
|
||||
return rid == Data(repeating: 0xFF, count: 8)
|
||||
}()
|
||||
// Persist to app files in type-specific folder
|
||||
let isAudio = file.mimeType.lowercased().hasPrefix("audio/")
|
||||
let subfolder = isAudio ? "voicenotes/incoming" : "images/incoming"
|
||||
let ext: String = {
|
||||
if let inferred = file.fileName.split(separator: ".").last { return String(inferred) }
|
||||
if isAudio { return "m4a" }
|
||||
return "bin"
|
||||
}()
|
||||
let unique = "\(Int(Date().timeIntervalSince1970))_\(UUID().uuidString.prefix(8)).\(ext)"
|
||||
guard let savedURL = saveToAppFiles(subpath: subfolder, fileName: unique, data: file.content) else { return }
|
||||
|
||||
// Build synthetic message
|
||||
let nickname = peerNickname(peerID: peerID) ?? "user"
|
||||
let ts = Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000.0)
|
||||
let marker = isAudio ? "[voice]" : "[image]"
|
||||
let content = "\(marker) \(savedURL.path)"
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: UUID().uuidString,
|
||||
sender: nickname,
|
||||
content: content,
|
||||
timestamp: ts,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: (packet.recipientID != nil) && !isBroadcastRecipient,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: peerID,
|
||||
mentions: nil,
|
||||
deliveryStatus: nil
|
||||
)
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didReceiveMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveToAppFiles(subpath: String, fileName: String, data: Data) -> URL? {
|
||||
do {
|
||||
let fm = FileManager.default
|
||||
let base = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let folder = base.appendingPathComponent("bitchat_\(subpath)", isDirectory: true)
|
||||
if !fm.fileExists(atPath: folder.path) {
|
||||
try fm.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
}
|
||||
let url = folder.appendingPathComponent(fileName)
|
||||
try data.write(to: url, options: .atomic)
|
||||
return url
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to save incoming file: \(error)", category: .session)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAnnounce(_ packet: BitchatPacket, from peerID: String) {
|
||||
guard let announcement = AnnouncementPacket.decode(from: packet.payload) else {
|
||||
@@ -2597,8 +2656,7 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
peerID: nil,
|
||||
isConnecting: true,
|
||||
isConnected: false,
|
||||
lastConnectionAttempt: Date(),
|
||||
assembler: NotificationStreamAssembler()
|
||||
lastConnectionAttempt: Date()
|
||||
)
|
||||
peripheral.delegate = self
|
||||
|
||||
@@ -2647,9 +2705,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
|
||||
characteristic: nil,
|
||||
peerID: nil,
|
||||
isConnecting: false,
|
||||
isConnected: true,
|
||||
lastConnectionAttempt: nil,
|
||||
assembler: NotificationStreamAssembler()
|
||||
isConnected: true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2787,8 +2843,7 @@ extension BLEService {
|
||||
peerID: nil,
|
||||
isConnecting: true,
|
||||
isConnected: false,
|
||||
lastConnectionAttempt: Date(),
|
||||
assembler: NotificationStreamAssembler()
|
||||
lastConnectionAttempt: Date()
|
||||
)
|
||||
peripheral.delegate = self
|
||||
let options: [String: Any] = [
|
||||
@@ -2925,72 +2980,53 @@ extension BLEService: CBPeripheralDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = characteristic.value, !data.isEmpty else {
|
||||
guard let data = characteristic.value else {
|
||||
SecureLogger.warning("⚠️ No data in notification", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
bufferNotificationChunk(data, from: peripheral)
|
||||
}
|
||||
|
||||
private func bufferNotificationChunk(_ chunk: Data, from peripheral: CBPeripheral) {
|
||||
let peripheralUUID = peripheral.identifier.uuidString
|
||||
|
||||
var state = peripherals[peripheralUUID] ?? PeripheralState(
|
||||
peripheral: peripheral,
|
||||
characteristic: nil,
|
||||
peerID: nil,
|
||||
isConnecting: false,
|
||||
isConnected: peripheral.state == .connected,
|
||||
lastConnectionAttempt: nil,
|
||||
assembler: NotificationStreamAssembler()
|
||||
)
|
||||
|
||||
var assembler = state.assembler
|
||||
let result = assembler.append(chunk)
|
||||
state.assembler = assembler
|
||||
peripherals[peripheralUUID] = state
|
||||
|
||||
for byte in result.droppedPrefixes {
|
||||
SecureLogger.warning("⚠️ Dropping byte from BLE stream (unexpected prefix \(String(format: "%02x", byte)))", category: .session)
|
||||
|
||||
// Received BLE notification
|
||||
|
||||
// Process directly on main thread to avoid deadlocks (matches original implementation)
|
||||
guard let packet = BinaryProtocol.decode(data) else {
|
||||
// Avoid dumping entire payload; log size and short prefix for diagnostics
|
||||
let prefix = data.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
|
||||
SecureLogger.error("❌ Failed to decode notification packet (len=\(data.count), prefix=\(prefix))", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
if result.reset {
|
||||
SecureLogger.error("❌ Invalid BLE frame length; reset notification stream", category: .session)
|
||||
}
|
||||
|
||||
for frame in result.frames {
|
||||
guard let packet = BinaryProtocol.decode(frame) else {
|
||||
let prefix = frame.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
|
||||
SecureLogger.error("❌ Failed to decode assembled notification frame (len=\(frame.count), prefix=\(prefix))", category: .session)
|
||||
continue
|
||||
}
|
||||
processNotificationPacket(packet, from: peripheral, peripheralUUID: peripheralUUID)
|
||||
}
|
||||
}
|
||||
|
||||
private func processNotificationPacket(_ packet: BitchatPacket, from peripheral: CBPeripheral, peripheralUUID: String) {
|
||||
|
||||
// Use the packet's senderID as the peer identifier
|
||||
let senderID = packet.senderID.hexEncodedString()
|
||||
|
||||
if packet.type != MessageType.announce.rawValue {
|
||||
SecureLogger.debug("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID)", category: .session)
|
||||
}
|
||||
|
||||
// Only log non-announce packets
|
||||
if packet.type != MessageType.announce.rawValue {
|
||||
SecureLogger.debug("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID)", category: .session)
|
||||
}
|
||||
|
||||
let peripheralUUID = peripheral.identifier.uuidString
|
||||
|
||||
// Update mapping ONLY for announce packets that come directly from the peer (not relayed)
|
||||
if packet.type == MessageType.announce.rawValue {
|
||||
// Only update mapping if this is a direct announce (TTL == messageTTL means not relayed)
|
||||
if packet.ttl == messageTTL {
|
||||
if var state = peripherals[peripheralUUID] {
|
||||
state.peerID = senderID
|
||||
peripherals[peripheralUUID] = state
|
||||
}
|
||||
peerToPeripheralUUID[senderID] = peripheralUUID
|
||||
// Mapping update - direct announce from peer
|
||||
}
|
||||
|
||||
// Record ingress link for last-hop suppression and process
|
||||
let msgID = makeMessageID(for: packet)
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())
|
||||
}
|
||||
// Process the announce packet regardless of whether we updated the mapping
|
||||
handleReceivedPacket(packet, from: senderID)
|
||||
} else {
|
||||
// For non-announce packets, DO NOT update mappings
|
||||
// These could be relayed packets from other peers
|
||||
// Always use the packet's original senderID
|
||||
// Record ingress link for last-hop suppression and process
|
||||
let msgID = makeMessageID(for: packet)
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())
|
||||
|
||||
@@ -282,7 +282,6 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
"com.bitchat.deviceidentity",
|
||||
"com.bitchat.noise.identity",
|
||||
"chat.bitchat.passwords",
|
||||
"chat.bitchat.nostr",
|
||||
"bitchat.keychain",
|
||||
"bitchat",
|
||||
"com.bitchat"
|
||||
|
||||
@@ -1,34 +1,5 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
struct LocationNotesCounterDependencies {
|
||||
typealias RelayLookup = @MainActor (_ geohash: String, _ count: Int) -> [String]
|
||||
typealias Subscribe = @MainActor (_ filter: NostrFilter, _ id: String, _ relays: [String], _ handler: @escaping (NostrEvent) -> Void, _ onEOSE: (() -> Void)?) -> Void
|
||||
typealias Unsubscribe = @MainActor (_ id: String) -> Void
|
||||
|
||||
var relayLookup: RelayLookup
|
||||
var subscribe: Subscribe
|
||||
var unsubscribe: Unsubscribe
|
||||
|
||||
static let live = LocationNotesCounterDependencies(
|
||||
relayLookup: { geohash, count in
|
||||
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
|
||||
},
|
||||
subscribe: { filter, id, relays, handler, onEOSE in
|
||||
NostrRelayManager.shared.subscribe(
|
||||
filter: filter,
|
||||
id: id,
|
||||
relayUrls: relays,
|
||||
handler: handler,
|
||||
onEOSE: onEOSE
|
||||
)
|
||||
},
|
||||
unsubscribe: { id in
|
||||
NostrRelayManager.shared.unsubscribe(id: id)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Lightweight background counter for location notes (kind 1) at building-level geohash (8 chars).
|
||||
@MainActor
|
||||
final class LocationNotesCounter: ObservableObject {
|
||||
@@ -37,45 +8,29 @@ final class LocationNotesCounter: ObservableObject {
|
||||
@Published private(set) var geohash: String? = nil
|
||||
@Published private(set) var count: Int? = 0
|
||||
@Published private(set) var initialLoadComplete: Bool = false
|
||||
@Published private(set) var relayAvailable: Bool = true
|
||||
|
||||
private var subscriptionID: String? = nil
|
||||
private var noteIDs = Set<String>()
|
||||
private let dependencies: LocationNotesCounterDependencies
|
||||
|
||||
private init(dependencies: LocationNotesCounterDependencies = .live) {
|
||||
self.dependencies = dependencies
|
||||
}
|
||||
|
||||
init(testDependencies: LocationNotesCounterDependencies) {
|
||||
self.dependencies = testDependencies
|
||||
}
|
||||
private init() {}
|
||||
|
||||
func subscribe(geohash gh: String) {
|
||||
let norm = gh.lowercased()
|
||||
if geohash == norm, subscriptionID != nil { return }
|
||||
// Unsubscribe previous without clearing count to avoid flicker
|
||||
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
||||
if let sub = subscriptionID { NostrRelayManager.shared.unsubscribe(id: sub) }
|
||||
subscriptionID = nil
|
||||
geohash = norm
|
||||
noteIDs.removeAll()
|
||||
initialLoadComplete = false
|
||||
relayAvailable = true
|
||||
|
||||
// Subscribe only to the building geohash (precision 8)
|
||||
let subID = "locnotes-count-\(norm)-\(UUID().uuidString.prefix(6))"
|
||||
let relays = dependencies.relayLookup(norm, TransportConfig.nostrGeoRelayCount)
|
||||
guard !relays.isEmpty else {
|
||||
relayAvailable = false
|
||||
initialLoadComplete = true
|
||||
count = 0
|
||||
SecureLogger.warning("LocationNotesCounter: no geo relays for geohash=\(norm)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
subscriptionID = subID
|
||||
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 500)
|
||||
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
||||
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: norm, count: TransportConfig.nostrGeoRelayCount)
|
||||
let relayUrls: [String]? = relays.isEmpty ? nil : relays
|
||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: relayUrls, handler: { [weak self] event in
|
||||
guard let self = self else { return }
|
||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == norm }) else { return }
|
||||
@@ -83,17 +38,16 @@ final class LocationNotesCounter: ObservableObject {
|
||||
self.noteIDs.insert(event.id)
|
||||
self.count = self.noteIDs.count
|
||||
}
|
||||
}, { [weak self] in
|
||||
}, onEOSE: { [weak self] in
|
||||
self?.initialLoadComplete = true
|
||||
})
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
||||
if let sub = subscriptionID { NostrRelayManager.shared.unsubscribe(id: sub) }
|
||||
subscriptionID = nil
|
||||
geohash = nil
|
||||
count = 0
|
||||
noteIDs.removeAll()
|
||||
relayAvailable = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +1,10 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// Dependencies for location notes, allowing tests to stub relay/identity behavior.
|
||||
struct LocationNotesDependencies {
|
||||
typealias RelayLookup = @MainActor (_ geohash: String, _ count: Int) -> [String]
|
||||
typealias Subscribe = @MainActor (_ filter: NostrFilter, _ id: String, _ relays: [String], _ handler: @escaping (NostrEvent) -> Void, _ onEOSE: (() -> Void)?) -> Void
|
||||
typealias Unsubscribe = @MainActor (_ id: String) -> Void
|
||||
typealias SendEvent = @MainActor (_ event: NostrEvent, _ relayUrls: [String]) -> Void
|
||||
|
||||
var relayLookup: RelayLookup
|
||||
var subscribe: Subscribe
|
||||
var unsubscribe: Unsubscribe
|
||||
var sendEvent: SendEvent
|
||||
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
|
||||
var now: () -> Date
|
||||
|
||||
static let live = LocationNotesDependencies(
|
||||
relayLookup: { geohash, count in
|
||||
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
|
||||
},
|
||||
subscribe: { filter, id, relays, handler, onEOSE in
|
||||
NostrRelayManager.shared.subscribe(
|
||||
filter: filter,
|
||||
id: id,
|
||||
relayUrls: relays,
|
||||
handler: handler,
|
||||
onEOSE: onEOSE
|
||||
)
|
||||
},
|
||||
unsubscribe: { id in
|
||||
NostrRelayManager.shared.unsubscribe(id: id)
|
||||
},
|
||||
sendEvent: { event, relays in
|
||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
||||
},
|
||||
deriveIdentity: { geohash in
|
||||
try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
|
||||
},
|
||||
now: { Date() }
|
||||
)
|
||||
}
|
||||
|
||||
/// Persistent location notes (Nostr kind 1) scoped to a building-level geohash (precision 8).
|
||||
/// Persistent location notes (Nostr kind 1) scoped to a street-level geohash (precision 7).
|
||||
/// Subscribes to and publishes notes for a given geohash and provides a send API.
|
||||
@MainActor
|
||||
final class LocationNotesManager: ObservableObject {
|
||||
enum State: Equatable {
|
||||
case idle
|
||||
case loading
|
||||
case ready
|
||||
case noRelays
|
||||
}
|
||||
|
||||
struct Note: Identifiable, Equatable {
|
||||
let id: String
|
||||
let pubkey: String
|
||||
@@ -71,29 +24,10 @@ final class LocationNotesManager: ObservableObject {
|
||||
@Published private(set) var notes: [Note] = [] // reverse-chron sorted
|
||||
@Published private(set) var geohash: String
|
||||
@Published private(set) var initialLoadComplete: Bool = false
|
||||
@Published private(set) var state: State = .loading
|
||||
@Published private(set) var errorMessage: String?
|
||||
private var subscriptionID: String?
|
||||
private let dependencies: LocationNotesDependencies
|
||||
|
||||
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 func failedToSend(_ detail: String) -> String {
|
||||
let format = NSLocalizedString(
|
||||
"location_notes.error.failed_to_send",
|
||||
comment: "Shown when a location note fails to send"
|
||||
)
|
||||
return String(format: format, detail)
|
||||
}
|
||||
}
|
||||
|
||||
init(geohash: String, dependencies: LocationNotesDependencies = .live) {
|
||||
init(geohash: String) {
|
||||
self.geohash = geohash.lowercased()
|
||||
self.dependencies = dependencies
|
||||
subscribe()
|
||||
}
|
||||
|
||||
@@ -101,7 +35,7 @@ final class LocationNotesManager: ObservableObject {
|
||||
let norm = newGeohash.lowercased()
|
||||
guard norm != geohash else { return }
|
||||
if let sub = subscriptionID {
|
||||
dependencies.unsubscribe(sub)
|
||||
NostrRelayManager.shared.unsubscribe(id: sub)
|
||||
subscriptionID = nil
|
||||
}
|
||||
geohash = norm
|
||||
@@ -109,43 +43,15 @@ final class LocationNotesManager: ObservableObject {
|
||||
subscribe()
|
||||
}
|
||||
|
||||
func refresh() {
|
||||
if let sub = subscriptionID {
|
||||
dependencies.unsubscribe(sub)
|
||||
subscriptionID = nil
|
||||
}
|
||||
notes.removeAll()
|
||||
subscribe()
|
||||
}
|
||||
|
||||
func clearError() {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
private func subscribe() {
|
||||
state = .loading
|
||||
errorMessage = nil
|
||||
if let sub = subscriptionID {
|
||||
dependencies.unsubscribe(sub)
|
||||
subscriptionID = nil
|
||||
}
|
||||
let subID = "locnotes-\(geohash)-\(UUID().uuidString.prefix(8))"
|
||||
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||
guard !relays.isEmpty else {
|
||||
subscriptionID = nil
|
||||
initialLoadComplete = true
|
||||
state = .noRelays
|
||||
errorMessage = Strings.noRelays
|
||||
SecureLogger.warning("LocationNotesManager: no geo relays for geohash=\(geohash)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
subscriptionID = subID
|
||||
initialLoadComplete = false
|
||||
// For persistent notes, allow relays to return recent history without an aggressive time cutoff
|
||||
let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200)
|
||||
|
||||
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
||||
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
|
||||
let relayUrls: [String]? = relays.isEmpty ? nil : relays
|
||||
initialLoadComplete = false
|
||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: relayUrls, handler: { [weak self] event in
|
||||
guard let self = self else { return }
|
||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||
// Ensure matching tag
|
||||
@@ -156,13 +62,8 @@ final class LocationNotesManager: ObservableObject {
|
||||
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.state = .ready
|
||||
}, { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.initialLoadComplete = true
|
||||
if self.state != .noRelays {
|
||||
self.state = .ready
|
||||
}
|
||||
}, onEOSE: { [weak self] in
|
||||
self?.initialLoadComplete = true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -170,46 +71,29 @@ final class LocationNotesManager: ObservableObject {
|
||||
func send(content: String, nickname: String) {
|
||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||
guard !relays.isEmpty else {
|
||||
state = .noRelays
|
||||
errorMessage = Strings.noRelays
|
||||
SecureLogger.warning("LocationNotesManager: send blocked, no geo relays for geohash=\(geohash)", category: .session)
|
||||
return
|
||||
}
|
||||
do {
|
||||
let id = try dependencies.deriveIdentity(geohash)
|
||||
let id = try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
|
||||
let event = try NostrProtocol.createGeohashTextNote(
|
||||
content: trimmed,
|
||||
geohash: geohash,
|
||||
senderIdentity: id,
|
||||
nickname: nickname
|
||||
)
|
||||
dependencies.sendEvent(event, relays)
|
||||
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
|
||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
||||
// Optimistic local-echo
|
||||
let echo = Note(
|
||||
id: event.id,
|
||||
pubkey: id.publicKeyHex,
|
||||
content: trimmed,
|
||||
createdAt: dependencies.now(),
|
||||
nickname: nickname
|
||||
)
|
||||
let echo = Note(id: event.id, pubkey: id.publicKeyHex, content: trimmed, createdAt: Date(), nickname: nickname)
|
||||
self.notes.insert(echo, at: 0)
|
||||
self.state = .ready
|
||||
self.errorMessage = nil
|
||||
} catch {
|
||||
SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session)
|
||||
errorMessage = Strings.failedToSend(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicitly cancel subscription and release resources.
|
||||
func cancel() {
|
||||
if let sub = subscriptionID {
|
||||
dependencies.unsubscribe(sub)
|
||||
NostrRelayManager.shared.unsubscribe(id: sub)
|
||||
subscriptionID = nil
|
||||
}
|
||||
state = .idle
|
||||
errorMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,9 @@ final class NetworkActivationService: ObservableObject {
|
||||
static let shared = NetworkActivationService()
|
||||
|
||||
@Published private(set) var activationAllowed: Bool = false
|
||||
@Published private(set) var userTorEnabled: Bool = true
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var started = false
|
||||
private let torPreferenceKey = "networkActivationService.userTorEnabled"
|
||||
private var torAutoStartDesired: Bool = false
|
||||
|
||||
private init() {}
|
||||
|
||||
@@ -23,23 +20,9 @@ final class NetworkActivationService: ObservableObject {
|
||||
guard !started else { return }
|
||||
started = true
|
||||
|
||||
if let stored = UserDefaults.standard.object(forKey: torPreferenceKey) as? Bool {
|
||||
userTorEnabled = stored
|
||||
} else {
|
||||
userTorEnabled = true
|
||||
}
|
||||
|
||||
// Initial compute
|
||||
let allowed = basePolicyAllowed()
|
||||
activationAllowed = allowed
|
||||
torAutoStartDesired = allowed && userTorEnabled
|
||||
TorManager.shared.setAutoStartAllowed(torAutoStartDesired)
|
||||
applyTorState(torDesired: torAutoStartDesired)
|
||||
if allowed {
|
||||
NostrRelayManager.shared.connect()
|
||||
} else {
|
||||
NostrRelayManager.shared.disconnect()
|
||||
}
|
||||
activationAllowed = Self.computeAllowed()
|
||||
TorManager.shared.setAutoStartAllowed(activationAllowed)
|
||||
|
||||
// React to location permission changes
|
||||
LocationChannelManager.shared.$permissionState
|
||||
@@ -58,56 +41,30 @@ final class NetworkActivationService: ObservableObject {
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func setUserTorEnabled(_ enabled: Bool) {
|
||||
guard enabled != userTorEnabled else { return }
|
||||
userTorEnabled = enabled
|
||||
UserDefaults.standard.set(enabled, forKey: torPreferenceKey)
|
||||
NotificationCenter.default.post(
|
||||
name: .TorUserPreferenceChanged,
|
||||
object: nil,
|
||||
userInfo: ["enabled": enabled]
|
||||
)
|
||||
reevaluate()
|
||||
}
|
||||
|
||||
private func reevaluate() {
|
||||
let allowed = basePolicyAllowed()
|
||||
let torDesired = allowed && userTorEnabled
|
||||
let statusChanged = allowed != activationAllowed
|
||||
let torChanged = torDesired != torAutoStartDesired
|
||||
if statusChanged {
|
||||
let allowed = Self.computeAllowed()
|
||||
if allowed != activationAllowed {
|
||||
SecureLogger.info("NetworkActivationService: activationAllowed -> \(allowed)", category: .session)
|
||||
activationAllowed = allowed
|
||||
}
|
||||
if statusChanged || torChanged {
|
||||
torAutoStartDesired = torDesired
|
||||
TorManager.shared.setAutoStartAllowed(torDesired)
|
||||
applyTorState(torDesired: torDesired)
|
||||
}
|
||||
|
||||
if allowed {
|
||||
if torChanged {
|
||||
// Reset relay sockets when switching transport path (Tor ↔︎ direct)
|
||||
TorManager.shared.setAutoStartAllowed(allowed)
|
||||
if allowed {
|
||||
// Kick Tor + relays if we're now permitted
|
||||
TorManager.shared.startIfNeeded()
|
||||
// If app is in foreground, begin relay connections
|
||||
if TorManager.shared.isForeground() {
|
||||
NostrRelayManager.shared.connect()
|
||||
}
|
||||
} else {
|
||||
// Transitioned to disallowed: disconnect relays and shut down Tor
|
||||
NostrRelayManager.shared.disconnect()
|
||||
TorManager.shared.goDormantOnBackground()
|
||||
}
|
||||
NostrRelayManager.shared.connect()
|
||||
} else if statusChanged {
|
||||
NostrRelayManager.shared.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private func basePolicyAllowed() -> Bool {
|
||||
private static func computeAllowed() -> Bool {
|
||||
let permOK = LocationChannelManager.shared.permissionState == .authorized
|
||||
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
||||
return permOK || hasMutual
|
||||
}
|
||||
|
||||
private func applyTorState(torDesired: Bool) {
|
||||
TorURLSession.shared.setProxyMode(useTor: torDesired)
|
||||
if torDesired {
|
||||
TorManager.shared.startIfNeeded()
|
||||
} else {
|
||||
TorManager.shared.shutdownCompletely()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
/// ## Integration Points
|
||||
/// - **BLEService**: Calls this service for all private messages
|
||||
/// - **ChatViewModel**: Monitors encryption status for UI indicators
|
||||
/// - **NoiseHandshakeCoordinator**: Prevents handshake race conditions
|
||||
/// - **KeychainManager**: Secure storage for identity keys
|
||||
///
|
||||
/// ## Thread Safety
|
||||
@@ -115,60 +116,15 @@ enum EncryptionStatus: Equatable {
|
||||
var description: String {
|
||||
switch self {
|
||||
case .none:
|
||||
return L10n.string(
|
||||
"encryption.status.failed",
|
||||
comment: "Status text when encryption failed"
|
||||
)
|
||||
return "Encryption failed"
|
||||
case .noHandshake:
|
||||
return L10n.string(
|
||||
"encryption.status.not_encrypted",
|
||||
comment: "Status text when no encryption handshake happened"
|
||||
)
|
||||
return "Not encrypted"
|
||||
case .noiseHandshaking:
|
||||
return L10n.string(
|
||||
"encryption.status.establishing",
|
||||
comment: "Status text when encryption is being established"
|
||||
)
|
||||
return "Establishing encryption..."
|
||||
case .noiseSecured:
|
||||
return L10n.string(
|
||||
"encryption.status.secured",
|
||||
comment: "Status text when encryption is secured but not verified"
|
||||
)
|
||||
return "Encrypted"
|
||||
case .noiseVerified:
|
||||
return L10n.string(
|
||||
"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"
|
||||
)
|
||||
case .noHandshake:
|
||||
return L10n.string(
|
||||
"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"
|
||||
)
|
||||
case .noiseSecured:
|
||||
return L10n.string(
|
||||
"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 "Encrypted & Verified"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -570,15 +526,6 @@ final class NoiseEncryptionService {
|
||||
|
||||
SecureLogger.info(.sessionExpired(peerID: peerID))
|
||||
}
|
||||
|
||||
func clearEphemeralStateForPanic() {
|
||||
sessionManager.removeAllSessions()
|
||||
serviceQueue.sync(flags: .barrier) {
|
||||
peerFingerprints.removeAll()
|
||||
fingerprintToPeerID.removeAll()
|
||||
}
|
||||
rateLimiter.resetAll()
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ import Combine
|
||||
|
||||
// Minimal Nostr transport conforming to Transport for offline sending
|
||||
final class NostrTransport: Transport {
|
||||
func sendFileTransferTLV(_ payload: Data, recipientPeerID: String?, transferId: String, messageID: String) {
|
||||
return
|
||||
}
|
||||
|
||||
weak var delegate: BitchatDelegate?
|
||||
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||
|
||||
@@ -18,17 +18,13 @@ struct RelayController {
|
||||
isAnnounce: Bool,
|
||||
degree: Int,
|
||||
highDegreeThreshold: Int) -> RelayDecision {
|
||||
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
|
||||
|
||||
// Suppress obvious non-relays
|
||||
if ttlCap <= 1 || senderIsSelf {
|
||||
return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)
|
||||
}
|
||||
if ttl <= 1 || senderIsSelf { return RelayDecision(shouldRelay: false, newTTL: ttl, delayMs: 0) }
|
||||
|
||||
// For session-critical or directed traffic, be deterministic and reliable
|
||||
if isHandshake || isDirectedFragment || isDirectedEncrypted {
|
||||
// Always relay with no TTL cap for these types
|
||||
let newTTL = ttlCap &- 1
|
||||
let newTTL = (ttl &- 1)
|
||||
// Slight jitter to desynchronize without adding too much latency
|
||||
// Tighter for faster multi-hop handshakes and directed DMs
|
||||
let delayRange: ClosedRange<Int> = isHandshake ? 10...35 : 20...60
|
||||
@@ -36,17 +32,28 @@ struct RelayController {
|
||||
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
||||
}
|
||||
|
||||
// Degree-aware probability to reduce floods in dense graphs (broadcast/public)
|
||||
let baseProb: Double
|
||||
switch degree {
|
||||
case 0...2: baseProb = 1.0
|
||||
case 3...4: baseProb = 0.9
|
||||
case 5...6: baseProb = 0.7
|
||||
case 7...9: baseProb = 0.55
|
||||
default: baseProb = 0.45
|
||||
}
|
||||
let prob = baseProb
|
||||
let shouldRelay = Double.random(in: 0...1) <= prob
|
||||
|
||||
// TTL clamping for broadcast
|
||||
// - Dense graphs: keep lower but still allow multi-hop bridging
|
||||
// - Announces get a bit more headroom
|
||||
let ttlLimit: UInt8 = {
|
||||
if degree >= highDegreeThreshold {
|
||||
return max(UInt8(2), min(ttlCap, UInt8(5)))
|
||||
}
|
||||
let preferred = UInt8(isAnnounce ? 7 : 6)
|
||||
return max(UInt8(2), min(ttlCap, preferred))
|
||||
// - Dense graphs: keep very low to avoid floods
|
||||
// - Sparse graphs: allow slightly longer reach for multi-hop discovery
|
||||
// - Announces in sparse graphs get a bit more headroom
|
||||
let ttlCap: UInt8 = {
|
||||
if degree >= highDegreeThreshold { return 3 }
|
||||
return isAnnounce ? 7 : 6
|
||||
}()
|
||||
let newTTL = ttlLimit &- 1
|
||||
let clamped = max(1, min(ttl, ttlCap))
|
||||
let newTTL = clamped &- 1
|
||||
|
||||
// Wider jitter window to allow duplicate suppression to win more often
|
||||
// For sparse graphs (<=2), relay quickly to avoid cancellation races
|
||||
@@ -57,6 +64,6 @@ struct RelayController {
|
||||
case 6...9: delayMs = Int.random(in: 80...180)
|
||||
default: delayMs = Int.random(in: 100...220)
|
||||
}
|
||||
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
||||
return RelayDecision(shouldRelay: shouldRelay, newTTL: newTTL, delayMs: delayMs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,6 @@ final class TorManager: ObservableObject {
|
||||
private var controlMonitorStarted = false
|
||||
private var pathMonitor: NWPathMonitor?
|
||||
private var isAppForeground: Bool = true
|
||||
private var isDormant: Bool = false
|
||||
private var lastRestartAt: Date? = nil
|
||||
// Global policy gate: only allow Tor to start when true
|
||||
private(set) var allowAutoStart: Bool = false
|
||||
@@ -84,7 +83,6 @@ final class TorManager: ObservableObject {
|
||||
guard isAppForeground else { return }
|
||||
guard !didStart else { return }
|
||||
didStart = true
|
||||
isDormant = false
|
||||
isStarting = true
|
||||
lastError = nil
|
||||
// Announce initial start so UI can show a status message
|
||||
@@ -494,39 +492,18 @@ final class TorManager: ObservableObject {
|
||||
return true
|
||||
}
|
||||
if !claimed { return }
|
||||
if await self.resumeTorIfPossible() {
|
||||
await MainActor.run {
|
||||
self.restarting = false
|
||||
self.isStarting = false
|
||||
}
|
||||
return
|
||||
}
|
||||
await self.restartTor()
|
||||
await MainActor.run { self.restarting = false }
|
||||
}
|
||||
}
|
||||
|
||||
func goDormantOnBackground() {
|
||||
// Prefer Tor's DORMANT mode so we can resume on foreground without a full restart.
|
||||
// If the control port is unreachable, fall back to a hard shutdown.
|
||||
// Stricter model: fully stop Tor when app backgrounds to save power
|
||||
// and avoid half-suspended states. We'll restart cleanly on .active.
|
||||
Task.detached { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let signaled = await self.controlSendSignal("DORMANT")
|
||||
if signaled {
|
||||
SecureLogger.info("TorManager: signalled DORMANT", category: .session)
|
||||
await MainActor.run {
|
||||
self.isDormant = true
|
||||
self.isReady = false
|
||||
self.socksReady = false
|
||||
self.isStarting = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
SecureLogger.warning("TorManager: DORMANT signal failed; shutting down", category: .session)
|
||||
_ = tor_host_shutdown()
|
||||
await MainActor.run {
|
||||
self.isDormant = false
|
||||
self.isReady = false
|
||||
self.socksReady = false
|
||||
self.bootstrapProgress = 0
|
||||
@@ -539,24 +516,6 @@ final class TorManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func shutdownCompletely() {
|
||||
Task.detached { [weak self] in
|
||||
guard let self = self else { return }
|
||||
_ = tor_host_shutdown()
|
||||
await MainActor.run {
|
||||
self.isDormant = false
|
||||
self.isReady = false
|
||||
self.socksReady = false
|
||||
self.bootstrapProgress = 0
|
||||
self.bootstrapSummary = ""
|
||||
self.isStarting = false
|
||||
self.didStart = false
|
||||
self.restarting = false
|
||||
self.controlMonitorStarted = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func restartTor() async {
|
||||
await MainActor.run {
|
||||
// Announce restart so UI can notify the user
|
||||
@@ -566,28 +525,14 @@ final class TorManager: ObservableObject {
|
||||
self.bootstrapProgress = 0
|
||||
self.bootstrapSummary = ""
|
||||
self.isStarting = true
|
||||
self.isDormant = false
|
||||
self.lastRestartAt = Date()
|
||||
}
|
||||
// Prefer clean shutdown via owning controller FD; join the tor thread
|
||||
_ = tor_host_shutdown()
|
||||
// As a fallback, try control signal if needed (harmless if tor already down)
|
||||
_ = await controlSendSignal("SHUTDOWN")
|
||||
// Allow Tor thread to fully terminate before re-starting.
|
||||
var waited = 0
|
||||
while tor_host_is_running() != 0 && waited < 40 {
|
||||
try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
|
||||
waited += 1
|
||||
}
|
||||
if waited >= 40 {
|
||||
SecureLogger.warning("TorManager: tor_host_is_running still true before restart", category: .session)
|
||||
}
|
||||
// Allow control monitor and start logic to reinitialize cleanly
|
||||
await MainActor.run {
|
||||
self.controlMonitorStarted = false
|
||||
self.didStart = false
|
||||
}
|
||||
// Now start fresh
|
||||
await MainActor.run { self.didStart = false }
|
||||
await MainActor.run { self.startIfNeeded() }
|
||||
}
|
||||
|
||||
@@ -653,62 +598,6 @@ final class TorManager: ObservableObject {
|
||||
return (text?.contains("250")) == true
|
||||
}
|
||||
|
||||
private func resumeTorIfPossible() async -> Bool {
|
||||
let wasDormant = await MainActor.run { self.isDormant }
|
||||
let pendingReady = await MainActor.run { self.socksReady && !self.isReady }
|
||||
let needsWake = wasDormant || pendingReady
|
||||
if !needsWake {
|
||||
return false
|
||||
}
|
||||
|
||||
let activated = await controlSendSignal("ACTIVE")
|
||||
let pinged = await controlPingBootstrap(timeout: 3.0)
|
||||
if !activated && !pinged {
|
||||
SecureLogger.warning("TorManager: ACTIVE signal failed", category: .session)
|
||||
return false
|
||||
}
|
||||
|
||||
if let info = await controlGetBootstrapInfo() {
|
||||
await MainActor.run {
|
||||
self.bootstrapProgress = info.progress
|
||||
self.bootstrapSummary = info.summary
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
self.isDormant = false
|
||||
self.isStarting = true
|
||||
self.socksReady = false
|
||||
}
|
||||
|
||||
let firstReady = await waitForSocksReady(timeout: 12.0)
|
||||
if firstReady {
|
||||
await MainActor.run {
|
||||
self.socksReady = true
|
||||
self.isStarting = false
|
||||
}
|
||||
SecureLogger.info("TorManager: resumed Tor via ACTIVE signal", category: .session)
|
||||
return true
|
||||
}
|
||||
|
||||
if pinged {
|
||||
let secondReady = await waitForSocksReady(timeout: 20.0)
|
||||
await MainActor.run {
|
||||
self.socksReady = secondReady
|
||||
self.isStarting = !secondReady
|
||||
}
|
||||
if secondReady {
|
||||
SecureLogger.info("TorManager: resumed Tor after extended wait", category: .session)
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
await MainActor.run { self.isStarting = false }
|
||||
}
|
||||
|
||||
SecureLogger.warning("TorManager: ACTIVE resume failed; will restart", category: .session)
|
||||
return false
|
||||
}
|
||||
|
||||
private func controlExchange(lines: [String], timeout: TimeInterval) async -> String? {
|
||||
guard let cookiePath = dataDirectoryURL()?.appendingPathComponent("control_auth_cookie"),
|
||||
let cookie = try? Data(contentsOf: cookiePath) else { return nil }
|
||||
|
||||
@@ -4,5 +4,4 @@ extension Notification.Name {
|
||||
static let TorDidBecomeReady = Notification.Name("TorDidBecomeReady")
|
||||
static let TorWillRestart = Notification.Name("TorWillRestart")
|
||||
static let TorWillStart = Notification.Name("TorWillStart")
|
||||
static let TorUserPreferenceChanged = Notification.Name("TorUserPreferenceChanged")
|
||||
}
|
||||
|
||||
@@ -4,34 +4,43 @@ import CFNetwork
|
||||
#endif
|
||||
|
||||
/// Provides a shared URLSession that routes traffic via Tor's SOCKS5 proxy
|
||||
/// when Tor is enforced/ready. Allows swapping between proxied and direct
|
||||
/// sessions so UI can toggle Tor usage at runtime.
|
||||
/// when Tor is enforced/ready. Falls back to a default session only when
|
||||
/// compiled with the `BITCHAT_DEV_ALLOW_CLEARNET` flag.
|
||||
final class TorURLSession {
|
||||
static let shared = TorURLSession()
|
||||
|
||||
// Default (no proxy) session for direct Nostr access when Tor is disabled.
|
||||
private var defaultSession: URLSession = TorURLSession.makeDefaultSession()
|
||||
// Default (no proxy) session for local development when dev bypass is enabled.
|
||||
private var defaultSession: URLSession = {
|
||||
let cfg = URLSessionConfiguration.default
|
||||
cfg.waitsForConnectivity = true
|
||||
return URLSession(configuration: cfg)
|
||||
}()
|
||||
|
||||
// Proxied (SOCKS5) session that routes through Tor.
|
||||
private var torSession: URLSession = TorURLSession.makeTorSession()
|
||||
private var useTorProxy: Bool = true
|
||||
|
||||
var session: URLSession {
|
||||
useTorProxy ? torSession : defaultSession
|
||||
#if BITCHAT_DEV_ALLOW_CLEARNET
|
||||
// Dev bypass: use direct session. Call sites may still await Tor if desired.
|
||||
return defaultSession
|
||||
#else
|
||||
// Production: always use the Tor-proxied session. Call sites ensure readiness.
|
||||
return torSession
|
||||
#endif
|
||||
}
|
||||
|
||||
// Recreate sessions so new clients bind to the fresh SOCKS/control ports after a Tor restart.
|
||||
func rebuild() {
|
||||
defaultSession = TorURLSession.makeDefaultSession()
|
||||
#if BITCHAT_DEV_ALLOW_CLEARNET
|
||||
defaultSession = {
|
||||
let cfg = URLSessionConfiguration.default
|
||||
cfg.waitsForConnectivity = true
|
||||
return URLSession(configuration: cfg)
|
||||
}()
|
||||
#endif
|
||||
torSession = TorURLSession.makeTorSession()
|
||||
}
|
||||
|
||||
func setProxyMode(useTor: Bool) {
|
||||
guard useTorProxy != useTor else { return }
|
||||
useTorProxy = useTor
|
||||
rebuild()
|
||||
}
|
||||
|
||||
private static func makeTorSession() -> URLSession {
|
||||
let cfg = URLSessionConfiguration.ephemeral
|
||||
cfg.waitsForConnectivity = true
|
||||
@@ -54,10 +63,4 @@ final class TorURLSession {
|
||||
#endif
|
||||
return URLSession(configuration: cfg)
|
||||
}
|
||||
|
||||
private static func makeDefaultSession() -> URLSession {
|
||||
let cfg = URLSessionConfiguration.default
|
||||
cfg.waitsForConnectivity = true
|
||||
return URLSession(configuration: cfg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ protocol Transport: AnyObject {
|
||||
func sendBroadcastAnnounce()
|
||||
func sendDeliveryAck(for messageID: String, to peerID: String)
|
||||
|
||||
// Media transfer (file transfer TLV)
|
||||
func sendFileTransferTLV(_ payload: Data, recipientPeerID: String?, transferId: String, messageID: String)
|
||||
|
||||
// QR verification (optional for transports)
|
||||
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
||||
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
||||
|
||||
@@ -188,7 +188,7 @@ enum TransportConfig {
|
||||
static let uiWindowStepCount: Int = 200
|
||||
|
||||
// Share extension
|
||||
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
|
||||
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 0.3
|
||||
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
|
||||
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import CryptoKit
|
||||
// Golomb-Coded Set (GCS) filter utilities for sync.
|
||||
// Hashing:
|
||||
// - Packet ID is 16 bytes (see PacketIdUtil). For GCS mapping, use h64 = first 8 bytes of SHA-256 over the 16-byte ID.
|
||||
// - Map to [1, M) by computing (h64 % M) and remapping 0 -> 1 to avoid zero-length deltas.
|
||||
// - Map to [0, M) via (h64 % M).
|
||||
// Encoding (v1):
|
||||
// - Sort mapped values ascending; encode deltas (first is v0, then vi - v{i-1}) as positive integers x >= 1.
|
||||
// - Golomb-Rice with parameter P: q = (x - 1) >> P encoded as unary (q ones then a zero), then write P-bit remainder r = (x - 1) & ((1<<P)-1).
|
||||
@@ -29,40 +29,25 @@ enum GCSFilter {
|
||||
|
||||
static func buildFilter(ids: [Data], maxBytes: Int, targetFpr: Double) -> Params {
|
||||
let p = deriveP(targetFpr: targetFpr)
|
||||
guard !ids.isEmpty else {
|
||||
return Params(p: p, m: 1, data: Data())
|
||||
}
|
||||
|
||||
let cap = estimateMaxElements(sizeBytes: maxBytes, p: p)
|
||||
let selected = Array(ids.prefix(cap))
|
||||
let range = max(1, hashRange(count: selected.count, p: p))
|
||||
let modulo = UInt64(range)
|
||||
|
||||
var mapped = selected
|
||||
.map { h64($0) }
|
||||
.map { mapHash($0, modulo: modulo) }
|
||||
.sorted()
|
||||
mapped = normalizeMappedValues(mapped, modulo: modulo)
|
||||
|
||||
if mapped.isEmpty {
|
||||
return Params(p: p, m: range, data: Data())
|
||||
}
|
||||
|
||||
let n = min(ids.count, cap)
|
||||
let selected = Array(ids.prefix(n))
|
||||
// Map to [0, M)
|
||||
let mInit = UInt32(n << p)
|
||||
var mapped = selected.map { id16 -> UInt64 in
|
||||
let h = h64(id16)
|
||||
return UInt64(h % UInt64(max(1, mInit)))
|
||||
}.sorted()
|
||||
var encoded = encode(sorted: mapped, p: p)
|
||||
var trimmedCount = mapped.count
|
||||
|
||||
while encoded.count > maxBytes && trimmedCount > 0 {
|
||||
if trimmedCount == 1 {
|
||||
mapped.removeAll()
|
||||
encoded = Data()
|
||||
break
|
||||
}
|
||||
trimmedCount = max(1, (trimmedCount * 9) / 10)
|
||||
mapped = Array(mapped.prefix(trimmedCount))
|
||||
var trimmedN = n
|
||||
// Trim if over budget
|
||||
while encoded.count > maxBytes && trimmedN > 0 {
|
||||
trimmedN = (trimmedN * 9) / 10 // drop ~10%
|
||||
mapped = Array(mapped.prefix(trimmedN))
|
||||
encoded = encode(sorted: mapped, p: p)
|
||||
}
|
||||
|
||||
return Params(p: p, m: range, data: encoded)
|
||||
let finalM = UInt32(max(1, trimmedN << p))
|
||||
return Params(p: p, m: finalM, data: encoded)
|
||||
}
|
||||
|
||||
static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] {
|
||||
@@ -92,12 +77,6 @@ enum GCSFilter {
|
||||
return false
|
||||
}
|
||||
|
||||
static func bucket(for id: Data, modulus m: UInt32) -> UInt64 {
|
||||
let modulo = UInt64(max(1, m))
|
||||
guard modulo > 1 else { return 0 }
|
||||
return mapHash(h64(id), modulo: modulo)
|
||||
}
|
||||
|
||||
private static func h64(_ id16: Data) -> UInt64 {
|
||||
var hasher = SHA256()
|
||||
hasher.update(data: id16)
|
||||
@@ -109,39 +88,6 @@ enum GCSFilter {
|
||||
return x & 0x7fff_ffff_ffff_ffff
|
||||
}
|
||||
|
||||
private static func hashRange(count: Int, p: Int) -> UInt32 {
|
||||
guard count > 0 else { return 1 }
|
||||
if p >= 64 { return UInt32.max }
|
||||
let multiplier = UInt64(1) << UInt64(p)
|
||||
let (product, overflow) = UInt64(count).multipliedReportingOverflow(by: multiplier)
|
||||
if overflow { return UInt32.max }
|
||||
if product == 0 { return 1 }
|
||||
return product > UInt64(UInt32.max) ? UInt32.max : UInt32(product)
|
||||
}
|
||||
|
||||
private static func mapHash(_ hash: UInt64, modulo: UInt64) -> UInt64 {
|
||||
guard modulo > 1 else { return 0 }
|
||||
let value = hash % modulo
|
||||
if value == 0 { return 1 }
|
||||
return value
|
||||
}
|
||||
|
||||
private static func normalizeMappedValues(_ values: [UInt64], modulo: UInt64) -> [UInt64] {
|
||||
guard modulo > 1 else { return [] }
|
||||
guard !values.isEmpty else { return [] }
|
||||
var result: [UInt64] = []
|
||||
result.reserveCapacity(values.count)
|
||||
var last: UInt64 = 0
|
||||
for value in values {
|
||||
let normalized = min(value, modulo - 1)
|
||||
if normalized > last {
|
||||
result.append(normalized)
|
||||
last = normalized
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func encode(sorted: [UInt64], p: Int) -> Data {
|
||||
let writer = BitWriter()
|
||||
var prev: UInt64 = 0
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// Gossip-based sync manager using on-demand GCS filters
|
||||
final class GossipSyncManager {
|
||||
@@ -52,12 +53,6 @@ final class GossipSyncManager {
|
||||
}
|
||||
|
||||
func onPublicPacketSeen(_ packet: BitchatPacket) {
|
||||
queue.async { [weak self] in
|
||||
self?._onPublicPacketSeen(packet)
|
||||
}
|
||||
}
|
||||
|
||||
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
||||
let mt = MessageType(rawValue: packet.type)
|
||||
let isBroadcastRecipient: Bool = {
|
||||
guard let r = packet.recipientID else { return true }
|
||||
@@ -124,17 +119,18 @@ final class GossipSyncManager {
|
||||
}
|
||||
|
||||
func handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
|
||||
queue.async { [weak self] in
|
||||
self?._handleRequestSync(fromPeerID: fromPeerID, request: request)
|
||||
}
|
||||
}
|
||||
|
||||
private func _handleRequestSync(fromPeerID: String, 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 {
|
||||
let bucket = GCSFilter.bucket(for: id, modulus: request.m)
|
||||
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
||||
var hasher = SHA256()
|
||||
hasher.update(data: id) // 16-byte PacketId
|
||||
let digest = hasher.finalize()
|
||||
let db = Data(digest)
|
||||
var x: UInt64 = 0
|
||||
let take = min(8, db.count)
|
||||
for i in 0..<take { x = (x << 8) | UInt64(db[i]) }
|
||||
let v = (x & 0x7fff_ffff_ffff_ffff) % UInt64(request.m)
|
||||
return GCSFilter.contains(sortedValues: sorted, candidate: v)
|
||||
}
|
||||
|
||||
// 1) Announcements: send latest per peer if requester lacks them
|
||||
@@ -186,15 +182,9 @@ final class GossipSyncManager {
|
||||
|
||||
// Explicit removal hook for LEAVE/stale peer
|
||||
func removeAnnouncementForPeer(_ peerID: String) {
|
||||
queue.async { [weak self] in
|
||||
self?._removeAnnouncementForPeer(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
private func _removeAnnouncementForPeer(_ peerID: String) {
|
||||
let normalizedPeerID = peerID.lowercased()
|
||||
_ = 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
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Provides Dynamic Type aware font helpers that map existing fixed sizes onto
|
||||
/// preferred text styles so the UI scales with user accessibility settings.
|
||||
extension Font {
|
||||
static func bitchatSystem(size: CGFloat, weight: Font.Weight = .regular, design: Font.Design = .default) -> Font {
|
||||
let style = Font.TextStyle.bitchatPreferredStyle(for: size)
|
||||
var font = Font.system(style, design: design)
|
||||
if weight != .regular {
|
||||
font = font.weight(weight)
|
||||
}
|
||||
return font
|
||||
}
|
||||
}
|
||||
|
||||
private extension Font.TextStyle {
|
||||
static func bitchatPreferredStyle(for size: CGFloat) -> Font.TextStyle {
|
||||
switch size {
|
||||
case ..<11.5:
|
||||
return .caption2
|
||||
case ..<13.0:
|
||||
return .caption
|
||||
case ..<13.75:
|
||||
return .footnote
|
||||
case ..<15.5:
|
||||
return .subheadline
|
||||
case ..<17.5:
|
||||
return .callout
|
||||
case ..<19.5:
|
||||
return .body
|
||||
case ..<22.5:
|
||||
return .title3
|
||||
case ..<27.5:
|
||||
return .title2
|
||||
case ..<34.0:
|
||||
return .title
|
||||
default:
|
||||
return .largeTitle
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,7 @@ struct InputValidator {
|
||||
|
||||
struct Limits {
|
||||
static let maxNicknameLength = 50
|
||||
// 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 maxMessageLength = 10_000
|
||||
static let maxReasonLength = 200
|
||||
static let maxPeerIDLength = 64
|
||||
static let hexPeerIDLength = 16 // 8 bytes = 16 hex chars
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// ChatViewModel+Extensions.swift
|
||||
// bitchat
|
||||
//
|
||||
// Deprecated helper. No longer needed after MessageHeaderLine simplification.
|
||||
// Intentionally left empty to avoid accessing private properties across files.
|
||||
|
||||
// This file remains as a placeholder to preserve project references if any.
|
||||
@@ -0,0 +1,113 @@
|
||||
// ChatViewModel+Images.swift
|
||||
// bitchat
|
||||
//
|
||||
// Extracted image sending helpers to keep ChatViewModel compact.
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
extension ChatViewModel {
|
||||
// MARK: - Images (Send)
|
||||
#if os(iOS)
|
||||
@MainActor
|
||||
func sendImage(_ image: UIImage) {
|
||||
// Downscale on long edge to 512px with 85% JPEG quality (Android parity)
|
||||
guard let data = downscaleJPEG(image, maxDimension: 512, quality: 0.85) else { return }
|
||||
|
||||
// Persist to app files (outgoing)
|
||||
guard let fileURL = saveOutgoingImage(data: data) else { return }
|
||||
|
||||
let name = fileURL.lastPathComponent
|
||||
let mime = "image/jpeg"
|
||||
let tlv = BitchatFilePacket(fileName: name, fileSize: UInt32(data.count), mimeType: mime, content: data)
|
||||
guard let payload = tlv.encode() else { return }
|
||||
let transferId = payload.sha256Hex()
|
||||
|
||||
let contentMarker = "[image] \(fileURL.path)"
|
||||
let now = Date()
|
||||
|
||||
if let peer = selectedPrivateChatPeer {
|
||||
let messageID = UUID().uuidString
|
||||
let msg = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: nickname,
|
||||
content: contentMarker,
|
||||
timestamp: now,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: meshService.peerNickname(peerID: peer),
|
||||
senderPeerID: meshService.myPeerID,
|
||||
mentions: nil,
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
// appendToPrivateChat is private in ChatViewModel; inline minimal append
|
||||
var arr = privateChats[peer] ?? []
|
||||
arr.append(msg)
|
||||
privateChats[peer] = arr
|
||||
objectWillChange.send()
|
||||
meshService.sendFileTransferTLV(payload, recipientPeerID: peer, transferId: transferId, messageID: messageID)
|
||||
} else {
|
||||
let messageID = UUID().uuidString
|
||||
let msg = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: nickname,
|
||||
content: contentMarker,
|
||||
timestamp: now,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: meshService.myPeerID,
|
||||
mentions: nil,
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
// public buffer helpers are private; append directly to visible messages
|
||||
messages.append(msg)
|
||||
meshService.sendFileTransferTLV(payload, recipientPeerID: nil, transferId: transferId, messageID: messageID)
|
||||
}
|
||||
}
|
||||
|
||||
// Downscale without cropping; preserves aspect ratio. Long edge == maxDimension.
|
||||
internal func downscaleJPEG(_ image: UIImage, maxDimension: CGFloat, quality: CGFloat) -> Data? {
|
||||
let size = image.size
|
||||
guard size.width > 0 && size.height > 0 else { return image.jpegData(compressionQuality: quality) }
|
||||
let maxSide = max(size.width, size.height)
|
||||
let scale = min(1.0, maxDimension / maxSide)
|
||||
let target = CGSize(width: floor(size.width * scale), height: floor(size.height * scale))
|
||||
|
||||
let rendererFormat = UIGraphicsImageRendererFormat.default()
|
||||
rendererFormat.scale = 1
|
||||
let renderer = UIGraphicsImageRenderer(size: target, format: rendererFormat)
|
||||
let scaled = renderer.image { _ in
|
||||
image.draw(in: CGRect(origin: .zero, size: target))
|
||||
}
|
||||
return scaled.jpegData(compressionQuality: quality)
|
||||
}
|
||||
|
||||
internal func saveOutgoingImage(data: Data) -> URL? {
|
||||
do {
|
||||
let fm = FileManager.default
|
||||
let base = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let folder = base.appendingPathComponent("bitchat_images/outgoing", isDirectory: true)
|
||||
if !fm.fileExists(atPath: folder.path) {
|
||||
try fm.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
}
|
||||
let ts = Int(Date().timeIntervalSince1970)
|
||||
let fileURL = folder.appendingPathComponent("img_\(ts)_\(UUID().uuidString.prefix(8)).jpg")
|
||||
try data.write(to: fileURL, options: .atomic)
|
||||
return fileURL
|
||||
} catch {
|
||||
// Avoid BitLogger dependency in this small extension
|
||||
print("❌ Failed to save outgoing image: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+97
-129
@@ -18,77 +18,41 @@ struct AppInfoView: View {
|
||||
|
||||
// MARK: - Constants
|
||||
private enum Strings {
|
||||
static let appName: LocalizedStringKey = "app_info.app_name"
|
||||
static let tagline: LocalizedStringKey = "app_info.tagline"
|
||||
|
||||
static let appName = "bitchat"
|
||||
static let tagline = "sidegroupchat"
|
||||
|
||||
enum Features {
|
||||
static let title: LocalizedStringKey = "app_info.features.title"
|
||||
static let offlineComm = AppInfoFeatureInfo(
|
||||
icon: "wifi.slash",
|
||||
title: "app_info.features.offline.title",
|
||||
description: "app_info.features.offline.description"
|
||||
)
|
||||
static let encryption = AppInfoFeatureInfo(
|
||||
icon: "lock.shield",
|
||||
title: "app_info.features.encryption.title",
|
||||
description: "app_info.features.encryption.description"
|
||||
)
|
||||
static let extendedRange = AppInfoFeatureInfo(
|
||||
icon: "antenna.radiowaves.left.and.right",
|
||||
title: "app_info.features.extended_range.title",
|
||||
description: "app_info.features.extended_range.description"
|
||||
)
|
||||
static let mentions = AppInfoFeatureInfo(
|
||||
icon: "at",
|
||||
title: "app_info.features.mentions.title",
|
||||
description: "app_info.features.mentions.description"
|
||||
)
|
||||
static let favorites = AppInfoFeatureInfo(
|
||||
icon: "star.fill",
|
||||
title: "app_info.features.favorites.title",
|
||||
description: "app_info.features.favorites.description"
|
||||
)
|
||||
static let geohash = AppInfoFeatureInfo(
|
||||
icon: "number",
|
||||
title: "app_info.features.geohash.title",
|
||||
description: "app_info.features.geohash.description"
|
||||
)
|
||||
static let title = "FEATURES"
|
||||
static let offlineComm = ("wifi.slash", "offline communication", "works without internet using Bluetooth low energy")
|
||||
static let encryption = ("lock.shield", "end-to-end encryption", "private messages encrypted with noise protocol")
|
||||
static let extendedRange = ("antenna.radiowaves.left.and.right", "extended range", "messages relay through peers, going the distance")
|
||||
static let mentions = ("at", "mentions", "use @nickname to notify specific people")
|
||||
static let favorites = ("star.fill", "favorites", "get notified when your favorite people join")
|
||||
static let geohash = ("number", "local channels", "geohash channels to chat with people in nearby regions over decentralized anonymous relays")
|
||||
}
|
||||
|
||||
|
||||
enum Privacy {
|
||||
static let title: LocalizedStringKey = "app_info.privacy.title"
|
||||
static let noTracking = AppInfoFeatureInfo(
|
||||
icon: "eye.slash",
|
||||
title: "app_info.privacy.no_tracking.title",
|
||||
description: "app_info.privacy.no_tracking.description"
|
||||
)
|
||||
static let ephemeral = AppInfoFeatureInfo(
|
||||
icon: "shuffle",
|
||||
title: "app_info.privacy.ephemeral.title",
|
||||
description: "app_info.privacy.ephemeral.description"
|
||||
)
|
||||
static let panic = AppInfoFeatureInfo(
|
||||
icon: "hand.raised.fill",
|
||||
title: "app_info.privacy.panic.title",
|
||||
description: "app_info.privacy.panic.description"
|
||||
)
|
||||
static let title = "PRIVACY"
|
||||
static let noTracking = ("eye.slash", "no tracking", "no servers, accounts, or data collection")
|
||||
static let ephemeral = ("shuffle", "ephemeral identity", "new peer ID generated regularly")
|
||||
static let panic = ("hand.raised.fill", "panic mode", "triple-tap logo to instantly clear all data")
|
||||
}
|
||||
|
||||
|
||||
enum HowToUse {
|
||||
static let title: LocalizedStringKey = "app_info.how_to_use.title"
|
||||
static let instructions: [LocalizedStringKey] = [
|
||||
"app_info.how_to_use.set_nickname",
|
||||
"app_info.how_to_use.change_channels",
|
||||
"app_info.how_to_use.open_sidebar",
|
||||
"app_info.how_to_use.start_dm",
|
||||
"app_info.how_to_use.clear_chat",
|
||||
"app_info.how_to_use.commands"
|
||||
static let title = "HOW TO USE"
|
||||
static let instructions = [
|
||||
"• set your nickname by tapping it",
|
||||
"• tap #mesh to change channels",
|
||||
"• tap people icon for sidebar",
|
||||
"• tap a peer's name to start a DM",
|
||||
"• triple-tap chat to clear",
|
||||
"• type / for commands"
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
enum Warning {
|
||||
static let title: LocalizedStringKey = "app_info.warning.title"
|
||||
static let message: LocalizedStringKey = "app_info.warning.message"
|
||||
static let title = "WARNING"
|
||||
static let message = "private message security has not yet been fully audited. do not use for critical situations until this warning disappears."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +62,7 @@ struct AppInfoView: View {
|
||||
// Custom header for macOS
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("app_info.done") {
|
||||
Button("DONE") {
|
||||
dismiss()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -124,12 +88,12 @@ struct AppInfoView: View {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("app_info.close")
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,64 +106,82 @@ struct AppInfoView: View {
|
||||
// Header
|
||||
VStack(alignment: .center, spacing: 8) {
|
||||
Text(Strings.appName)
|
||||
.font(.bitchatSystem(size: 32, weight: .bold, design: .monospaced))
|
||||
.font(.system(size: 32, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Text(Strings.tagline)
|
||||
.font(.bitchatSystem(size: 16, design: .monospaced))
|
||||
.font(.system(size: 16, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical)
|
||||
|
||||
// How to Use
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
SectionHeader(Strings.HowToUse.title)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(Array(Strings.HowToUse.instructions.enumerated()), id: \.offset) { _, instruction in
|
||||
Text(instruction)
|
||||
}
|
||||
}
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
|
||||
// Features
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
SectionHeader(Strings.Features.title)
|
||||
|
||||
FeatureRow(info: Strings.Features.offlineComm)
|
||||
|
||||
FeatureRow(info: Strings.Features.encryption)
|
||||
|
||||
FeatureRow(info: Strings.Features.extendedRange)
|
||||
|
||||
FeatureRow(info: Strings.Features.favorites)
|
||||
|
||||
FeatureRow(info: Strings.Features.geohash)
|
||||
|
||||
FeatureRow(info: Strings.Features.mentions)
|
||||
|
||||
FeatureRow(icon: Strings.Features.offlineComm.0,
|
||||
title: Strings.Features.offlineComm.1,
|
||||
description: Strings.Features.offlineComm.2)
|
||||
|
||||
FeatureRow(icon: Strings.Features.encryption.0,
|
||||
title: Strings.Features.encryption.1,
|
||||
description: Strings.Features.encryption.2)
|
||||
|
||||
FeatureRow(icon: Strings.Features.extendedRange.0,
|
||||
title: Strings.Features.extendedRange.1,
|
||||
description: Strings.Features.extendedRange.2)
|
||||
|
||||
FeatureRow(icon: Strings.Features.favorites.0,
|
||||
title: Strings.Features.favorites.1,
|
||||
description: Strings.Features.favorites.2)
|
||||
|
||||
FeatureRow(icon: Strings.Features.geohash.0,
|
||||
title: Strings.Features.geohash.1,
|
||||
description: Strings.Features.geohash.2)
|
||||
|
||||
FeatureRow(icon: Strings.Features.mentions.0,
|
||||
title: Strings.Features.mentions.1,
|
||||
description: Strings.Features.mentions.2)
|
||||
}
|
||||
|
||||
|
||||
// Privacy
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
SectionHeader(Strings.Privacy.title)
|
||||
|
||||
FeatureRow(info: Strings.Privacy.noTracking)
|
||||
|
||||
FeatureRow(info: Strings.Privacy.ephemeral)
|
||||
|
||||
FeatureRow(info: Strings.Privacy.panic)
|
||||
|
||||
FeatureRow(icon: Strings.Privacy.noTracking.0,
|
||||
title: Strings.Privacy.noTracking.1,
|
||||
description: Strings.Privacy.noTracking.2)
|
||||
|
||||
FeatureRow(icon: Strings.Privacy.ephemeral.0,
|
||||
title: Strings.Privacy.ephemeral.1,
|
||||
description: Strings.Privacy.ephemeral.2)
|
||||
|
||||
FeatureRow(icon: Strings.Privacy.panic.0,
|
||||
title: Strings.Privacy.panic.1,
|
||||
description: Strings.Privacy.panic.2)
|
||||
}
|
||||
|
||||
|
||||
// How to Use
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
SectionHeader(Strings.HowToUse.title)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(Strings.HowToUse.instructions, id: \.self) { instruction in
|
||||
Text(instruction)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
|
||||
// Warning
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
SectionHeader(Strings.Warning.title)
|
||||
.foregroundColor(Color.red)
|
||||
|
||||
Text(Strings.Warning.message)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(Color.red)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
@@ -215,34 +197,30 @@ struct AppInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct AppInfoFeatureInfo {
|
||||
let icon: String
|
||||
let title: LocalizedStringKey
|
||||
let description: LocalizedStringKey
|
||||
}
|
||||
|
||||
struct SectionHeader: View {
|
||||
let title: LocalizedStringKey
|
||||
let title: String
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
private var textColor: Color {
|
||||
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||
}
|
||||
|
||||
init(_ title: LocalizedStringKey) {
|
||||
init(_ title: String) {
|
||||
self.title = title
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Text(title)
|
||||
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
struct FeatureRow: View {
|
||||
let info: AppInfoFeatureInfo
|
||||
let icon: String
|
||||
let title: String
|
||||
let description: String
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
private var textColor: Color {
|
||||
@@ -255,18 +233,18 @@ struct FeatureRow: View {
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: info.icon)
|
||||
.font(.bitchatSystem(size: 20))
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(textColor)
|
||||
.frame(width: 30)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(info.title)
|
||||
.font(.bitchatSystem(size: 14, weight: .semibold, design: .monospaced))
|
||||
Text(title)
|
||||
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Text(info.description)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
Text(description)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
@@ -276,16 +254,6 @@ struct FeatureRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Default") {
|
||||
#Preview {
|
||||
AppInfoView()
|
||||
}
|
||||
|
||||
#Preview("Dynamic Type XXL") {
|
||||
AppInfoView()
|
||||
.environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge)
|
||||
}
|
||||
|
||||
#Preview("Dynamic Type XS") {
|
||||
AppInfoView()
|
||||
.environment(\.sizeCategory, .extraSmall)
|
||||
}
|
||||
|
||||
+390
-367
File diff suppressed because it is too large
Load Diff
@@ -21,46 +21,20 @@ struct FingerprintView: View {
|
||||
private var backgroundColor: Color {
|
||||
colorScheme == .dark ? Color.black : Color.white
|
||||
}
|
||||
|
||||
private enum Strings {
|
||||
static let title: LocalizedStringKey = "fingerprint.title"
|
||||
static let theirFingerprint: LocalizedStringKey = "fingerprint.their_label"
|
||||
static let handshakePending: LocalizedStringKey = "fingerprint.handshake_pending"
|
||||
static let yourFingerprint: LocalizedStringKey = "fingerprint.your_label"
|
||||
static let copy: LocalizedStringKey = "common.copy"
|
||||
static let verifiedBadge: LocalizedStringKey = "fingerprint.badge.verified"
|
||||
static let notVerifiedBadge: LocalizedStringKey = "fingerprint.badge.not_verified"
|
||||
static let verifiedMessage: LocalizedStringKey = "fingerprint.message.verified"
|
||||
static func verifyHint(_ nickname: String) -> String {
|
||||
L10n.format(
|
||||
"fingerprint.message.verify_hint",
|
||||
comment: "Instruction to compare fingerprints with a named peer",
|
||||
nickname
|
||||
)
|
||||
}
|
||||
static let markVerified: LocalizedStringKey = "fingerprint.action.mark_verified"
|
||||
static let removeVerification: LocalizedStringKey = "fingerprint.action.remove_verification"
|
||||
static func unknownPeer() -> String {
|
||||
L10n.string(
|
||||
"common.unknown",
|
||||
comment: "Label for an unknown peer"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
// Header
|
||||
HStack {
|
||||
Text(Strings.title)
|
||||
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||
Text("SECURITY VERIFICATION")
|
||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.bitchatSystem(size: 14, weight: .semibold))
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
@@ -84,7 +58,7 @@ struct FingerprintView: View {
|
||||
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
||||
}
|
||||
}
|
||||
return Strings.unknownPeer()
|
||||
return "Unknown"
|
||||
}()
|
||||
// Accurate encryption state based on short ID session
|
||||
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
||||
@@ -92,17 +66,17 @@ struct FingerprintView: View {
|
||||
HStack {
|
||||
if let icon = encryptionStatus.icon {
|
||||
Image(systemName: icon)
|
||||
.font(.bitchatSystem(size: 20))
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(encryptionStatus == .noiseVerified ? Color.green : textColor)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(peerNickname)
|
||||
.font(.bitchatSystem(size: 18, weight: .semibold, design: .monospaced))
|
||||
.font(.system(size: 18, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Text(encryptionStatus.description)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(textColor.opacity(0.7))
|
||||
}
|
||||
|
||||
@@ -114,13 +88,13 @@ struct FingerprintView: View {
|
||||
|
||||
// Their fingerprint
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(Strings.theirFingerprint)
|
||||
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
||||
Text("THEIR FINGERPRINT:")
|
||||
.font(.system(size: 12, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(textColor.opacity(0.7))
|
||||
|
||||
if let fingerprint = viewModel.getFingerprint(for: statusPeerID) {
|
||||
Text(formatFingerprint(fingerprint))
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(nil)
|
||||
@@ -130,7 +104,7 @@ struct FingerprintView: View {
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
.contextMenu {
|
||||
Button(Strings.copy) {
|
||||
Button("Copy") {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string = fingerprint
|
||||
#else
|
||||
@@ -140,22 +114,22 @@ struct FingerprintView: View {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(Strings.handshakePending)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
Text("not available - handshake in progress")
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(Color.orange)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// My fingerprint
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(Strings.yourFingerprint)
|
||||
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
||||
Text("YOUR FINGERPRINT:")
|
||||
.font(.system(size: 12, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(textColor.opacity(0.7))
|
||||
|
||||
let myFingerprint = viewModel.getMyFingerprint()
|
||||
Text(formatFingerprint(myFingerprint))
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(nil)
|
||||
@@ -165,7 +139,7 @@ struct FingerprintView: View {
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
.contextMenu {
|
||||
Button(Strings.copy) {
|
||||
Button("Copy") {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string = myFingerprint
|
||||
#else
|
||||
@@ -181,19 +155,15 @@ struct FingerprintView: View {
|
||||
let isVerified = encryptionStatus == .noiseVerified
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Text(isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
|
||||
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||
Text(isVerified ? "✓ VERIFIED" : "⚠️ NOT VERIFIED")
|
||||
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(isVerified ? Color.green : Color.orange)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Group {
|
||||
if isVerified {
|
||||
Text(Strings.verifiedMessage)
|
||||
} else {
|
||||
Text(Strings.verifyHint(peerNickname))
|
||||
}
|
||||
}
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
Text(isVerified ?
|
||||
"you have verified this person's identity." :
|
||||
"compare these fingerprints with \(peerNickname) using a secure channel.")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(textColor.opacity(0.7))
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(nil)
|
||||
@@ -205,8 +175,8 @@ struct FingerprintView: View {
|
||||
viewModel.verifyFingerprint(for: peerID)
|
||||
dismiss()
|
||||
}) {
|
||||
Text(Strings.markVerified)
|
||||
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||
Text("MARK AS VERIFIED")
|
||||
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
@@ -219,8 +189,8 @@ struct FingerprintView: View {
|
||||
viewModel.unverifyFingerprint(for: peerID)
|
||||
dismiss()
|
||||
}) {
|
||||
Text(Strings.removeVerification)
|
||||
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||
Text("REMOVE VERIFICATION")
|
||||
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
|
||||
@@ -8,22 +8,11 @@ struct GeohashPeopleList: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@State private var orderedIDs: [String] = []
|
||||
|
||||
private enum Strings {
|
||||
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
|
||||
static let youSuffix: LocalizedStringKey = "geohash_people.you_suffix"
|
||||
static let blockedTooltip = L10n.string(
|
||||
"geohash_people.tooltip.blocked",
|
||||
comment: "Tooltip shown next to users blocked in geohash channels"
|
||||
)
|
||||
static let unblock: LocalizedStringKey = "geohash_people.action.unblock"
|
||||
static let block: LocalizedStringKey = "geohash_people.action.block"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if viewModel.visibleGeohashPeople().isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(Strings.noneNearby)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
Text("nobody around...")
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 12)
|
||||
@@ -62,32 +51,32 @@ struct GeohashPeopleList: View {
|
||||
let icon = teleported ? "face.dashed" : "mappin.and.ellipse"
|
||||
let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark)
|
||||
let rowColor: Color = isMe ? .orange : assignedColor
|
||||
Image(systemName: icon).font(.bitchatSystem(size: 12)).foregroundColor(rowColor)
|
||||
Image(systemName: icon).font(.system(size: 12)).foregroundColor(rowColor)
|
||||
|
||||
let (base, suffix) = splitSuffix(from: person.displayName)
|
||||
HStack(spacing: 0) {
|
||||
Text(base)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.fontWeight(isMe ? .bold : .regular)
|
||||
.foregroundColor(rowColor)
|
||||
if !suffix.isEmpty {
|
||||
let suffixColor = isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6)
|
||||
Text(suffix)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(suffixColor)
|
||||
}
|
||||
if isMe {
|
||||
Text(Strings.youSuffix)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
Text(" (you)")
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(rowColor)
|
||||
}
|
||||
}
|
||||
if let me = myHex, person.id != me {
|
||||
if viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) {
|
||||
Image(systemName: "nosign")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.red)
|
||||
.help(Strings.blockedTooltip)
|
||||
.help("Blocked in geochash")
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
@@ -108,9 +97,9 @@ struct GeohashPeopleList: View {
|
||||
} else {
|
||||
let blocked = viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id)
|
||||
if blocked {
|
||||
Button(Strings.unblock) { viewModel.unblockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
|
||||
Button("Unblock") { viewModel.unblockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
|
||||
} else {
|
||||
Button(Strings.block) { viewModel.blockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
|
||||
Button("Block") { viewModel.blockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// ImageMessageRow.swift
|
||||
// bitchat
|
||||
//
|
||||
// Renders an image message bubble with rounded corners and optional delivery status.
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
struct ImageMessageRow: View {
|
||||
let path: String
|
||||
let message: BitchatMessage
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
|
||||
var body: some View {
|
||||
#if os(iOS)
|
||||
if let uiImage = UIImage(contentsOfFile: path) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
// Ensure image aligns with the same left edge as text messages
|
||||
HStack(spacing: 0) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(maxWidth: 300, maxHeight: 400, alignment: .topLeading)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
if message.isPrivate && message.sender == viewModel.nickname,
|
||||
let status = message.deliveryStatus {
|
||||
DeliveryStatusView(status: status, colorScheme: colorScheme)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
Text("⚠️ Image unavailable")
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
#else
|
||||
Text("📷 Image: \(path)")
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(colorScheme == .dark ? .green : Color(red: 0, green: 0.5, blue: 0))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// ImagePickerView.swift
|
||||
// Simple UIKit-based image picker bridge
|
||||
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
import PhotosUI
|
||||
|
||||
struct ImagePickerView: UIViewControllerRepresentable {
|
||||
typealias UIViewControllerType = UIViewController
|
||||
var onPicked: (UIImage?) -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> UIViewController {
|
||||
if #available(iOS 14.0, *) {
|
||||
var config = PHPickerConfiguration(photoLibrary: .shared())
|
||||
config.filter = .images
|
||||
config.selectionLimit = 1
|
||||
let picker = PHPickerViewController(configuration: config)
|
||||
picker.delegate = context.coordinator
|
||||
return picker
|
||||
} else {
|
||||
let picker = UIImagePickerController()
|
||||
picker.delegate = context.coordinator
|
||||
picker.sourceType = .photoLibrary
|
||||
picker.allowsEditing = false
|
||||
return picker
|
||||
}
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(onPicked: onPicked)
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, PHPickerViewControllerDelegate, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||
let onPicked: (UIImage?) -> Void
|
||||
init(onPicked: @escaping (UIImage?) -> Void) { self.onPicked = onPicked }
|
||||
|
||||
// PHPicker
|
||||
@available(iOS 14.0, *)
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
picker.dismiss(animated: true)
|
||||
guard let item = results.first else { onPicked(nil); return }
|
||||
if item.itemProvider.canLoadObject(ofClass: UIImage.self) {
|
||||
item.itemProvider.loadObject(ofClass: UIImage.self) { object, _ in
|
||||
DispatchQueue.main.async {
|
||||
self.onPicked(object as? UIImage)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
onPicked(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// UIImagePicker
|
||||
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||
picker.dismiss(animated: true)
|
||||
onPicked(nil)
|
||||
}
|
||||
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
|
||||
let image = info[.originalImage] as? UIImage
|
||||
picker.dismiss(animated: true)
|
||||
onPicked(image)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -9,7 +9,6 @@ struct LocationChannelsSheet: View {
|
||||
@Binding var isPresented: Bool
|
||||
@ObservedObject private var manager = LocationChannelManager.shared
|
||||
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
||||
@ObservedObject private var network = NetworkActivationService.shared
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@State private var customGeohash: String = ""
|
||||
@@ -17,89 +16,21 @@ struct LocationChannelsSheet: View {
|
||||
|
||||
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
|
||||
|
||||
private enum Strings {
|
||||
static let title: LocalizedStringKey = "location_channels.title"
|
||||
static let description: LocalizedStringKey = "location_channels.description"
|
||||
static let requestPermissions: LocalizedStringKey = "location_channels.action.request_permissions"
|
||||
static let permissionDenied: LocalizedStringKey = "location_channels.permission_denied"
|
||||
static let openSettings: LocalizedStringKey = "location_channels.action.open_settings"
|
||||
static let loadingNearby: LocalizedStringKey = "location_channels.loading_nearby"
|
||||
static let teleport: LocalizedStringKey = "location_channels.action.teleport"
|
||||
static let bookmarked: LocalizedStringKey = "location_channels.bookmarked_section_title"
|
||||
static let removeAccess: LocalizedStringKey = "location_channels.action.remove_access"
|
||||
static let torTitle: LocalizedStringKey = "location_channels.tor.title"
|
||||
static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle"
|
||||
static let toggleOn: LocalizedStringKey = "common.toggle.on"
|
||||
static let toggleOff: LocalizedStringKey = "common.toggle.off"
|
||||
|
||||
static let invalidGeohash = L10n.string(
|
||||
"location_channels.error.invalid_geohash",
|
||||
comment: "Error shown when a custom geohash is invalid"
|
||||
)
|
||||
|
||||
static func meshTitle(_ count: Int) -> String {
|
||||
let label = L10n.string(
|
||||
"location_channels.mesh_label",
|
||||
comment: "Label for the mesh channel row"
|
||||
)
|
||||
return rowTitle(label: label, count: count)
|
||||
}
|
||||
|
||||
static func levelTitle(for level: GeohashChannelLevel, count: Int) -> String {
|
||||
return rowTitle(label: level.displayName, count: count)
|
||||
}
|
||||
|
||||
static func bookmarkTitle(geohash: String, count: Int) -> String {
|
||||
return rowTitle(label: "#\(geohash)", count: count)
|
||||
}
|
||||
|
||||
static func subtitlePrefix(geohash: String, coverage: String) -> String {
|
||||
L10n.format(
|
||||
"location_channels.subtitle_prefix",
|
||||
comment: "Subtitle prefix showing geohash and coverage",
|
||||
geohash,
|
||||
coverage
|
||||
)
|
||||
}
|
||||
|
||||
static func subtitle(prefix: String, name: String?) -> String {
|
||||
guard let name, !name.isEmpty else { return prefix }
|
||||
return L10n.format(
|
||||
"location_channels.subtitle_with_name",
|
||||
comment: "Subtitle combining prefix and resolved location name",
|
||||
prefix,
|
||||
name
|
||||
)
|
||||
}
|
||||
|
||||
private static func rowTitle(label: String, count: Int) -> String {
|
||||
let format = NSLocalizedString(
|
||||
"location_channels.row_title",
|
||||
comment: "List row title with participant count"
|
||||
)
|
||||
return NSString.localizedStringWithFormat(format as NSString, label, count) as String
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
Text(Strings.title)
|
||||
.font(.bitchatSystem(size: 18, design: .monospaced))
|
||||
Spacer()
|
||||
closeButton
|
||||
}
|
||||
Text(Strings.description)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
Text("#location channels")
|
||||
.font(.system(size: 18, design: .monospaced))
|
||||
Text("chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps. your IP address is hidden by routing all traffic over tor.")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Group {
|
||||
switch manager.permissionState {
|
||||
case LocationChannelManager.PermissionState.notDetermined:
|
||||
Button(action: { manager.enableLocationChannels() }) {
|
||||
Text(Strings.requestPermissions)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
Text("get location and my geohashes")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(standardGreen)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
@@ -109,10 +40,10 @@ struct LocationChannelsSheet: View {
|
||||
.buttonStyle(.plain)
|
||||
case LocationChannelManager.PermissionState.denied, LocationChannelManager.PermissionState.restricted:
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(Strings.permissionDenied)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
Text("location permission denied. enable in settings to use location channels.")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
Button(Strings.openSettings) { openSystemLocationSettings() }
|
||||
Button("open settings") { openSystemLocationSettings() }
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
case LocationChannelManager.PermissionState.authorized:
|
||||
@@ -128,9 +59,29 @@ struct LocationChannelsSheet: View {
|
||||
.background(backgroundColor)
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarHidden(true)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: { isPresented = false }) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
}
|
||||
#else
|
||||
.navigationTitle("")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .automatic) {
|
||||
Button(action: { isPresented = false }) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||
.frame(width: 20, height: 20)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(iOS)
|
||||
@@ -160,224 +111,181 @@ struct LocationChannelsSheet: View {
|
||||
.onChange(of: manager.availableChannels) { _ in }
|
||||
}
|
||||
|
||||
private var closeButton: some View {
|
||||
Button(action: { isPresented = false }) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
|
||||
private var channelList: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
channelRow(title: Strings.meshTitle(meshCount()), subtitlePrefix: Strings.subtitlePrefix(geohash: "bluetooth", coverage: bluetoothRangeString()), isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
|
||||
manager.select(ChannelID.mesh)
|
||||
isPresented = false
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
|
||||
let nearby = manager.availableChannels.filter { $0.level != .building }
|
||||
if !nearby.isEmpty {
|
||||
ForEach(nearby) { channel in
|
||||
sectionDivider
|
||||
let coverage = coverageString(forPrecision: channel.geohash.count)
|
||||
let nameBase = locationName(for: channel.level)
|
||||
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
|
||||
let participantCount = viewModel.geohashParticipantCount(for: channel.geohash)
|
||||
let subtitlePrefix = Strings.subtitlePrefix(geohash: channel.geohash, coverage: coverage)
|
||||
let highlight = participantCount > 0
|
||||
channelRow(
|
||||
title: Strings.levelTitle(for: channel.level, count: participantCount),
|
||||
subtitlePrefix: subtitlePrefix,
|
||||
subtitleName: namePart,
|
||||
isSelected: isSelected(channel),
|
||||
titleBold: highlight,
|
||||
trailingAccessory: {
|
||||
Button(action: { bookmarks.toggle(channel.geohash) }) {
|
||||
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
) {
|
||||
manager.markTeleported(for: channel.geohash, false)
|
||||
manager.select(ChannelID.location(channel))
|
||||
isPresented = false
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
} else {
|
||||
sectionDivider
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text(Strings.loadingNearby)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
sectionDivider
|
||||
customTeleportSection
|
||||
.padding(.vertical, 8)
|
||||
|
||||
let bookmarkedList = bookmarks.bookmarks
|
||||
if !bookmarkedList.isEmpty {
|
||||
sectionDivider
|
||||
bookmarkedSection(bookmarkedList)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||||
sectionDivider
|
||||
torToggleSection
|
||||
.padding(.top, 12)
|
||||
Button(action: {
|
||||
openSystemLocationSettings()
|
||||
}) {
|
||||
Text(Strings.removeAccess)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.red.opacity(0.08))
|
||||
.cornerRadius(6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
List {
|
||||
// Mesh option first (no bookmark)
|
||||
channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth • \(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
|
||||
manager.select(ChannelID.mesh)
|
||||
isPresented = false
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 6)
|
||||
.background(backgroundColor)
|
||||
}
|
||||
.background(backgroundColor)
|
||||
}
|
||||
|
||||
private var sectionDivider: some View {
|
||||
Rectangle()
|
||||
.fill(dividerColor)
|
||||
.frame(height: 1)
|
||||
}
|
||||
|
||||
private var dividerColor: Color {
|
||||
colorScheme == .dark ? Color.white.opacity(0.12) : Color.black.opacity(0.08)
|
||||
}
|
||||
|
||||
private var customTeleportSection: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 2) {
|
||||
Text("#")
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
TextField("geohash", text: $customGeohash)
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled(true)
|
||||
.keyboardType(.asciiCapable)
|
||||
#endif
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.onChange(of: customGeohash) { newValue in
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
let filtered = newValue
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: "#", with: "")
|
||||
.filter { allowed.contains($0) }
|
||||
if filtered.count > 12 {
|
||||
customGeohash = String(filtered.prefix(12))
|
||||
} else if filtered != newValue {
|
||||
customGeohash = filtered
|
||||
}
|
||||
}
|
||||
let normalized = customGeohash
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: "#", with: "")
|
||||
let isValid = validateGeohash(normalized)
|
||||
Button(action: {
|
||||
let gh = normalized
|
||||
guard isValid else { customError = Strings.invalidGeohash; return }
|
||||
let level = levelForLength(gh.count)
|
||||
let ch = GeohashChannel(level: level, geohash: gh)
|
||||
manager.markTeleported(for: ch.geohash, true)
|
||||
manager.select(ChannelID.location(ch))
|
||||
isPresented = false
|
||||
}) {
|
||||
HStack(spacing: 6) {
|
||||
Text(Strings.teleport)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
Image(systemName: "face.dashed")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 10)
|
||||
.background(Color.secondary.opacity(0.12))
|
||||
.cornerRadius(6)
|
||||
.opacity(isValid ? 1.0 : 0.4)
|
||||
.disabled(!isValid)
|
||||
}
|
||||
if let err = customError {
|
||||
Text(err)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func bookmarkedSection(_ entries: [String]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(Strings.bookmarked)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in
|
||||
let level = levelForLength(gh.count)
|
||||
let channel = GeohashChannel(level: level, geohash: gh)
|
||||
let coverage = coverageString(forPrecision: gh.count)
|
||||
let subtitle = Strings.subtitlePrefix(geohash: gh, coverage: coverage)
|
||||
let name = bookmarks.bookmarkNames[gh]
|
||||
let participantCount = viewModel.geohashParticipantCount(for: gh)
|
||||
// Nearby options
|
||||
if !manager.availableChannels.isEmpty {
|
||||
ForEach(manager.availableChannels.filter { $0.level != .building }) { channel in
|
||||
let coverage = coverageString(forPrecision: channel.geohash.count)
|
||||
let nameBase = locationName(for: channel.level)
|
||||
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
|
||||
let subtitlePrefix = "#\(channel.geohash) • \(coverage)"
|
||||
let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0
|
||||
channelRow(
|
||||
title: Strings.bookmarkTitle(geohash: gh, count: participantCount),
|
||||
subtitlePrefix: subtitle,
|
||||
subtitleName: name.map { formattedNamePrefix(for: level) + $0 },
|
||||
title: geohashTitleWithCount(for: channel),
|
||||
subtitlePrefix: subtitlePrefix,
|
||||
subtitleName: namePart,
|
||||
isSelected: isSelected(channel),
|
||||
titleBold: highlight,
|
||||
trailingAccessory: {
|
||||
Button(action: { bookmarks.toggle(gh) }) {
|
||||
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
Button(action: { bookmarks.toggle(channel.geohash) }) {
|
||||
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
) {
|
||||
let inRegional = manager.availableChannels.contains { $0.geohash == gh }
|
||||
if !inRegional && !manager.availableChannels.isEmpty {
|
||||
manager.markTeleported(for: gh, true)
|
||||
} else {
|
||||
manager.markTeleported(for: gh, false)
|
||||
}
|
||||
// Selecting a suggested nearby channel is not a teleport. Persist this.
|
||||
manager.markTeleported(for: channel.geohash, false)
|
||||
manager.select(ChannelID.location(channel))
|
||||
isPresented = false
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.onAppear { bookmarks.resolveNameIfNeeded(for: gh) }
|
||||
|
||||
if index < entries.count - 1 {
|
||||
sectionDivider
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HStack {
|
||||
ProgressView()
|
||||
Text("finding nearby channels…")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom geohash teleport
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 2) {
|
||||
Text("#")
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
TextField("geohash", text: $customGeohash)
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled(true)
|
||||
.keyboardType(.asciiCapable)
|
||||
#endif
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.onChange(of: customGeohash) { newValue in
|
||||
// Allow only geohash base32 characters, strip '#', limit length
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
let filtered = newValue
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: "#", with: "")
|
||||
.filter { allowed.contains($0) }
|
||||
if filtered.count > 12 {
|
||||
customGeohash = String(filtered.prefix(12))
|
||||
} else if filtered != newValue {
|
||||
customGeohash = filtered
|
||||
}
|
||||
}
|
||||
let normalized = customGeohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().replacingOccurrences(of: "#", with: "")
|
||||
let isValid = validateGeohash(normalized)
|
||||
Button(action: {
|
||||
let gh = normalized
|
||||
guard isValid else { customError = "invalid geohash"; return }
|
||||
let level = levelForLength(gh.count)
|
||||
let ch = GeohashChannel(level: level, geohash: gh)
|
||||
// Mark this selection as a manual teleport
|
||||
manager.markTeleported(for: ch.geohash, true)
|
||||
manager.select(ChannelID.location(ch))
|
||||
isPresented = false
|
||||
}) {
|
||||
HStack(spacing: 6) {
|
||||
Text("teleport")
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
Image(systemName: "face.dashed")
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.secondary.opacity(0.12))
|
||||
.cornerRadius(6)
|
||||
.opacity(isValid ? 1.0 : 0.4)
|
||||
.disabled(!isValid)
|
||||
}
|
||||
if let err = customError {
|
||||
Text(err)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
// Bookmarked geohashes
|
||||
if !bookmarks.bookmarks.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("bookmarked")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
VStack(spacing: 6) {
|
||||
ForEach(bookmarks.bookmarks, id: \.self) { gh in
|
||||
let level = levelForLength(gh.count)
|
||||
let channel = GeohashChannel(level: level, geohash: gh)
|
||||
let coverage = coverageString(forPrecision: gh.count)
|
||||
let subtitle = "#\(gh) • \(coverage)"
|
||||
let name = bookmarks.bookmarkNames[gh]
|
||||
channelRow(
|
||||
title: geohashHashTitleWithCount(gh),
|
||||
subtitlePrefix: subtitle,
|
||||
subtitleName: name.map { formattedNamePrefix(for: level) + $0 },
|
||||
isSelected: isSelected(channel),
|
||||
trailingAccessory: {
|
||||
Button(action: { bookmarks.toggle(gh) }) {
|
||||
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
) {
|
||||
// For bookmarked selection, mark teleported based on regional membership
|
||||
let inRegional = manager.availableChannels.contains { $0.geohash == gh }
|
||||
if !inRegional && !manager.availableChannels.isEmpty {
|
||||
manager.markTeleported(for: gh, true)
|
||||
} else {
|
||||
manager.markTeleported(for: gh, false)
|
||||
}
|
||||
manager.select(ChannelID.location(channel))
|
||||
isPresented = false
|
||||
}
|
||||
.onAppear { bookmarks.resolveNameIfNeeded(for: gh) }
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.secondary.opacity(0.12))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
|
||||
// Footer action inside the list
|
||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||||
Button(action: {
|
||||
openSystemLocationSettings()
|
||||
}) {
|
||||
Text("remove location access")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.red.opacity(0.08))
|
||||
.cornerRadius(6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(backgroundColor)
|
||||
}
|
||||
|
||||
private func isSelected(_ channel: GeohashChannel) -> Bool {
|
||||
if case .location(let ch) = manager.selectedChannel {
|
||||
@@ -409,18 +317,23 @@ struct LocationChannelsSheet: View {
|
||||
let parts = splitTitleAndCount(title)
|
||||
HStack(spacing: 4) {
|
||||
Text(parts.base)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.fontWeight(titleBold ? .bold : .regular)
|
||||
.foregroundColor(titleColor ?? Color.primary)
|
||||
if let count = parts.countSuffix, !count.isEmpty {
|
||||
Text(count)
|
||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName)
|
||||
let subtitleFull: String = {
|
||||
if let name = subtitleName, !name.isEmpty {
|
||||
return subtitlePrefix + " • " + name
|
||||
}
|
||||
return subtitlePrefix
|
||||
}()
|
||||
Text(subtitleFull)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
@@ -428,7 +341,7 @@ struct LocationChannelsSheet: View {
|
||||
Spacer()
|
||||
if isSelected {
|
||||
Text("✔︎")
|
||||
.font(.bitchatSystem(size: 16, design: .monospaced))
|
||||
.font(.system(size: 16, design: .monospaced))
|
||||
.foregroundColor(standardGreen)
|
||||
}
|
||||
trailingAccessory()
|
||||
@@ -447,6 +360,13 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
|
||||
// MARK: - Helpers for counts
|
||||
private func meshTitleWithCount() -> String {
|
||||
// Count currently connected mesh peers (excluding self)
|
||||
let meshCount = meshCount()
|
||||
let noun = meshCount == 1 ? "person" : "people"
|
||||
return "mesh [\(meshCount) \(noun)]"
|
||||
}
|
||||
|
||||
private func meshCount() -> Int {
|
||||
// Count mesh-connected OR mesh-reachable peers (exclude self)
|
||||
let myID = viewModel.meshService.myPeerID
|
||||
@@ -456,6 +376,20 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func geohashTitleWithCount(for channel: GeohashChannel) -> String {
|
||||
// Main list: keep level labels (block/neighborhood/city/province/region)
|
||||
let count = viewModel.geohashParticipantCount(for: channel.geohash)
|
||||
let noun = count == 1 ? "person" : "people"
|
||||
return "\(channel.level.displayName.lowercased()) [\(count) \(noun)]"
|
||||
}
|
||||
|
||||
private func geohashHashTitleWithCount(_ geohash: String) -> String {
|
||||
// Bookmarked list: show the #geohash as the main label
|
||||
let count = viewModel.geohashParticipantCount(for: geohash)
|
||||
let noun = count == 1 ? "person" : "people"
|
||||
return "#\(geohash) [\(count) \(noun)]"
|
||||
}
|
||||
|
||||
private func validateGeohash(_ s: String) -> Bool {
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
guard !s.isEmpty, s.count <= 12 else { return false }
|
||||
@@ -475,34 +409,8 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TOR Toggle & Standardized Colors
|
||||
// MARK: - Standardized Colors
|
||||
extension LocationChannelsSheet {
|
||||
private var torToggleBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { network.userTorEnabled },
|
||||
set: { network.setUserTorEnabled($0) }
|
||||
)
|
||||
}
|
||||
|
||||
private var torToggleSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Toggle(isOn: torToggleBinding) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(Strings.torTitle)
|
||||
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(.primary)
|
||||
Text(Strings.torSubtitle)
|
||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.toggleStyle(IRCToggleStyle(accent: standardGreen, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.secondary.opacity(0.12))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
private var standardGreen: Color {
|
||||
(colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||
}
|
||||
@@ -511,36 +419,6 @@ extension LocationChannelsSheet {
|
||||
}
|
||||
}
|
||||
|
||||
private struct IRCToggleStyle: ToggleStyle {
|
||||
let accent: Color
|
||||
let onLabel: LocalizedStringKey
|
||||
let offLabel: LocalizedStringKey
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
Button(action: { configuration.isOn.toggle() }) {
|
||||
HStack(spacing: 12) {
|
||||
configuration.label
|
||||
Spacer()
|
||||
Text(configuration.isOn ? onLabel : offLabel)
|
||||
.textCase(.uppercase)
|
||||
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(configuration.isOn ? accent : .secondary)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(accent.opacity(configuration.isOn ? 0.18 : 0.08))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(accent.opacity(configuration.isOn ? 0.35 : 0.15), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Coverage helpers
|
||||
extension LocationChannelsSheet {
|
||||
private func coverageString(forPrecision len: Int) -> String {
|
||||
|
||||
@@ -7,7 +7,6 @@ struct LocationNotesView: View {
|
||||
let onNotesCountChanged: ((Int) -> Void)?
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var draft: String = ""
|
||||
@@ -19,42 +18,30 @@ struct LocationNotesView: View {
|
||||
_manager = StateObject(wrappedValue: LocationNotesManager(geohash: gh))
|
||||
}
|
||||
|
||||
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
|
||||
private var accentGreen: Color { colorScheme == .dark ? .green : Color(red: 0, green: 0.5, blue: 0) }
|
||||
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
|
||||
|
||||
private enum Strings {
|
||||
static let closeAccessibility = L10n.string(
|
||||
"common.close",
|
||||
comment: "Accessibility label for close buttons"
|
||||
)
|
||||
static let description: LocalizedStringKey = "location_notes.description"
|
||||
static let loadingRecent: LocalizedStringKey = "location_notes.loading_recent"
|
||||
static let relaysPaused: LocalizedStringKey = "location_notes.relays_paused"
|
||||
static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby"
|
||||
static let retry: LocalizedStringKey = "location_notes.action.retry"
|
||||
static let relaysRetryHint: LocalizedStringKey = "location_notes.relays_retry_hint"
|
||||
static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes"
|
||||
static let emptyTitle: LocalizedStringKey = "location_notes.empty_title"
|
||||
static let emptySubtitle: LocalizedStringKey = "location_notes.empty_subtitle"
|
||||
static let dismissError: LocalizedStringKey = "location_notes.action.dismiss"
|
||||
static let addPlaceholder: LocalizedStringKey = "location_notes.placeholder"
|
||||
private var backgroundColor: Color {
|
||||
colorScheme == .dark ? Color.black : Color.white
|
||||
}
|
||||
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)
|
||||
}
|
||||
// Slightly darker green for hash suffix emphasis
|
||||
private var darkerTextColor: Color {
|
||||
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.4, blue: 0)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
headerSection
|
||||
notesContent
|
||||
}
|
||||
}
|
||||
.background(backgroundColor)
|
||||
inputSection
|
||||
header
|
||||
Divider()
|
||||
list
|
||||
Divider()
|
||||
input
|
||||
}
|
||||
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
|
||||
.background(backgroundColor)
|
||||
.foregroundColor(textColor)
|
||||
.onDisappear { manager.cancel() }
|
||||
.onChange(of: geohash) { newValue in
|
||||
manager.setGeohash(newValue)
|
||||
@@ -63,210 +50,100 @@ struct LocationNotesView: View {
|
||||
.onChange(of: manager.notes.count) { newValue in
|
||||
onNotesCountChanged?(newValue)
|
||||
}
|
||||
#else
|
||||
NavigationView {
|
||||
VStack(spacing: 0) {
|
||||
headerSection
|
||||
ScrollView {
|
||||
notesContent
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
let c = manager.notes.count
|
||||
Text("\(c) \(c == 1 ? "note" : "notes") ")
|
||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||
Text("@ ")
|
||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||
Text("#\(geohash)")
|
||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
inputSection
|
||||
}
|
||||
.background(backgroundColor)
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarHidden(true)
|
||||
#else
|
||||
.navigationTitle("")
|
||||
#endif
|
||||
}
|
||||
#if os(iOS)
|
||||
.presentationDetents([.large])
|
||||
#endif
|
||||
.background(backgroundColor)
|
||||
.onDisappear { manager.cancel() }
|
||||
.onChange(of: geohash) { newValue in
|
||||
manager.setGeohash(newValue)
|
||||
}
|
||||
.onAppear { onNotesCountChanged?(manager.notes.count) }
|
||||
.onChange(of: manager.notes.count) { newValue in
|
||||
onNotesCountChanged?(newValue)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var closeButton: some View {
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Strings.closeAccessibility)
|
||||
}
|
||||
|
||||
private var headerSection: some View {
|
||||
let count = manager.notes.count
|
||||
return VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 12) {
|
||||
Text(headerTitle(for: count))
|
||||
.font(.bitchatSystem(size: 18, design: .monospaced))
|
||||
Spacer()
|
||||
closeButton
|
||||
}
|
||||
if let building = locationManager.locationNames[.building], !building.isEmpty {
|
||||
Text(building)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(accentGreen)
|
||||
} else if let block = locationManager.locationNames[.block], !block.isEmpty {
|
||||
Text(block)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(accentGreen)
|
||||
}
|
||||
Text(Strings.description)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if manager.state == .loading && !manager.initialLoadComplete {
|
||||
Text(Strings.loadingRecent)
|
||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
} else if manager.state == .noRelays {
|
||||
Text(Strings.relaysPaused)
|
||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 12)
|
||||
.background(backgroundColor)
|
||||
}
|
||||
|
||||
private func headerTitle(for count: Int) -> String {
|
||||
let format = NSLocalizedString(
|
||||
"location_notes.header",
|
||||
comment: "Header displaying the geohash and localized note count"
|
||||
)
|
||||
return NSString.localizedStringWithFormat(format as NSString, geohash, count) as String
|
||||
}
|
||||
|
||||
private var notesContent: some View {
|
||||
LazyVStack(alignment: .leading, spacing: 12) {
|
||||
if manager.state == .noRelays {
|
||||
noRelaysRow
|
||||
} else if manager.state == .loading && !manager.initialLoadComplete {
|
||||
loadingRow
|
||||
} else if manager.notes.isEmpty {
|
||||
emptyRow
|
||||
} else {
|
||||
ForEach(manager.notes) { note in
|
||||
noteRow(note)
|
||||
if let buildingName = locationManager.locationNames[.building], !buildingName.isEmpty {
|
||||
Text(buildingName)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
} else if let blockName = locationManager.locationNames[.block], !blockName.isEmpty {
|
||||
Text(blockName)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
}
|
||||
}
|
||||
|
||||
if let error = manager.errorMessage, manager.state != .noRelays {
|
||||
errorRow(message: error)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private func noteRow(_ note: LocationNotesManager.Note) -> some View {
|
||||
let baseName = note.displayName.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? note.displayName
|
||||
let ts = timestampText(for: note.createdAt)
|
||||
return VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text("@\(baseName)")
|
||||
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||
if !ts.isEmpty {
|
||||
Text(ts)
|
||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
Text(note.content)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private var noRelaysRow: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(Strings.noRelaysNearby)
|
||||
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||
Text(Strings.relaysRetryHint)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
Button(Strings.retry) { manager.refresh() }
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
private var loadingRow: some View {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
Text(Strings.loadingNotes)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private var emptyRow: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(Strings.emptyTitle)
|
||||
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||
Text(Strings.emptySubtitle)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
private func errorRow(message: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
Text(message)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
Spacer()
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.frame(width: 32, height: 32)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
Button(Strings.dismissError) { manager.clearError() }
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.frame(height: 44)
|
||||
.padding(.horizontal, 12)
|
||||
.background(backgroundColor.opacity(0.95))
|
||||
}
|
||||
|
||||
private var inputSection: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
TextField(Strings.addPlaceholder, text: $draft, axis: .vertical)
|
||||
private var list: some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(manager.notes) { note in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
// Show @name without the #abcd suffix; timestamp in brackets
|
||||
HStack(spacing: 0) {
|
||||
Text("@")
|
||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
let parts = splitSuffix(from: note.displayName)
|
||||
Text(parts.0)
|
||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
let ts = timestampText(for: note.createdAt)
|
||||
Text(ts.isEmpty ? "" : "[\(ts)]")
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.8))
|
||||
}
|
||||
Text(note.content)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.background(backgroundColor)
|
||||
}
|
||||
|
||||
private var input: some View {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
TextField("add a note for this place", text: $draft, axis: .vertical)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.lineLimit(maxDraftLines, reservesSpace: true)
|
||||
.padding(.vertical, 6)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.lineLimit(3, reservesSpace: true)
|
||||
.padding(.horizontal, 12)
|
||||
|
||||
Button(action: send) {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.bitchatSystem(size: 20))
|
||||
.foregroundColor(sendButtonEnabled ? accentGreen : .secondary)
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? Color.gray : textColor)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!sendButtonEnabled)
|
||||
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
.padding(.trailing, 12)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background(backgroundColor)
|
||||
.overlay(Divider(), alignment: .top)
|
||||
.frame(minHeight: 44)
|
||||
.padding(.vertical, 8)
|
||||
.background(backgroundColor.opacity(0.95))
|
||||
}
|
||||
|
||||
private func send() {
|
||||
@@ -276,17 +153,15 @@ struct LocationNotesView: View {
|
||||
draft = ""
|
||||
}
|
||||
|
||||
private var sendButtonEnabled: Bool {
|
||||
!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && manager.state != .noRelays
|
||||
}
|
||||
|
||||
// MARK: - Timestamp Formatting
|
||||
private func timestampText(for date: Date) -> String {
|
||||
let now = Date()
|
||||
if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
|
||||
// Relative (minute/hour/day), no seconds
|
||||
let rel = Self.relativeFormatter.string(from: date, to: now) ?? ""
|
||||
return rel.isEmpty ? "" : "\(rel) ago"
|
||||
} else {
|
||||
// Absolute date (MMM d or MMM d, yyyy if different year)
|
||||
let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year)
|
||||
let fmt = sameYear ? Self.absDateFormatter : Self.absDateYearFormatter
|
||||
return fmt.string(from: date)
|
||||
@@ -314,3 +189,16 @@ struct LocationNotesView: View {
|
||||
return f
|
||||
}()
|
||||
}
|
||||
|
||||
// Helper to split a trailing #abcd suffix
|
||||
private func splitSuffix(from name: String) -> (String, String) {
|
||||
guard name.count >= 5 else { return (name, "") }
|
||||
let suffix = String(name.suffix(5))
|
||||
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
|
||||
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
|
||||
}) {
|
||||
let base = String(name.dropLast(5))
|
||||
return (base, suffix)
|
||||
}
|
||||
return (name, "")
|
||||
}
|
||||
|
||||
@@ -11,23 +11,11 @@ struct MeshPeerList: View {
|
||||
|
||||
@State private var orderedIDs: [String] = []
|
||||
|
||||
private enum Strings {
|
||||
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
|
||||
static let blockedTooltip = L10n.string(
|
||||
"geohash_people.tooltip.blocked",
|
||||
comment: "Tooltip shown next to a blocked peer indicator"
|
||||
)
|
||||
static let newMessagesTooltip = L10n.string(
|
||||
"mesh_peers.tooltip.new_messages",
|
||||
comment: "Tooltip for the unread messages indicator"
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if viewModel.allPeers.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(Strings.noneNearby)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
Text("nobody around...")
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 12)
|
||||
@@ -57,27 +45,27 @@ struct MeshPeerList: View {
|
||||
let baseColor = isMe ? Color.orange : assigned
|
||||
if isMe {
|
||||
Image(systemName: "person.fill")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
} else if peer.isConnected {
|
||||
// Mesh-connected peer: radio icon
|
||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
} else if peer.isReachable {
|
||||
// Mesh-reachable (relayed): point.3 icon
|
||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
} else if peer.isMutualFavorite {
|
||||
// Mutual favorite reachable via Nostr: globe icon (purple)
|
||||
Image(systemName: "globe")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.purple)
|
||||
} else {
|
||||
// Fallback icon for others (dimmed)
|
||||
Image(systemName: "person")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
}
|
||||
|
||||
@@ -85,28 +73,28 @@ struct MeshPeerList: View {
|
||||
let (base, suffix) = splitSuffix(from: displayName)
|
||||
HStack(spacing: 0) {
|
||||
Text(base)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(baseColor)
|
||||
if !suffix.isEmpty {
|
||||
let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6)
|
||||
Text(suffix)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(suffixColor)
|
||||
}
|
||||
}
|
||||
|
||||
if !isMe, viewModel.isPeerBlocked(peer.id) {
|
||||
Image(systemName: "nosign")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.red)
|
||||
.help(Strings.blockedTooltip)
|
||||
.help("Blocked")
|
||||
}
|
||||
|
||||
if !isMe {
|
||||
if peer.isConnected {
|
||||
if let icon = item.enc.icon {
|
||||
Image(systemName: icon)
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
}
|
||||
} else {
|
||||
@@ -114,12 +102,12 @@ struct MeshPeerList: View {
|
||||
if let fp = viewModel.getFingerprint(for: peer.id),
|
||||
viewModel.verifiedFingerprints.contains(fp) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
} else if let icon = item.enc.icon {
|
||||
// Fallback to whatever status says (likely lock if we had a past session)
|
||||
Image(systemName: icon)
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
}
|
||||
}
|
||||
@@ -130,15 +118,15 @@ struct MeshPeerList: View {
|
||||
// Unread message indicator for this peer
|
||||
if !isMe, item.hasUnread {
|
||||
Image(systemName: "envelope.fill")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.orange)
|
||||
.help(Strings.newMessagesTooltip)
|
||||
.help("New messages")
|
||||
}
|
||||
|
||||
if !isMe {
|
||||
Button(action: { onToggleFavorite(peer.id) }) {
|
||||
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// MessageHeaderLine.swift
|
||||
// bitchat
|
||||
//
|
||||
// Renders a header like normal messages: "<@nickname> [HH:mm:ss]"
|
||||
// Used above media rows (images, audio) to match text message style.
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MessageHeaderLine: View {
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
let message: BitchatMessage
|
||||
|
||||
var body: some View {
|
||||
Text(formattedHeader())
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.bottom, 2)
|
||||
}
|
||||
|
||||
private func formattedHeader() -> AttributedString {
|
||||
var result = AttributedString()
|
||||
let isDark = colorScheme == .dark
|
||||
let baseColor = viewModel.peerColor(for: message, isDark: isDark)
|
||||
|
||||
// Determine self to bold our own name
|
||||
let isSelf: Bool = {
|
||||
if let spid = message.senderPeerID {
|
||||
return spid == viewModel.meshService.myPeerID
|
||||
}
|
||||
return message.sender == viewModel.nickname || message.sender.hasPrefix(viewModel.nickname + "#")
|
||||
}()
|
||||
|
||||
// Split suffix like name#abcd
|
||||
let (base, suffix) = message.sender.splitSuffix()
|
||||
|
||||
var senderStyle = AttributeContainer()
|
||||
senderStyle.foregroundColor = baseColor
|
||||
senderStyle.font = .system(size: 14, weight: isSelf ? .bold : .medium, design: .monospaced)
|
||||
if let spid = message.senderPeerID,
|
||||
let url = URL(string: "bitchat://user/\(spid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid)") {
|
||||
senderStyle.link = url
|
||||
}
|
||||
|
||||
// "<@"
|
||||
result.append(AttributedString("<@").mergingAttributes(senderStyle))
|
||||
// base name
|
||||
result.append(AttributedString(base).mergingAttributes(senderStyle))
|
||||
// optional suffix with lighter color
|
||||
if !suffix.isEmpty {
|
||||
var suf = senderStyle
|
||||
suf.foregroundColor = baseColor.opacity(0.6)
|
||||
result.append(AttributedString(suffix).mergingAttributes(suf))
|
||||
}
|
||||
// Close angle without adding content or ">"
|
||||
result.append(AttributedString("> ").mergingAttributes(senderStyle))
|
||||
|
||||
// Timestamp like normal messages
|
||||
let ts = AttributedString("[\(message.formattedTimestamp)]")
|
||||
var tsStyle = AttributeContainer()
|
||||
tsStyle.foregroundColor = Color.gray.opacity(0.7)
|
||||
tsStyle.font = .system(size: 10, design: .monospaced)
|
||||
result.append(ts.mergingAttributes(tsStyle))
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
/// Real-time scrolling waveform for live recording.
|
||||
/// Provide a normalized amplitude [0,1]. The view maintains a sliding window of bars.
|
||||
struct RealtimeScrollingWaveform: View {
|
||||
var amplitudeNorm: CGFloat
|
||||
var bars: Int = 240
|
||||
var barColor: Color = Color(red: 0, green: 1, blue: 0.5) // neon-ish green
|
||||
|
||||
@State private var samples: [CGFloat] = []
|
||||
@State private var ticker = Timer.publish(every: 0.02, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
Canvas { ctx, size in
|
||||
let w = size.width
|
||||
let h = size.height
|
||||
guard w > 0 && h > 0 else { return }
|
||||
let n = samples.count
|
||||
guard n > 0 else { return }
|
||||
let stepX = w / CGFloat(n)
|
||||
let midY = h / 2
|
||||
let stroke: CGFloat = 1.2
|
||||
|
||||
for i in 0..<n {
|
||||
let amp = max(0, min(1, samples[i]))
|
||||
// Amplify only higher amplitudes so quiet parts stay subtle
|
||||
let t: CGFloat = 0.6
|
||||
let k: CGFloat = 0.7
|
||||
let boosted = amp <= t ? amp : min(1, amp + k * (amp - t))
|
||||
let lineH = max(1, boosted * (h * 0.95))
|
||||
let x = CGFloat(i) * stepX + stepX / 2
|
||||
let yTop = midY - lineH / 2
|
||||
let yBot = midY + lineH / 2
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: x, y: yTop))
|
||||
path.addLine(to: CGPoint(x: x, y: yBot))
|
||||
ctx.stroke(path, with: .color(barColor), lineWidth: stroke)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if samples.isEmpty { samples = Array(repeating: 0, count: bars) }
|
||||
}
|
||||
.onChange(of: bars) { newVal in
|
||||
let clamped = max(8, newVal)
|
||||
samples = Array(samples.suffix(clamped))
|
||||
if samples.count < clamped {
|
||||
samples.insert(contentsOf: Array(repeating: 0, count: clamped - samples.count), at: 0)
|
||||
}
|
||||
}
|
||||
.onReceive(ticker) { _ in
|
||||
let v = max(0, min(1, amplitudeNorm))
|
||||
samples.append(v)
|
||||
let overflow = samples.count - bars
|
||||
if overflow > 0 { samples.removeFirst(overflow) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,26 +13,18 @@ struct MyQRView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
private var boxColor: Color { Color.gray.opacity(0.1) }
|
||||
|
||||
private enum Strings {
|
||||
static let title: LocalizedStringKey = "verification.my_qr.title"
|
||||
static let accessibilityLabel = L10n.string(
|
||||
"verification.my_qr.accessibility_label",
|
||||
comment: "Accessibility label describing the verification QR code"
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Text(Strings.title)
|
||||
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||
Text("scan to verify me")
|
||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||
|
||||
VStack(spacing: 10) {
|
||||
QRCodeImage(data: qrString, size: 240)
|
||||
.accessibilityLabel(Strings.accessibilityLabel)
|
||||
.accessibilityLabel("verification qr code")
|
||||
|
||||
// Non-scrolling, fully visible URL (wraps across lines)
|
||||
Text(qrString)
|
||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.multilineTextAlignment(.leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -57,10 +49,6 @@ struct QRCodeImage: View {
|
||||
private let context = CIContext()
|
||||
private let filter = CIFilter.qrCodeGenerator()
|
||||
|
||||
private enum Strings {
|
||||
static let unavailable: LocalizedStringKey = "verification.my_qr.unavailable"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image = generateImage() {
|
||||
@@ -71,8 +59,8 @@ struct QRCodeImage: View {
|
||||
.stroke(Color.gray.opacity(0.5), lineWidth: 1)
|
||||
.frame(width: size, height: size)
|
||||
.overlay(
|
||||
Text(Strings.unavailable)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
Text("qr unavailable")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.gray)
|
||||
)
|
||||
}
|
||||
@@ -115,27 +103,6 @@ struct QRScanView: View {
|
||||
@State private var input = ""
|
||||
@State private var result: String = "" // not shown for iOS scanner
|
||||
@State private var lastValid: String = ""
|
||||
|
||||
private enum Strings {
|
||||
static let pastePrompt: LocalizedStringKey = "verification.scan.paste_prompt"
|
||||
static let validate: LocalizedStringKey = "verification.scan.validate"
|
||||
static func requested(_ nickname: String) -> String {
|
||||
L10n.format(
|
||||
"verification.scan.status.requested",
|
||||
comment: "Status text when verification is requested for a nickname",
|
||||
nickname
|
||||
)
|
||||
}
|
||||
static let notFound = L10n.string(
|
||||
"verification.scan.status.no_peer",
|
||||
comment: "Status when no matching peer is found for a verification request"
|
||||
)
|
||||
static let invalid = L10n.string(
|
||||
"verification.scan.status.invalid",
|
||||
comment: "Status when a scanned QR payload is invalid"
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
#if os(iOS)
|
||||
@@ -151,17 +118,17 @@ struct QRScanView: View {
|
||||
.frame(height: 260)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
#else
|
||||
Text(Strings.pastePrompt)
|
||||
.font(.bitchatSystem(size: 14, weight: .medium, design: .monospaced))
|
||||
Text("paste qr content to validate:")
|
||||
.font(.system(size: 14, weight: .medium, design: .monospaced))
|
||||
TextEditor(text: $input)
|
||||
.frame(height: 100)
|
||||
.border(Color.gray.opacity(0.4))
|
||||
Button(Strings.validate) {
|
||||
Button("validate") {
|
||||
if let qr = VerificationService.shared.verifyScannedQR(input) {
|
||||
let ok = viewModel.beginQRVerification(with: qr)
|
||||
result = ok ? Strings.requested(qr.nickname) : Strings.notFound
|
||||
result = ok ? "verification requested for \(qr.nickname)" : "could not find matching peer"
|
||||
} else {
|
||||
result = Strings.invalid
|
||||
result = "invalid or expired qr payload"
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
@@ -287,8 +254,8 @@ struct VerificationSheetView: View {
|
||||
VStack(spacing: 0) {
|
||||
// Top header (always at top)
|
||||
HStack {
|
||||
Text("verification.sheet.title")
|
||||
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||
Text("VERIFY")
|
||||
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(accentColor)
|
||||
Spacer()
|
||||
Button(action: {
|
||||
@@ -296,7 +263,7 @@ struct VerificationSheetView: View {
|
||||
isPresented = false
|
||||
}) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.bitchatSystem(size: 14, weight: .semibold))
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(accentColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -311,8 +278,8 @@ struct VerificationSheetView: View {
|
||||
Group {
|
||||
if showingScanner {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("verification.scan.prompt_friend")
|
||||
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||
Text("scan a friend's qr")
|
||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||
.frame(maxWidth: .infinity)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(accentColor)
|
||||
@@ -343,13 +310,13 @@ struct VerificationSheetView: View {
|
||||
if showingScanner {
|
||||
Button(action: { showingScanner = false }) {
|
||||
Label("show my qr", systemImage: "qrcode")
|
||||
.font(.bitchatSystem(size: 13, design: .monospaced))
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
} else {
|
||||
Button(action: { showingScanner = true }) {
|
||||
Label("scan someone else's qr", systemImage: "camera.viewfinder")
|
||||
.font(.bitchatSystem(size: 13, weight: .medium, design: .monospaced))
|
||||
.font(.system(size: 13, weight: .medium, design: .monospaced))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.gray)
|
||||
@@ -361,7 +328,7 @@ struct VerificationSheetView: View {
|
||||
viewModel.verifiedFingerprints.contains(fp) {
|
||||
Button(action: { viewModel.unverifyFingerprint(for: pid) }) {
|
||||
Label("remove verification", systemImage: "minus.circle")
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.gray)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
|
||||
struct VoiceMessageRow: View {
|
||||
let fileURL: URL
|
||||
var sendProgress: Double? = nil // 0..1 filling during send
|
||||
@State private var bins: [Float] = []
|
||||
@State private var isPlaying = false
|
||||
@State private var progress: Double = 0
|
||||
@State private var duration: TimeInterval = 0
|
||||
@State private var player: AVAudioPlayer?
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
// Play / Pause control (fixed size similar to Android's 28dp)
|
||||
Button(action: togglePlay) {
|
||||
Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||||
.font(.system(size: 24))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// Waveform takes remaining width, fixed height, vertically centered
|
||||
GeometryReader { geo in
|
||||
let barWidth: CGFloat = 2
|
||||
let barSpacing: CGFloat = 2
|
||||
let step = barWidth + barSpacing
|
||||
let maxBars = max(0, Int(floor(geo.size.width / step)))
|
||||
let displayCount = min(maxBars, bins.count)
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
// Base waveform (gray)
|
||||
HStack(spacing: barSpacing) {
|
||||
ForEach(0..<displayCount, id: \.self) { i in
|
||||
let v = bins[i]
|
||||
let normalizedV = v <= 0 ? 0 : min(1.0, log(1.0 + v * 9.0) / log(10.0))
|
||||
let h = max(2, CGFloat(normalizedV) * geo.size.height)
|
||||
VStack { Spacer(minLength: 0); Capsule().fill(Color.gray.opacity(0.4)).frame(width: barWidth, height: h); Spacer(minLength: 0) }
|
||||
}
|
||||
}
|
||||
|
||||
// Filled progress (blue) or send progress if provided
|
||||
let activeProgress = sendProgress ?? progress
|
||||
let filledBars = Int(Double(displayCount) * activeProgress)
|
||||
HStack(spacing: barSpacing) {
|
||||
ForEach(0..<max(0, min(displayCount, filledBars)), id: \.self) { i in
|
||||
let v = bins[i]
|
||||
let normalizedV = v <= 0 ? 0 : min(1.0, log(1.0 + v * 9.0) / log(10.0))
|
||||
let h = max(2, CGFloat(normalizedV) * geo.size.height)
|
||||
VStack { Spacer(minLength: 0); Capsule().fill(Color.blue).frame(width: barWidth, height: h); Spacer(minLength: 0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.clipped() // prevent overflow beyond available width/height
|
||||
}
|
||||
.frame(height: 36)
|
||||
|
||||
// Reserve width for duration to avoid being pushed out by waveform
|
||||
Text(formattedDuration)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 44, alignment: .trailing)
|
||||
}
|
||||
.onAppear(perform: load)
|
||||
.onDisappear { player?.stop(); isPlaying = false }
|
||||
}
|
||||
|
||||
private var formattedDuration: String {
|
||||
let total = duration
|
||||
let mins = Int(total) / 60
|
||||
let secs = Int(total) % 60
|
||||
return String(format: "%d:%02d", mins, secs)
|
||||
}
|
||||
|
||||
private func load() {
|
||||
bins = WaveformExtractor.extractBins(url: fileURL, binCount: 120)
|
||||
do {
|
||||
player = try AVAudioPlayer(contentsOf: fileURL)
|
||||
duration = player?.duration ?? 0
|
||||
player?.prepareToPlay()
|
||||
startProgressTimer()
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
private func togglePlay() {
|
||||
guard let p = player else { return }
|
||||
if isPlaying { p.pause(); isPlaying = false }
|
||||
else { p.play(); isPlaying = true; startProgressTimer() }
|
||||
}
|
||||
|
||||
private func startProgressTimer() {
|
||||
guard let p = player else { return }
|
||||
Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { t in
|
||||
if !isPlaying || p.duration == 0 { t.invalidate(); return }
|
||||
progress = min(1.0, p.currentTime / p.duration)
|
||||
if progress >= 1.0 { isPlaying = false; t.invalidate() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.chat.bitchat</string>
|
||||
<string>group.chat.bitchat-local</string>
|
||||
</array>
|
||||
<key>com.apple.security.device.bluetooth</key>
|
||||
<true/>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.chat.bitchat</string>
|
||||
<string>group.chat.bitchat-local</string>
|
||||
</array>
|
||||
<key>com.apple.security.device.bluetooth</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
</plist>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
bitchatShareExtension
|
||||
|
||||
Base English strings for the share extension.
|
||||
*/
|
||||
|
||||
"share.status.nothing_to_share" = "nothing to share";
|
||||
"share.status.no_shareable_content" = "no shareable content";
|
||||
"share.fallback.shared_link_title" = "shared Link";
|
||||
"share.status.shared_link" = "✓ shared link to bitchat";
|
||||
"share.status.shared_text" = "✓ shared text to bitchat";
|
||||
"share.status.failed_to_encode" = "failed to encode link";
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
bitchatShareExtension (Arabic)
|
||||
*/
|
||||
|
||||
"share.status.nothing_to_share" = "لا شيء لمشاركته";
|
||||
"share.status.no_shareable_content" = "لا محتوى قابلاً للمشاركة";
|
||||
"share.fallback.shared_link_title" = "رابط مشترك";
|
||||
"share.status.shared_link" = "✓ تم إرسال الرابط إلى bitchat";
|
||||
"share.status.shared_text" = "✓ تم إرسال النص إلى bitchat";
|
||||
"share.status.failed_to_encode" = "تعذر ترميز الرابط";
|
||||
@@ -1,11 +0,0 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
bitchatShareExtension (German)
|
||||
*/
|
||||
|
||||
"share.status.nothing_to_share" = "nichts zum teilen";
|
||||
"share.status.no_shareable_content" = "kein teilbarer inhalt";
|
||||
"share.fallback.shared_link_title" = "geteilter link";
|
||||
"share.status.shared_link" = "✓ link zu bitchat geteilt";
|
||||
"share.status.shared_text" = "✓ text zu bitchat geteilt";
|
||||
"share.status.failed_to_encode" = "link konnte nicht codiert werden";
|
||||
@@ -1,12 +0,0 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
bitchatShareExtension (Spanish)
|
||||
*/
|
||||
|
||||
"share.status.nothing_to_share" = "nada que compartir";
|
||||
"share.status.no_shareable_content" = "sin contenido que se pueda compartir";
|
||||
"share.fallback.shared_link_title" = "enlace compartido";
|
||||
"share.status.shared_link" = "✓ enlace compartido con bitchat";
|
||||
"share.status.shared_text" = "✓ texto compartido con bitchat";
|
||||
"share.status.failed_to_encode" = "no se pudo codificar el enlace";
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
bitchatShareExtension (French)
|
||||
*/
|
||||
|
||||
"share.status.nothing_to_share" = "rien à partager";
|
||||
"share.status.no_shareable_content" = "aucun contenu partageable";
|
||||
"share.fallback.shared_link_title" = "lien partagé";
|
||||
"share.status.shared_link" = "✓ lien partagé vers bitchat";
|
||||
"share.status.shared_text" = "✓ texte partagé vers bitchat";
|
||||
"share.status.failed_to_encode" = "échec de l'encodage du lien";
|
||||
@@ -1,11 +0,0 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
bitchatShareExtension (Hebrew)
|
||||
*/
|
||||
|
||||
"share.status.nothing_to_share" = "אין מה לשתף";
|
||||
"share.status.no_shareable_content" = "אין תוכן שניתן לשתף";
|
||||
"share.fallback.shared_link_title" = "קישור משותף";
|
||||
"share.status.shared_link" = "✓ הקישור נשלח אל bitchat";
|
||||
"share.status.shared_text" = "✓ הטקסט נשלח אל bitchat";
|
||||
"share.status.failed_to_encode" = "לא ניתן לקודד את הקישור";
|
||||
@@ -1,11 +0,0 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
bitchatShareExtension (Indonesian)
|
||||
*/
|
||||
|
||||
"share.status.nothing_to_share" = "tidak ada yang bisa dibagikan";
|
||||
"share.status.no_shareable_content" = "tidak ada konten yang bisa dibagikan";
|
||||
"share.fallback.shared_link_title" = "tautan dibagikan";
|
||||
"share.status.shared_link" = "✓ tautan dikirim ke bitchat";
|
||||
"share.status.shared_text" = "✓ teks dikirim ke bitchat";
|
||||
"share.status.failed_to_encode" = "gagal mengodekan tautan";
|
||||
@@ -1,11 +0,0 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
bitchatShareExtension (Italian)
|
||||
*/
|
||||
|
||||
"share.status.nothing_to_share" = "niente da condividere";
|
||||
"share.status.no_shareable_content" = "nessun contenuto condivisibile";
|
||||
"share.fallback.shared_link_title" = "link condiviso";
|
||||
"share.status.shared_link" = "✓ link inviato a bitchat";
|
||||
"share.status.shared_text" = "✓ testo inviato a bitchat";
|
||||
"share.status.failed_to_encode" = "impossibile codificare il link";
|
||||
@@ -1,11 +0,0 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
bitchatShareExtension (Japanese)
|
||||
*/
|
||||
|
||||
"share.status.nothing_to_share" = "共有できるものがありません";
|
||||
"share.status.no_shareable_content" = "共有可能なコンテンツがありません";
|
||||
"share.fallback.shared_link_title" = "共有リンク";
|
||||
"share.status.shared_link" = "✓ bitchatにリンクを共有";
|
||||
"share.status.shared_text" = "✓ bitchatにテキストを共有";
|
||||
"share.status.failed_to_encode" = "リンクのエンコードに失敗しました";
|
||||
@@ -1,11 +0,0 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
bitchatShareExtension (Nepali)
|
||||
*/
|
||||
|
||||
"share.status.nothing_to_share" = "बाँड्ने केही छैन";
|
||||
"share.status.no_shareable_content" = "बाँड्न मिल्ने सामग्री छैन";
|
||||
"share.fallback.shared_link_title" = "साझा गरिएको लिङ्क";
|
||||
"share.status.shared_link" = "✓ bitchat मा लिङ्क पठाइयो";
|
||||
"share.status.shared_text" = "✓ bitchat मा पाठ पठाइयो";
|
||||
"share.status.failed_to_encode" = "लिङ्क सङ्केत गर्न सकेन";
|
||||
@@ -1,11 +0,0 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
bitchatShareExtension (Portuguese - Brazil)
|
||||
*/
|
||||
|
||||
"share.status.nothing_to_share" = "nada para compartilhar";
|
||||
"share.status.no_shareable_content" = "nenhum conteúdo compartilhável";
|
||||
"share.fallback.shared_link_title" = "link compartilhado";
|
||||
"share.status.shared_link" = "✓ link enviado para bitchat";
|
||||
"share.status.shared_text" = "✓ texto enviado para bitchat";
|
||||
"share.status.failed_to_encode" = "falha ao codificar link";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user