Merge branch 'feature/session-id-blinding-part-2' into feature/database-refactor
# Conflicts: # Podfile # Podfile.lock # Session.xcodeproj/project.pbxproj # Session/Closed Groups/EditClosedGroupVC.swift # Session/Closed Groups/NewClosedGroupVC.swift # Session/Conversations/Context Menu/ContextMenuVC+Action.swift # Session/Conversations/Context Menu/ContextMenuVC.swift # Session/Conversations/ConversationMessageMapping.swift # Session/Conversations/ConversationSearch.swift # Session/Conversations/ConversationVC+Interaction.swift # Session/Conversations/ConversationVC.swift # Session/Conversations/ConversationViewItem.h # Session/Conversations/ConversationViewItem.m # Session/Conversations/ConversationViewModel.m # Session/Conversations/Input View/InputView.swift # Session/Conversations/Input View/MentionSelectionView.swift # Session/Conversations/LongTextViewController.swift # Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift # Session/Conversations/Message Cells/MessageCell.swift # Session/Conversations/Message Cells/VisibleMessageCell.swift # Session/Conversations/Settings/OWSConversationSettingsViewController.m # Session/Conversations/Views & Modals/ConversationTitleView.swift # Session/Conversations/Views & Modals/DownloadAttachmentModal.swift # Session/Conversations/Views & Modals/JoinOpenGroupModal.swift # Session/Conversations/Views & Modals/LinkPreviewModal.swift # Session/Conversations/Views & Modals/MessagesTableView.swift # Session/Conversations/Views & Modals/URLModal.swift # Session/Home/GlobalSearch/GlobalSearchViewController.swift # Session/Home/HomeVC.swift # Session/Home/Message Requests/MessageRequestsViewController.swift # Session/Media Viewing & Editing/MediaDetailViewController.m # Session/Media Viewing & Editing/MediaPageViewController.swift # Session/Meta/AppDelegate.m # Session/Meta/AppDelegate.swift # Session/Meta/AppEnvironment.swift # Session/Meta/Signal-Bridging-Header.h # Session/Meta/Translations/en.lproj/Localizable.strings # Session/Meta/Translations/hi.lproj/Localizable.strings # Session/Meta/Translations/si.lproj/Localizable.strings # Session/Meta/Translations/zh-Hant.lproj/Localizable.strings # Session/Notifications/AppNotifications.swift # Session/Open Groups/JoinOpenGroupVC.swift # Session/Settings/NukeDataModal.swift # Session/Settings/SeedModal.swift # Session/Settings/SettingsVC.swift # Session/Settings/ShareLogsModal.swift # Session/Shared/ConversationCell.swift # Session/Shared/UserSelectionVC.swift # Session/Utilities/BackgroundPoller.swift # Session/Utilities/MentionUtilities.swift # Session/Utilities/MockDataGenerator.swift # SessionMessagingKit/Database/OWSPrimaryStorage.m # SessionMessagingKit/Database/SSKPreferences.swift # SessionMessagingKit/Database/Storage+Contacts.swift # SessionMessagingKit/Database/Storage+Jobs.swift # SessionMessagingKit/Database/Storage+Messaging.swift # SessionMessagingKit/Database/Storage+OpenGroups.swift # SessionMessagingKit/Database/TSDatabaseView.m # SessionMessagingKit/File Server/FileServerAPIV2.swift # SessionMessagingKit/Jobs/AttachmentDownloadJob.swift # SessionMessagingKit/Jobs/AttachmentUploadJob.swift # SessionMessagingKit/Jobs/JobQueue.swift # SessionMessagingKit/Jobs/MessageReceiveJob.swift # SessionMessagingKit/Jobs/MessageSendJob.swift # SessionMessagingKit/Jobs/NotifyPNServerJob.swift # SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift # SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift # SessionMessagingKit/Messages/Message+Destination.swift # SessionMessagingKit/Messages/Signal/TSIncomingMessage.h # SessionMessagingKit/Messages/Signal/TSIncomingMessage.m # SessionMessagingKit/Messages/Signal/TSInfoMessage.h # SessionMessagingKit/Messages/Signal/TSInfoMessage.m # SessionMessagingKit/Messages/Signal/TSInteraction.h # SessionMessagingKit/Messages/Signal/TSInteraction.m # SessionMessagingKit/Messages/Signal/TSMessage.h # SessionMessagingKit/Messages/Signal/TSMessage.m # SessionMessagingKit/Open Groups/OpenGroupAPIV2+ObjC.swift # SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift # SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift # SessionMessagingKit/Open Groups/OpenGroupMessageV2.swift # SessionMessagingKit/Sending & Receiving/Mentions/MentionsManager.swift # SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift # SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift # SessionMessagingKit/Sending & Receiving/MessageReceiver.swift # SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift # SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift # SessionMessagingKit/Sending & Receiving/MessageSender.swift # SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.h # SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift # SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift # SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift # SessionMessagingKit/Storage.swift # SessionMessagingKit/Threads/Notification+Thread.swift # SessionMessagingKit/Threads/TSContactThread.h # SessionMessagingKit/Threads/TSContactThread.m # SessionMessagingKit/Threads/TSGroupModel.h # SessionMessagingKit/Threads/TSGroupModel.m # SessionMessagingKit/Threads/TSGroupThread.m # SessionMessagingKit/Utilities/General.swift # SessionNotificationServiceExtension/NSENotificationPresenter.swift # SessionNotificationServiceExtension/NotificationServiceExtension.swift # SessionSnodeKit/OnionRequestAPI+Encryption.swift # SessionSnodeKit/OnionRequestAPI.swift # SessionSnodeKit/SnodeAPI.swift # SessionSnodeKit/SnodeMessage.swift # SessionSnodeKit/Storage+SnodeAPI.swift # SessionSnodeKit/Storage.swift # SessionUtilitiesKit/General/Array+Utilities.swift # SessionUtilitiesKit/General/Dictionary+Utilities.swift # SessionUtilitiesKit/General/SNUserDefaults.swift # SessionUtilitiesKit/General/Set+Utilities.swift # SessionUtilitiesKit/Meta/SessionUtilitiesKit.h # SessionUtilitiesKit/Utilities/Optional+Utilities.swift # SessionUtilitiesKit/Utilities/Sodium+Conversion.swift # SignalUtilitiesKit/Configuration.swift # SignalUtilitiesKit/Database/Migrations/OpenGroupServerIdLookupMigration.swift # SignalUtilitiesKit/Messaging/FullTextSearcher.swift # SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift # SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift # SignalUtilitiesKit/To Do/OWSProfileManager.m # SignalUtilitiesKit/Utilities/NoopNotificationsManager.swift # SignalUtilitiesKit/Utilities/UIView+OWS.swift
This commit is contained in:
commit
290bce5ce0
|
@ -27,7 +27,6 @@ DerivedData
|
|||
*.ipa
|
||||
*.xcuserstate
|
||||
Index/
|
||||
Session-Turn-Server
|
||||
|
||||
# CocoaPods
|
||||
Pods
|
||||
|
|
21
Podfile
21
Podfile
|
@ -8,12 +8,15 @@ inhibit_all_warnings!
|
|||
abstract_target 'GlobalDependencies' do
|
||||
pod 'PromiseKit'
|
||||
pod 'CryptoSwift'
|
||||
pod 'Sodium', '~> 0.9.1'
|
||||
# FIXME: If https://github.com/jedisct1/swift-sodium/pull/249 gets resolved then revert this back to the standard pod
|
||||
pod 'Sodium', :git => 'https://github.com/oxen-io/session-ios-swift-sodium.git', branch: 'session-build'
|
||||
pod 'GRDB.swift/SQLCipher'
|
||||
pod 'SQLCipher', '~> 4.0'
|
||||
|
||||
# FIXME: We want to remove this once it's been long enough since the migration to GRDB
|
||||
pod 'YapDatabase/SQLCipher', :git => 'https://github.com/oxen-io/session-ios-yap-database.git', branch: 'signal-release'
|
||||
pod 'WebRTC-lib'
|
||||
pod 'SocketRocket', '~> 0.5.1'
|
||||
|
||||
target 'Session' do
|
||||
pod 'AFNetworking'
|
||||
|
@ -27,7 +30,7 @@ abstract_target 'GlobalDependencies' do
|
|||
|
||||
# Dependencies to be included only in all extensions/frameworks
|
||||
abstract_target 'FrameworkAndExtensionDependencies' do
|
||||
pod 'Curve25519Kit', git: 'https://github.com/signalapp/Curve25519Kit.git'
|
||||
pod 'Curve25519Kit', git: 'https://github.com/oxen-io/session-ios-curve-25519-kit.git', branch: 'session-version'
|
||||
pod 'SignalCoreKit', git: 'https://github.com/oxen-io/session-ios-core-kit', branch: 'session-version'
|
||||
|
||||
target 'SessionNotificationServiceExtension'
|
||||
|
@ -57,10 +60,24 @@ abstract_target 'GlobalDependencies' do
|
|||
pod 'SAMKeychain'
|
||||
pod 'SwiftProtobuf', '~> 1.5.0'
|
||||
pod 'DifferenceKit'
|
||||
|
||||
target 'SessionMessagingKitTests' do
|
||||
inherit! :complete
|
||||
|
||||
pod 'Quick'
|
||||
pod 'Nimble'
|
||||
end
|
||||
end
|
||||
|
||||
target 'SessionUtilitiesKit' do
|
||||
pod 'SAMKeychain'
|
||||
|
||||
target 'SessionUtilitiesKitTests' do
|
||||
inherit! :complete
|
||||
|
||||
pod 'Quick'
|
||||
pod 'Nimble'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
40
Podfile.lock
40
Podfile.lock
|
@ -29,6 +29,7 @@ PODS:
|
|||
- DifferenceKit/Core
|
||||
- GRDB.swift/SQLCipher (5.24.1):
|
||||
- SQLCipher (>= 3.4.0)
|
||||
- Nimble (10.0.0)
|
||||
- NVActivityIndicatorView (5.1.1):
|
||||
- NVActivityIndicatorView/Base (= 5.1.1)
|
||||
- NVActivityIndicatorView/Base (5.1.1)
|
||||
|
@ -43,11 +44,13 @@ PODS:
|
|||
- PromiseKit/UIKit (6.15.3):
|
||||
- PromiseKit/CorePromise
|
||||
- PureLayout (3.1.9)
|
||||
- Quick (5.0.1)
|
||||
- Reachability (3.2)
|
||||
- SAMKeychain (1.5.3)
|
||||
- SignalCoreKit (1.0.0):
|
||||
- CocoaLumberjack
|
||||
- OpenSSL-Universal
|
||||
- SocketRocket (0.5.1)
|
||||
- Sodium (0.9.1)
|
||||
- SQLCipher (4.5.0):
|
||||
- SQLCipher/standard (= 4.5.0)
|
||||
|
@ -55,6 +58,7 @@ PODS:
|
|||
- SQLCipher/standard (4.5.0):
|
||||
- SQLCipher/common
|
||||
- SwiftProtobuf (1.5.0)
|
||||
- WebRTC-lib (96.0.0)
|
||||
- YapDatabase/SQLCipher (3.1.1):
|
||||
- YapDatabase/SQLCipher/Core (= 3.1.1)
|
||||
- YapDatabase/SQLCipher/Extensions (= 3.1.1)
|
||||
|
@ -127,18 +131,22 @@ PODS:
|
|||
DEPENDENCIES:
|
||||
- AFNetworking
|
||||
- CryptoSwift
|
||||
- Curve25519Kit (from `https://github.com/signalapp/Curve25519Kit.git`)
|
||||
- Curve25519Kit (from `https://github.com/oxen-io/session-ios-curve-25519-kit.git`, branch `session-version`)
|
||||
- DifferenceKit
|
||||
- GRDB.swift/SQLCipher
|
||||
- Nimble
|
||||
- NVActivityIndicatorView
|
||||
- PromiseKit
|
||||
- PureLayout (~> 3.1.8)
|
||||
- Quick
|
||||
- Reachability
|
||||
- SAMKeychain
|
||||
- SignalCoreKit (from `https://github.com/oxen-io/session-ios-core-kit`, branch `session-version`)
|
||||
- Sodium (~> 0.9.1)
|
||||
- SocketRocket (~> 0.5.1)
|
||||
- Sodium (from `https://github.com/oxen-io/session-ios-swift-sodium.git`, branch `session-build`)
|
||||
- SQLCipher (~> 4.0)
|
||||
- SwiftProtobuf (~> 1.5.0)
|
||||
- WebRTC-lib
|
||||
- YapDatabase/SQLCipher (from `https://github.com/oxen-io/session-ios-yap-database.git`, branch `signal-release`)
|
||||
- YYImage (from `https://github.com/signalapp/YYImage`)
|
||||
- ZXingObjC
|
||||
|
@ -150,23 +158,30 @@ SPEC REPOS:
|
|||
- CryptoSwift
|
||||
- DifferenceKit
|
||||
- GRDB.swift
|
||||
- Nimble
|
||||
- NVActivityIndicatorView
|
||||
- OpenSSL-Universal
|
||||
- PromiseKit
|
||||
- PureLayout
|
||||
- Quick
|
||||
- Reachability
|
||||
- SAMKeychain
|
||||
- Sodium
|
||||
- SocketRocket
|
||||
- SQLCipher
|
||||
- SwiftProtobuf
|
||||
- WebRTC-lib
|
||||
- ZXingObjC
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
Curve25519Kit:
|
||||
:git: https://github.com/signalapp/Curve25519Kit.git
|
||||
:branch: session-version
|
||||
:git: https://github.com/oxen-io/session-ios-curve-25519-kit.git
|
||||
SignalCoreKit:
|
||||
:branch: session-version
|
||||
:git: https://github.com/oxen-io/session-ios-core-kit
|
||||
Sodium:
|
||||
:branch: session-build
|
||||
:git: https://github.com/oxen-io/session-ios-swift-sodium.git
|
||||
YapDatabase:
|
||||
:branch: signal-release
|
||||
:git: https://github.com/oxen-io/session-ios-yap-database.git
|
||||
|
@ -175,11 +190,14 @@ EXTERNAL SOURCES:
|
|||
|
||||
CHECKOUT OPTIONS:
|
||||
Curve25519Kit:
|
||||
:commit: 4fc1c10e98fff2534b5379a9bb587430fdb8e577
|
||||
:git: https://github.com/signalapp/Curve25519Kit.git
|
||||
:commit: b79c2ace600bfd3784e9c33cf1f254b121312edc
|
||||
:git: https://github.com/oxen-io/session-ios-curve-25519-kit.git
|
||||
SignalCoreKit:
|
||||
:commit: 4590c2737a2b5dc0ef4ace9f9019b581caccc1de
|
||||
:git: https://github.com/oxen-io/session-ios-core-kit
|
||||
Sodium:
|
||||
:commit: 6d4317cd4c67e7a617d474d7c5bf20d319aa4536
|
||||
:git: https://github.com/oxen-io/session-ios-swift-sodium.git
|
||||
YapDatabase:
|
||||
:commit: d84069e25e12a16ab4422e5258127a04b70489ad
|
||||
:git: https://github.com/oxen-io/session-ios-yap-database.git
|
||||
|
@ -194,20 +212,24 @@ SPEC CHECKSUMS:
|
|||
Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6
|
||||
DifferenceKit: 5659c430bb7fe45876fa32ce5cba5d6167f0c805
|
||||
GRDB.swift: b3180ce2135fc06a453297889b746b1478c4d8c7
|
||||
Nimble: 5316ef81a170ce87baf72dd961f22f89a602ff84
|
||||
NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667
|
||||
OpenSSL-Universal: e7311447fd2419f57420c79524b641537387eff2
|
||||
PromiseKit: 3b2b6995e51a954c46dbc550ce3da44fbfb563c5
|
||||
PureLayout: 5fb5e5429519627d60d079ccb1eaa7265ce7cf88
|
||||
Quick: 749aa754fd1e7d984f2000fe051e18a3a9809179
|
||||
Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96
|
||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||
SignalCoreKit: 1fbd8732163ef76de16cd1107d1fa3684b607e5d
|
||||
Sodium: 23d11554ecd556196d313cf6130d406dfe7ac6da
|
||||
SocketRocket: d57c7159b83c3c6655745cd15302aa24b6bae531
|
||||
Sodium: a7d42cb46e789d2630fa552d35870b416ed055ae
|
||||
SQLCipher: 98dc22f27c0b1790d39e710d440f22a466ebdb59
|
||||
SwiftProtobuf: 241400280f912735c1e1b9fe675fdd2c6c4d42e2
|
||||
WebRTC-lib: 508fe02efa0c1a3a8867082a77d24c9be5d29aeb
|
||||
YapDatabase: b418a4baa6906e8028748938f9159807fd039af4
|
||||
YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331
|
||||
ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb
|
||||
|
||||
PODFILE CHECKSUM: 05dc0000aee6d863406fc684884935594fcf14fa
|
||||
PODFILE CHECKSUM: 834c7307b7e53560d3b40bb4d54d789739efcd88
|
||||
|
||||
COCOAPODS: 1.11.2
|
||||
COCOAPODS: 1.11.3
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
Session integrates directly with [Oxen Service Nodes](https://docs.oxen.io/about-the-oxen-blockchain/oxen-service-nodes), which are a set of distributed, decentralized and Sybil resistant nodes. Service Nodes act as servers which store messages, and a set of nodes which allow for onion routing functionality obfuscating users' IP addresses. For a full understanding of how Session works, read the [Session Whitepaper](https://getsession.org/whitepaper).
|
||||
|
||||
<img src="https://i.imgur.com/bzQKSiB.png" width="320" />
|
||||
<img src="https://i.imgur.com/SocRFTh.jpg" width="320" />
|
||||
|
||||
## Want to contribute? Found a bug or have a feature request?
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1020"
|
||||
LastUpgradeVersion = "1320"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
@ -20,27 +20,15 @@
|
|||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "11319FE11E0F163FEF714A606CCC265F"
|
||||
BuildableName = "SignalServiceKit.framework"
|
||||
BlueprintName = "SignalServiceKit"
|
||||
ReferencedContainer = "container:Pods/Pods.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "NO">
|
||||
shouldUseLaunchSchemeArgsEnv = "NO"
|
||||
codeCoverageEnabled = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
|
@ -57,173 +45,87 @@
|
|||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
<CodeCoverageTargets>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D221A088169C9E5E00537ABF"
|
||||
BuildableName = "Session.app"
|
||||
BlueprintName = "Session"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "7BC01A3A241F40AB00BC7C55"
|
||||
BuildableName = "SessionNotificationServiceExtension.appex"
|
||||
BlueprintName = "SessionNotificationServiceExtension"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "453518671FC635DD00210559"
|
||||
BuildableName = "SessionShareExtension.appex"
|
||||
BlueprintName = "SessionShareExtension"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C3C2A6EF25539DE700C340D1"
|
||||
BuildableName = "SessionMessagingKit.framework"
|
||||
BlueprintName = "SessionMessagingKit"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C3C2A59E255385C100C340D1"
|
||||
BuildableName = "SessionSnodeKit.framework"
|
||||
BlueprintName = "SessionSnodeKit"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C331FF1A2558F9D300070591"
|
||||
BuildableName = "SessionUIKit.framework"
|
||||
BlueprintName = "SessionUIKit"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C3C2A678255388CC00C340D1"
|
||||
BuildableName = "SessionUtilitiesKit.framework"
|
||||
BlueprintName = "SessionUtilitiesKit"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C33FD9AA255A548A00E217F9"
|
||||
BuildableName = "SignalUtilitiesKit.framework"
|
||||
BlueprintName = "SignalUtilitiesKit"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</CodeCoverageTargets>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
skipped = "NO"
|
||||
parallelizable = "YES"
|
||||
testExecutionOrdering = "random">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D221A0A9169C9E5F00537ABF"
|
||||
BuildableName = "SignalTests.xctest"
|
||||
BlueprintName = "SignalTests"
|
||||
BlueprintIdentifier = "FDC4388D27B9FFC700C60D73"
|
||||
BuildableName = "SessionMessagingKitTests.xctest"
|
||||
BlueprintName = "SessionMessagingKitTests"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
skipped = "NO"
|
||||
parallelizable = "YES"
|
||||
testExecutionOrdering = "random">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "B772E882F193AA2F25932C514BBF0805"
|
||||
BuildableName = "SignalServiceKit-Unit-Tests.xctest"
|
||||
BlueprintName = "SignalServiceKit-Unit-Tests"
|
||||
ReferencedContainer = "container:Pods/Pods.xcodeproj">
|
||||
</BuildableReference>
|
||||
<SkippedTests>
|
||||
<Test
|
||||
Identifier = "ContactSortingTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "DeviceNamesTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "JobQueueTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "MessageSenderJobQueueTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "OWSAnalyticsTests">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "OWSDeviceProvisionerTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "OWSDisappearingMessageFinderTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "OWSDisappearingMessagesConfigurationTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "OWSDisappearingMessagesJobTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "OWSFingerprintTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "OWSIncomingMessageFinderTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "OWSLinkPreviewTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "OWSMessageManagerTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "OWSMessageSenderTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "OWSProvisioningCipherTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "OWSSignalAddressTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "OWSUDManagerTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "PhoneNumberTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "PhoneNumberUtilTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "SSKBaseTestObjC">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "SSKBaseTestSwift">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "SSKMessageSenderJobRecordTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "SignalRecipientTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "SignedPreKeyDeletionTests">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "TSContactThreadTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "TSGroupThreadTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "TSMessageStorageTests">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "TSMessageTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "TSOutgoingMessageTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "TSStorageIdentityKeyStoreTests">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "TSStoragePreKeyStoreTests">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "TSThreadTest">
|
||||
</Test>
|
||||
</SkippedTests>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "5C9F6BA9ADC4724B2612C9F20FBE2076"
|
||||
BuildableName = "SignalCoreKit-Unit-Tests.xctest"
|
||||
BlueprintName = "SignalCoreKit-Unit-Tests"
|
||||
ReferencedContainer = "container:Pods/Pods.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BF2BCB29C9D47F15FB156F1EC64E5CC2"
|
||||
BuildableName = "AxolotlKit-Unit-Tests.xctest"
|
||||
BlueprintName = "AxolotlKit-Unit-Tests"
|
||||
ReferencedContainer = "container:Pods/Pods.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "78DE33AED82B26B4B8D899CC403003AF"
|
||||
BuildableName = "Curve25519Kit-Unit-Tests.xctest"
|
||||
BlueprintName = "Curve25519Kit-Unit-Tests"
|
||||
ReferencedContainer = "container:Pods/Pods.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF7FC2C93AA68E33600807F168BD483A"
|
||||
BuildableName = "HKDFKit-Unit-Tests.xctest"
|
||||
BlueprintName = "HKDFKit-Unit-Tests"
|
||||
ReferencedContainer = "container:Pods/Pods.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "B086B0C72F8A5814FF48795531F21635"
|
||||
BuildableName = "SignalMetadataKit-Unit-Tests.xctest"
|
||||
BlueprintName = "SignalMetadataKit-Unit-Tests"
|
||||
ReferencedContainer = "container:Pods/Pods.xcodeproj">
|
||||
BlueprintIdentifier = "FD83B9AE27CF200A005E1583"
|
||||
BuildableName = "SessionUtilitiesKitTests.xctest"
|
||||
BlueprintName = "SessionUtilitiesKitTests"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1320"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C3C2A6EF25539DE700C340D1"
|
||||
BuildableName = "SessionMessagingKit.framework"
|
||||
BlueprintName = "SessionMessagingKit"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
codeCoverageEnabled = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "YES">
|
||||
<CodeCoverageTargets>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C3C2A6EF25539DE700C340D1"
|
||||
BuildableName = "SessionMessagingKit.framework"
|
||||
BlueprintName = "SessionMessagingKit"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</CodeCoverageTargets>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES"
|
||||
testExecutionOrdering = "random">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "FDC4388D27B9FFC700C60D73"
|
||||
BuildableName = "SessionMessagingKitTests.xctest"
|
||||
BlueprintName = "SessionMessagingKitTests"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "App Store Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C3C2A6EF25539DE700C340D1"
|
||||
BuildableName = "SessionMessagingKit.framework"
|
||||
BlueprintName = "SessionMessagingKit"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "App Store Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1140"
|
||||
LastUpgradeVersion = "1320"
|
||||
wasCreatedForAppExtension = "YES"
|
||||
version = "2.0">
|
||||
<BuildAction
|
||||
|
@ -43,6 +43,16 @@
|
|||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "FDC4388027B9FF1E00C60D73"
|
||||
BuildableName = "SessionTests.xctest"
|
||||
BlueprintName = "SessionTests"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
|
@ -73,6 +83,7 @@
|
|||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
askForAppToLaunch = "Yes"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1020"
|
||||
LastUpgradeVersion = "1320"
|
||||
wasCreatedForAppExtension = "YES"
|
||||
version = "2.0">
|
||||
<BuildAction
|
||||
|
@ -52,6 +52,16 @@
|
|||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "FDC4388027B9FF1E00C60D73"
|
||||
BuildableName = "SessionTests.xctest"
|
||||
BlueprintName = "SessionTests"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
|
@ -83,6 +93,7 @@
|
|||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
askForAppToLaunch = "Yes"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1320"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C3C2A678255388CC00C340D1"
|
||||
BuildableName = "SessionUtilitiesKit.framework"
|
||||
BlueprintName = "SessionUtilitiesKit"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
codeCoverageEnabled = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "YES">
|
||||
<CodeCoverageTargets>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C3C2A678255388CC00C340D1"
|
||||
BuildableName = "SessionUtilitiesKit.framework"
|
||||
BlueprintName = "SessionUtilitiesKit"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</CodeCoverageTargets>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES"
|
||||
testExecutionOrdering = "random">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "FD83B9AE27CF200A005E1583"
|
||||
BuildableName = "SessionUtilitiesKitTests.xctest"
|
||||
BlueprintName = "SessionUtilitiesKitTests"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "App Store Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C3C2A678255388CC00C340D1"
|
||||
BuildableName = "SessionUtilitiesKit.framework"
|
||||
BlueprintName = "SessionUtilitiesKit"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "App Store Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1210"
|
||||
LastUpgradeVersion = "1320"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
|
|
@ -0,0 +1,213 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import "OWSBackupSettingsViewController.h"
|
||||
#import "OWSBackup.h"
|
||||
#import "Session-Swift.h"
|
||||
|
||||
#import <PromiseKit/AnyPromise.h>
|
||||
#import <SessionMessagingKit/Environment.h>
|
||||
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
|
||||
#import <SignalUtilitiesKit/UIColor+OWS.h>
|
||||
#import <SignalUtilitiesKit/UIFont+OWS.h>
|
||||
#import <SessionUtilitiesKit/UIView+OWS.h>
|
||||
#import <SessionUtilitiesKit/MIMETypeUtil.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface OWSBackupSettingsViewController ()
|
||||
|
||||
@property (nonatomic, nullable) NSError *iCloudError;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@implementation OWSBackupSettingsViewController
|
||||
|
||||
#pragma mark - Dependencies
|
||||
|
||||
- (OWSBackup *)backup
|
||||
{
|
||||
OWSAssertDebug(AppEnvironment.shared.backup);
|
||||
|
||||
return AppEnvironment.shared.backup;
|
||||
}
|
||||
|
||||
#pragma mark -
|
||||
|
||||
- (void)viewDidLoad
|
||||
{
|
||||
[super viewDidLoad];
|
||||
|
||||
self.title = NSLocalizedString(@"SETTINGS_BACKUP", @"Label for the backup view in app settings.");
|
||||
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(backupStateDidChange:)
|
||||
name:NSNotificationNameBackupStateDidChange
|
||||
object:nil];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(applicationDidBecomeActive:)
|
||||
name:OWSApplicationDidBecomeActiveNotification
|
||||
object:nil];
|
||||
|
||||
[self updateTableContents];
|
||||
}
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
}
|
||||
|
||||
- (void)viewDidAppear:(BOOL)animated
|
||||
{
|
||||
[super viewDidAppear:animated];
|
||||
|
||||
[self updateTableContents];
|
||||
[self updateICloudStatus];
|
||||
}
|
||||
|
||||
- (void)updateICloudStatus
|
||||
{
|
||||
__weak OWSBackupSettingsViewController *weakSelf = self;
|
||||
[[self.backup ensureCloudKitAccess]
|
||||
.then(^{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
weakSelf.iCloudError = nil;
|
||||
[weakSelf updateTableContents];
|
||||
})
|
||||
.catch(^(NSError *error) {
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
weakSelf.iCloudError = error;
|
||||
[weakSelf updateTableContents];
|
||||
}) retainUntilComplete];
|
||||
}
|
||||
|
||||
#pragma mark - Table Contents
|
||||
|
||||
- (void)updateTableContents
|
||||
{
|
||||
OWSTableContents *contents = [OWSTableContents new];
|
||||
|
||||
BOOL isBackupEnabled = [OWSBackup.sharedManager isBackupEnabled];
|
||||
|
||||
if (self.iCloudError) {
|
||||
OWSTableSection *iCloudSection = [OWSTableSection new];
|
||||
iCloudSection.headerTitle = NSLocalizedString(
|
||||
@"SETTINGS_BACKUP_ICLOUD_STATUS", @"Label for iCloud status row in the in the backup settings view.");
|
||||
[iCloudSection
|
||||
addItem:[OWSTableItem
|
||||
longDisclosureItemWithText:[OWSBackupAPI errorMessageForCloudKitAccessError:self.iCloudError]
|
||||
actionBlock:^{
|
||||
[[UIApplication sharedApplication]
|
||||
openURL:[NSURL URLWithString:UIApplicationOpenSettingsURLString]];
|
||||
}]];
|
||||
[contents addSection:iCloudSection];
|
||||
}
|
||||
|
||||
// TODO: This UI is temporary.
|
||||
// Enabling backup will involve entering and registering a PIN.
|
||||
OWSTableSection *enableSection = [OWSTableSection new];
|
||||
enableSection.headerTitle = NSLocalizedString(@"SETTINGS_BACKUP", @"Label for the backup view in app settings.");
|
||||
[enableSection
|
||||
addItem:[OWSTableItem switchItemWithText:
|
||||
NSLocalizedString(@"SETTINGS_BACKUP_ENABLING_SWITCH",
|
||||
@"Label for switch in settings that controls whether or not backup is enabled.")
|
||||
isOnBlock:^{
|
||||
return [OWSBackup.sharedManager isBackupEnabled];
|
||||
}
|
||||
target:self
|
||||
selector:@selector(isBackupEnabledDidChange:)]];
|
||||
[contents addSection:enableSection];
|
||||
|
||||
if (isBackupEnabled) {
|
||||
// TODO: This UI is temporary.
|
||||
// Enabling backup will involve entering and registering a PIN.
|
||||
OWSTableSection *progressSection = [OWSTableSection new];
|
||||
[progressSection
|
||||
addItem:[OWSTableItem
|
||||
labelItemWithText:NSLocalizedString(@"SETTINGS_BACKUP_STATUS",
|
||||
@"Label for backup status row in the in the backup settings view.")
|
||||
accessoryText:NSStringForBackupExportState(OWSBackup.sharedManager.backupExportState)]];
|
||||
if (OWSBackup.sharedManager.backupExportState == OWSBackupState_InProgress) {
|
||||
if (OWSBackup.sharedManager.backupExportDescription) {
|
||||
[progressSection
|
||||
addItem:[OWSTableItem
|
||||
labelItemWithText:NSLocalizedString(@"SETTINGS_BACKUP_PHASE",
|
||||
@"Label for phase row in the in the backup settings view.")
|
||||
accessoryText:OWSBackup.sharedManager.backupExportDescription]];
|
||||
if (OWSBackup.sharedManager.backupExportProgress) {
|
||||
NSUInteger progressPercent
|
||||
= (NSUInteger)round(OWSBackup.sharedManager.backupExportProgress.floatValue * 100);
|
||||
NSNumberFormatter *numberFormatter = [[NSNumberFormatter alloc] init];
|
||||
[numberFormatter setNumberStyle:NSNumberFormatterPercentStyle];
|
||||
[numberFormatter setMaximumFractionDigits:0];
|
||||
[numberFormatter setMultiplier:@1];
|
||||
NSString *progressString = [numberFormatter stringFromNumber:@(progressPercent)];
|
||||
[progressSection
|
||||
addItem:[OWSTableItem
|
||||
labelItemWithText:NSLocalizedString(@"SETTINGS_BACKUP_PROGRESS",
|
||||
@"Label for phase row in the in the backup settings view.")
|
||||
accessoryText:progressString]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch (OWSBackup.sharedManager.backupExportState) {
|
||||
case OWSBackupState_Idle:
|
||||
case OWSBackupState_Failed:
|
||||
case OWSBackupState_Succeeded:
|
||||
[progressSection
|
||||
addItem:[OWSTableItem disclosureItemWithText:
|
||||
NSLocalizedString(@"SETTINGS_BACKUP_BACKUP_NOW",
|
||||
@"Label for 'backup now' button in the backup settings view.")
|
||||
actionBlock:^{
|
||||
[OWSBackup.sharedManager tryToExportBackup];
|
||||
}]];
|
||||
break;
|
||||
case OWSBackupState_InProgress:
|
||||
[progressSection
|
||||
addItem:[OWSTableItem disclosureItemWithText:
|
||||
NSLocalizedString(@"SETTINGS_BACKUP_CANCEL_BACKUP",
|
||||
@"Label for 'cancel backup' button in the backup settings view.")
|
||||
actionBlock:^{
|
||||
[OWSBackup.sharedManager cancelExportBackup];
|
||||
}]];
|
||||
break;
|
||||
}
|
||||
|
||||
[contents addSection:progressSection];
|
||||
}
|
||||
|
||||
self.contents = contents;
|
||||
}
|
||||
|
||||
- (void)isBackupEnabledDidChange:(UISwitch *)sender
|
||||
{
|
||||
[OWSBackup.sharedManager setIsBackupEnabled:sender.isOn];
|
||||
|
||||
[self updateTableContents];
|
||||
}
|
||||
|
||||
#pragma mark - Events
|
||||
|
||||
- (void)backupStateDidChange:(NSNotification *)notification
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
[self updateTableContents];
|
||||
}
|
||||
|
||||
- (void)applicationDidBecomeActive:(NSNotification *)notification
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
[self updateICloudStatus];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,357 @@
|
|||
import Foundation
|
||||
import WebRTC
|
||||
import SessionMessagingKit
|
||||
import PromiseKit
|
||||
import CallKit
|
||||
|
||||
public final class SessionCall: NSObject, WebRTCSessionDelegate {
|
||||
|
||||
@objc static let isEnabled = true
|
||||
|
||||
// MARK: Metadata Properties
|
||||
let uuid: String
|
||||
let callID: UUID // This is for CallKit
|
||||
let sessionID: String
|
||||
let mode: Mode
|
||||
var audioMode: AudioMode
|
||||
let webRTCSession: WebRTCSession
|
||||
let isOutgoing: Bool
|
||||
var remoteSDP: RTCSessionDescription? = nil
|
||||
var callMessageID: String?
|
||||
var answerCallAction: CXAnswerCallAction? = nil
|
||||
var contactName: String {
|
||||
let contact = Storage.shared.getContact(with: self.sessionID)
|
||||
return contact?.displayName(for: Contact.Context.regular) ?? "\(self.sessionID.prefix(4))...\(self.sessionID.suffix(4))"
|
||||
}
|
||||
var profilePicture: UIImage {
|
||||
if let result = OWSProfileManager.shared().profileAvatar(forRecipientId: sessionID) {
|
||||
return result
|
||||
} else {
|
||||
return Identicon.generatePlaceholderIcon(seed: sessionID, text: contactName, size: 300)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Control
|
||||
lazy public var videoCapturer: RTCVideoCapturer = {
|
||||
return RTCCameraVideoCapturer(delegate: webRTCSession.localVideoSource)
|
||||
}()
|
||||
|
||||
var isRemoteVideoEnabled = false {
|
||||
didSet {
|
||||
remoteVideoStateDidChange?(isRemoteVideoEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
var isMuted = false {
|
||||
willSet {
|
||||
if newValue {
|
||||
webRTCSession.mute()
|
||||
} else {
|
||||
webRTCSession.unmute()
|
||||
}
|
||||
}
|
||||
}
|
||||
var isVideoEnabled = false {
|
||||
willSet {
|
||||
if newValue {
|
||||
webRTCSession.turnOnVideo()
|
||||
} else {
|
||||
webRTCSession.turnOffVideo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Mode
|
||||
enum Mode {
|
||||
case offer
|
||||
case answer
|
||||
}
|
||||
|
||||
// MARK: End call mode
|
||||
enum EndCallMode {
|
||||
case local
|
||||
case remote
|
||||
case unanswered
|
||||
case answeredElsewhere
|
||||
}
|
||||
|
||||
// MARK: Audio I/O mode
|
||||
enum AudioMode {
|
||||
case earpiece
|
||||
case speaker
|
||||
case headphone
|
||||
case bluetooth
|
||||
}
|
||||
|
||||
// MARK: Call State Properties
|
||||
var connectingDate: Date? {
|
||||
didSet {
|
||||
stateDidChange?()
|
||||
hasStartedConnectingDidChange?()
|
||||
}
|
||||
}
|
||||
|
||||
var connectedDate: Date? {
|
||||
didSet {
|
||||
stateDidChange?()
|
||||
hasConnectedDidChange?()
|
||||
}
|
||||
}
|
||||
|
||||
var endDate: Date? {
|
||||
didSet {
|
||||
stateDidChange?()
|
||||
hasEndedDidChange?()
|
||||
}
|
||||
}
|
||||
|
||||
// Not yet implemented
|
||||
var isOnHold = false {
|
||||
didSet {
|
||||
stateDidChange?()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: State Change Callbacks
|
||||
var stateDidChange: (() -> Void)?
|
||||
var hasStartedConnectingDidChange: (() -> Void)?
|
||||
var hasConnectedDidChange: (() -> Void)?
|
||||
var hasEndedDidChange: (() -> Void)?
|
||||
var remoteVideoStateDidChange: ((Bool) -> Void)?
|
||||
var hasStartedReconnecting: (() -> Void)?
|
||||
var hasReconnected: (() -> Void)?
|
||||
|
||||
// MARK: Derived Properties
|
||||
var hasStartedConnecting: Bool {
|
||||
get { return connectingDate != nil }
|
||||
set { connectingDate = newValue ? Date() : nil }
|
||||
}
|
||||
|
||||
var hasConnected: Bool {
|
||||
get { return connectedDate != nil }
|
||||
set { connectedDate = newValue ? Date() : nil }
|
||||
}
|
||||
|
||||
var hasEnded: Bool {
|
||||
get { return endDate != nil }
|
||||
set { endDate = newValue ? Date() : nil }
|
||||
}
|
||||
|
||||
var timeOutTimer: Timer? = nil
|
||||
var didTimeout = false
|
||||
|
||||
var duration: TimeInterval {
|
||||
guard let connectedDate = connectedDate else {
|
||||
return 0
|
||||
}
|
||||
if let endDate = endDate {
|
||||
return endDate.timeIntervalSince(connectedDate)
|
||||
}
|
||||
|
||||
return Date().timeIntervalSince(connectedDate)
|
||||
}
|
||||
|
||||
var reconnectTimer: Timer? = nil
|
||||
|
||||
// MARK: Initialization
|
||||
init(for sessionID: String, uuid: String, mode: Mode, outgoing: Bool = false) {
|
||||
self.sessionID = sessionID
|
||||
self.uuid = uuid
|
||||
self.callID = UUID()
|
||||
self.mode = mode
|
||||
self.audioMode = .earpiece
|
||||
self.webRTCSession = WebRTCSession.current ?? WebRTCSession(for: sessionID, with: uuid)
|
||||
self.isOutgoing = outgoing
|
||||
WebRTCSession.current = self.webRTCSession
|
||||
super.init()
|
||||
self.webRTCSession.delegate = self
|
||||
if AppEnvironment.shared.callManager.currentCall == nil {
|
||||
AppEnvironment.shared.callManager.currentCall = self
|
||||
} else {
|
||||
SNLog("[Calls] A call is ongoing.")
|
||||
}
|
||||
}
|
||||
|
||||
func reportIncomingCallIfNeeded(completion: @escaping (Error?) -> Void) {
|
||||
guard case .answer = mode else { return }
|
||||
setupTimeoutTimer()
|
||||
AppEnvironment.shared.callManager.reportIncomingCall(self, callerName: contactName) { error in
|
||||
completion(error)
|
||||
}
|
||||
}
|
||||
|
||||
func didReceiveRemoteSDP(sdp: RTCSessionDescription) {
|
||||
SNLog("[Calls] Did receive remote sdp.")
|
||||
remoteSDP = sdp
|
||||
if hasStartedConnecting {
|
||||
webRTCSession.handleRemoteSDP(sdp, from: sessionID) // This sends an answer message internally
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
func startSessionCall() {
|
||||
guard case .offer = mode else { return }
|
||||
guard let thread = TSContactThread.fetch(uniqueId: TSContactThread.threadID(fromContactSessionID: sessionID)) else { return }
|
||||
|
||||
let message = CallMessage()
|
||||
message.sender = getUserHexEncodedPublicKey()
|
||||
message.sentTimestamp = NSDate.millisecondTimestamp()
|
||||
message.uuid = self.uuid
|
||||
message.kind = .preOffer
|
||||
let infoMessage = TSInfoMessage.from(message, associatedWith: thread)
|
||||
infoMessage.save()
|
||||
self.callMessageID = infoMessage.uniqueId
|
||||
|
||||
var promise: Promise<Void>!
|
||||
Storage.write(with: { transaction in
|
||||
promise = self.webRTCSession.sendPreOffer(message, in: thread, using: transaction)
|
||||
}, completion: { [weak self] in
|
||||
let _ = promise.done {
|
||||
Storage.shared.write { transaction in
|
||||
self?.webRTCSession.sendOffer(to: self!.sessionID, using: transaction as! YapDatabaseReadWriteTransaction).retainUntilComplete()
|
||||
}
|
||||
self?.setupTimeoutTimer()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func answerSessionCall() {
|
||||
guard case .answer = mode else { return }
|
||||
hasStartedConnecting = true
|
||||
if let sdp = remoteSDP {
|
||||
webRTCSession.handleRemoteSDP(sdp, from: sessionID) // This sends an answer message internally
|
||||
}
|
||||
}
|
||||
|
||||
func answerSessionCallInBackground(action: CXAnswerCallAction) {
|
||||
answerCallAction = action
|
||||
self.answerSessionCall()
|
||||
}
|
||||
|
||||
func endSessionCall() {
|
||||
guard !hasEnded else { return }
|
||||
webRTCSession.hangUp()
|
||||
Storage.write { transaction in
|
||||
self.webRTCSession.endCall(with: self.sessionID, using: transaction)
|
||||
}
|
||||
hasEnded = true
|
||||
}
|
||||
|
||||
// MARK: Update call message
|
||||
func updateCallMessage(mode: EndCallMode) {
|
||||
guard let callMessageID = callMessageID else { return }
|
||||
Storage.write { transaction in
|
||||
let infoMessage = TSInfoMessage.fetch(uniqueId: callMessageID, transaction: transaction)
|
||||
if let messageToUpdate = infoMessage {
|
||||
var shouldMarkAsRead = false
|
||||
if self.duration > 0 {
|
||||
shouldMarkAsRead = true
|
||||
} else if self.hasStartedConnecting {
|
||||
shouldMarkAsRead = true
|
||||
} else {
|
||||
switch mode {
|
||||
case .local:
|
||||
shouldMarkAsRead = true
|
||||
fallthrough
|
||||
case .remote:
|
||||
fallthrough
|
||||
case .unanswered:
|
||||
if messageToUpdate.callState == .incoming {
|
||||
messageToUpdate.updateCallInfoMessage(.missed, using: transaction)
|
||||
}
|
||||
case .answeredElsewhere:
|
||||
shouldMarkAsRead = true
|
||||
}
|
||||
}
|
||||
if shouldMarkAsRead {
|
||||
messageToUpdate.markAsRead(atTimestamp: NSDate.ows_millisecondTimeStamp(), trySendReadReceipt: false, transaction: transaction)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Renderer
|
||||
func attachRemoteVideoRenderer(_ renderer: RTCVideoRenderer) {
|
||||
webRTCSession.attachRemoteRenderer(renderer)
|
||||
}
|
||||
|
||||
func removeRemoteVideoRenderer(_ renderer: RTCVideoRenderer) {
|
||||
webRTCSession.removeRemoteRenderer(renderer)
|
||||
}
|
||||
|
||||
func attachLocalVideoRenderer(_ renderer: RTCVideoRenderer) {
|
||||
webRTCSession.attachLocalRenderer(renderer)
|
||||
}
|
||||
|
||||
// MARK: Delegate
|
||||
public func webRTCIsConnected() {
|
||||
self.invalidateTimeoutTimer()
|
||||
self.reconnectTimer?.invalidate()
|
||||
guard !self.hasConnected else {
|
||||
hasReconnected?()
|
||||
return
|
||||
}
|
||||
self.hasConnected = true
|
||||
self.answerCallAction?.fulfill()
|
||||
}
|
||||
|
||||
public func isRemoteVideoDidChange(isEnabled: Bool) {
|
||||
isRemoteVideoEnabled = isEnabled
|
||||
}
|
||||
|
||||
public func didReceiveHangUpSignal() {
|
||||
self.hasEnded = true
|
||||
DispatchQueue.main.async {
|
||||
if let currentBanner = IncomingCallBanner.current { currentBanner.dismiss() }
|
||||
if let callVC = CurrentAppContext().frontmostViewController() as? CallVC { callVC.handleEndCallMessage() }
|
||||
if let miniCallView = MiniCallView.current { miniCallView.dismiss() }
|
||||
AppEnvironment.shared.callManager.reportCurrentCallEnded(reason: .remoteEnded)
|
||||
}
|
||||
}
|
||||
|
||||
public func dataChannelDidOpen() {
|
||||
// Send initial video status
|
||||
if (isVideoEnabled) {
|
||||
webRTCSession.turnOnVideo()
|
||||
} else {
|
||||
webRTCSession.turnOffVideo()
|
||||
}
|
||||
}
|
||||
|
||||
public func reconnectIfNeeded() {
|
||||
setupTimeoutTimer()
|
||||
hasStartedReconnecting?()
|
||||
guard isOutgoing else { return }
|
||||
tryToReconnect()
|
||||
}
|
||||
|
||||
private func tryToReconnect() {
|
||||
reconnectTimer?.invalidate()
|
||||
if SSKEnvironment.shared.reachabilityManager.isReachable {
|
||||
Storage.write { transaction in
|
||||
self.webRTCSession.sendOffer(to: self.sessionID, using: transaction, isRestartingICEConnection: true).retainUntilComplete()
|
||||
}
|
||||
} else {
|
||||
reconnectTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: 5, repeats: false) { _ in
|
||||
self.tryToReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Timeout
|
||||
public func setupTimeoutTimer() {
|
||||
invalidateTimeoutTimer()
|
||||
let timeInterval: TimeInterval = hasConnected ? 60 : 30
|
||||
timeOutTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: timeInterval, repeats: false) { _ in
|
||||
self.didTimeout = true
|
||||
AppEnvironment.shared.callManager.endCall(self) { error in
|
||||
self.timeOutTimer = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func invalidateTimeoutTimer() {
|
||||
timeOutTimer?.invalidate()
|
||||
timeOutTimer = nil
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
extension SessionCallManager {
|
||||
@discardableResult
|
||||
public func startCallAction() -> Bool {
|
||||
guard let call = self.currentCall else { return false }
|
||||
call.startSessionCall()
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func answerCallAction() -> Bool {
|
||||
guard let call = self.currentCall else { return false }
|
||||
if let _ = CurrentAppContext().frontmostViewController() as? CallVC {
|
||||
call.answerSessionCall()
|
||||
} else {
|
||||
guard let presentingVC = CurrentAppContext().frontmostViewController() else { return false } // FIXME: Handle more gracefully
|
||||
let callVC = CallVC(for: self.currentCall!)
|
||||
if let conversationVC = presentingVC as? ConversationVC {
|
||||
callVC.conversationVC = conversationVC
|
||||
conversationVC.inputAccessoryView?.isHidden = true
|
||||
conversationVC.inputAccessoryView?.alpha = 0
|
||||
}
|
||||
presentingVC.present(callVC, animated: true) {
|
||||
call.answerSessionCall()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func endCallAction() -> Bool {
|
||||
guard let call = self.currentCall else { return false }
|
||||
call.endSessionCall()
|
||||
if call.didTimeout {
|
||||
reportCurrentCallEnded(reason: .unanswered)
|
||||
} else {
|
||||
reportCurrentCallEnded(reason: nil)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func setMutedCallAction(isMuted: Bool) -> Bool {
|
||||
guard let call = self.currentCall else { return false }
|
||||
call.isMuted = isMuted
|
||||
return true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
import CallKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
extension SessionCallManager {
|
||||
public func startCall(_ call: SessionCall, completion: ((Error?) -> Void)?) {
|
||||
guard case .offer = call.mode else { return }
|
||||
guard !call.hasConnected else { return }
|
||||
reportOutgoingCall(call)
|
||||
if callController != nil {
|
||||
let handle = CXHandle(type: .generic, value: call.sessionID)
|
||||
let startCallAction = CXStartCallAction(call: call.callID, handle: handle)
|
||||
|
||||
startCallAction.isVideo = false
|
||||
|
||||
let transaction = CXTransaction()
|
||||
transaction.addAction(startCallAction)
|
||||
|
||||
requestTransaction(transaction, completion: completion)
|
||||
} else {
|
||||
startCallAction()
|
||||
completion?(nil)
|
||||
}
|
||||
}
|
||||
|
||||
public func answerCall(_ call: SessionCall, completion: ((Error?) -> Void)?) {
|
||||
if callController != nil {
|
||||
let answerCallAction = CXAnswerCallAction(call: call.callID)
|
||||
let transaction = CXTransaction()
|
||||
transaction.addAction(answerCallAction)
|
||||
|
||||
requestTransaction(transaction, completion: completion)
|
||||
} else {
|
||||
answerCallAction()
|
||||
completion?(nil)
|
||||
}
|
||||
}
|
||||
|
||||
public func endCall(_ call: SessionCall, completion: ((Error?) -> Void)?) {
|
||||
if callController != nil {
|
||||
let endCallAction = CXEndCallAction(call: call.callID)
|
||||
let transaction = CXTransaction()
|
||||
transaction.addAction(endCallAction)
|
||||
|
||||
requestTransaction(transaction, completion: completion)
|
||||
} else {
|
||||
endCallAction()
|
||||
completion?(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Not currently in use
|
||||
public func setOnHoldStatus(for call: SessionCall) {
|
||||
if callController != nil {
|
||||
let setHeldCallAction = CXSetHeldCallAction(call: call.callID, onHold: true)
|
||||
let transaction = CXTransaction()
|
||||
transaction.addAction(setHeldCallAction)
|
||||
|
||||
requestTransaction(transaction)
|
||||
}
|
||||
}
|
||||
|
||||
private func requestTransaction(_ transaction: CXTransaction, completion: ((Error?) -> Void)? = nil) {
|
||||
callController?.request(transaction) { error in
|
||||
if let error = error {
|
||||
SNLog("Error requesting transaction: \(error)")
|
||||
} else {
|
||||
SNLog("Requested transaction successfully")
|
||||
}
|
||||
completion?(error)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
import CallKit
|
||||
|
||||
extension SessionCallManager: CXProviderDelegate {
|
||||
public func providerDidReset(_ provider: CXProvider) {
|
||||
AssertIsOnMainThread()
|
||||
currentCall?.endSessionCall()
|
||||
}
|
||||
|
||||
public func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
|
||||
AssertIsOnMainThread()
|
||||
if startCallAction() {
|
||||
action.fulfill()
|
||||
} else {
|
||||
action.fail()
|
||||
}
|
||||
}
|
||||
|
||||
public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
|
||||
AssertIsOnMainThread()
|
||||
print("[CallKit] Perform CXAnswerCallAction")
|
||||
guard let call = self.currentCall else { return action.fail() }
|
||||
if CurrentAppContext().isMainAppAndActive {
|
||||
if answerCallAction() {
|
||||
action.fulfill()
|
||||
} else {
|
||||
action.fail()
|
||||
}
|
||||
} else {
|
||||
call.answerSessionCallInBackground(action: action)
|
||||
}
|
||||
}
|
||||
|
||||
public func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
|
||||
print("[CallKit] Perform CXEndCallAction")
|
||||
AssertIsOnMainThread()
|
||||
if endCallAction() {
|
||||
action.fulfill()
|
||||
} else {
|
||||
action.fail()
|
||||
}
|
||||
}
|
||||
|
||||
public func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
|
||||
print("[CallKit] Perform CXSetMutedCallAction, isMuted: \(action.isMuted)")
|
||||
AssertIsOnMainThread()
|
||||
if setMutedCallAction(isMuted: action.isMuted) {
|
||||
action.fulfill()
|
||||
} else {
|
||||
action.fail()
|
||||
}
|
||||
}
|
||||
|
||||
public func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) {
|
||||
// TODO: set on hold
|
||||
}
|
||||
|
||||
public func provider(_ provider: CXProvider, timedOutPerforming action: CXAction) {
|
||||
// TODO: handle timeout
|
||||
}
|
||||
|
||||
public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
|
||||
print("[CallKit] Audio session did activate.")
|
||||
AssertIsOnMainThread()
|
||||
guard let call = self.currentCall else { return }
|
||||
call.webRTCSession.audioSessionDidActivate(audioSession)
|
||||
if call.isOutgoing && !call.hasConnected { CallRingTonePlayer.shared.startPlayingRingTone() }
|
||||
}
|
||||
|
||||
public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
|
||||
print("[CallKit] Audio session did deactivate.")
|
||||
AssertIsOnMainThread()
|
||||
guard let call = self.currentCall else { return }
|
||||
call.webRTCSession.audioSessionDidDeactivate(audioSession)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
import CallKit
|
||||
import SessionMessagingKit
|
||||
|
||||
public final class SessionCallManager: NSObject {
|
||||
let provider: CXProvider?
|
||||
let callController: CXCallController?
|
||||
var currentCall: SessionCall? = nil {
|
||||
willSet {
|
||||
if (newValue != nil) {
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.isIdleTimerDisabled = true
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.isIdleTimerDisabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static var _sharedProvider: CXProvider?
|
||||
class func sharedProvider(useSystemCallLog: Bool) -> CXProvider {
|
||||
let configuration = buildProviderConfiguration(useSystemCallLog: useSystemCallLog)
|
||||
|
||||
if let sharedProvider = self._sharedProvider {
|
||||
sharedProvider.configuration = configuration
|
||||
return sharedProvider
|
||||
} else {
|
||||
SwiftSingletons.register(self)
|
||||
let provider = CXProvider(configuration: configuration)
|
||||
_sharedProvider = provider
|
||||
return provider
|
||||
}
|
||||
}
|
||||
|
||||
class func buildProviderConfiguration(useSystemCallLog: Bool) -> CXProviderConfiguration {
|
||||
let localizedName = NSLocalizedString("APPLICATION_NAME", comment: "Name of application")
|
||||
let providerConfiguration = CXProviderConfiguration(localizedName: localizedName)
|
||||
providerConfiguration.supportsVideo = true
|
||||
providerConfiguration.maximumCallGroups = 1
|
||||
providerConfiguration.maximumCallsPerCallGroup = 1
|
||||
providerConfiguration.supportedHandleTypes = [.generic]
|
||||
let iconMaskImage = #imageLiteral(resourceName: "SessionGreen32")
|
||||
providerConfiguration.iconTemplateImageData = iconMaskImage.pngData()
|
||||
providerConfiguration.includesCallsInRecents = useSystemCallLog
|
||||
|
||||
return providerConfiguration
|
||||
}
|
||||
|
||||
init(useSystemCallLog: Bool = false) {
|
||||
AssertIsOnMainThread()
|
||||
if SSKPreferences.isCallKitSupported {
|
||||
self.provider = type(of: self).sharedProvider(useSystemCallLog: useSystemCallLog)
|
||||
self.callController = CXCallController()
|
||||
} else {
|
||||
self.provider = nil
|
||||
self.callController = nil
|
||||
}
|
||||
super.init()
|
||||
// We cannot assert singleton here, because this class gets rebuilt when the user changes relevant call settings
|
||||
self.provider?.setDelegate(self, queue: nil)
|
||||
}
|
||||
|
||||
// MARK: Report calls
|
||||
public func reportOutgoingCall(_ call: SessionCall) {
|
||||
AssertIsOnMainThread()
|
||||
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(true, forKey: "isCallOngoing")
|
||||
call.stateDidChange = {
|
||||
if call.hasStartedConnecting {
|
||||
self.provider?.reportOutgoingCall(with: call.callID, startedConnectingAt: call.connectingDate)
|
||||
}
|
||||
if call.hasConnected {
|
||||
self.provider?.reportOutgoingCall(with: call.callID, connectedAt: call.connectedDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func reportIncomingCall(_ call: SessionCall, callerName: String, completion: @escaping (Error?) -> Void) {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
if let provider = provider {
|
||||
// Construct a CXCallUpdate describing the incoming call, including the caller.
|
||||
let update = CXCallUpdate()
|
||||
update.localizedCallerName = callerName
|
||||
update.remoteHandle = CXHandle(type: .generic, value: call.callID.uuidString)
|
||||
update.hasVideo = false
|
||||
|
||||
disableUnsupportedFeatures(callUpdate: update)
|
||||
|
||||
// Report the incoming call to the system
|
||||
provider.reportNewIncomingCall(with: call.callID, update: update) { error in
|
||||
guard error == nil else {
|
||||
self.reportCurrentCallEnded(reason: .failed)
|
||||
completion(error)
|
||||
return
|
||||
}
|
||||
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(true, forKey: "isCallOngoing")
|
||||
completion(nil)
|
||||
}
|
||||
} else {
|
||||
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(true, forKey: "isCallOngoing")
|
||||
completion(nil)
|
||||
}
|
||||
}
|
||||
|
||||
public func reportCurrentCallEnded(reason: CXCallEndedReason?) {
|
||||
guard let call = currentCall else { return }
|
||||
if let reason = reason {
|
||||
self.provider?.reportCall(with: call.callID, endedAt: nil, reason: reason)
|
||||
switch (reason) {
|
||||
case .answeredElsewhere: call.updateCallMessage(mode: .answeredElsewhere)
|
||||
case .unanswered: call.updateCallMessage(mode: .unanswered)
|
||||
case .declinedElsewhere: call.updateCallMessage(mode: .local)
|
||||
default: call.updateCallMessage(mode: .remote)
|
||||
}
|
||||
} else {
|
||||
call.updateCallMessage(mode: .local)
|
||||
}
|
||||
call.webRTCSession.dropConnection()
|
||||
self.currentCall = nil
|
||||
WebRTCSession.current = nil
|
||||
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(false, forKey: "isCallOngoing")
|
||||
}
|
||||
|
||||
// MARK: Util
|
||||
private func disableUnsupportedFeatures(callUpdate: CXCallUpdate) {
|
||||
// Call Holding is failing to restart audio when "swapping" calls on the CallKit screen
|
||||
// until user returns to in-app call screen.
|
||||
callUpdate.supportsHolding = false
|
||||
|
||||
// Not yet supported
|
||||
callUpdate.supportsGrouping = false
|
||||
callUpdate.supportsUngrouping = false
|
||||
|
||||
// Is there any reason to support this?
|
||||
callUpdate.supportsDTMF = false
|
||||
}
|
||||
|
||||
public func handleIncomingCallOfferInBusyState(offerMessage: CallMessage, using transaction: YapDatabaseReadWriteTransaction) {
|
||||
guard let caller = offerMessage.sender, let thread = TSContactThread.fetch(for: caller, using: transaction) else { return }
|
||||
let message = CallMessage()
|
||||
message.uuid = offerMessage.uuid
|
||||
message.kind = .endCall
|
||||
SNLog("[Calls] Sending end call message because there is an ongoing call.")
|
||||
MessageSender.sendNonDurably(message, in: thread, using: transaction).retainUntilComplete()
|
||||
let infoMessage = TSInfoMessage.from(offerMessage, associatedWith: thread)
|
||||
infoMessage.updateCallInfoMessage(.missed, using: transaction)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import WebRTC
|
||||
|
||||
extension CallVC : CameraManagerDelegate {
|
||||
|
||||
func handleVideoOutputCaptured(sampleBuffer: CMSampleBuffer) {
|
||||
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
|
||||
let rtcPixelBuffer = RTCCVPixelBuffer(pixelBuffer: pixelBuffer)
|
||||
let timestamp = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sampleBuffer))
|
||||
let timestampNs = Int64(timestamp * 1000000000)
|
||||
let rotation: RTCVideoRotation = {
|
||||
switch UIDevice.current.orientation {
|
||||
case .landscapeRight: return RTCVideoRotation._90
|
||||
case .portraitUpsideDown: return RTCVideoRotation._180
|
||||
case .landscapeLeft: return RTCVideoRotation._270
|
||||
default: return RTCVideoRotation._0
|
||||
}
|
||||
}()
|
||||
let frame = RTCVideoFrame(buffer: rtcPixelBuffer, rotation: rotation, timeStampNs: timestampNs)
|
||||
frame.timeStamp = Int32(timestamp)
|
||||
call.webRTCSession.handleLocalFrameCaptured(frame)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,550 @@
|
|||
import WebRTC
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
import UIKit
|
||||
import MediaPlayer
|
||||
|
||||
final class CallVC : UIViewController, VideoPreviewDelegate {
|
||||
let call: SessionCall
|
||||
var latestKnownAudioOutputDeviceName: String?
|
||||
var durationTimer: Timer?
|
||||
var duration: Int = 0
|
||||
var shouldRestartCamera = true
|
||||
weak var conversationVC: ConversationVC? = nil
|
||||
|
||||
lazy var cameraManager: CameraManager = {
|
||||
let result = CameraManager()
|
||||
result.delegate = self
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: UI Components
|
||||
private lazy var localVideoView: LocalVideoView = {
|
||||
let result = LocalVideoView()
|
||||
result.isHidden = !call.isVideoEnabled
|
||||
result.layer.cornerRadius = 10
|
||||
result.layer.masksToBounds = true
|
||||
result.set(.width, to: LocalVideoView.width)
|
||||
result.set(.height, to: LocalVideoView.height)
|
||||
result.makeViewDraggable()
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var remoteVideoView: RemoteVideoView = {
|
||||
let result = RemoteVideoView()
|
||||
result.alpha = 0
|
||||
result.backgroundColor = .black
|
||||
result.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleRemoteVieioViewTapped)))
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var fadeView: UIView = {
|
||||
let result = UIView()
|
||||
let height: CGFloat = 64
|
||||
var frame = UIScreen.main.bounds
|
||||
frame.size.height = height
|
||||
let layer = CAGradientLayer()
|
||||
layer.frame = frame
|
||||
layer.colors = [ UIColor(hex: 0x000000).withAlphaComponent(0.4).cgColor, UIColor(hex: 0x000000).withAlphaComponent(0).cgColor ]
|
||||
result.layer.insertSublayer(layer, at: 0)
|
||||
result.set(.height, to: height)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var profilePictureView: UIImageView = {
|
||||
let result = UIImageView()
|
||||
let radius: CGFloat = isIPhone6OrSmaller ? 100 : 120
|
||||
result.image = self.call.profilePicture
|
||||
result.set(.width, to: radius * 2)
|
||||
result.set(.height, to: radius * 2)
|
||||
result.layer.cornerRadius = radius
|
||||
result.layer.masksToBounds = true
|
||||
result.contentMode = .scaleAspectFill
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var minimizeButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
result.isHidden = !call.hasConnected
|
||||
let image = UIImage(named: "Minimize")!.withTint(.white)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.addTarget(self, action: #selector(minimize), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var answerButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
result.isHidden = call.hasStartedConnecting
|
||||
let image = UIImage(named: "AnswerCall")!.withTint(.white)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.backgroundColor = Colors.accent
|
||||
result.layer.cornerRadius = 30
|
||||
result.addTarget(self, action: #selector(answerCall), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var hangUpButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let image = UIImage(named: "EndCall")!.withTint(.white)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.backgroundColor = Colors.destructive
|
||||
result.layer.cornerRadius = 30
|
||||
result.addTarget(self, action: #selector(endCall), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var responsePanel: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [hangUpButton, answerButton])
|
||||
result.axis = .horizontal
|
||||
result.spacing = Values.veryLargeSpacing * 2 + 40
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var switchCameraButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
result.isEnabled = call.isVideoEnabled
|
||||
let image = UIImage(named: "SwitchCamera")!.withTint(.white)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.backgroundColor = UIColor(hex: 0x1F1F1F)
|
||||
result.layer.cornerRadius = 30
|
||||
result.addTarget(self, action: #selector(switchCamera), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var switchAudioButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let image = UIImage(named: "AudioOff")!.withTint(.white)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.backgroundColor = call.isMuted ? Colors.destructive : UIColor(hex: 0x1F1F1F)
|
||||
result.layer.cornerRadius = 30
|
||||
result.addTarget(self, action: #selector(switchAudio), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var videoButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let image = UIImage(named: "VideoCall")?.withRenderingMode(.alwaysTemplate)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.tintColor = .white
|
||||
result.backgroundColor = UIColor(hex: 0x1F1F1F)
|
||||
result.layer.cornerRadius = 30
|
||||
result.addTarget(self, action: #selector(operateCamera), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var volumeView: MPVolumeView = {
|
||||
let result = MPVolumeView()
|
||||
let image = UIImage(named: "Speaker")?.withRenderingMode(.alwaysTemplate)
|
||||
result.showsVolumeSlider = false
|
||||
result.showsRouteButton = true
|
||||
result.setRouteButtonImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.tintColor = .white
|
||||
result.backgroundColor = UIColor(hex: 0x1F1F1F)
|
||||
result.layer.cornerRadius = 30
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var operationPanel: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [switchCameraButton, videoButton, switchAudioButton, volumeView])
|
||||
result.axis = .horizontal
|
||||
result.spacing = Values.veryLargeSpacing
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var titleLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.textColor = .white
|
||||
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
||||
result.textAlignment = .center
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var callInfoLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.isHidden = call.hasConnected
|
||||
result.textColor = .white
|
||||
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
||||
result.textAlignment = .center
|
||||
if call.hasStartedConnecting { result.text = "Connecting..." }
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var callDurationLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.isHidden = true
|
||||
result.textColor = .white
|
||||
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
||||
result.textAlignment = .center
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(for call: SessionCall) {
|
||||
self.call = call
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
setupStateChangeCallbacks()
|
||||
self.modalPresentationStyle = .overFullScreen
|
||||
self.modalTransitionStyle = .crossDissolve
|
||||
}
|
||||
|
||||
func setupStateChangeCallbacks() {
|
||||
self.call.remoteVideoStateDidChange = { isEnabled in
|
||||
DispatchQueue.main.async {
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.remoteVideoView.alpha = isEnabled ? 1 : 0
|
||||
}
|
||||
if self.callInfoLabel.alpha < 0.5 {
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.operationPanel.alpha = 1
|
||||
self.responsePanel.alpha = 1
|
||||
self.callInfoLabel.alpha = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.call.hasStartedConnectingDidChange = {
|
||||
DispatchQueue.main.async {
|
||||
self.callInfoLabel.text = "Connecting..."
|
||||
self.answerButton.alpha = 0
|
||||
UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 1, options: .curveEaseIn, animations: {
|
||||
self.answerButton.isHidden = true
|
||||
}, completion: nil)
|
||||
}
|
||||
}
|
||||
self.call.hasConnectedDidChange = {
|
||||
DispatchQueue.main.async {
|
||||
CallRingTonePlayer.shared.stopPlayingRingTone()
|
||||
self.callInfoLabel.text = "Connected"
|
||||
self.minimizeButton.isHidden = false
|
||||
self.durationTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
|
||||
self.updateDuration()
|
||||
}
|
||||
self.callInfoLabel.isHidden = true
|
||||
self.callDurationLabel.isHidden = false
|
||||
}
|
||||
}
|
||||
self.call.hasEndedDidChange = {
|
||||
DispatchQueue.main.async {
|
||||
self.durationTimer?.invalidate()
|
||||
self.durationTimer = nil
|
||||
self.handleEndCallMessage()
|
||||
}
|
||||
}
|
||||
self.call.hasStartedReconnecting = {
|
||||
DispatchQueue.main.async {
|
||||
self.callInfoLabel.isHidden = false
|
||||
self.callDurationLabel.isHidden = true
|
||||
self.callInfoLabel.text = "Reconnecting..."
|
||||
}
|
||||
}
|
||||
self.call.hasReconnected = {
|
||||
DispatchQueue.main.async {
|
||||
self.callInfoLabel.isHidden = true
|
||||
self.callDurationLabel.isHidden = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
required init(coder: NSCoder) { preconditionFailure("Use init(for:) instead.") }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
setUpViewHierarchy()
|
||||
if shouldRestartCamera { cameraManager.prepare() }
|
||||
touch(call.videoCapturer)
|
||||
titleLabel.text = self.call.contactName
|
||||
AppEnvironment.shared.callManager.startCall(call) { error in
|
||||
DispatchQueue.main.async {
|
||||
if let _ = error {
|
||||
self.callInfoLabel.text = "Can't start a call."
|
||||
self.endCall()
|
||||
} else {
|
||||
self.callInfoLabel.text = "Ringing..."
|
||||
self.answerButton.isHidden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
setupOrientationMonitoring()
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(audioRouteDidChange), name: AVAudioSession.routeChangeNotification, object: nil)
|
||||
}
|
||||
|
||||
deinit {
|
||||
UIDevice.current.endGeneratingDeviceOrientationNotifications()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
func setUpViewHierarchy() {
|
||||
// Profile picture container
|
||||
let profilePictureContainer = UIView()
|
||||
view.addSubview(profilePictureContainer)
|
||||
// Remote video view
|
||||
call.attachRemoteVideoRenderer(remoteVideoView)
|
||||
view.addSubview(remoteVideoView)
|
||||
remoteVideoView.translatesAutoresizingMaskIntoConstraints = false
|
||||
remoteVideoView.pin(to: view)
|
||||
// Local video view
|
||||
call.attachLocalVideoRenderer(localVideoView)
|
||||
// Fade view
|
||||
view.addSubview(fadeView)
|
||||
fadeView.translatesAutoresizingMaskIntoConstraints = false
|
||||
fadeView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.HorizontalEdge.right ], to: view)
|
||||
// Minimize button
|
||||
view.addSubview(minimizeButton)
|
||||
minimizeButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
minimizeButton.pin(.left, to: .left, of: view)
|
||||
minimizeButton.pin(.top, to: .top, of: view, withInset: 32)
|
||||
// Title label
|
||||
view.addSubview(titleLabel)
|
||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
titleLabel.center(.vertical, in: minimizeButton)
|
||||
titleLabel.center(.horizontal, in: view)
|
||||
// Response Panel
|
||||
view.addSubview(responsePanel)
|
||||
responsePanel.center(.horizontal, in: view)
|
||||
responsePanel.pin(.bottom, to: .bottom, of: view, withInset: -Values.newConversationButtonBottomOffset)
|
||||
// Operation Panel
|
||||
view.addSubview(operationPanel)
|
||||
operationPanel.center(.horizontal, in: view)
|
||||
operationPanel.pin(.bottom, to: .top, of: responsePanel, withInset: -Values.veryLargeSpacing)
|
||||
// Profile picture view
|
||||
profilePictureContainer.pin(.top, to: .bottom, of: fadeView)
|
||||
profilePictureContainer.pin(.bottom, to: .top, of: operationPanel)
|
||||
profilePictureContainer.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: view)
|
||||
profilePictureContainer.addSubview(profilePictureView)
|
||||
profilePictureView.center(in: profilePictureContainer)
|
||||
// Call info label
|
||||
let callInfoLabelContainer = UIView()
|
||||
view.addSubview(callInfoLabelContainer)
|
||||
callInfoLabelContainer.pin(.top, to: .bottom, of: profilePictureView)
|
||||
callInfoLabelContainer.pin(.bottom, to: .bottom, of: profilePictureContainer)
|
||||
callInfoLabelContainer.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: view)
|
||||
callInfoLabelContainer.addSubview(callInfoLabel)
|
||||
callInfoLabelContainer.addSubview(callDurationLabel)
|
||||
callInfoLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
callInfoLabel.center(in: callInfoLabelContainer)
|
||||
callDurationLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
callDurationLabel.center(in: callInfoLabelContainer)
|
||||
}
|
||||
|
||||
private func addLocalVideoView() {
|
||||
let safeAreaInsets = UIApplication.shared.keyWindow!.safeAreaInsets
|
||||
let window = CurrentAppContext().mainWindow!
|
||||
window.addSubview(localVideoView)
|
||||
localVideoView.autoPinEdge(toSuperviewEdge: .right, withInset: Values.smallSpacing)
|
||||
let topMargin = safeAreaInsets.top + Values.veryLargeSpacing
|
||||
localVideoView.autoPinEdge(toSuperviewEdge: .top, withInset: topMargin)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
if (call.isVideoEnabled && shouldRestartCamera) { cameraManager.start() }
|
||||
shouldRestartCamera = true
|
||||
addLocalVideoView()
|
||||
remoteVideoView.alpha = call.isRemoteVideoEnabled ? 1 : 0
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
if (call.isVideoEnabled && shouldRestartCamera) { cameraManager.stop() }
|
||||
localVideoView.removeFromSuperview()
|
||||
}
|
||||
|
||||
// MARK: - Orientation
|
||||
|
||||
private func setupOrientationMonitoring() {
|
||||
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(didChangeDeviceOrientation), name: UIDevice.orientationDidChangeNotification, object: UIDevice.current)
|
||||
}
|
||||
|
||||
@objc func didChangeDeviceOrientation(notification: Notification) {
|
||||
|
||||
func rotateAllButtons(rotationAngle: CGFloat) {
|
||||
let transform = CGAffineTransform(rotationAngle: rotationAngle)
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
self.answerButton.transform = transform
|
||||
self.hangUpButton.transform = transform
|
||||
self.switchAudioButton.transform = transform
|
||||
self.switchCameraButton.transform = transform
|
||||
self.videoButton.transform = transform
|
||||
self.volumeView.transform = transform
|
||||
}
|
||||
}
|
||||
|
||||
switch UIDevice.current.orientation {
|
||||
case .portrait:
|
||||
rotateAllButtons(rotationAngle: 0)
|
||||
case .portraitUpsideDown:
|
||||
rotateAllButtons(rotationAngle: .pi)
|
||||
case .landscapeLeft:
|
||||
rotateAllButtons(rotationAngle: .halfPi)
|
||||
case .landscapeRight:
|
||||
rotateAllButtons(rotationAngle: .pi + .halfPi)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Call signalling
|
||||
func handleAnswerMessage(_ message: CallMessage) {
|
||||
callInfoLabel.text = "Connecting..."
|
||||
}
|
||||
|
||||
func handleEndCallMessage() {
|
||||
SNLog("[Calls] Ending call.")
|
||||
self.callInfoLabel.isHidden = false
|
||||
self.callDurationLabel.isHidden = true
|
||||
callInfoLabel.text = "Call Ended"
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.remoteVideoView.alpha = 0
|
||||
self.operationPanel.alpha = 1
|
||||
self.responsePanel.alpha = 1
|
||||
self.callInfoLabel.alpha = 1
|
||||
}
|
||||
Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in
|
||||
self.conversationVC?.showInputAccessoryView()
|
||||
self.presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func answerCall() {
|
||||
AppEnvironment.shared.callManager.answerCall(call) { error in
|
||||
DispatchQueue.main.async {
|
||||
if let _ = error {
|
||||
self.callInfoLabel.text = "Can't answer the call."
|
||||
self.endCall()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func endCall() {
|
||||
AppEnvironment.shared.callManager.endCall(call) { error in
|
||||
if let _ = error {
|
||||
self.call.endSessionCall()
|
||||
AppEnvironment.shared.callManager.reportCurrentCallEnded(reason: nil)
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.conversationVC?.showInputAccessoryView()
|
||||
self.presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func updateDuration() {
|
||||
callDurationLabel.text = String(format: "%.2d:%.2d", duration/60, duration%60)
|
||||
duration += 1
|
||||
}
|
||||
|
||||
// MARK: Minimize to a floating view
|
||||
@objc private func minimize() {
|
||||
self.shouldRestartCamera = false
|
||||
let miniCallView = MiniCallView(from: self)
|
||||
miniCallView.show()
|
||||
self.conversationVC?.showInputAccessoryView()
|
||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
// MARK: Video and Audio
|
||||
@objc private func operateCamera() {
|
||||
if (call.isVideoEnabled) {
|
||||
localVideoView.isHidden = true
|
||||
cameraManager.stop()
|
||||
videoButton.tintColor = .white
|
||||
videoButton.backgroundColor = UIColor(hex: 0x1F1F1F)
|
||||
switchCameraButton.isEnabled = false
|
||||
call.isVideoEnabled = false
|
||||
} else {
|
||||
guard requestCameraPermissionIfNeeded() else { return }
|
||||
let previewVC = VideoPreviewVC()
|
||||
previewVC.delegate = self
|
||||
present(previewVC, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func cameraDidConfirmTurningOn() {
|
||||
localVideoView.isHidden = false
|
||||
cameraManager.prepare()
|
||||
cameraManager.start()
|
||||
videoButton.tintColor = UIColor(hex: 0x1F1F1F)
|
||||
videoButton.backgroundColor = .white
|
||||
switchCameraButton.isEnabled = true
|
||||
call.isVideoEnabled = true
|
||||
}
|
||||
|
||||
@objc private func switchCamera() {
|
||||
cameraManager.switchCamera()
|
||||
}
|
||||
|
||||
@objc private func switchAudio() {
|
||||
if call.isMuted {
|
||||
switchAudioButton.backgroundColor = UIColor(hex: 0x1F1F1F)
|
||||
call.isMuted = false
|
||||
} else {
|
||||
switchAudioButton.backgroundColor = Colors.destructive
|
||||
call.isMuted = true
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func audioRouteDidChange() {
|
||||
let currentSession = AVAudioSession.sharedInstance()
|
||||
let currentRoute = currentSession.currentRoute
|
||||
if let currentOutput = currentRoute.outputs.first {
|
||||
if let latestKnownAudioOutputDeviceName = latestKnownAudioOutputDeviceName, currentOutput.portName == latestKnownAudioOutputDeviceName { return }
|
||||
latestKnownAudioOutputDeviceName = currentOutput.portName
|
||||
switch currentOutput.portType {
|
||||
case .builtInSpeaker:
|
||||
let image = UIImage(named: "Speaker")?.withRenderingMode(.alwaysTemplate)
|
||||
volumeView.setRouteButtonImage(image, for: .normal)
|
||||
volumeView.tintColor = UIColor(hex: 0x1F1F1F)
|
||||
volumeView.backgroundColor = .white
|
||||
case .headphones:
|
||||
let image = UIImage(named: "Headsets")?.withRenderingMode(.alwaysTemplate)
|
||||
volumeView.setRouteButtonImage(image, for: .normal)
|
||||
volumeView.tintColor = UIColor(hex: 0x1F1F1F)
|
||||
volumeView.backgroundColor = .white
|
||||
case .bluetoothLE: fallthrough
|
||||
case .bluetoothA2DP:
|
||||
let image = UIImage(named: "Bluetooth")?.withRenderingMode(.alwaysTemplate)
|
||||
volumeView.setRouteButtonImage(image, for: .normal)
|
||||
volumeView.tintColor = UIColor(hex: 0x1F1F1F)
|
||||
volumeView.backgroundColor = .white
|
||||
case .bluetoothHFP:
|
||||
let image = UIImage(named: "Airpods")?.withRenderingMode(.alwaysTemplate)
|
||||
volumeView.setRouteButtonImage(image, for: .normal)
|
||||
volumeView.tintColor = UIColor(hex: 0x1F1F1F)
|
||||
volumeView.backgroundColor = .white
|
||||
case .builtInReceiver: fallthrough
|
||||
default:
|
||||
let image = UIImage(named: "Speaker")?.withRenderingMode(.alwaysTemplate)
|
||||
volumeView.setRouteButtonImage(image, for: .normal)
|
||||
volumeView.tintColor = .white
|
||||
volumeView.backgroundColor = UIColor(hex: 0x1F1F1F)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleRemoteVieioViewTapped(gesture: UITapGestureRecognizer) {
|
||||
let isHidden = callDurationLabel.alpha < 0.5
|
||||
UIView.animate(withDuration: 0.5) {
|
||||
self.operationPanel.alpha = isHidden ? 1 : 0
|
||||
self.responsePanel.alpha = isHidden ? 1 : 0
|
||||
self.callDurationLabel.alpha = isHidden ? 1 : 0
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
import Foundation
|
||||
import AVFoundation
|
||||
import SessionUtilitiesKit
|
||||
|
||||
@objc
|
||||
protocol CameraManagerDelegate : AnyObject {
|
||||
|
||||
func handleVideoOutputCaptured(sampleBuffer: CMSampleBuffer)
|
||||
}
|
||||
|
||||
final class CameraManager : NSObject {
|
||||
private let captureSession = AVCaptureSession()
|
||||
private let videoDataOutput = AVCaptureVideoDataOutput()
|
||||
private let videoDataOutputQueue
|
||||
= DispatchQueue(label: "CameraManager.videoDataOutputQueue", qos: .userInitiated, attributes: [], autoreleaseFrequency: .workItem)
|
||||
private let audioDataOutput = AVCaptureAudioDataOutput()
|
||||
private var isCapturing = false
|
||||
weak var delegate: CameraManagerDelegate?
|
||||
|
||||
private var videoCaptureDevice: AVCaptureDevice?
|
||||
private var videoInput: AVCaptureDeviceInput?
|
||||
|
||||
func prepare() {
|
||||
print("[Calls] Preparing camera.")
|
||||
addNewVideoIO(position: .front)
|
||||
}
|
||||
|
||||
private func addNewVideoIO(position: AVCaptureDevice.Position) {
|
||||
if let videoCaptureDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position),
|
||||
let videoInput = try? AVCaptureDeviceInput(device: videoCaptureDevice), captureSession.canAddInput(videoInput) {
|
||||
captureSession.addInput(videoInput)
|
||||
self.videoCaptureDevice = videoCaptureDevice
|
||||
self.videoInput = videoInput
|
||||
}
|
||||
if captureSession.canAddOutput(videoDataOutput) {
|
||||
captureSession.addOutput(videoDataOutput)
|
||||
videoDataOutput.videoSettings = [ kCVPixelBufferPixelFormatTypeKey as String : Int(kCVPixelFormatType_32BGRA) ]
|
||||
videoDataOutput.setSampleBufferDelegate(self, queue: videoDataOutputQueue)
|
||||
guard let connection = videoDataOutput.connection(with: AVMediaType.video) else { return }
|
||||
connection.videoOrientation = .portrait
|
||||
connection.automaticallyAdjustsVideoMirroring = false
|
||||
connection.isVideoMirrored = (position == .front)
|
||||
} else {
|
||||
SNLog("Couldn't add video data output to capture session.")
|
||||
}
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard !isCapturing else { return }
|
||||
print("[Calls] Starting camera.")
|
||||
isCapturing = true
|
||||
captureSession.startRunning()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard isCapturing else { return }
|
||||
print("[Calls] Stopping camera.")
|
||||
isCapturing = false
|
||||
captureSession.stopRunning()
|
||||
}
|
||||
|
||||
func switchCamera() {
|
||||
guard let videoCaptureDevice = videoCaptureDevice, let videoInput = videoInput else { return }
|
||||
stop()
|
||||
if videoCaptureDevice.position == .front {
|
||||
captureSession.removeInput(videoInput)
|
||||
captureSession.removeOutput(videoDataOutput)
|
||||
addNewVideoIO(position: .back)
|
||||
} else {
|
||||
captureSession.removeInput(videoInput)
|
||||
captureSession.removeOutput(videoDataOutput)
|
||||
addNewVideoIO(position: .front)
|
||||
}
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
extension CameraManager : AVCaptureVideoDataOutputSampleBufferDelegate, AVCaptureAudioDataOutputSampleBufferDelegate {
|
||||
|
||||
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
|
||||
guard connection == videoDataOutput.connection(with: .video) else { return }
|
||||
delegate?.handleVideoOutputCaptured(sampleBuffer: sampleBuffer)
|
||||
}
|
||||
|
||||
func captureOutput(_ output: AVCaptureOutput, didDrop sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
|
||||
print("[Calls] Frame dropped.")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,123 @@
|
|||
import UIKit
|
||||
import WebRTC
|
||||
|
||||
public protocol VideoPreviewDelegate : AnyObject {
|
||||
func cameraDidConfirmTurningOn()
|
||||
}
|
||||
|
||||
class VideoPreviewVC: UIViewController, CameraManagerDelegate {
|
||||
weak var delegate: VideoPreviewDelegate?
|
||||
|
||||
lazy var cameraManager: CameraManager = {
|
||||
let result = CameraManager()
|
||||
result.delegate = self
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: UI Components
|
||||
private lazy var renderView: RenderView = {
|
||||
let result = RenderView()
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var fadeView: UIView = {
|
||||
let result = UIView()
|
||||
let height: CGFloat = 64
|
||||
var frame = UIScreen.main.bounds
|
||||
frame.size.height = height
|
||||
let layer = CAGradientLayer()
|
||||
layer.frame = frame
|
||||
layer.colors = [ UIColor(hex: 0x000000).withAlphaComponent(0.4).cgColor, UIColor(hex: 0x000000).withAlphaComponent(0).cgColor ]
|
||||
result.layer.insertSublayer(layer, at: 0)
|
||||
result.set(.height, to: height)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var closeButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let image = UIImage(named: "X")!.withTint(.white)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var confirmButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let image = UIImage(named: "Check")!.withTint(.white)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.addTarget(self, action: #selector(confirm), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var titleLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.text = "Preview"
|
||||
result.textColor = .white
|
||||
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
||||
result.textAlignment = .center
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Lifecycle
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
setUpViewHierarchy()
|
||||
cameraManager.prepare()
|
||||
}
|
||||
|
||||
func setUpViewHierarchy() {
|
||||
// Preview video view
|
||||
view.addSubview(renderView)
|
||||
renderView.translatesAutoresizingMaskIntoConstraints = false
|
||||
renderView.pin(to: view)
|
||||
// Fade view
|
||||
view.addSubview(fadeView)
|
||||
fadeView.translatesAutoresizingMaskIntoConstraints = false
|
||||
fadeView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.HorizontalEdge.right ], to: view)
|
||||
// Close button
|
||||
view.addSubview(closeButton)
|
||||
closeButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
closeButton.pin(.left, to: .left, of: view)
|
||||
closeButton.center(.vertical, in: fadeView)
|
||||
// Confirm button
|
||||
view.addSubview(confirmButton)
|
||||
confirmButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
confirmButton.pin(.right, to: .right, of: view)
|
||||
confirmButton.center(.vertical, in: fadeView)
|
||||
// Title label
|
||||
view.addSubview(titleLabel)
|
||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
titleLabel.center(.vertical, in: closeButton)
|
||||
titleLabel.center(.horizontal, in: view)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
cameraManager.start()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
cameraManager.stop()
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
@objc func confirm() {
|
||||
delegate?.cameraDidConfirmTurningOn()
|
||||
self.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
@objc func cancel() {
|
||||
self.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
// MARK: CameraManagerDelegate
|
||||
func handleVideoOutputCaptured(sampleBuffer: CMSampleBuffer) {
|
||||
renderView.enqueue(sampleBuffer: sampleBuffer)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
import UIKit
|
||||
|
||||
@objc
|
||||
final class CallMissedTipsModal : Modal {
|
||||
private let caller: String
|
||||
|
||||
// MARK: Lifecycle
|
||||
@objc
|
||||
init(caller: String) {
|
||||
self.caller = caller
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
self.modalPresentationStyle = .overFullScreen
|
||||
self.modalTransitionStyle = .crossDissolve
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(onCallEnabled:) instead.")
|
||||
}
|
||||
|
||||
override init(nibName: String?, bundle: Bundle?) {
|
||||
preconditionFailure("Use init(onCallEnabled:) instead.")
|
||||
}
|
||||
|
||||
override func populateContentView() {
|
||||
// Tips icon
|
||||
let tipsIconImageView = UIImageView(image: UIImage(named: "Tips")?.withTint(Colors.text))
|
||||
tipsIconImageView.set(.width, to: 19)
|
||||
tipsIconImageView.set(.height, to: 28)
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = NSLocalizedString("modal_call_missed_tips_title", comment: "")
|
||||
titleLabel.textAlignment = .center
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
let message = String(format: NSLocalizedString("modal_call_missed_tips_explanation", comment: ""), caller)
|
||||
messageLabel.text = message
|
||||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .natural
|
||||
// Cancel Button
|
||||
cancelButton.setTitle(NSLocalizedString("OK", comment: ""), for: .normal)
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ tipsIconImageView, titleLabel, messageLabel, cancelButton ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.alignment = .center
|
||||
mainStackView.spacing = Values.largeSpacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,97 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
import WebRTC
|
||||
import Foundation
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
// Note: 'RTCMTLVideoView' doesn't seem to work on the simulator so use 'RTCEAGLVideoView' instead
|
||||
typealias TargetView = RTCEAGLVideoView
|
||||
#else
|
||||
typealias TargetView = RTCMTLVideoView
|
||||
#endif
|
||||
|
||||
// MARK: RemoteVideoView
|
||||
|
||||
class RemoteVideoView: TargetView {
|
||||
|
||||
override func renderFrame(_ frame: RTCVideoFrame?) {
|
||||
super.renderFrame(frame)
|
||||
guard let frame = frame else { return }
|
||||
|
||||
DispatchMainThreadSafe {
|
||||
let frameRatio = Double(frame.height) / Double(frame.width)
|
||||
let frameRotation = frame.rotation
|
||||
let deviceRotation = UIDevice.current.orientation
|
||||
var rotationOverride: RTCVideoRotation? = nil
|
||||
switch deviceRotation {
|
||||
case .portrait, .portraitUpsideDown:
|
||||
// We don't have to do anything, the renderer will automatically make sure it's right-side-up.
|
||||
break
|
||||
case .landscapeLeft:
|
||||
switch frameRotation {
|
||||
case RTCVideoRotation._0: rotationOverride = RTCVideoRotation._90 // Landscape left
|
||||
case RTCVideoRotation._90: rotationOverride = RTCVideoRotation._180 // Portrait
|
||||
case RTCVideoRotation._180: rotationOverride = RTCVideoRotation._270 // Landscape right
|
||||
case RTCVideoRotation._270: rotationOverride = RTCVideoRotation._0 // Portrait upside-down
|
||||
default: break
|
||||
}
|
||||
case .landscapeRight:
|
||||
switch frameRotation {
|
||||
case RTCVideoRotation._0: rotationOverride = RTCVideoRotation._270 // Landscape left
|
||||
case RTCVideoRotation._90: rotationOverride = RTCVideoRotation._0 // Portrait
|
||||
case RTCVideoRotation._180: rotationOverride = RTCVideoRotation._90 // Landscape right
|
||||
case RTCVideoRotation._270: rotationOverride = RTCVideoRotation._180 // Portrait upside-down
|
||||
default: break
|
||||
}
|
||||
default:
|
||||
// Do nothing if we're face down, up, etc.
|
||||
// Assume we're already setup for the correct orientation.
|
||||
break
|
||||
}
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
#else
|
||||
if let rotationOverride = rotationOverride {
|
||||
self.rotationOverride = NSNumber(value: rotationOverride.rawValue)
|
||||
if [ RTCVideoRotation._0, RTCVideoRotation._180 ].contains(rotationOverride) {
|
||||
self.videoContentMode = .scaleAspectFill
|
||||
} else {
|
||||
self.videoContentMode = .scaleAspectFit
|
||||
}
|
||||
} else {
|
||||
self.rotationOverride = nil
|
||||
if [ RTCVideoRotation._0, RTCVideoRotation._180 ].contains(frameRotation) {
|
||||
self.videoContentMode = .scaleAspectFill
|
||||
} else {
|
||||
self.videoContentMode = .scaleAspectFit
|
||||
}
|
||||
}
|
||||
// if not a mobile ratio, always use .scaleAspectFit
|
||||
if frameRatio < 1.5 {
|
||||
self.videoContentMode = .scaleAspectFit
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: LocalVideoView
|
||||
|
||||
class LocalVideoView: TargetView {
|
||||
|
||||
static let width: CGFloat = 80
|
||||
static let height: CGFloat = 173
|
||||
|
||||
override func renderFrame(_ frame: RTCVideoFrame?) {
|
||||
super.renderFrame(frame)
|
||||
DispatchMainThreadSafe {
|
||||
// This is a workaround for a weird issue that
|
||||
// sometimes the rotationOverride is not working
|
||||
// if it is only set once on initialization
|
||||
self.rotationOverride = NSNumber(value: RTCVideoRotation._0.rawValue)
|
||||
#if targetEnvironment(simulator)
|
||||
#else
|
||||
self.videoContentMode = .scaleAspectFill
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,191 @@
|
|||
import UIKit
|
||||
import WebRTC
|
||||
import SessionMessagingKit
|
||||
|
||||
final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
|
||||
private static let swipeToOperateThreshold: CGFloat = 60
|
||||
private var previousY: CGFloat = 0
|
||||
let call: SessionCall
|
||||
|
||||
// MARK: UI Components
|
||||
private lazy var profilePictureView: ProfilePictureView = {
|
||||
let result = ProfilePictureView()
|
||||
let size = CGFloat(60)
|
||||
result.size = size
|
||||
result.set(.width, to: size)
|
||||
result.set(.height, to: size)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var displayNameLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.textColor = UIColor.white
|
||||
result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
result.lineBreakMode = .byTruncatingTail
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var answerButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let image = UIImage(named: "AnswerCall")!.withTint(.white)?.resizedImage(to: CGSize(width: 24.8, height: 24.8))
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 48)
|
||||
result.set(.height, to: 48)
|
||||
result.backgroundColor = Colors.accent
|
||||
result.layer.cornerRadius = 24
|
||||
result.addTarget(self, action: #selector(answerCall), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var hangUpButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let image = UIImage(named: "EndCall")!.withTint(.white)?.resizedImage(to: CGSize(width: 29.6, height: 11.2))
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 48)
|
||||
result.set(.height, to: 48)
|
||||
result.backgroundColor = Colors.destructive
|
||||
result.layer.cornerRadius = 24
|
||||
result.addTarget(self, action: #selector(endCall), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var panGestureRecognizer: UIPanGestureRecognizer = {
|
||||
let result = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
|
||||
result.delegate = self
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Initialization
|
||||
public static var current: IncomingCallBanner?
|
||||
|
||||
init(for call: SessionCall) {
|
||||
self.call = call
|
||||
super.init(frame: CGRect.zero)
|
||||
setUpViewHierarchy()
|
||||
setUpGestureRecognizers()
|
||||
if let incomingCallBanner = IncomingCallBanner.current {
|
||||
incomingCallBanner.dismiss()
|
||||
}
|
||||
IncomingCallBanner.current = self
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
preconditionFailure("Use init(message:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(coder:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
self.backgroundColor = UIColor(hex: 0x000000).withAlphaComponent(0.8)
|
||||
self.layer.cornerRadius = Values.largeSpacing
|
||||
self.layer.masksToBounds = true
|
||||
self.set(.height, to: 100)
|
||||
profilePictureView.publicKey = call.sessionID
|
||||
profilePictureView.update()
|
||||
displayNameLabel.text = call.contactName
|
||||
let stackView = UIStackView(arrangedSubviews: [profilePictureView, displayNameLabel, hangUpButton, answerButton])
|
||||
stackView.axis = .horizontal
|
||||
stackView.alignment = .center
|
||||
stackView.spacing = Values.largeSpacing
|
||||
self.addSubview(stackView)
|
||||
stackView.center(.vertical, in: self)
|
||||
stackView.autoPinWidthToSuperview(withMargin: Values.mediumSpacing)
|
||||
}
|
||||
|
||||
private func setUpGestureRecognizers() {
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
tapGestureRecognizer.numberOfTapsRequired = 1
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
addGestureRecognizer(panGestureRecognizer)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
if gestureRecognizer == panGestureRecognizer {
|
||||
let v = panGestureRecognizer.velocity(in: self)
|
||||
return abs(v.y) > abs(v.x) // It has to be more vertical than horizontal
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) {
|
||||
showCallVC(answer: false)
|
||||
}
|
||||
|
||||
@objc private func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) {
|
||||
let translationY = gestureRecognizer.translation(in: self).y
|
||||
switch gestureRecognizer.state {
|
||||
case .changed:
|
||||
self.transform = CGAffineTransform(translationX: 0, y: min(translationY, IncomingCallBanner.swipeToOperateThreshold))
|
||||
if abs(translationY) > IncomingCallBanner.swipeToOperateThreshold && abs(previousY) < IncomingCallBanner.swipeToOperateThreshold {
|
||||
UIImpactFeedbackGenerator(style: .heavy).impactOccurred() // Let the user know when they've hit the swipe to reply threshold
|
||||
}
|
||||
previousY = translationY
|
||||
case .ended, .cancelled:
|
||||
if abs(translationY) > IncomingCallBanner.swipeToOperateThreshold {
|
||||
if translationY > 0 { showCallVC(answer: false) }
|
||||
else { endCall() } // TODO: Or just put the call on hold?
|
||||
} else {
|
||||
self.transform = .identity
|
||||
}
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func answerCall() {
|
||||
showCallVC(answer: true)
|
||||
}
|
||||
|
||||
@objc private func endCall() {
|
||||
AppEnvironment.shared.callManager.endCall(call) { error in
|
||||
if let _ = error {
|
||||
self.call.endSessionCall()
|
||||
AppEnvironment.shared.callManager.reportCurrentCallEnded(reason: nil)
|
||||
}
|
||||
self.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
public func showCallVC(answer: Bool) {
|
||||
dismiss()
|
||||
guard let presentingVC = CurrentAppContext().frontmostViewController() else { preconditionFailure() } // FIXME: Handle more gracefully
|
||||
let callVC = CallVC(for: self.call)
|
||||
if let conversationVC = presentingVC as? ConversationVC {
|
||||
callVC.conversationVC = conversationVC
|
||||
conversationVC.inputAccessoryView?.isHidden = true
|
||||
conversationVC.inputAccessoryView?.alpha = 0
|
||||
}
|
||||
presentingVC.present(callVC, animated: true) {
|
||||
if answer { self.call.answerSessionCall() }
|
||||
}
|
||||
}
|
||||
|
||||
public func show() {
|
||||
self.alpha = 0.0
|
||||
let window = CurrentAppContext().mainWindow!
|
||||
window.addSubview(self)
|
||||
let topMargin = window.safeAreaInsets.top - Values.smallSpacing
|
||||
self.autoPinWidthToSuperview(withMargin: Values.smallSpacing)
|
||||
self.autoPinEdge(toSuperviewEdge: .top, withInset: topMargin)
|
||||
UIView.animate(withDuration: 0.5, delay: 0, options: [], animations: {
|
||||
self.alpha = 1.0
|
||||
}, completion: nil)
|
||||
CallRingTonePlayer.shared.startVibration()
|
||||
CallRingTonePlayer.shared.startPlayingRingTone()
|
||||
}
|
||||
|
||||
public func dismiss() {
|
||||
CallRingTonePlayer.shared.stopVibrationIfPossible()
|
||||
CallRingTonePlayer.shared.stopPlayingRingTone()
|
||||
UIView.animate(withDuration: 0.5, delay: 0, options: [], animations: {
|
||||
self.alpha = 0.0
|
||||
}, completion: { _ in
|
||||
IncomingCallBanner.current = nil
|
||||
self.removeFromSuperview()
|
||||
})
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,172 @@
|
|||
import UIKit
|
||||
import WebRTC
|
||||
|
||||
final class MiniCallView: UIView, RTCVideoViewDelegate {
|
||||
var callVC: CallVC
|
||||
|
||||
// MARK: UI
|
||||
private static let defaultSize: CGFloat = 100
|
||||
private let topMargin = UIApplication.shared.keyWindow!.safeAreaInsets.top + Values.veryLargeSpacing
|
||||
private let bottomMargin = UIApplication.shared.keyWindow!.safeAreaInsets.bottom
|
||||
|
||||
private var width: NSLayoutConstraint?
|
||||
private var height: NSLayoutConstraint?
|
||||
private var left: NSLayoutConstraint?
|
||||
private var right: NSLayoutConstraint?
|
||||
private var top: NSLayoutConstraint?
|
||||
private var bottom: NSLayoutConstraint?
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
// Note: 'RTCMTLVideoView' doesn't seem to work on the simulator so use 'RTCEAGLVideoView' instead
|
||||
private lazy var remoteVideoView: RTCEAGLVideoView = {
|
||||
let result = RTCEAGLVideoView()
|
||||
result.delegate = self
|
||||
result.alpha = self.callVC.call.isRemoteVideoEnabled ? 1 : 0
|
||||
result.backgroundColor = .black
|
||||
return result
|
||||
}()
|
||||
#else
|
||||
private lazy var remoteVideoView: RTCMTLVideoView = {
|
||||
let result = RTCMTLVideoView()
|
||||
result.delegate = self
|
||||
result.alpha = self.callVC.call.isRemoteVideoEnabled ? 1 : 0
|
||||
result.videoContentMode = .scaleAspectFit
|
||||
result.backgroundColor = .black
|
||||
return result
|
||||
}()
|
||||
#endif
|
||||
|
||||
// MARK: Initialization
|
||||
public static var current: MiniCallView?
|
||||
|
||||
init(from callVC: CallVC) {
|
||||
self.callVC = callVC
|
||||
super.init(frame: CGRect.zero)
|
||||
self.backgroundColor = UIColor.init(white: 0, alpha: 0.8)
|
||||
setUpViewHierarchy()
|
||||
setUpGestureRecognizers()
|
||||
MiniCallView.current = self
|
||||
self.callVC.call.remoteVideoStateDidChange = { isEnabled in
|
||||
DispatchQueue.main.async {
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.remoteVideoView.alpha = isEnabled ? 1 : 0
|
||||
if !isEnabled {
|
||||
self.width?.constant = MiniCallView.defaultSize
|
||||
self.height?.constant = MiniCallView.defaultSize
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
preconditionFailure("Use init(message:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(coder:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
self.width = self.set(.width, to: MiniCallView.defaultSize)
|
||||
self.height = self.set(.height, to: MiniCallView.defaultSize)
|
||||
self.layer.cornerRadius = 10
|
||||
self.layer.masksToBounds = true
|
||||
// Background
|
||||
let background = getBackgroudView()
|
||||
self.addSubview(background)
|
||||
background.pin(to: self)
|
||||
// Remote video view
|
||||
callVC.call.attachRemoteVideoRenderer(remoteVideoView)
|
||||
self.addSubview(remoteVideoView)
|
||||
remoteVideoView.translatesAutoresizingMaskIntoConstraints = false
|
||||
remoteVideoView.pin(to: self)
|
||||
}
|
||||
|
||||
private func getBackgroudView() -> UIView {
|
||||
let background = UIView()
|
||||
let imageView = UIImageView()
|
||||
imageView.layer.cornerRadius = 32
|
||||
imageView.layer.masksToBounds = true
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.image = callVC.call.profilePicture
|
||||
background.addSubview(imageView)
|
||||
imageView.set(.width, to: 64)
|
||||
imageView.set(.height, to: 64)
|
||||
imageView.center(in: background)
|
||||
return background
|
||||
}
|
||||
|
||||
private func setUpGestureRecognizers() {
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
tapGestureRecognizer.numberOfTapsRequired = 1
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
makeViewDraggable()
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
@objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) {
|
||||
dismiss()
|
||||
guard let presentingVC = CurrentAppContext().frontmostViewController() else { preconditionFailure() } // FIXME: Handle more gracefully
|
||||
presentingVC.present(callVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
public func show() {
|
||||
self.alpha = 0.0
|
||||
let window = CurrentAppContext().mainWindow!
|
||||
window.addSubview(self)
|
||||
left = self.autoPinEdge(toSuperviewEdge: .left)
|
||||
left?.isActive = false
|
||||
right = self.autoPinEdge(toSuperviewEdge: .right)
|
||||
top = self.autoPinEdge(toSuperviewEdge: .top, withInset: topMargin)
|
||||
bottom = self.autoPinEdge(toSuperviewEdge: .bottom, withInset: bottomMargin)
|
||||
bottom?.isActive = false
|
||||
UIView.animate(withDuration: 0.5, delay: 0, options: [], animations: {
|
||||
self.alpha = 1.0
|
||||
}, completion: nil)
|
||||
}
|
||||
|
||||
public func dismiss() {
|
||||
UIView.animate(withDuration: 0.5, delay: 0, options: [], animations: {
|
||||
self.alpha = 0.0
|
||||
}, completion: { _ in
|
||||
self.callVC.call.removeRemoteVideoRenderer(self.remoteVideoView)
|
||||
self.callVC.setupStateChangeCallbacks()
|
||||
MiniCallView.current = nil
|
||||
self.removeFromSuperview()
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: RTCVideoViewDelegate
|
||||
func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize size: CGSize) {
|
||||
let newSize = CGSize(width: min(160.0, 160.0 * size.width / size.height), height: min(160.0, 160.0 * size.height / size.width))
|
||||
persistCurrentPosition(newSize: newSize)
|
||||
self.width?.constant = newSize.width
|
||||
self.height?.constant = newSize.height
|
||||
}
|
||||
|
||||
func persistCurrentPosition(newSize: CGSize) {
|
||||
let currentCenter = self.center
|
||||
|
||||
if currentCenter.x < self.superview!.width() / 2 {
|
||||
left?.isActive = true
|
||||
right?.isActive = false
|
||||
} else {
|
||||
left?.isActive = false
|
||||
right?.isActive = true
|
||||
}
|
||||
|
||||
let willTouchTop = currentCenter.y < newSize.height / 2 + topMargin
|
||||
let willTouchBottom = currentCenter.y + newSize.height / 2 >= self.superview!.height()
|
||||
if willTouchBottom {
|
||||
top?.isActive = false
|
||||
bottom?.isActive = true
|
||||
} else {
|
||||
let constant = willTouchTop ? topMargin : currentCenter.y - newSize.height / 2
|
||||
top?.constant = constant
|
||||
top?.isActive = true
|
||||
bottom?.isActive = false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
// Copyright © 2021 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import CoreMedia
|
||||
|
||||
class RenderView: UIView {
|
||||
|
||||
private lazy var displayLayer: AVSampleBufferDisplayLayer = {
|
||||
let result = AVSampleBufferDisplayLayer()
|
||||
result.videoGravity = .resizeAspectFill
|
||||
return result
|
||||
}()
|
||||
|
||||
init() {
|
||||
super.init(frame: CGRect.zero)
|
||||
self.layer.addSublayer(displayLayer)
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
preconditionFailure("Use init(message:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(coder:) instead.")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
displayLayer.frame = self.bounds
|
||||
}
|
||||
|
||||
public func enqueue(sampleBuffer: CMSampleBuffer) {
|
||||
displayLayer.enqueue(sampleBuffer)
|
||||
}
|
||||
|
||||
}
|
|
@ -416,6 +416,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
|||
}
|
||||
|
||||
ModalActivityIndicatorViewController.present(fromViewController: navigationController) { _ in
|
||||
// TODO: WriteAsync???
|
||||
GRDBStorage.shared
|
||||
.write { db in
|
||||
if !updatedMemberIds.contains(userPublicKey) {
|
||||
|
|
|
@ -132,4 +132,5 @@ protocol ContextMenuActionDelegate {
|
|||
func save(_ cellViewModel: MessageViewModel)
|
||||
func ban(_ cellViewModel: MessageViewModel)
|
||||
func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel)
|
||||
func contextMenuDismissed()
|
||||
}
|
||||
|
|
|
@ -176,6 +176,7 @@ final class ContextMenuVC: UIViewController {
|
|||
},
|
||||
completion: { [weak self] _ in
|
||||
self?.dismiss()
|
||||
self.delegate?.contextMenuDismissed()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -10,6 +10,8 @@ public class ConversationSearchController: NSObject {
|
|||
public weak var delegate: ConversationSearchControllerDelegate?
|
||||
public let uiSearchController: UISearchController = UISearchController(searchResultsController: nil)
|
||||
public let resultsBar: SearchResultsBar = SearchResultsBar()
|
||||
|
||||
private var lastSearchText: String?
|
||||
|
||||
// MARK: Initializer
|
||||
|
||||
|
|
|
@ -4,8 +4,10 @@ import UIKit
|
|||
import CoreServices
|
||||
import Photos
|
||||
import PhotosUI
|
||||
import Sodium
|
||||
import PromiseKit
|
||||
import GRDB
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
|
@ -47,6 +49,30 @@ extension ConversationVC:
|
|||
// to scroll to the last row instead.
|
||||
scrollToBottom(isAnimated: true)
|
||||
}
|
||||
|
||||
// MARK: Call
|
||||
@objc func startCall(_ sender: Any?) {
|
||||
guard SessionCall.isEnabled else { return }
|
||||
guard SSKPreferences.areCallsEnabled else {
|
||||
let callPermissionRequestModal = CallPermissionRequestModal()
|
||||
self.navigationController?.present(callPermissionRequestModal, animated: true, completion: nil)
|
||||
return
|
||||
}
|
||||
|
||||
requestMicrophonePermissionIfNeeded { }
|
||||
|
||||
guard AVAudioSession.sharedInstance().recordPermission == .granted else { return }
|
||||
guard self.viewModel.threadData.threadVariant == .contact else { return }
|
||||
guard AppEnvironment.shared.callManager.currentCall == nil else { return }
|
||||
|
||||
let call = SessionCall(for: self.viewModel.threadId, uuid: UUID().uuidString.lowercased(), mode: .offer, outgoing: true)
|
||||
let callVC = CallVC(for: call)
|
||||
callVC.conversationVC = self
|
||||
self.inputAccessoryView?.isHidden = true
|
||||
self.inputAccessoryView?.alpha = 0
|
||||
|
||||
present(callVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
// MARK: - Blocking
|
||||
|
||||
|
@ -695,6 +721,10 @@ extension ConversationVC:
|
|||
switch cellViewModel.cellType {
|
||||
case .audio: viewModel.playOrPauseAudio(for: cellViewModel)
|
||||
|
||||
case .call:
|
||||
let callMissedTipsModal: CallMissedTipsModal = CallMissedTipsModal(caller: cellViewModel.authorName)
|
||||
present(callMissedTipsModal, animated: true, completion: nil)
|
||||
|
||||
case .mediaMessage:
|
||||
guard
|
||||
let sectionIndex: Int = self.viewModel.interactionData
|
||||
|
@ -794,6 +824,14 @@ extension ConversationVC:
|
|||
|
||||
// Otherwise share the file
|
||||
let shareVC = UIActivityViewController(activityItems: [ fileUrl ], applicationActivities: nil)
|
||||
|
||||
if UIDevice.current.isIPad {
|
||||
shareVC.excludedActivityTypes = []
|
||||
shareVC.popoverPresentationController?.permittedArrowDirections = []
|
||||
shareVC.popoverPresentationController?.sourceView = self.view
|
||||
shareVC.popoverPresentationController?.sourceRect = self.view.bounds
|
||||
}
|
||||
|
||||
navigationController?.present(shareVC, animated: true, completion: nil)
|
||||
|
||||
case .textOnlyMessage:
|
||||
|
@ -872,8 +910,32 @@ extension ConversationVC:
|
|||
present(userDetailsSheet, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
// MARK: --action handling
|
||||
func startThread(with sessionId: String, openGroupServer: String, openGroupPublicKey: String) {
|
||||
// If the sessionId is blinded then check if there is an existing un-blinded thread with the contact
|
||||
if SessionId.Prefix(from: sessionId) == .blinded, let mapping: BlindedIdMapping = ContactUtilities.mapping(for: sessionId, serverPublicKey: openGroupPublicKey) {
|
||||
let thread: TSContactThread = TSContactThread.getOrCreateThread(contactSessionID: mapping.sessionId)
|
||||
let conversationVC: ConversationVC = ConversationVC(thread: thread)
|
||||
|
||||
self.navigationController?.pushViewController(conversationVC, animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
// Just create a new thread with the provided sessionId
|
||||
let thread = TSContactThread.getOrCreateThread(
|
||||
contactSessionID: sessionId,
|
||||
openGroupServer: openGroupServer,
|
||||
openGroupPublicKey: openGroupPublicKey
|
||||
)
|
||||
let conversationVC: ConversationVC = ConversationVC(thread: thread)
|
||||
|
||||
self.navigationController?.pushViewController(conversationVC, animated: true)
|
||||
}
|
||||
|
||||
func contextMenuDismissed() {
|
||||
recoverInputView()
|
||||
}
|
||||
|
||||
// MARK: --action handling
|
||||
|
||||
func showFailedMessageSheet(for cellViewModel: MessageViewModel) {
|
||||
let sheet = UIAlertController(title: cellViewModel.mostRecentFailureText, message: nil, preferredStyle: .actionSheet)
|
||||
|
@ -1228,16 +1290,23 @@ extension ConversationVC:
|
|||
message: "This will ban the selected user from this room. It won't ban them from other rooms.",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { [weak self] _ in
|
||||
guard let openGroup: OpenGroup = GRDBStorage.shared.read({ db in try OpenGroup.fetchOne(db, id: threadId) }) else {
|
||||
return
|
||||
}
|
||||
|
||||
OpenGroupAPIV2
|
||||
.ban(cellViewModel.authorId, from: openGroup.room, on: openGroup.server)
|
||||
OpenGroupAPI
|
||||
.userBan(cellViewModel.authorId, from: [openGroup.room], on: openGroup.server)
|
||||
.catch(on: DispatchQueue.main) { _ in
|
||||
OWSAlerts.showErrorAlert(message: "context_menu_ban_user_error_alert_message".localized())
|
||||
}
|
||||
.retainUntilComplete()
|
||||
|
||||
self?.becomeFirstResponder()
|
||||
}))
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: { [weak self] _ in
|
||||
self?.becomeFirstResponder()
|
||||
}))
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil))
|
||||
|
||||
present(alert, animated: true, completion: nil)
|
||||
}
|
||||
|
@ -1251,16 +1320,23 @@ extension ConversationVC:
|
|||
message: "This will ban the selected user from this room and delete all messages sent by them. It won't ban them from other rooms or delete the messages they sent there.",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { [weak self] _ in
|
||||
guard let openGroup: OpenGroup = GRDBStorage.shared.read({ db in try OpenGroup.fetchOne(db, id: threadId) }) else {
|
||||
return
|
||||
}
|
||||
|
||||
OpenGroupAPIV2
|
||||
.banAndDeleteAllMessages(cellViewModel.authorId, from: openGroup.room, on: openGroup.server)
|
||||
OpenGroupAPI
|
||||
.userBanAndDeleteAllMessages(cellViewModel.authorId, in: openGroup.room, on: openGroup.server)
|
||||
.catch(on: DispatchQueue.main) { _ in
|
||||
OWSAlerts.showErrorAlert(message: "context_menu_ban_user_error_alert_message".localized())
|
||||
}
|
||||
.retainUntilComplete()
|
||||
|
||||
self?.becomeFirstResponder()
|
||||
}))
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: { [weak self] _ in
|
||||
self?.becomeFirstResponder()
|
||||
}))
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil))
|
||||
|
||||
present(alert, animated: true, completion: nil)
|
||||
}
|
||||
|
@ -1486,7 +1562,7 @@ extension ConversationVC:
|
|||
}
|
||||
|
||||
// MARK: - Convenience
|
||||
|
||||
|
||||
func showErrorAlert(for attachment: SignalAttachment, onDismiss: (() -> ())?) {
|
||||
OWSAlerts.showAlert(
|
||||
title: "ATTACHMENT_ERROR_ALERT_TITLE".localized(),
|
||||
|
@ -1684,6 +1760,7 @@ extension ConversationVC {
|
|||
)
|
||||
})
|
||||
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
|
||||
|
||||
self.present(alertVC, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,11 +8,6 @@ import SessionMessagingKit
|
|||
import SessionUtilitiesKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
// TODO:
|
||||
// • Slight paging glitch when scrolling up and loading more content
|
||||
// • Photo rounding (the small corners don't have the correct rounding)
|
||||
// • Remaining search glitchiness
|
||||
|
||||
final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, ConversationSearchControllerDelegate, UITableViewDataSource, UITableViewDelegate {
|
||||
private static let loadingHeaderHeight: CGFloat = 20
|
||||
|
||||
|
@ -23,6 +18,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
private var currentTargetOffset: CGPoint?
|
||||
private var isAutoLoadingNextPage: Bool = false
|
||||
private var isLoadingMore: Bool = false
|
||||
var isReplacingThread: Bool = false
|
||||
|
||||
/// This flag indicates whether the thread data has been reloaded after a disappearance (it defaults to true as it will
|
||||
/// never have disappeared before - this is only needed for value observers since they run asynchronously)
|
||||
|
@ -61,7 +57,12 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
/// This flag is used to temporarily prevent the ConversationVC from becoming the first responder (primarily used with
|
||||
/// custom transitions from preventing them from being buggy
|
||||
var delayFirstResponder: Bool = false
|
||||
override var canBecomeFirstResponder: Bool { !delayFirstResponder }
|
||||
override var canBecomeFirstResponder: Bool {
|
||||
!delayFirstResponder &&
|
||||
|
||||
// Need to return false during the swap between threads to prevent keyboard dismissal
|
||||
!isReplacingThread
|
||||
}
|
||||
|
||||
override var inputAccessoryView: UIView? {
|
||||
guard
|
||||
|
@ -120,7 +121,11 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
// MARK: - UI
|
||||
|
||||
private static let messageRequestButtonHeight: CGFloat = 34
|
||||
|
||||
|
||||
var scrollButtonBottomConstraint: NSLayoutConstraint?
|
||||
var scrollButtonMessageRequestsBottomConstraint: NSLayoutConstraint?
|
||||
var messageRequestsViewBotomConstraint: NSLayoutConstraint?
|
||||
|
||||
lazy var titleView: ConversationTitleView = {
|
||||
let result: ConversationTitleView = ConversationTitleView()
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(
|
||||
|
@ -149,6 +154,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
result.register(view: VisibleMessageCell.self)
|
||||
result.register(view: InfoMessageCell.self)
|
||||
result.register(view: TypingIndicatorCell.self)
|
||||
register(CallMessageCell.self, forCellReuseIdentifier: CallMessageCell.identifier)
|
||||
result.dataSource = self
|
||||
result.delegate = self
|
||||
|
||||
|
@ -370,14 +376,9 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
messageRequestAcceptButton.pin(.left, to: .left, of: messageRequestView, withInset: 20)
|
||||
messageRequestAcceptButton.pin(.bottom, to: .bottom, of: messageRequestView)
|
||||
messageRequestAcceptButton.set(.height, to: ConversationVC.messageRequestButtonHeight)
|
||||
|
||||
messageRequestAcceptButton.pin(.top, to: .bottom, of: messageRequestDescriptionLabel, withInset: 20)
|
||||
messageRequestAcceptButton.pin(.left, to: .left, of: messageRequestView, withInset: 20)
|
||||
messageRequestAcceptButton.pin(.bottom, to: .bottom, of: messageRequestView)
|
||||
messageRequestAcceptButton.set(.height, to: ConversationVC.messageRequestButtonHeight)
|
||||
|
||||
|
||||
messageRequestDeleteButton.pin(.top, to: .bottom, of: messageRequestDescriptionLabel, withInset: 20)
|
||||
messageRequestDeleteButton.pin(.left, to: .right, of: messageRequestAcceptButton, withInset: 20)
|
||||
messageRequestDeleteButton.pin(.left, to: .right, of: messageRequestAcceptButton, withInset: UIDevice.current.isIPad ? Values.iPadButtonSpacing : 20)
|
||||
messageRequestDeleteButton.pin(.right, to: .right, of: messageRequestView, withInset: -20)
|
||||
messageRequestDeleteButton.pin(.bottom, to: .bottom, of: messageRequestView)
|
||||
messageRequestDeleteButton.set(.width, to: .width, of: messageRequestAcceptButton)
|
||||
|
@ -417,6 +418,8 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
name: UIResponder.keyboardWillHideNotification,
|
||||
object: nil
|
||||
)
|
||||
|
||||
notificationCenter.addObserver(self, selector: #selector(handleContactThreadReplaced(_:)), name: .contactThreadReplaced, object: nil) // TODO: Is this needed???
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
|
@ -452,11 +455,16 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
}
|
||||
|
||||
viewModel.markAllAsRead()
|
||||
recoverInputView()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
// Don't set the draft or resign the first responder if we are replacing the thread (want the keyboard
|
||||
// to appear to remain focussed)
|
||||
guard !isReplacingThread else { return }
|
||||
|
||||
stopObservingChanges()
|
||||
viewModel.updateDraft(to: snInputView.text)
|
||||
inputAccessoryView?.resignFirstResponder()
|
||||
|
@ -471,6 +479,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
|
||||
@objc func applicationDidBecomeActive(_ notification: Notification) {
|
||||
startObservingChanges()
|
||||
recoverInputView()
|
||||
}
|
||||
|
||||
@objc func applicationDidResignActive(_ notification: Notification) {
|
||||
|
@ -908,19 +917,21 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
}
|
||||
else {
|
||||
guard let threadData: SessionThreadViewModel = threadData, threadData.threadRequiresApproval == false else {
|
||||
// Note: Adding an empty button because without it the title alignment is
|
||||
// busted (Note: The size was taken from the layout inspector for the back
|
||||
// button in Xcode
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
customView: UIView(
|
||||
frame: CGRect(
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: (44 - 16), // Width of the standard back button
|
||||
height: 44
|
||||
// Note: Adding empty buttons because without it the title alignment is busted (Note: The size was
|
||||
// taken from the layout inspector for the back button in Xcode
|
||||
navigationItem.rightBarButtonItems = [
|
||||
UIBarButtonItem(
|
||||
customView: UIView(
|
||||
frame: CGRect(
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: (44 - 16), // Width of the standard back button
|
||||
height: 44
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
UIBarButtonItem(customView: UIView(frame: CGRect(x: 0, y: 0, width: 44, height: 44)))
|
||||
]
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -939,11 +950,28 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(openSettings))
|
||||
profilePictureView.addGestureRecognizer(tapGestureRecognizer)
|
||||
|
||||
let rightBarButtonItem: UIBarButtonItem = UIBarButtonItem(customView: profilePictureView)
|
||||
rightBarButtonItem.accessibilityLabel = "Settings button"
|
||||
rightBarButtonItem.isAccessibilityElement = true
|
||||
let settingsButtonItem: UIBarButtonItem = UIBarButtonItem(customView: profilePictureView)
|
||||
settingsButtonItem.accessibilityLabel = "Settings button"
|
||||
settingsButtonItem.isAccessibilityElement = true
|
||||
|
||||
navigationItem.rightBarButtonItem = rightBarButtonItem
|
||||
if SessionCall.isEnabled && !threadData.threadIsNoteToSelf && !threadData.threadIsMessageRequest {
|
||||
let callButton = UIBarButtonItem(
|
||||
image: UIImage(named: "Phone"),
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(startCall)
|
||||
)
|
||||
|
||||
navigationItem.rightBarButtonItems = [settingsButtonItem, callButton]
|
||||
}
|
||||
else {
|
||||
navigationItem.rightBarButtonItem = rightBarButtonItem
|
||||
}
|
||||
let shouldShowCallButton = SessionCall.isEnabled && !thread.isNoteToSelf() && !thread.isMessageRequest()
|
||||
if shouldShowCallButton {
|
||||
let callButton = UIBarButtonItem(image: UIImage(named: "Phone")!, style: .plain, target: self, action: #selector(startCall))
|
||||
rightBarButtonItems.append(callButton)
|
||||
}
|
||||
|
||||
default:
|
||||
let rightBarButtonItem: UIBarButtonItem = UIBarButtonItem(image: UIImage(named: "Gear"), style: .plain, target: self, action: #selector(openSettings))
|
||||
|
@ -1061,6 +1089,98 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
self.view.addSubview(self.blockedBanner)
|
||||
self.blockedBanner.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.HorizontalEdge.right ], to: self.view)
|
||||
}
|
||||
|
||||
func recoverInputView() {
|
||||
// This is a workaround for an issue where the textview is not scrollable
|
||||
// after the app goes into background and goes back in foreground.
|
||||
DispatchQueue.main.async {
|
||||
self.snInputView.text = self.snInputView.text
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleContactThreadReplaced(_ notification: Notification) {
|
||||
// Ensure the current thread is one of the removed ones
|
||||
guard let newThreadId: String = notification.userInfo?[NotificationUserInfoKey.threadId] as? String else { return }
|
||||
guard let removedThreadIds: [String] = notification.userInfo?[NotificationUserInfoKey.removedThreadIds] as? [String] else {
|
||||
return
|
||||
}
|
||||
guard let threadId: String = thread.uniqueId, removedThreadIds.contains(threadId) else { return }
|
||||
|
||||
// Then look to swap the current ConversationVC with a replacement one with the new thread
|
||||
DispatchQueue.main.async {
|
||||
guard let navController: UINavigationController = self.navigationController else { return }
|
||||
guard let viewControllerIndex: Int = navController.viewControllers.firstIndex(of: self) else { return }
|
||||
guard let newThread: TSContactThread = TSContactThread.fetch(uniqueId: newThreadId) else { return }
|
||||
|
||||
// Let the view controller know we are replacing the thread
|
||||
self.isReplacingThread = true
|
||||
|
||||
// Create the new ConversationVC and swap the old one out for it
|
||||
let conversationVC: ConversationVC = ConversationVC(thread: newThread)
|
||||
let currentlyOnThisScreen: Bool = (navController.topViewController == self)
|
||||
|
||||
navController.viewControllers = [
|
||||
(viewControllerIndex == 0 ?
|
||||
[] :
|
||||
navController.viewControllers[0..<viewControllerIndex]
|
||||
),
|
||||
[conversationVC],
|
||||
(viewControllerIndex == (navController.viewControllers.count - 1) ?
|
||||
[] :
|
||||
navController.viewControllers[(viewControllerIndex + 1)..<navController.viewControllers.count]
|
||||
)
|
||||
].flatMap { $0 }
|
||||
|
||||
// If the top vew controller isn't the current one then we need to make sure to swap out child ones as well
|
||||
if !currentlyOnThisScreen {
|
||||
let maybeSettingsViewController: UIViewController? = navController
|
||||
.viewControllers[viewControllerIndex..<navController.viewControllers.count]
|
||||
.first(where: { $0 is OWSConversationSettingsViewController })
|
||||
|
||||
// Update the settings screen (if there is one)
|
||||
if let settingsViewController: OWSConversationSettingsViewController = maybeSettingsViewController as? OWSConversationSettingsViewController {
|
||||
settingsViewController.configure(with: newThread, uiDatabaseConnection: OWSPrimaryStorage.shared().uiDatabaseConnection)
|
||||
}
|
||||
}
|
||||
|
||||
// Try to minimise painful UX issues by keeping the 'first responder' state, current input text and
|
||||
// cursor position (Unfortunately there doesn't seem to be a way to prevent the keyboard from
|
||||
// flickering during the swap but other than that it's relatively seamless)
|
||||
if self.snInputView.inputTextViewIsFirstResponder {
|
||||
conversationVC.isReplacingThread = true
|
||||
conversationVC.snInputView.frame = self.snInputView.frame
|
||||
conversationVC.snInputView.text = self.snInputView.text
|
||||
conversationVC.snInputView.selectedRange = self.snInputView.selectedRange
|
||||
|
||||
// Make the current snInputView invisible and add the new one the the UI
|
||||
self.snInputView.alpha = 0
|
||||
self.snInputView.superview?.addSubview(conversationVC.snInputView)
|
||||
|
||||
// Add the old first responder to the window so it the keyboard won't get dismissed when the
|
||||
// OS removes it's parent view from the view hierarchy due to the view controller swap
|
||||
var maybeOldFirstResponderView: UIView?
|
||||
|
||||
if let oldFirstResponderView: UIView = UIResponder.currentFirstResponder() as? UIView {
|
||||
maybeOldFirstResponderView = oldFirstResponderView
|
||||
self.view.window?.addSubview(oldFirstResponderView)
|
||||
}
|
||||
|
||||
// On the next run loop setup the first responder state for the new screen and remove the
|
||||
// old first responder from the window
|
||||
DispatchQueue.main.async {
|
||||
UIView.performWithoutAnimation {
|
||||
conversationVC.isReplacingThread = false
|
||||
maybeOldFirstResponderView?.resignFirstResponder()
|
||||
maybeOldFirstResponderView?.removeFromSuperview()
|
||||
conversationVC.snInputView.removeFromSuperview()
|
||||
|
||||
_ = conversationVC.becomeFirstResponder()
|
||||
conversationVC.snInputView.inputTextViewBecomeFirstResponder()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSource
|
||||
|
||||
|
@ -1265,7 +1385,31 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
// Search bar
|
||||
let searchBar = searchController.uiSearchController.searchBar
|
||||
searchBar.setUpSessionStyle()
|
||||
navigationItem.titleView = searchBar
|
||||
|
||||
let searchBarContainer = UIView()
|
||||
searchBarContainer.layoutMargins = UIEdgeInsets.zero
|
||||
searchBar.sizeToFit()
|
||||
searchBar.layoutMargins = UIEdgeInsets.zero
|
||||
searchBarContainer.set(.height, to: 44)
|
||||
searchBarContainer.set(.width, to: UIScreen.main.bounds.width - 32)
|
||||
searchBarContainer.addSubview(searchBar)
|
||||
navigationItem.titleView = searchBarContainer
|
||||
|
||||
// On iPad, the cancel button won't show
|
||||
// See more https://developer.apple.com/documentation/uikit/uisearchbar/1624283-showscancelbutton?language=objc
|
||||
if UIDevice.current.isIPad {
|
||||
let ipadCancelButton = UIButton()
|
||||
ipadCancelButton.setTitle("Cancel", for: .normal)
|
||||
ipadCancelButton.addTarget(self, action: #selector(hideSearchUI), for: .touchUpInside)
|
||||
ipadCancelButton.setTitleColor(Colors.text, for: .normal)
|
||||
searchBarContainer.addSubview(ipadCancelButton)
|
||||
ipadCancelButton.pin(.trailing, to: .trailing, of: searchBarContainer)
|
||||
ipadCancelButton.autoVCenterInSuperview()
|
||||
searchBar.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets.zero, excludingEdge: .trailing)
|
||||
searchBar.pin(.trailing, to: .leading, of: ipadCancelButton, withInset: -Values.smallSpacing)
|
||||
} else {
|
||||
searchBar.autoPinEdgesToSuperviewMargins()
|
||||
}
|
||||
|
||||
// Nav bar buttons
|
||||
updateNavBarButtons(threadData: self.viewModel.threadData)
|
||||
|
@ -1301,7 +1445,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
navBar.stubbedNextResponder = self
|
||||
}
|
||||
|
||||
func hideSearchUI() {
|
||||
@objc func hideSearchUI() {
|
||||
isShowingSearchUI = false
|
||||
navigationItem.titleView = titleView
|
||||
updateNavBarButtons(threadData: self.viewModel.threadData)
|
||||
|
|
|
@ -0,0 +1,126 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import SessionSnodeKit
|
||||
import SessionMessagingKit
|
||||
|
||||
extension ConversationViewItem {
|
||||
func deleteLocallyAction() {
|
||||
guard let message: TSMessage = self.interaction as? TSMessage else { return }
|
||||
|
||||
Storage.write { transaction in
|
||||
MessageInvalidator.invalidate(message, with: transaction)
|
||||
message.remove(with: transaction)
|
||||
|
||||
if message.interactionType() == .outgoingMessage {
|
||||
Storage.shared.cancelPendingMessageSendJobIfNeeded(for: message.timestamp, using: transaction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteRemotelyAction() {
|
||||
guard let message: TSMessage = self.interaction as? TSMessage else { return }
|
||||
|
||||
if isGroupThread {
|
||||
guard let groupThread: TSGroupThread = message.thread as? TSGroupThread else { return }
|
||||
|
||||
// Only allow deletion on incoming and outgoing messages
|
||||
guard message.interactionType() == .incomingMessage || message.interactionType() == .outgoingMessage else {
|
||||
return
|
||||
}
|
||||
|
||||
if groupThread.isOpenGroup {
|
||||
// Make sure it's an open group message and get the open group
|
||||
guard message.isOpenGroupMessage, let uniqueId: String = groupThread.uniqueId, let openGroup: OpenGroup = Storage.shared.getOpenGroup(for: uniqueId) else {
|
||||
return
|
||||
}
|
||||
|
||||
// If it's an incoming message the user must have moderator status
|
||||
if message.interactionType() == .incomingMessage {
|
||||
guard let userPublicKey: String = Storage.shared.getUserPublicKey() else { return }
|
||||
|
||||
if !OpenGroupManager.isUserModeratorOrAdmin(userPublicKey, for: openGroup.room, on: openGroup.server) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the message
|
||||
OpenGroupAPI.messageDelete(message.openGroupServerMessageID, in: openGroup.room, on: openGroup.server)
|
||||
.catch { _ in
|
||||
// Roll back
|
||||
message.save()
|
||||
}
|
||||
.retainUntilComplete()
|
||||
}
|
||||
else {
|
||||
guard let serverHash: String = message.serverHash else { return }
|
||||
|
||||
let groupPublicKey: String = LKGroupUtilities.getDecodedGroupID(groupThread.groupModel.groupId)
|
||||
|
||||
SnodeAPI.deleteMessage(publicKey: groupPublicKey, serverHashes: [serverHash])
|
||||
.catch { _ in
|
||||
// Roll back
|
||||
message.save()
|
||||
}
|
||||
.retainUntilComplete()
|
||||
}
|
||||
}
|
||||
else {
|
||||
guard let contactThread: TSContactThread = message.thread as? TSContactThread, let serverHash: String = message.serverHash else {
|
||||
return
|
||||
}
|
||||
|
||||
SnodeAPI.deleteMessage(publicKey: contactThread.contactSessionID(), serverHashes: [serverHash])
|
||||
.catch { _ in
|
||||
// Roll back
|
||||
message.save()
|
||||
}
|
||||
.retainUntilComplete()
|
||||
}
|
||||
}
|
||||
|
||||
// Remove this after the unsend request is enabled
|
||||
func deleteAction() {
|
||||
Storage.write { transaction in
|
||||
self.interaction.remove(with: transaction)
|
||||
|
||||
if self.interaction.interactionType() == .outgoingMessage {
|
||||
Storage.shared.cancelPendingMessageSendJobIfNeeded(for: self.interaction.timestamp, using: transaction)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if self.isGroupThread {
|
||||
guard let message: TSMessage = self.interaction as? TSMessage, let groupThread: TSGroupThread = message.thread as? TSGroupThread else {
|
||||
return
|
||||
}
|
||||
|
||||
// Only allow deletion on incoming and outgoing messages
|
||||
guard message.interactionType() == .incomingMessage || message.interactionType() == .outgoingMessage else {
|
||||
return
|
||||
}
|
||||
|
||||
// Make sure it's an open group message and get the open group
|
||||
guard message.isOpenGroupMessage, let uniqueId: String = groupThread.uniqueId, let openGroup: OpenGroup = Storage.shared.getOpenGroup(for: uniqueId) else {
|
||||
return
|
||||
}
|
||||
|
||||
// If it's an incoming message the user must have moderator status
|
||||
if message.interactionType() == .incomingMessage {
|
||||
guard let userPublicKey: String = Storage.shared.getUserPublicKey() else { return }
|
||||
|
||||
if !OpenGroupManager.isUserModeratorOrAdmin(userPublicKey, for: openGroup.room, on: openGroup.server) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the message
|
||||
OpenGroupAPI.messageDelete(message.openGroupServerMessageID, in: openGroup.room, on: openGroup.server)
|
||||
.catch { _ in
|
||||
// Roll back
|
||||
message.save()
|
||||
}
|
||||
.retainUntilComplete()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -30,8 +30,15 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
get { inputTextView.text ?? "" }
|
||||
set { inputTextView.text = newValue }
|
||||
}
|
||||
|
||||
var enabledMessageTypes: MessageInputTypes = .all {
|
||||
|
||||
var selectedRange: NSRange {
|
||||
get { inputTextView.selectedRange }
|
||||
set { inputTextView.selectedRange = newValue }
|
||||
}
|
||||
|
||||
var inputTextViewIsFirstResponder: Bool { inputTextView.isFirstResponder }
|
||||
|
||||
var enabledMessageTypes: MessageTypes = .all {
|
||||
didSet {
|
||||
setEnabledMessageTypes(enabledMessageTypes, message: nil)
|
||||
}
|
||||
|
@ -383,6 +390,10 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
override func resignFirstResponder() -> Bool {
|
||||
inputTextView.resignFirstResponder()
|
||||
}
|
||||
|
||||
func inputTextViewBecomeFirstResponder() {
|
||||
inputTextView.becomeFirstResponder()
|
||||
}
|
||||
|
||||
func handleLongPress() {
|
||||
// Not relevant in this case
|
||||
|
|
|
@ -12,9 +12,7 @@ final class MentionSelectionView: UIView, UITableViewDataSource, UITableViewDele
|
|||
tableView.reloadData()
|
||||
}
|
||||
}
|
||||
var openGroupServer: String?
|
||||
var openGroupChannel: UInt64?
|
||||
var openGroupRoom: String?
|
||||
|
||||
weak var delegate: MentionSelectionViewDelegate?
|
||||
|
||||
var contentOffset: CGPoint {
|
||||
|
@ -86,7 +84,7 @@ final class MentionSelectionView: UIView, UITableViewDataSource, UITableViewDele
|
|||
cell.update(
|
||||
with: candidates[indexPath.row].profile,
|
||||
threadVariant: candidates[indexPath.row].threadVariant,
|
||||
isUserModerator: OpenGroupAPIV2.isUserModerator(
|
||||
isUserModeratorOrAdmin: OpenGroupAPIV2.isUserModerator( // TODO: This
|
||||
candidates[indexPath.row].profile.id,
|
||||
for: (candidates[indexPath.row].openGroupRoom ?? ""),
|
||||
on: (candidates[indexPath.row].openGroupServer ?? "")
|
||||
|
@ -194,7 +192,7 @@ private extension MentionSelectionView {
|
|||
fileprivate func update(
|
||||
with profile: Profile,
|
||||
threadVariant: SessionThread.Variant,
|
||||
isUserModerator: Bool,
|
||||
isUserModeratorOrAdmin: Bool,
|
||||
isLast: Bool
|
||||
) {
|
||||
displayNameLabel.text = profile.displayName(for: threadVariant)
|
||||
|
@ -203,7 +201,7 @@ private extension MentionSelectionView {
|
|||
profile: profile,
|
||||
threadVariant: threadVariant
|
||||
)
|
||||
moderatorIconImageView.isHidden = !isUserModerator
|
||||
moderatorIconImageView.isHidden = !isUserModeratorOrAdmin
|
||||
separator.isHidden = isLast
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,119 @@
|
|||
import UIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class CallMessageCell : MessageCell {
|
||||
private lazy var iconImageViewWidthConstraint = iconImageView.set(.width, to: 0)
|
||||
private lazy var iconImageViewHeightConstraint = iconImageView.set(.height, to: 0)
|
||||
|
||||
private lazy var infoImageViewWidthConstraint = infoImageView.set(.width, to: 0)
|
||||
private lazy var infoImageViewHeightConstraint = infoImageView.set(.height, to: 0)
|
||||
|
||||
// MARK: UI Components
|
||||
private lazy var iconImageView = UIImageView()
|
||||
|
||||
private lazy var infoImageView = UIImageView(image: UIImage(named: "ic_info")?.withTint(Colors.text))
|
||||
|
||||
private lazy var timestampLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
|
||||
result.textColor = Colors.text
|
||||
result.textAlignment = .center
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var label: UILabel = {
|
||||
let result = UILabel()
|
||||
result.numberOfLines = 0
|
||||
result.lineBreakMode = .byWordWrapping
|
||||
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||
result.textColor = Colors.text
|
||||
result.textAlignment = .center
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var container: UIView = {
|
||||
let result = UIView()
|
||||
result.set(.height, to: 50)
|
||||
result.layer.cornerRadius = 18
|
||||
result.backgroundColor = Colors.callMessageBackground
|
||||
result.addSubview(label)
|
||||
label.autoCenterInSuperview()
|
||||
result.addSubview(iconImageView)
|
||||
iconImageView.autoVCenterInSuperview()
|
||||
iconImageView.pin(.left, to: .left, of: result, withInset: CallMessageCell.inset)
|
||||
result.addSubview(infoImageView)
|
||||
infoImageView.autoVCenterInSuperview()
|
||||
infoImageView.pin(.right, to: .right, of: result, withInset: -CallMessageCell.inset)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var stackView: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [ timestampLabel, container ])
|
||||
result.axis = .vertical
|
||||
result.alignment = .center
|
||||
result.spacing = Values.smallSpacing
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Settings
|
||||
private static let iconSize: CGFloat = 16
|
||||
private static let inset = Values.mediumSpacing
|
||||
private static let margin = UIScreen.main.bounds.width * 0.1
|
||||
|
||||
override class var identifier: String { "CallMessageCell" }
|
||||
|
||||
// MARK: Lifecycle
|
||||
override func setUpViewHierarchy() {
|
||||
super.setUpViewHierarchy()
|
||||
iconImageViewWidthConstraint.isActive = true
|
||||
iconImageViewHeightConstraint.isActive = true
|
||||
addSubview(stackView)
|
||||
container.autoPinWidthToSuperview()
|
||||
stackView.pin(.left, to: .left, of: self, withInset: CallMessageCell.margin)
|
||||
stackView.pin(.top, to: .top, of: self, withInset: CallMessageCell.inset)
|
||||
stackView.pin(.right, to: .right, of: self, withInset: -CallMessageCell.margin)
|
||||
stackView.pin(.bottom, to: .bottom, of: self, withInset: -CallMessageCell.inset)
|
||||
}
|
||||
|
||||
override func setUpGestureRecognizers() {
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
tapGestureRecognizer.numberOfTapsRequired = 1
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
override func update() {
|
||||
guard let message = viewItem?.interaction as? TSInfoMessage, message.messageType == .call else { return }
|
||||
let icon: UIImage?
|
||||
switch message.callState {
|
||||
case .outgoing: icon = UIImage(named: "CallOutgoing")?.withTint(Colors.text)
|
||||
case .incoming: icon = UIImage(named: "CallIncoming")?.withTint(Colors.text)
|
||||
case .missed, .permissionDenied: icon = UIImage(named: "CallMissed")?.withTint(Colors.destructive)
|
||||
default: icon = nil
|
||||
}
|
||||
iconImageView.image = icon
|
||||
iconImageViewWidthConstraint.constant = (icon != nil) ? CallMessageCell.iconSize : 0
|
||||
iconImageViewHeightConstraint.constant = (icon != nil) ? CallMessageCell.iconSize : 0
|
||||
|
||||
let shouldShowInfoIcon = message.callState == .permissionDenied && !SSKPreferences.areCallsEnabled
|
||||
infoImageViewWidthConstraint.constant = shouldShowInfoIcon ? CallMessageCell.iconSize : 0
|
||||
infoImageViewHeightConstraint.constant = shouldShowInfoIcon ? CallMessageCell.iconSize : 0
|
||||
|
||||
Storage.read { transaction in
|
||||
self.label.text = message.previewText(with: transaction)
|
||||
}
|
||||
|
||||
let date = message.dateForUI()
|
||||
let description = DateUtil.formatDate(forDisplay: date)
|
||||
timestampLabel.text = description
|
||||
}
|
||||
|
||||
@objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) {
|
||||
guard let viewItem = viewItem, let message = viewItem.interaction as? TSInfoMessage, message.messageType == .call else { return }
|
||||
let shouldBeTappable = message.callState == .permissionDenied && !SSKPreferences.areCallsEnabled
|
||||
if shouldBeTappable {
|
||||
delegate?.handleViewItemTapped(viewItem, gestureRecognizer: gestureRecognizer)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
|
||||
final class CallMessageView : UIView {
|
||||
private let viewItem: ConversationViewItem
|
||||
private let textColor: UIColor
|
||||
|
||||
// MARK: Settings
|
||||
private static let iconSize: CGFloat = 24
|
||||
private static let iconImageViewSize: CGFloat = 40
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(viewItem: ConversationViewItem, textColor: UIColor) {
|
||||
self.viewItem = viewItem
|
||||
self.textColor = textColor
|
||||
super.init(frame: CGRect.zero)
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
guard let message = viewItem.interaction as? TSMessage else { preconditionFailure() }
|
||||
// Image view
|
||||
let iconSize = CallMessageView.iconSize
|
||||
let icon = UIImage(named: "Phone")?.withTint(textColor)?.resizedImage(to: CGSize(width: iconSize, height: iconSize))
|
||||
let imageView = UIImageView(image: icon)
|
||||
imageView.contentMode = .center
|
||||
let iconImageViewSize = CallMessageView.iconImageViewSize
|
||||
imageView.set(.width, to: iconImageViewSize)
|
||||
imageView.set(.height, to: iconImageViewSize)
|
||||
// Body label
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
titleLabel.text = message.body
|
||||
titleLabel.textColor = textColor
|
||||
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
// Stack view
|
||||
let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ])
|
||||
stackView.axis = .horizontal
|
||||
stackView.alignment = .center
|
||||
stackView.isLayoutMarginsRelativeArrangement = true
|
||||
stackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 12)
|
||||
addSubview(stackView)
|
||||
stackView.pin(to: self, withInset: Values.smallSpacing)
|
||||
}
|
||||
}
|
|
@ -16,7 +16,9 @@ final class LinkPreviewView: UIView {
|
|||
|
||||
private lazy var imageViewContainerWidthConstraint = imageView.set(.width, to: 100)
|
||||
private lazy var imageViewContainerHeightConstraint = imageView.set(.height, to: 100)
|
||||
|
||||
|
||||
// MARK: UI Components
|
||||
|
||||
private lazy var imageView: UIImageView = {
|
||||
let result: UIImageView = UIImageView()
|
||||
result.contentMode = .scaleAspectFill
|
||||
|
@ -192,6 +194,7 @@ final class LinkPreviewView: UIView {
|
|||
searchText: lastSearchText,
|
||||
delegate: delegate
|
||||
)
|
||||
|
||||
self.bodyTextView = bodyTextView
|
||||
bodyTextViewContainer.addSubview(bodyTextView)
|
||||
bodyTextView.pin(to: bodyTextViewContainer, withInset: 12)
|
||||
|
|
|
@ -66,6 +66,9 @@ public class MessageCell: UITableViewCell {
|
|||
.infoDisappearingMessagesUpdate, .infoScreenshotNotification, .infoMediaSavedNotification,
|
||||
.infoMessageRequestAccepted:
|
||||
return InfoMessageCell.self
|
||||
|
||||
case .infoMessageCall:
|
||||
return CallMessageCell.self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -80,4 +83,5 @@ protocol MessageCellDelegate: AnyObject {
|
|||
func openUrl(_ urlString: String)
|
||||
func handleReplyButtonTapped(for cellViewModel: MessageViewModel)
|
||||
func showUserDetails(for profile: Profile)
|
||||
func startThread(with sessionId: String, openGroupServer: String, openGroupPublicKey: String)
|
||||
}
|
||||
|
|
|
@ -118,8 +118,16 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
static let largeCornerRadius: CGFloat = 18
|
||||
static let contactThreadHSpacing = Values.mediumSpacing
|
||||
|
||||
static var gutterSize: CGFloat { groupThreadHSpacing + profilePictureSize + groupThreadHSpacing }
|
||||
|
||||
static var gutterSize: CGFloat = {
|
||||
var result = groupThreadHSpacing + profilePictureSize + groupThreadHSpacing
|
||||
|
||||
if UIDevice.current.isIPad {
|
||||
result += CGFloat(UIScreen.main.bounds.width / 2 - 88)
|
||||
}
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Direction & Position
|
||||
|
||||
enum Direction { case incoming, outgoing }
|
||||
|
@ -232,8 +240,8 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
profile: cellViewModel.profile,
|
||||
threadVariant: cellViewModel.threadVariant
|
||||
)
|
||||
moderatorIconImageView.isHidden = !cellViewModel.isSenderOpenGroupModerator
|
||||
|
||||
moderatorIconImageView.isHidden = (!cellViewModel.isSenderOpenGroupModerator || !cellViewModel.shouldShowProfile)
|
||||
|
||||
// Bubble view
|
||||
bubbleViewLeftConstraint1.isActive = (
|
||||
cellViewModel.variant == .standardIncoming ||
|
||||
|
@ -284,6 +292,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
messageStatusImageView.backgroundColor = backgroundColor
|
||||
messageStatusImageView.isHidden = (
|
||||
cellViewModel.variant != .standardOutgoing ||
|
||||
cellViewModel.variant == .infoMessageCall ||
|
||||
(
|
||||
cellViewModel.state == .sent &&
|
||||
!cellViewModel.isLast
|
||||
|
@ -320,7 +329,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
)
|
||||
|
||||
// Swipe to reply
|
||||
if cellViewModel.variant == .standardIncomingDeleted {
|
||||
if cellViewModel.variant == .standardIncomingDeleted || cellViewModel.variant == .infoMessageCall {
|
||||
removeGestureRecognizer(panGestureRecognizer)
|
||||
}
|
||||
else {
|
||||
|
@ -494,7 +503,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
// Body text view
|
||||
if let body: String = cellViewModel.body, !body.isEmpty {
|
||||
let inset: CGFloat = 12
|
||||
let maxWidth = size.width - 2 * inset
|
||||
let maxWidth: CGFloat = (size.width - (2 * inset))
|
||||
let bodyTextView = VisibleMessageCell.getBodyTextView(
|
||||
for: cellViewModel,
|
||||
with: maxWidth,
|
||||
|
@ -502,6 +511,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
searchText: lastSearchText,
|
||||
delegate: self
|
||||
)
|
||||
|
||||
self.bodyTextView = bodyTextView
|
||||
stackView.addArrangedSubview(UIView(wrapping: bodyTextView, withInsets: UIEdgeInsets(top: 0, left: inset, bottom: inset, right: inset)))
|
||||
}
|
||||
|
@ -553,6 +563,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
searchText: lastSearchText,
|
||||
delegate: self
|
||||
)
|
||||
|
||||
self.bodyTextView = bodyTextView
|
||||
stackView.addArrangedSubview(bodyTextView)
|
||||
}
|
||||
|
@ -677,8 +688,21 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
|
||||
let location = gestureRecognizer.location(in: self)
|
||||
|
||||
if profilePictureView.frame.contains(location), let profile: Profile = cellViewModel.profile, cellViewModel.threadVariant != .openGroup {
|
||||
delegate?.showUserDetails(for: profile)
|
||||
if profilePictureView.frame.contains(location), let profile: Profile = cellViewModel.profile, cellViewModel.shouldShowProfile {
|
||||
// For open groups only attempt to start a conversation if the author has a blinded id
|
||||
if cellViewModel.threadVariant != .openGroup {
|
||||
guard let openGroup: OpenGroup = Storage.shared.getOpenGroup(for: message.uniqueThreadId) else { return }
|
||||
guard SessionId.Prefix(from: cellViewModel.authorId) == .blinded else { return }
|
||||
|
||||
delegate?.startThread(
|
||||
with: cellViewModel.authorId,
|
||||
openGroupServer: openGroup.server,
|
||||
openGroupPublicKey: openGroup.publicKey
|
||||
)
|
||||
}
|
||||
else {
|
||||
delegate?.showUserDetails(for: profile)
|
||||
}
|
||||
}
|
||||
else if replyButton.alpha > 0 && replyButton.frame.contains(location) {
|
||||
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
|
||||
|
|
|
@ -103,7 +103,7 @@ CGFloat kIconViewLength = 24;
|
|||
object:nil];
|
||||
}
|
||||
|
||||
- (void)configureWithThreadId:(NSString *)threadId threadName:(nullable NSString *)threadName isClosedGroup:(BOOL)isClosedGroup isOpenGroup:(BOOL)isOpenGroup isNoteToSelf:(BOOL)isNoteToSelf {
|
||||
- (void)configureWithThreadId:(NSString *)threadId threadName:(NSString *)threadName isClosedGroup:(BOOL)isClosedGroup isOpenGroup:(BOOL)isOpenGroup isNoteToSelf:(BOOL)isNoteToSelf {
|
||||
self.threadId = threadId;
|
||||
self.threadName = threadName;
|
||||
self.isClosedGroup = isClosedGroup;
|
||||
|
@ -114,7 +114,7 @@ CGFloat kIconViewLength = 24;
|
|||
self.threadName = [SMKProfile displayNameWithId:threadId customFallback:@"Anonymous"];
|
||||
}
|
||||
else {
|
||||
self.threadName = (threadName ?: [MessageStrings newGroupDefaultTitle]);
|
||||
self.threadName = threadName;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -268,7 +268,7 @@ CGFloat kIconViewLength = 24;
|
|||
}]];
|
||||
|
||||
// Disappearing messages
|
||||
if (![self isOpenGroup]) {
|
||||
if (![self isOpenGroup] && !self.thread.isBlocked) {
|
||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||
UITableViewCell *cell = [OWSTableItem newCell];
|
||||
OWSConversationSettingsViewController *strongSelf = weakSelf;
|
||||
|
|
|
@ -29,7 +29,7 @@ final class BlockedModal: Modal {
|
|||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = String(format: NSLocalizedString("modal_blocked_title", comment: ""), name)
|
||||
titleLabel.textAlignment = .center
|
||||
// Message
|
||||
|
@ -57,15 +57,20 @@ final class BlockedModal: Modal {
|
|||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = Values.largeSpacing
|
||||
mainStackView.spacing = spacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
|
||||
@objc
|
||||
final class CallModal : Modal {
|
||||
private let onCallEnabled: () -> Void
|
||||
|
||||
// MARK: Lifecycle
|
||||
@objc
|
||||
init(onCallEnabled: @escaping () -> Void) {
|
||||
self.onCallEnabled = onCallEnabled
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
self.modalPresentationStyle = .overFullScreen
|
||||
self.modalTransitionStyle = .crossDissolve
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(onCallEnabled:) instead.")
|
||||
}
|
||||
|
||||
override init(nibName: String?, bundle: Bundle?) {
|
||||
preconditionFailure("Use init(onCallEnabled:) instead.")
|
||||
}
|
||||
|
||||
override func populateContentView() {
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
titleLabel.text = NSLocalizedString("modal_call_title", comment: "")
|
||||
titleLabel.textAlignment = .center
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
let message = NSLocalizedString("modal_call_explanation", comment: "")
|
||||
messageLabel.text = message
|
||||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .center
|
||||
// Enable button
|
||||
let enableButton = UIButton()
|
||||
enableButton.set(.height, to: Values.mediumButtonHeight)
|
||||
enableButton.layer.cornerRadius = Modal.buttonCornerRadius
|
||||
enableButton.backgroundColor = Colors.buttonBackground
|
||||
enableButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
enableButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||
enableButton.setTitle(NSLocalizedString("modal_link_previews_button_title", comment: ""), for: UIControl.State.normal)
|
||||
enableButton.addTarget(self, action: #selector(enable), for: UIControl.Event.touchUpInside)
|
||||
// Button stack view
|
||||
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, enableButton ])
|
||||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = Values.largeSpacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
@objc private func enable() {
|
||||
SSKPreferences.areCallsEnabled = true
|
||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
onCallEnabled()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
@objc
|
||||
final class CallPermissionRequestModal : Modal {
|
||||
|
||||
// MARK: Lifecycle
|
||||
@objc
|
||||
init() {
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
self.modalPresentationStyle = .overFullScreen
|
||||
self.modalTransitionStyle = .crossDissolve
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(onCallEnabled:) instead.")
|
||||
}
|
||||
|
||||
override init(nibName: String?, bundle: Bundle?) {
|
||||
preconditionFailure("Use init(onCallEnabled:) instead.")
|
||||
}
|
||||
|
||||
override func populateContentView() {
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = NSLocalizedString("modal_call_permission_request_title", comment: "")
|
||||
titleLabel.textAlignment = .center
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
let message = NSLocalizedString("modal_call_permission_request_explanation", comment: "")
|
||||
messageLabel.text = message
|
||||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .center
|
||||
// Enable button
|
||||
let goToSettingsButton = UIButton()
|
||||
goToSettingsButton.set(.height, to: Values.mediumButtonHeight)
|
||||
goToSettingsButton.layer.cornerRadius = Modal.buttonCornerRadius
|
||||
goToSettingsButton.backgroundColor = Colors.buttonBackground
|
||||
goToSettingsButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
goToSettingsButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||
goToSettingsButton.setTitle(NSLocalizedString("vc_settings_title", comment: ""), for: UIControl.State.normal)
|
||||
goToSettingsButton.addTarget(self, action: #selector(goToSettings), for: UIControl.Event.touchUpInside)
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
// Button stack view
|
||||
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, goToSettingsButton ])
|
||||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.distribution = .fillEqually
|
||||
// Main stack view
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = spacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
@objc func goToSettings(_ sender: Any) {
|
||||
dismiss(animated: true, completion: {
|
||||
if let vc = CurrentAppContext().frontmostViewController() {
|
||||
let privacySettingsVC = PrivacySettingsTableViewController()
|
||||
privacySettingsVC.shouldShowCloseButton = true
|
||||
let nav = OWSNavigationController(rootViewController: privacySettingsVC)
|
||||
nav.modalPresentationStyle = .fullScreen
|
||||
vc.present(nav, animated: true, completion: nil)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -29,6 +29,14 @@ final class ConversationTitleView: UIView {
|
|||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var stackView: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [ titleLabel, subtitleLabel ])
|
||||
result.axis = .vertical
|
||||
result.alignment = .center
|
||||
result.isLayoutMarginsRelativeArrangement = true
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
|
@ -42,6 +50,22 @@ final class ConversationTitleView: UIView {
|
|||
addSubview(stackView)
|
||||
|
||||
stackView.pin(to: self)
|
||||
|
||||
let shouldShowCallButton = SessionCall.isEnabled && !thread.isNoteToSelf() && !thread.isGroupThread()
|
||||
let leftMargin: CGFloat = shouldShowCallButton ? 54 : 8 // Contact threads also have the call button to compensate for
|
||||
stackView.layoutMargins = UIEdgeInsets(top: 0, left: leftMargin, bottom: 0, right: 0)
|
||||
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
let notificationCenter = NotificationCenter.default
|
||||
notificationCenter.addObserver(self, selector: #selector(update), name: Notification.Name.groupThreadUpdated, object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(update), name: Notification.Name.muteSettingUpdated, object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(update), name: Notification.Name.contactUpdated, object: nil)
|
||||
update()
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
|
|
|
@ -34,7 +34,7 @@ final class DownloadAttachmentModal: Modal {
|
|||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = String(format: NSLocalizedString("modal_download_attachment_title", comment: ""), name)
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
|
@ -57,7 +57,6 @@ final class DownloadAttachmentModal: Modal {
|
|||
let downloadButton = UIButton()
|
||||
downloadButton.set(.height, to: Values.mediumButtonHeight)
|
||||
downloadButton.layer.cornerRadius = Modal.buttonCornerRadius
|
||||
downloadButton.backgroundColor = Colors.buttonBackground
|
||||
downloadButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
downloadButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||
downloadButton.setTitle(NSLocalizedString("modal_download_button_title", comment: ""), for: UIControl.State.normal)
|
||||
|
@ -69,15 +68,21 @@ final class DownloadAttachmentModal: Modal {
|
|||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = Values.largeSpacing
|
||||
mainStackView.spacing = spacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
|
|
@ -30,7 +30,7 @@ final class JoinOpenGroupModal: Modal {
|
|||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = "Join \(name)?"
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
|
@ -62,32 +62,44 @@ final class JoinOpenGroupModal: Modal {
|
|||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = Values.largeSpacing
|
||||
mainStackView.spacing = spacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func joinOpenGroup() {
|
||||
guard let presentingViewController: UIViewController = self.presentingViewController else { return }
|
||||
guard let (room, server, publicKey) = OpenGroupManagerV2.parseV2OpenGroup(from: url) else {
|
||||
guard let (room, server, publicKey) = OpenGroupManager.parseOpenGroup(from: url) else {
|
||||
let alert = UIAlertController(title: "Couldn't Join", message: nil, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil))
|
||||
alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil))
|
||||
|
||||
return presentingViewController.present(alert, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
presentingViewController.dismiss(animated: true, completion: nil)
|
||||
|
||||
GRDBStorage.shared.write { db in
|
||||
OpenGroupManagerV2.shared
|
||||
.add(db, room: room, server: server, publicKey: publicKey)
|
||||
OpenGroupManager.shared.add(
|
||||
db,
|
||||
roomToken: room,
|
||||
server: server,
|
||||
publicKey: publicKey,
|
||||
isConfigMessage: false
|
||||
)
|
||||
}
|
||||
.done(on: DispatchQueue.main) { _ in
|
||||
GRDBStorage.shared.write { db in
|
||||
|
@ -96,7 +108,7 @@ final class JoinOpenGroupModal: Modal {
|
|||
}
|
||||
.catch(on: DispatchQueue.main) { error in
|
||||
let alert = UIAlertController(title: "Couldn't Join", message: error.localizedDescription, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil))
|
||||
alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil))
|
||||
presentingViewController.present(alert, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,7 +27,7 @@ final class LinkPreviewModal: Modal {
|
|||
// Title
|
||||
let titleLabel: UILabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = "modal_link_previews_title".localized()
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
|
@ -56,15 +56,22 @@ final class LinkPreviewModal: Modal {
|
|||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
|
||||
// Main stack view
|
||||
let mainStackView: UIStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = Values.largeSpacing
|
||||
mainStackView.spacing = spacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
|
|
@ -22,7 +22,7 @@ final class PermissionMissingModal : Modal {
|
|||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = "Session"
|
||||
titleLabel.textAlignment = .center
|
||||
// Message
|
||||
|
@ -50,15 +50,20 @@ final class PermissionMissingModal : Modal {
|
|||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = Values.largeSpacing
|
||||
mainStackView.spacing = spacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
|
|
|
@ -5,7 +5,7 @@ final class SendSeedModal : Modal {
|
|||
private lazy var titleLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.textColor = Colors.text
|
||||
result.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
result.text = NSLocalizedString("modal_send_seed_title", comment: "")
|
||||
result.textAlignment = .center
|
||||
return result
|
||||
|
@ -44,19 +44,27 @@ final class SendSeedModal : Modal {
|
|||
return result
|
||||
}()
|
||||
|
||||
private lazy var mainStackView: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [ titleLabel, explanationLabel, buttonStackView ])
|
||||
private lazy var contentStackView: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [ titleLabel, explanationLabel ])
|
||||
result.axis = .vertical
|
||||
result.spacing = Values.largeSpacing
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var mainStackView: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
result.axis = .vertical
|
||||
result.spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Lifecycle
|
||||
override func populateContentView() {
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: mainStackView.spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
|
|
|
@ -25,7 +25,7 @@ final class URLModal: Modal {
|
|||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = NSLocalizedString("modal_open_url_title", comment: "")
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
|
@ -57,15 +57,21 @@ final class URLModal: Modal {
|
|||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = Values.largeSpacing
|
||||
mainStackView.spacing = spacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
|
|
@ -80,7 +80,7 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll
|
|||
tabBar.pin(.leading, to: .leading, of: view)
|
||||
let tabBarInset: CGFloat
|
||||
if #available(iOS 13, *) {
|
||||
tabBarInset = navigationBar.height()
|
||||
tabBarInset = UIDevice.current.isIPad ? navigationBar.height() + 20 : navigationBar.height()
|
||||
} else {
|
||||
tabBarInset = 0
|
||||
}
|
||||
|
@ -190,6 +190,7 @@ private final class EnterPublicKeyVC : UIViewController {
|
|||
weak var NewDMVC: NewDMVC!
|
||||
private var isKeyboardShowing = false
|
||||
private var bottomConstraint: NSLayoutConstraint!
|
||||
private let bottomMargin: CGFloat = UIDevice.current.isIPad ? Values.largeSpacing : 0
|
||||
|
||||
// MARK: Components
|
||||
private lazy var publicKeyTextView: TextView = {
|
||||
|
@ -225,8 +226,12 @@ private final class EnterPublicKeyVC : UIViewController {
|
|||
private lazy var buttonContainer: UIStackView = {
|
||||
let result = UIStackView()
|
||||
result.axis = .horizontal
|
||||
result.spacing = Values.mediumSpacing
|
||||
result.spacing = UIDevice.current.isIPad ? Values.iPadButtonSpacing : Values.mediumSpacing
|
||||
result.distribution = .fillEqually
|
||||
if (UIDevice.current.isIPad) {
|
||||
result.layoutMargins = UIEdgeInsets(top: 0, left: Values.iPadButtonContainerMargin, bottom: 0, right: Values.iPadButtonContainerMargin)
|
||||
result.isLayoutMarginsRelativeArrangement = true
|
||||
}
|
||||
return result
|
||||
}()
|
||||
|
||||
|
@ -234,6 +239,8 @@ private final class EnterPublicKeyVC : UIViewController {
|
|||
override func viewDidLoad() {
|
||||
// Remove background color
|
||||
view.backgroundColor = .clear
|
||||
// User session id container
|
||||
let userPublicKeyContainer = UIView(wrapping: userPublicKeyLabel, withInsets: .zero, shouldAdaptForIPadWithWidth: Values.iPadUserSessionIdContainerWidth)
|
||||
// Explanation label
|
||||
let explanationLabel = UILabel()
|
||||
explanationLabel.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
|
||||
|
@ -253,14 +260,9 @@ private final class EnterPublicKeyVC : UIViewController {
|
|||
let nextButton = Button(style: .prominentOutline, size: .large)
|
||||
nextButton.setTitle(NSLocalizedString("next", comment: ""), for: UIControl.State.normal)
|
||||
nextButton.addTarget(self, action: #selector(startNewDMIfPossible), for: UIControl.Event.touchUpInside)
|
||||
let nextButtonContainer = UIView()
|
||||
nextButtonContainer.addSubview(nextButton)
|
||||
nextButton.pin(.leading, to: .leading, of: nextButtonContainer, withInset: 80)
|
||||
nextButton.pin(.top, to: .top, of: nextButtonContainer)
|
||||
nextButtonContainer.pin(.trailing, to: .trailing, of: nextButton, withInset: 80)
|
||||
nextButtonContainer.pin(.bottom, to: .bottom, of: nextButton)
|
||||
let nextButtonContainer = UIView(wrapping: nextButton, withInsets: UIEdgeInsets(top: 0, leading: 80, bottom: 0, trailing: 80), shouldAdaptForIPadWithWidth: Values.iPadButtonWidth)
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ publicKeyTextView, UIView.spacer(withHeight: Values.smallSpacing), explanationLabel, spacer1, separator, spacer2, userPublicKeyLabel, spacer3, buttonContainer, UIView.vStretchingSpacer(), nextButtonContainer ])
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ publicKeyTextView, UIView.spacer(withHeight: Values.smallSpacing), explanationLabel, spacer1, separator, spacer2, userPublicKeyContainer, spacer3, buttonContainer, UIView.vStretchingSpacer(), nextButtonContainer ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.alignment = .fill
|
||||
mainStackView.layoutMargins = UIEdgeInsets(top: Values.largeSpacing, left: Values.largeSpacing, bottom: Values.largeSpacing, right: Values.largeSpacing)
|
||||
|
@ -269,7 +271,7 @@ private final class EnterPublicKeyVC : UIViewController {
|
|||
mainStackView.pin(.leading, to: .leading, of: view)
|
||||
mainStackView.pin(.top, to: .top, of: view)
|
||||
view.pin(.trailing, to: .trailing, of: mainStackView)
|
||||
bottomConstraint = view.pin(.bottom, to: .bottom, of: mainStackView)
|
||||
bottomConstraint = view.pin(.bottom, to: .bottom, of: mainStackView, withInset: bottomMargin)
|
||||
// Width constraint
|
||||
view.set(.width, to: UIScreen.main.bounds.width)
|
||||
// Dismiss keyboard on tap
|
||||
|
@ -310,7 +312,7 @@ private final class EnterPublicKeyVC : UIViewController {
|
|||
guard !isKeyboardShowing else { return }
|
||||
isKeyboardShowing = true
|
||||
guard let newHeight = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue.size.height else { return }
|
||||
bottomConstraint.constant = newHeight
|
||||
bottomConstraint.constant = newHeight + bottomMargin
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
[ self.spacer1, self.separator, self.spacer2, self.userPublicKeyLabel, self.spacer3, self.buttonContainer ].forEach {
|
||||
$0.alpha = 0
|
||||
|
@ -323,7 +325,7 @@ private final class EnterPublicKeyVC : UIViewController {
|
|||
@objc private func handleKeyboardWillHideNotification(_ notification: Notification) {
|
||||
guard isKeyboardShowing else { return }
|
||||
isKeyboardShowing = false
|
||||
bottomConstraint.constant = 0
|
||||
bottomConstraint.constant = bottomMargin
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
[ self.spacer1, self.separator, self.spacer2, self.userPublicKeyLabel, self.spacer3, self.buttonContainer ].forEach {
|
||||
$0.alpha = 1
|
||||
|
@ -345,6 +347,12 @@ private final class EnterPublicKeyVC : UIViewController {
|
|||
|
||||
@objc private func sharePublicKey() {
|
||||
let shareVC = UIActivityViewController(activityItems: [ getUserHexEncodedPublicKey() ], applicationActivities: nil)
|
||||
if UIDevice.current.isIPad {
|
||||
shareVC.excludedActivityTypes = []
|
||||
shareVC.popoverPresentationController?.permittedArrowDirections = []
|
||||
shareVC.popoverPresentationController?.sourceView = self.view
|
||||
shareVC.popoverPresentationController?.sourceRect = self.view.bounds
|
||||
}
|
||||
NewDMVC.navigationController!.present(shareVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
|
|
|
@ -109,8 +109,24 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
searchBarContainer.set(.height, to: 44)
|
||||
searchBarContainer.set(.width, to: UIScreen.main.bounds.width - 32)
|
||||
searchBarContainer.addSubview(searchBar)
|
||||
searchBar.autoPinEdgesToSuperviewMargins()
|
||||
navigationItem.titleView = searchBarContainer
|
||||
|
||||
// On iPad, the cancel button won't show
|
||||
// See more https://developer.apple.com/documentation/uikit/uisearchbar/1624283-showscancelbutton?language=objc
|
||||
if UIDevice.current.isIPad {
|
||||
let ipadCancelButton = UIButton()
|
||||
ipadCancelButton.setTitle("Cancel", for: .normal)
|
||||
ipadCancelButton.addTarget(self, action: #selector(cancel), for: .touchUpInside)
|
||||
ipadCancelButton.setTitleColor(Colors.text, for: .normal)
|
||||
searchBarContainer.addSubview(ipadCancelButton)
|
||||
ipadCancelButton.pin(.trailing, to: .trailing, of: searchBarContainer)
|
||||
ipadCancelButton.autoVCenterInSuperview()
|
||||
searchBar.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets.zero, excludingEdge: .trailing)
|
||||
searchBar.pin(.trailing, to: .leading, of: ipadCancelButton, withInset: -Values.smallSpacing)
|
||||
}
|
||||
else {
|
||||
searchBar.autoPinEdgesToSuperviewMargins()
|
||||
}
|
||||
}
|
||||
|
||||
private func reloadTableData() {
|
||||
|
@ -187,6 +203,10 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
@objc func cancel() {
|
||||
self.navigationController?.popViewController(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UISearchBarDelegate
|
||||
|
|
|
@ -100,7 +100,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
|
|||
let createNewPrivateChatButton = Button(style: .prominentOutline, size: .large)
|
||||
createNewPrivateChatButton.setTitle(NSLocalizedString("vc_home_empty_state_button_title", comment: ""), for: UIControl.State.normal)
|
||||
createNewPrivateChatButton.addTarget(self, action: #selector(createNewDM), for: UIControl.Event.touchUpInside)
|
||||
createNewPrivateChatButton.set(.width, to: 196)
|
||||
createNewPrivateChatButton.set(.width, to: Values.iPadButtonWidth)
|
||||
let result = UIStackView(arrangedSubviews: [ explanationLabel, createNewPrivateChatButton ])
|
||||
result.axis = .vertical
|
||||
result.spacing = Values.mediumSpacing
|
||||
|
@ -200,6 +200,9 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
|
|||
DispatchQueue.global(qos: .utility).sync {
|
||||
let _ = IP2Country.shared.populateCacheIfNeeded()
|
||||
}
|
||||
|
||||
// Get default open group rooms if needed
|
||||
// OpenGroupManager.getDefaultRoomsIfNeeded() // TODO: Needed???
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
|
@ -443,7 +446,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
|
|||
.retainUntilComplete()
|
||||
|
||||
case .openGroup:
|
||||
OpenGroupManagerV2.shared.delete(db, openGroupId: cellViewModel.threadId)
|
||||
OpenGroupManager.shared.delete(db, openGroupId: cellViewModel.threadId)
|
||||
|
||||
default: break
|
||||
}
|
||||
|
@ -530,6 +533,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
|
|||
if let presentedVC = self.presentedViewController {
|
||||
presentedVC.dismiss(animated: false, completion: nil)
|
||||
}
|
||||
|
||||
self.navigationController?.setViewControllers([ self, conversationVC ], animated: true)
|
||||
}
|
||||
|
||||
|
@ -551,12 +555,20 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
|
|||
@objc func joinOpenGroup() {
|
||||
let joinOpenGroupVC: JoinOpenGroupVC = JoinOpenGroupVC()
|
||||
let navigationController: OWSNavigationController = OWSNavigationController(rootViewController: joinOpenGroupVC)
|
||||
|
||||
if UIDevice.current.isIPad {
|
||||
navigationController.modalPresentationStyle = .fullScreen
|
||||
}
|
||||
|
||||
present(navigationController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
@objc func createNewDM() {
|
||||
let newDMVC = NewDMVC()
|
||||
let navigationController = OWSNavigationController(rootViewController: newDMVC)
|
||||
if UIDevice.current.isIPad {
|
||||
navigationController.modalPresentationStyle = .fullScreen
|
||||
}
|
||||
present(navigationController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
|
@ -564,12 +576,18 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
|
|||
func createNewDMFromDeepLink(sessionID: String) {
|
||||
let newDMVC = NewDMVC(sessionID: sessionID)
|
||||
let navigationController = OWSNavigationController(rootViewController: newDMVC)
|
||||
if UIDevice.current.isIPad {
|
||||
navigationController.modalPresentationStyle = .fullScreen
|
||||
}
|
||||
present(navigationController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
@objc func createClosedGroup() {
|
||||
let newClosedGroupVC = NewClosedGroupVC()
|
||||
let navigationController = OWSNavigationController(rootViewController: newClosedGroupVC)
|
||||
if UIDevice.current.isIPad {
|
||||
navigationController.modalPresentationStyle = .fullScreen
|
||||
}
|
||||
present(navigationController, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -151,8 +151,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
equalTo: view.safeAreaLayoutGuide.bottomAnchor,
|
||||
constant: -Values.largeSpacing
|
||||
),
|
||||
// Note: The '182' is to match the 'Next' button on the New DM page (which doesn't have a fixed width)
|
||||
clearAllButton.widthAnchor.constraint(equalToConstant: 182),
|
||||
clearAllButton.widthAnchor.constraint(equalToConstant: Values.iPadButtonWidth),
|
||||
clearAllButton.heightAnchor.constraint(equalToConstant: NewConversationButtonSet.collapsedButtonSize)
|
||||
])
|
||||
}
|
||||
|
@ -249,7 +248,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
|
||||
@objc private func clearAllTapped() {
|
||||
guard !viewModel.viewData.isEmpty else { return }
|
||||
|
||||
|
|
|
@ -94,6 +94,19 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
selectionPanGesture.delegate = self
|
||||
self.selectionPanGesture = selectionPanGesture
|
||||
collectionView.addGestureRecognizer(selectionPanGesture)
|
||||
|
||||
if #available(iOS 14, *) {
|
||||
if PHPhotoLibrary.authorizationStatus(for: .readWrite) == .limited {
|
||||
let addSeletedPhotoButton = UIBarButtonItem.init(barButtonSystemItem: .add, target: self, action: #selector(addSelectedPhoto))
|
||||
self.navigationItem.rightBarButtonItem = addSeletedPhotoButton
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func addSelectedPhoto(_ sender: Any) {
|
||||
if #available(iOS 14, *) {
|
||||
PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: self)
|
||||
}
|
||||
}
|
||||
|
||||
var selectionPanGesture: UIPanGestureRecognizer?
|
||||
|
@ -380,7 +393,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
}
|
||||
|
||||
collectionView.allowsMultipleSelection = delegate.isInBatchSelectMode
|
||||
collectionView.reloadData()
|
||||
reloadDataAndRestoreSelection()
|
||||
}
|
||||
|
||||
func clearCollectionViewSelection() {
|
||||
|
@ -550,12 +563,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
let assetItem = photoCollectionContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize)
|
||||
cell.configure(item: assetItem)
|
||||
|
||||
let isSelected = delegate.imagePicker(self, isAssetSelected: assetItem.asset)
|
||||
if isSelected {
|
||||
cell.isSelected = isSelected
|
||||
} else {
|
||||
cell.isSelected = isSelected
|
||||
}
|
||||
cell.isSelected = delegate.imagePicker(self, isAssetSelected: assetItem.asset)
|
||||
|
||||
return cell
|
||||
}
|
||||
|
|
|
@ -504,18 +504,22 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
}
|
||||
|
||||
let shareVC = UIActivityViewController(activityItems: [ URL(fileURLWithPath: originalFilePath) ], applicationActivities: nil)
|
||||
|
||||
if UIDevice.current.isIPad {
|
||||
shareVC.excludedActivityTypes = []
|
||||
shareVC.popoverPresentationController?.permittedArrowDirections = []
|
||||
shareVC.popoverPresentationController?.sourceView = self.view
|
||||
shareVC.popoverPresentationController?.sourceRect = self.view.bounds
|
||||
}
|
||||
|
||||
shareVC.completionWithItemsHandler = { activityType, completed, returnedItems, activityError in
|
||||
if let activityError = activityError {
|
||||
SNLog("Failed to share with activityError: \(activityError)")
|
||||
} else if completed {
|
||||
}
|
||||
else if completed {
|
||||
SNLog("Did share with activityType: \(activityType.debugDescription)")
|
||||
}
|
||||
|
||||
guard
|
||||
let activityType = activityType,
|
||||
activityType == .saveToCameraRoll,
|
||||
|
|
|
@ -3,6 +3,9 @@
|
|||
import Foundation
|
||||
import GRDB
|
||||
import PromiseKit
|
||||
import WebRTC
|
||||
import SessionUIKit
|
||||
import UIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
import SessionUIKit
|
||||
|
@ -151,7 +154,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||
DDLog.flushLog()
|
||||
|
||||
stopPollers()
|
||||
// NOTE: Fix an edge case where user taps on the callkit notification
|
||||
// but answers the call on another device
|
||||
stopPollers(shouldStopUserPoller: !self.hasIncomingCallWaiting())
|
||||
}
|
||||
|
||||
func applicationDidReceiveMemoryWarning(_ application: UIApplication) {
|
||||
|
@ -250,7 +255,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||
content: notificationContent,
|
||||
trigger: nil
|
||||
)
|
||||
|
||||
|
||||
// Make sure we clear any existing notifications so that they don't start stacking up
|
||||
// if the user receives multiple pushes.
|
||||
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
|
||||
|
@ -444,13 +449,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||
|
||||
poller.startIfNeeded()
|
||||
ClosedGroupPoller.shared.start()
|
||||
OpenGroupManagerV2.shared.startPolling()
|
||||
OpenGroupManager.shared.startPolling()
|
||||
}
|
||||
|
||||
public func stopPollers() {
|
||||
poller.stop()
|
||||
public func stopPollers(shouldStopUserPoller: Bool = true) {
|
||||
if shouldStopUserPoller {
|
||||
poller.stop()
|
||||
}
|
||||
|
||||
ClosedGroupPoller.shared.stop()
|
||||
OpenGroupManagerV2.shared.stopPolling()
|
||||
OpenGroupManager.shared.stopPolling()
|
||||
}
|
||||
|
||||
// MARK: - App Mode
|
||||
|
@ -514,6 +522,183 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||
|
||||
homeViewController.createNewDMFromDeepLink(sessionID: sessionId)
|
||||
}
|
||||
|
||||
// MARK: - Call handling
|
||||
|
||||
func hasIncomingCallWaiting() -> Bool {
|
||||
guard let call = AppEnvironment.shared.callManager.currentCall else { return false }
|
||||
|
||||
return !call.hasStartedConnecting
|
||||
}
|
||||
|
||||
func handleAppActivatedWithOngoingCallIfNeeded() {
|
||||
guard let call = AppEnvironment.shared.callManager.currentCall else { return }
|
||||
guard MiniCallView.current == nil else { return }
|
||||
|
||||
if let callVC = CurrentAppContext().frontmostViewController() as? CallVC, callVC.call == call { return }
|
||||
|
||||
// FIXME: Handle more gracefully
|
||||
guard let presentingVC = CurrentAppContext().frontmostViewController() else { preconditionFailure() }
|
||||
|
||||
let callVC = CallVC(for: call)
|
||||
|
||||
if let conversationVC = presentingVC as? ConversationVC, let contactThread = conversationVC.thread as? TSContactThread, contactThread.contactSessionID() == call.sessionID {
|
||||
callVC.conversationVC = conversationVC
|
||||
conversationVC.inputAccessoryView?.isHidden = true
|
||||
conversationVC.inputAccessoryView?.alpha = 0
|
||||
}
|
||||
|
||||
presentingVC.present(callVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
private func dismissAllCallUI() {
|
||||
if let currentBanner = IncomingCallBanner.current { currentBanner.dismiss() }
|
||||
if let callVC = CurrentAppContext().frontmostViewController() as? CallVC { callVC.handleEndCallMessage() }
|
||||
if let miniCallView = MiniCallView.current { miniCallView.dismiss() }
|
||||
}
|
||||
|
||||
private func showCallUIForCall(_ call: SessionCall) {
|
||||
DispatchQueue.main.async {
|
||||
call.reportIncomingCallIfNeeded{ error in
|
||||
if let error = error {
|
||||
SNLog("[Calls] Failed to report incoming call to CallKit due to error: \(error)")
|
||||
}
|
||||
else {
|
||||
if CurrentAppContext().isMainAppAndActive {
|
||||
guard let presentingVC = CurrentAppContext().frontmostViewController() else {
|
||||
preconditionFailure() // FIXME: Handle more gracefully
|
||||
}
|
||||
|
||||
if let conversationVC = presentingVC as? ConversationVC, let contactThread = conversationVC.thread as? TSContactThread, contactThread.contactSessionID() == call.sessionID {
|
||||
let callVC = CallVC(for: call)
|
||||
callVC.conversationVC = conversationVC
|
||||
conversationVC.inputAccessoryView?.isHidden = true
|
||||
conversationVC.inputAccessoryView?.alpha = 0
|
||||
presentingVC.present(callVC, animated: true, completion: nil)
|
||||
}
|
||||
else if !SSKPreferences.isCallKitSupported {
|
||||
let incomingCallBanner = IncomingCallBanner(for: call)
|
||||
incomingCallBanner.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func insertCallInfoMessage(for message: CallMessage, using transaction: YapDatabaseReadWriteTransaction) -> TSInfoMessage? {
|
||||
guard let sender = message.sender, let uuid = message.uuid else { return nil }
|
||||
|
||||
var receivedCalls = Storage.shared.getReceivedCalls(for: sender, using: transaction)
|
||||
|
||||
guard !receivedCalls.contains(uuid) else { return nil }
|
||||
|
||||
let thread = TSContactThread.getOrCreateThread(withContactSessionID: message.sender!, transaction: transaction)
|
||||
let infoMessage = TSInfoMessage.from(message, associatedWith: thread)
|
||||
infoMessage.save(with: transaction)
|
||||
receivedCalls.insert(uuid)
|
||||
Storage.shared.setReceivedCalls(to: receivedCalls, for: sender, using: transaction)
|
||||
|
||||
return infoMessage
|
||||
}
|
||||
|
||||
private func showMissedCallTipsIfNeeded(caller: String) {
|
||||
guard !UserDefaults.standard[.hasSeenCallMissedTips] else { return }
|
||||
guard let presentingVC = CurrentAppContext().frontmostViewController() else { preconditionFailure() }
|
||||
|
||||
let callMissedTipsModal = CallMissedTipsModal(caller: caller)
|
||||
presentingVC.present(callMissedTipsModal, animated: true, completion: nil)
|
||||
|
||||
userDefaults[.hasSeenCallMissedTips] = true
|
||||
}
|
||||
|
||||
func setUpCallHandling() {
|
||||
// Pre offer messages
|
||||
MessageReceiver.handleNewCallOfferMessageIfNeeded = { (message, transaction) in
|
||||
guard CurrentAppContext().isMainApp else { return }
|
||||
guard let timestamp = message.sentTimestamp, TimestampUtils.isWithinOneMinute(timestamp: timestamp) else {
|
||||
// Add missed call message for call offer messages from more than one minute
|
||||
if let infoMessage = self.insertCallInfoMessage(for: message, using: transaction) {
|
||||
infoMessage.updateCallInfoMessage(.missed, using: transaction)
|
||||
let thread = TSContactThread.getOrCreateThread(withContactSessionID: message.sender!, transaction: transaction)
|
||||
SSKEnvironment.shared.notificationsManager?.notifyUser(forIncomingCall: infoMessage, in: thread, transaction: transaction)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard SSKPreferences.areCallsEnabled else {
|
||||
if let infoMessage = self.insertCallInfoMessage(for: message, using: transaction) {
|
||||
infoMessage.updateCallInfoMessage(.permissionDenied, using: transaction)
|
||||
let thread = TSContactThread.getOrCreateThread(withContactSessionID: message.sender!, transaction: transaction)
|
||||
SSKEnvironment.shared.notificationsManager?.notifyUser(forIncomingCall: infoMessage, in: thread, transaction: transaction)
|
||||
let contactName = Storage.shared.getContact(with: message.sender!, using: transaction)?.displayName(for: Contact.Context.regular) ?? message.sender!
|
||||
DispatchQueue.main.async {
|
||||
self.showMissedCallTipsIfNeeded(caller: contactName)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let callManager = AppEnvironment.shared.callManager
|
||||
|
||||
// Ignore pre offer message after the same call instance has been generated
|
||||
if let currentCall = callManager.currentCall, currentCall.uuid == message.uuid! { return }
|
||||
|
||||
guard callManager.currentCall == nil else {
|
||||
callManager.handleIncomingCallOfferInBusyState(offerMessage: message, using: transaction)
|
||||
return
|
||||
}
|
||||
|
||||
let infoMessage = self.insertCallInfoMessage(for: message, using: transaction)
|
||||
|
||||
// Handle UI
|
||||
if let caller = message.sender, let uuid = message.uuid {
|
||||
let call = SessionCall(for: caller, uuid: uuid, mode: .answer)
|
||||
call.callMessageID = infoMessage?.uniqueId
|
||||
self.showCallUIForCall(call)
|
||||
}
|
||||
}
|
||||
|
||||
// Offer messages
|
||||
MessageReceiver.handleOfferCallMessage = { message in
|
||||
DispatchQueue.main.async {
|
||||
guard let call = AppEnvironment.shared.callManager.currentCall, message.uuid! == call.uuid else { return }
|
||||
let sdp = RTCSessionDescription(type: .offer, sdp: message.sdps![0])
|
||||
call.didReceiveRemoteSDP(sdp: sdp)
|
||||
}
|
||||
}
|
||||
|
||||
// Answer messages
|
||||
MessageReceiver.handleAnswerCallMessage = { message in
|
||||
DispatchQueue.main.async {
|
||||
guard let call = AppEnvironment.shared.callManager.currentCall, message.uuid! == call.uuid else { return }
|
||||
if message.sender! == getUserHexEncodedPublicKey() {
|
||||
guard !call.hasStartedConnecting else { return }
|
||||
self.dismissAllCallUI()
|
||||
AppEnvironment.shared.callManager.reportCurrentCallEnded(reason: .answeredElsewhere)
|
||||
} else {
|
||||
call.hasStartedConnecting = true
|
||||
let sdp = RTCSessionDescription(type: .answer, sdp: message.sdps![0])
|
||||
call.didReceiveRemoteSDP(sdp: sdp)
|
||||
guard let callVC = CurrentAppContext().frontmostViewController() as? CallVC else { return }
|
||||
callVC.handleAnswerMessage(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// End call messages
|
||||
MessageReceiver.handleEndCallMessage = { message in
|
||||
DispatchQueue.main.async {
|
||||
guard let call = AppEnvironment.shared.callManager.currentCall, message.uuid! == call.uuid else { return }
|
||||
self.dismissAllCallUI()
|
||||
if message.sender! == getUserHexEncodedPublicKey() {
|
||||
AppEnvironment.shared.callManager.reportCurrentCallEnded(reason: .declinedElsewhere)
|
||||
} else {
|
||||
AppEnvironment.shared.callManager.reportCurrentCallEnded(reason: .remoteEnded)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Config Sync
|
||||
|
||||
|
@ -535,4 +720,5 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||
.retainUntilComplete()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,20 +1,14 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import SignalUtilitiesKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
@objc public class AppEnvironment: NSObject {
|
||||
public class AppEnvironment {
|
||||
|
||||
private static var _shared: AppEnvironment = AppEnvironment()
|
||||
|
||||
@objc
|
||||
public class var shared: AppEnvironment {
|
||||
get {
|
||||
return _shared
|
||||
}
|
||||
get { return _shared }
|
||||
set {
|
||||
guard CurrentAppContext().isRunningTests else {
|
||||
owsFailDebug("Can only switch environments in tests.")
|
||||
|
@ -25,25 +19,21 @@ import SignalUtilitiesKit
|
|||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
public var callManager: SessionCallManager
|
||||
public var notificationPresenter: NotificationPresenter
|
||||
|
||||
@objc
|
||||
public var pushRegistrationManager: PushRegistrationManager
|
||||
|
||||
@objc
|
||||
public var fileLogger: DDFileLogger
|
||||
|
||||
// Stored properties cannot be marked as `@available`, only classes and functions.
|
||||
// Instead, store a private `Any` and wrap it with a public `@available` getter
|
||||
private var _userNotificationActionHandler: Any?
|
||||
|
||||
@objc
|
||||
public var userNotificationActionHandler: UserNotificationActionHandler {
|
||||
return _userNotificationActionHandler as! UserNotificationActionHandler
|
||||
}
|
||||
|
||||
private override init() {
|
||||
private init() {
|
||||
self.callManager = SessionCallManager()
|
||||
self.notificationPresenter = NotificationPresenter()
|
||||
self.pushRegistrationManager = PushRegistrationManager()
|
||||
self._userNotificationActionHandler = UserNotificationActionHandler()
|
||||
|
@ -54,7 +44,6 @@ import SignalUtilitiesKit
|
|||
SwiftSingletons.register(self)
|
||||
}
|
||||
|
||||
@objc
|
||||
public func setup() {
|
||||
// Hang certain singletons on SSKEnvironment too.
|
||||
Environment.shared.notificationsManager.mutate {
|
||||
|
|
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Airpods.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AnswerCall.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "audio_off_fill.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Bluetooth.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
Session/Meta/Images.xcassets/Session/CallIncoming.imageset/CallIncoming.pdf
vendored
Normal file
BIN
Session/Meta/Images.xcassets/Session/CallIncoming.imageset/CallIncoming.pdf
vendored
Normal file
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "CallIncoming.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "CallMissed.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
Session/Meta/Images.xcassets/Session/CallOutgoing.imageset/CallOutgoing.pdf
vendored
Normal file
BIN
Session/Meta/Images.xcassets/Session/CallOutgoing.imageset/CallOutgoing.pdf
vendored
Normal file
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "CallOutgoing.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "check.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Path.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Headsets.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "minimize.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Phone.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "speaker.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "switch_camera_fill.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
Session/Meta/Images.xcassets/Session/SwitchCamera.imageset/switch_camera_fill.pdf
vendored
Normal file
BIN
Session/Meta/Images.xcassets/Session/SwitchCamera.imageset/switch_camera_fill.pdf
vendored
Normal file
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Tips.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "video_call_fill.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
Session/Meta/Images.xcassets/Session/VideoCall.imageset/video_call_fill.pdf
vendored
Normal file
BIN
Session/Meta/Images.xcassets/Session/VideoCall.imageset/video_call_fill.pdf
vendored
Normal file
Binary file not shown.
|
@ -91,6 +91,8 @@
|
|||
<string>Signal uses your contacts to find users you know. We do not store your contacts on the server.</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Session's Screen Lock feature uses Face ID.</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>com.loki-project.loki-messenger</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Session needs access to your microphone to record media.</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
|
@ -105,6 +107,11 @@
|
|||
<string>SpaceMono-Bold.ttf</string>
|
||||
<string>SpaceMono-Regular.ttf</string>
|
||||
</array>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>UIApplicationShortcutItems</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
@ -118,8 +125,10 @@
|
|||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
<string>fetch</string>
|
||||
<string>remote-notification</string>
|
||||
<string>voip</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>Launch Screen</string>
|
||||
|
@ -127,6 +136,8 @@
|
|||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UIRequiresFullScreen</key>
|
||||
<true/>
|
||||
<key>UIStatusBarStyle</key>
|
||||
<string>UIStatusBarStyleLightContent</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
|
|
|
@ -3,13 +3,13 @@
|
|||
/* Message format for the 'new app version available' alert. Embeds: {{The latest app version number}} */
|
||||
"APP_UPDATE_NAG_ALERT_MESSAGE_FORMAT" = "Version %@ ist nun im App Store verfügbar.";
|
||||
/* Title for the 'new app version available' alert. */
|
||||
"APP_UPDATE_NAG_ALERT_TITLE" = "Neue Version von Session verfügbar";
|
||||
"APP_UPDATE_NAG_ALERT_TITLE" = "Eine neue Version von Session ist verfügbar";
|
||||
/* Label for the 'update' button in the 'new app version available' alert. */
|
||||
"APP_UPDATE_NAG_ALERT_UPDATE_BUTTON" = "Aktualisieren";
|
||||
/* No comment provided by engineer. */
|
||||
"ATTACHMENT" = "Anhang";
|
||||
/* One-line label indicating the user can add no more text to the attachment caption. */
|
||||
"ATTACHMENT_APPROVAL_CAPTION_LENGTH_LIMIT_REACHED" = "Zeichen Limit erreicht.";
|
||||
"ATTACHMENT_APPROVAL_CAPTION_LENGTH_LIMIT_REACHED" = "Maximale Zeichen erreicht.";
|
||||
/* placeholder text for an empty captioning field */
|
||||
"ATTACHMENT_APPROVAL_CAPTION_PLACEHOLDER" = "Bemerkung hinzufügen …";
|
||||
/* Title for 'caption' mode of the attachment approval view. */
|
||||
|
@ -19,7 +19,7 @@
|
|||
/* Format string for file size label in call interstitial view. Embeds: {{file size as 'N mb' or 'N kb'}}. */
|
||||
"ATTACHMENT_APPROVAL_FILE_SIZE_FORMAT" = "Größe: %@";
|
||||
/* One-line label indicating the user can add no more text to the media message field. */
|
||||
"ATTACHMENT_APPROVAL_MESSAGE_LENGTH_LIMIT_REACHED" = "Nachrichtenlimit erreicht.";
|
||||
"ATTACHMENT_APPROVAL_MESSAGE_LENGTH_LIMIT_REACHED" = "Nachrichtenlimit erreicht";
|
||||
/* Label for 'send' button in the 'attachment approval' dialog. */
|
||||
"ATTACHMENT_APPROVAL_SEND_BUTTON" = "Senden";
|
||||
/* Generic filename for an attachment with no known name */
|
||||
|
@ -75,7 +75,7 @@
|
|||
/* Indicates that the backup import data is being finalized. */
|
||||
"BACKUP_IMPORT_PHASE_FINALIZING" = "Sicherung wird abgeschlossen";
|
||||
/* Indicates that the backup import data is being imported. */
|
||||
"BACKUP_IMPORT_PHASE_IMPORT" = "Sicherung wird importiert";
|
||||
"BACKUP_IMPORT_PHASE_IMPORT" = "Sicherung wird importiert.";
|
||||
/* Indicates that the backup database is being restored. */
|
||||
"BACKUP_IMPORT_PHASE_RESTORING_DATABASE" = "Datenbank wird wiederhergestellt";
|
||||
/* Indicates that the backup import data is being restored. */
|
||||
|
@ -371,9 +371,15 @@
|
|||
/* Setting for enabling & disabling link previews. */
|
||||
"SETTINGS_LINK_PREVIEWS" = "Link-Vorschauen senden";
|
||||
/* Footer for setting for enabling & disabling link previews. */
|
||||
"SETTINGS_LINK_PREVIEWS_FOOTER" = "Linkvorschau ist verfügbar für Imgur, Instagram, Pinterest, Reddit, und YouTube Links.";
|
||||
"SETTINGS_LINK_PREVIEWS_FOOTER" = "Vorschauen werden für die meisten Urls unterstützt.";
|
||||
/* Header for setting for enabling & disabling link previews. */
|
||||
"SETTINGS_LINK_PREVIEWS_HEADER" = "Link-Vorschauen";
|
||||
/* Setting for enabling & disabling voice & video calls. */
|
||||
"SETTINGS_CALLS" = "Sprach- und Videoanrufe";
|
||||
/* Footer for setting for enabling & disabling voice & video calls. */
|
||||
"SETTINGS_CALLS_FOOTER" = "Erlauben, Sprach- und Videoanrufe von anderen Nutzern anzunehmen.";
|
||||
/* Header for setting for enabling & disabling voice & video calls. */
|
||||
"SETTINGS_CALLS_HEADER" = "Anrufe";
|
||||
/* table section header */
|
||||
"SETTINGS_NOTIFICATION_CONTENT_TITLE" = "Mitteilungsinhalt";
|
||||
/* Label for the 'read receipts' setting. */
|
||||
|
@ -519,10 +525,10 @@
|
|||
"modal_clear_all_data_title" = "Alle Daten löschen";
|
||||
"modal_clear_all_data_explanation" = "Dadurch werden Ihre Nachrichten, Sessions und Kontakte dauerhaft gelöscht.";
|
||||
"modal_clear_all_data_explanation_2" = "Möchtest du nur dieses Gerät löschen oder dein gesamtes Konto löschen?";
|
||||
"dialog_clear_all_data_deletion_failed_1" = "Data not deleted by 1 Service Node. Service Node ID: %@.";
|
||||
"dialog_clear_all_data_deletion_failed_2" = "Data not deleted by %@ Service Nodes. Service Node IDs: %@.";
|
||||
"modal_clear_all_data_device_only_button_title" = "Device Only";
|
||||
"modal_clear_all_data_entire_account_button_title" = "Entire Account";
|
||||
"dialog_clear_all_data_deletion_failed_1" = "Daten nicht gelöscht von Service Node 1. Service Node ID: %@.";
|
||||
"dialog_clear_all_data_deletion_failed_2" = "Daten nicht gelöscht von %@ Service Noten. Service Noten IDs: %@.";
|
||||
"modal_clear_all_data_device_only_button_title" = "Nur Geräte";
|
||||
"modal_clear_all_data_entire_account_button_title" = "Gesamtes Konto";
|
||||
"vc_qr_code_title" = "QR-Code";
|
||||
"vc_qr_code_view_my_qr_code_tab_title" = "Meinen QR-Code anzeigen";
|
||||
"vc_qr_code_view_scan_qr_code_tab_title" = "QR-Code scannen";
|
||||
|
@ -555,58 +561,82 @@
|
|||
"modal_open_url_title" = "URL öffnen?";
|
||||
"modal_open_url_explanation" = "Möchten Sie %@ wirklich öffnen?";
|
||||
"modal_open_url_button_title" = "Öffnen";
|
||||
"modal_copy_url_button_title" = "Copy Link";
|
||||
"modal_copy_url_button_title" = "Link kopieren";
|
||||
"modal_blocked_title" = "%@ entsperren?";
|
||||
"modal_blocked_explanation" = "Sind Sie sicher, dass Sie %@ entsperren möchten?";
|
||||
"modal_blocked_button_title" = "Entsperren";
|
||||
"modal_link_previews_title" = "Link-Vorschau aktivieren?";
|
||||
"modal_link_previews_explanation" = "Das Aktivieren von Link-Vorschauen zeigt Vorschaubilder für URLs, die Sie senden und empfangen. Dies kann nützlich sein, aber Session muss verlinkte Webseiten kontaktieren, um Vorschaubilder zu generieren. Du kannst die Link-Vorschau in den Session-Einstellungen immer deaktivieren.";
|
||||
"modal_link_previews_button_title" = "Aktivieren";
|
||||
"modal_share_logs_title" = "Share Logs";
|
||||
"modal_share_logs_explanation" = "Would you like to export your application logs to be able to share for troubleshooting?";
|
||||
"modal_call_title" = "Sprach-/Videoanrufe";
|
||||
"modal_call_explanation" = "Die aktuelle Implementierung von Sprach- und Videoanrufen wird deine IP-Adresse den Oxen Foundation Servern und dem anderen Benutzer offenbaren.";
|
||||
"modal_share_logs_title" = "Logs teilen";
|
||||
"modal_share_logs_explanation" = "Möchten Sie Ihre Anwendungsprotokolle exportieren, um sie später zur Fehlerbehebung teilen zu können?";
|
||||
"vc_share_title" = "Mit Session teilen";
|
||||
"vc_share_loading_message" = "Anlagen werden vorbereitet...";
|
||||
"vc_share_sending_message" = "Wird gesendet ...";
|
||||
"vc_share_link_previews_unsecure" = "Preview not loaded for unsecure link";
|
||||
"vc_share_link_previews_error" = "Unable to load preview";
|
||||
"vc_share_link_previews_disabled_title" = "Link Previews Disabled";
|
||||
"vc_share_link_previews_disabled_explanation" = "Enabling link previews will show previews for URLs you share. This can be useful, but Session will need to contact linked websites to generate previews.\n\nYou can enable link previews in Session's settings.";
|
||||
"vc_share_link_previews_unsecure" = "Vorschau nicht für unsicheren Link geladen";
|
||||
"vc_share_link_previews_error" = "Ansicht konnte nicht geladen werden";
|
||||
"vc_share_link_previews_disabled_title" = "Linkvorschau deaktiviert";
|
||||
"vc_share_link_previews_disabled_explanation" = "Das Aktivieren von Link-Vorschauen zeigt Vorschaubilder für URLs, die Sie senden und empfangen. Dies kann nützlich sein, aber Session muss verlinkte Webseiten kontaktieren, um Vorschaubilder zu generieren. Du kannst die Link-Vorschau in den Session-Einstellungen immer deaktivieren.";
|
||||
"view_open_group_invitation_description" = "Gruppeneinladung öffnen";
|
||||
"vc_conversation_settings_invite_button_title" = "Mitglieder hinzufügen";
|
||||
"vc_settings_faq_button_title" = "FAQ";
|
||||
"vc_settings_survey_button_title" = "Feedback / Survey";
|
||||
"vc_settings_support_button_title" = "Debug Log";
|
||||
"modal_send_seed_title" = "Warning";
|
||||
"modal_send_seed_explanation" = "This is your recovery phrase. If you send it to someone they'll have full access to your account.";
|
||||
"modal_send_seed_send_button_title" = "Send";
|
||||
"vc_conversation_settings_notify_for_mentions_only_title" = "Notify for Mentions Only";
|
||||
"vc_conversation_settings_notify_for_mentions_only_explanation" = "When enabled, you'll only be notified for messages mentioning you.";
|
||||
"view_conversation_title_notify_for_mentions_only" = "Notifying for Mentions Only";
|
||||
"message_deleted" = "This message has been deleted";
|
||||
"delete_message_for_me" = "Delete just for me";
|
||||
"delete_message_for_everyone" = "Delete for everyone";
|
||||
"delete_message_for_me_and_recipient" = "Delete for me and %@";
|
||||
"context_menu_reply" = "Reply";
|
||||
"context_menu_save" = "Save";
|
||||
"context_menu_ban_user" = "Ban User";
|
||||
"context_menu_ban_and_delete_all" = "Ban and Delete All";
|
||||
"accessibility_expanding_attachments_button" = "Add attachments";
|
||||
"accessibility_gif_button" = "Gif";
|
||||
"accessibility_document_button" = "Document";
|
||||
"accessibility_library_button" = "Photo library";
|
||||
"accessibility_camera_button" = "Camera";
|
||||
"accessibility_main_button_collapse" = "Collapse attachment options";
|
||||
"DISMISS_BUTTON_TEXT" = "Dismiss";
|
||||
"vc_settings_faq_button_title" = "Häufig gestellte Fragen";
|
||||
"vc_settings_survey_button_title" = "Feedback / Umfrage";
|
||||
"vc_settings_support_button_title" = "Debug-Protokoll";
|
||||
"modal_send_seed_title" = "Warnung";
|
||||
"modal_send_seed_explanation" = "Dies ist dein Wiederherstellungssatz. Wenn du ihn jemandem schickst, hat er oder sie vollen Zugriff auf dein Konto.";
|
||||
"modal_send_seed_send_button_title" = "Senden";
|
||||
"vc_conversation_settings_notify_for_mentions_only_title" = "Nur für Erwähnungen benachrichtigen";
|
||||
"vc_conversation_settings_notify_for_mentions_only_explanation" = "Wenn aktiviert, wirst du nur für Nachrichten benachrichtigt, die dich erwähnen.";
|
||||
"view_conversation_title_notify_for_mentions_only" = "Nur für Erwähnungen benachrichtigen";
|
||||
"message_deleted" = "Die Nachricht wurde gelöscht";
|
||||
"delete_message_for_me" = "Nur für mich löschen";
|
||||
"delete_message_for_everyone" = "Für jeden löschen";
|
||||
"delete_message_for_me_and_recipient" = "Für mich und %@ löschen";
|
||||
"context_menu_reply" = "Antworten";
|
||||
"context_menu_save" = "Speichern";
|
||||
"context_menu_ban_user" = "Nutzer sperren";
|
||||
"context_menu_ban_and_delete_all" = "Sperren und alles löschen";
|
||||
"context_menu_ban_user_error_alert_message" = "Unable to ban user";
|
||||
"accessibility_expanding_attachments_button" = "Anhänge hinzufügen";
|
||||
"accessibility_gif_button" = "GIF";
|
||||
"accessibility_document_button" = "Dokument";
|
||||
"accessibility_library_button" = "Fotobibliothek";
|
||||
"accessibility_camera_button" = "Kamera";
|
||||
"accessibility_main_button_collapse" = "Optionen für Anhänge einklappen";
|
||||
"invalid_recovery_phrase" = "Ungültige Wiederherstellungsphrase";
|
||||
"invalid_recovery_phrase" = "Ungültige Wiederherstellungsphrase";
|
||||
"DISMISS_BUTTON_TEXT" = "Verwerfen";
|
||||
/* Button text which opens the settings app */
|
||||
"OPEN_SETTINGS_BUTTON" = "Settings";
|
||||
"APN_Message" = "You've got a new message";
|
||||
"OPEN_SETTINGS_BUTTON" = "Einstellungen";
|
||||
"call_outgoing" = "Du hast %@ angerufen";
|
||||
"call_incoming" = "%@ rief Sie an";
|
||||
"call_missed" = "Verpasster Anruf von %@";
|
||||
"call_rejected" = "Anruf abgelehnt";
|
||||
"call_cancelled" = "Abgebrochener Anruf";
|
||||
"call_timeout" = "Unbeantworteter Anruf";
|
||||
"voice_call" = "Sprachanruf";
|
||||
"video_call" = "Videoanruf";
|
||||
"APN_Message" = "Du hast eine neue Nachricht";
|
||||
"APN_Collapsed_Messages" = "Du hast %@ neue Nachrichten.";
|
||||
"system_mode_theme" = "System";
|
||||
"dark_mode_theme" = "Dark";
|
||||
"light_mode_theme" = "Light";
|
||||
"dark_mode_theme" = "Dunkel";
|
||||
"light_mode_theme" = "Hell";
|
||||
"PIN_BUTTON_TEXT" = "Anheften";
|
||||
"UNPIN_BUTTON_TEXT" = "Loslösen";
|
||||
"modal_call_missed_tips_title" = "Verpasster Anruf";
|
||||
"modal_call_missed_tips_explanation" = "Verpasster Anruf von '%@', da du die Berechtigung 'Anrufe und Videoanrufe' in den Datenschutzeinstellungen aktivieren musst.";
|
||||
"meida_saved" = "Medien gespeichert von %@.";
|
||||
"screenshot_taken" = "%@ hat ein Screenshot gemacht.";
|
||||
"SEARCH_SECTION_CONTACTS" = "Kontakte und Gruppen";
|
||||
"SEARCH_SECTION_MESSAGES" = "Nachrichten";
|
||||
"SEARCH_SECTION_RECENT" = "Zuletzt verwendete";
|
||||
"RECENT_SEARCH_LAST_MESSAGE_DATETIME" = "letzte Nachricht: %@";
|
||||
"MESSAGE_REQUESTS_TITLE" = "Message Requests";
|
||||
"MESSAGE_REQUESTS_EMPTY_TEXT" = "No pending message requests";
|
||||
"MESSAGE_REQUESTS_CLEAR_ALL" = "Clear All";
|
||||
"MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "Are you sure you want to clear all message requests?";
|
||||
"MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "Möchten Sie wirklich alle Nachrichten löschen?";
|
||||
"MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON" = "Clear";
|
||||
"MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "Are you sure you want to delete this message request?";
|
||||
"MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE" = "An error occurred when trying to accept this message request";
|
||||
|
@ -615,9 +645,12 @@
|
|||
"MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request";
|
||||
"TXT_HIDE_TITLE" = "Hide";
|
||||
"TXT_DELETE_ACCEPT" = "Accept";
|
||||
"ALERT_ERROR_TITLE" = "Fehler";
|
||||
"NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group";
|
||||
"NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message";
|
||||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"modal_call_permission_request_title" = "Call Permissions Required";
|
||||
"modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings.";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"ALERT_ERROR_TITLE" = "Fehler";
|
||||
|
|
|
@ -374,6 +374,12 @@
|
|||
"SETTINGS_LINK_PREVIEWS_FOOTER" = "Previews are supported for most urls.";
|
||||
/* Header for setting for enabling & disabling link previews. */
|
||||
"SETTINGS_LINK_PREVIEWS_HEADER" = "Link Previews";
|
||||
/* Setting for enabling & disabling voice & video calls. */
|
||||
"SETTINGS_CALLS" = "Voice and video calls";
|
||||
/* Footer for setting for enabling & disabling voice & video calls. */
|
||||
"SETTINGS_CALLS_FOOTER" = "Allow access to accept voice and video calls from other users.";
|
||||
/* Header for setting for enabling & disabling voice & video calls. */
|
||||
"SETTINGS_CALLS_HEADER" = "Calls";
|
||||
/* table section header */
|
||||
"SETTINGS_NOTIFICATION_CONTENT_TITLE" = "Notification Content";
|
||||
/* Label for the 'read receipts' setting. */
|
||||
|
@ -562,6 +568,8 @@
|
|||
"modal_link_previews_title" = "Enable Link Previews?";
|
||||
"modal_link_previews_explanation" = "Enabling link previews will show previews for URLs you send and receive. This can be useful, but Session will need to contact linked websites to generate previews. You can always disable link previews in Session's settings.";
|
||||
"modal_link_previews_button_title" = "Enable";
|
||||
"modal_call_title" = "Voice / video calls";
|
||||
"modal_call_explanation" = "The current implementation of voice / video calls will expose your IP address to the Oxen Foundation servers and the calling / called user.";
|
||||
"modal_share_logs_title" = "Share Logs";
|
||||
"modal_share_logs_explanation" = "Would you like to export your application logs to be able to share for troubleshooting?";
|
||||
"vc_share_title" = "Share to Session";
|
||||
|
@ -590,6 +598,7 @@
|
|||
"context_menu_save" = "Save";
|
||||
"context_menu_ban_user" = "Ban User";
|
||||
"context_menu_ban_and_delete_all" = "Ban and Delete All";
|
||||
"context_menu_ban_user_error_alert_message" = "Unable to ban user";
|
||||
"accessibility_expanding_attachments_button" = "Add attachments";
|
||||
"accessibility_gif_button" = "Gif";
|
||||
"accessibility_document_button" = "Document";
|
||||
|
@ -597,9 +606,18 @@
|
|||
"accessibility_camera_button" = "Camera";
|
||||
"accessibility_main_button_collapse" = "Collapse attachment options";
|
||||
"invalid_recovery_phrase" = "Invalid Recovery Phrase";
|
||||
"invalid_recovery_phrase" = "Invalid Recovery Phrase";
|
||||
"DISMISS_BUTTON_TEXT" = "Dismiss";
|
||||
/* Button text which opens the settings app */
|
||||
"OPEN_SETTINGS_BUTTON" = "Settings";
|
||||
"call_outgoing" = "You called %@";
|
||||
"call_incoming" = "%@ called you";
|
||||
"call_missed" = "Missed Call from %@";
|
||||
"call_rejected" = "Rejected Call";
|
||||
"call_cancelled" = "Cancelled Call";
|
||||
"call_timeout" = "Unanswered Call";
|
||||
"voice_call" = "Voice Call";
|
||||
"video_call" = "Video Call";
|
||||
"APN_Message" = "You've got a new message.";
|
||||
"APN_Collapsed_Messages" = "You've got %@ new messages.";
|
||||
"system_mode_theme" = "System";
|
||||
|
@ -607,7 +625,9 @@
|
|||
"light_mode_theme" = "Light";
|
||||
"PIN_BUTTON_TEXT" = "Pin";
|
||||
"UNPIN_BUTTON_TEXT" = "Unpin";
|
||||
"media_saved" = "Media saved by %@.";
|
||||
"modal_call_missed_tips_title" = "Call missed";
|
||||
"modal_call_missed_tips_explanation" = "Call missed from '%@' because you needed to enable the 'Voice and video calls' permission in the Privacy Settings.";
|
||||
"meida_saved" = "Media saved by %@.";
|
||||
"screenshot_taken" = "%@ took a screenshot.";
|
||||
"SEARCH_SECTION_CONTACTS" = "Contacts and Groups";
|
||||
"SEARCH_SECTION_MESSAGES" = "Messages";
|
||||
|
@ -625,9 +645,12 @@
|
|||
"MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request";
|
||||
"TXT_HIDE_TITLE" = "Hide";
|
||||
"TXT_DELETE_ACCEPT" = "Accept";
|
||||
"ALERT_ERROR_TITLE" = "Error";
|
||||
"NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group";
|
||||
"NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message";
|
||||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"modal_call_permission_request_title" = "Call Permissions Required";
|
||||
"modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings.";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"ALERT_ERROR_TITLE" = "Error";
|
||||
|
|
|
@ -371,9 +371,15 @@
|
|||
/* Setting for enabling & disabling link previews. */
|
||||
"SETTINGS_LINK_PREVIEWS" = "Enviar previsualizaciones";
|
||||
/* Footer for setting for enabling & disabling link previews. */
|
||||
"SETTINGS_LINK_PREVIEWS_FOOTER" = "Las previsualizaciones están disponibles para enlaces hacia Imgur, Instagram, Pinterest, Reddit y YouTube.";
|
||||
"SETTINGS_LINK_PREVIEWS_FOOTER" = "Las vistas previas están soportadas para la mayoría de URLs.";
|
||||
/* Header for setting for enabling & disabling link previews. */
|
||||
"SETTINGS_LINK_PREVIEWS_HEADER" = "Previsualizar enlaces";
|
||||
/* Setting for enabling & disabling voice & video calls. */
|
||||
"SETTINGS_CALLS" = "Llamadas de voz y video";
|
||||
/* Footer for setting for enabling & disabling voice & video calls. */
|
||||
"SETTINGS_CALLS_FOOTER" = "Permitir el acceso para aceptar llamadas de voz y vídeo de otros usuarios.";
|
||||
/* Header for setting for enabling & disabling voice & video calls. */
|
||||
"SETTINGS_CALLS_HEADER" = "Llamadas";
|
||||
/* table section header */
|
||||
"SETTINGS_NOTIFICATION_CONTENT_TITLE" = "Contenido de notificaciones";
|
||||
/* Label for the 'read receipts' setting. */
|
||||
|
@ -555,15 +561,17 @@
|
|||
"modal_open_url_title" = "¿Abrir URL?";
|
||||
"modal_open_url_explanation" = "¿Estás seguro de que quieres abrir %@?";
|
||||
"modal_open_url_button_title" = "Abrir";
|
||||
"modal_copy_url_button_title" = "Copy Link";
|
||||
"modal_copy_url_button_title" = "Copiar Enlace";
|
||||
"modal_blocked_title" = "¿Desbloquear a %@?";
|
||||
"modal_blocked_explanation" = "¿Estás seguro de que quieres desbloquear a %@?";
|
||||
"modal_blocked_button_title" = "Desbloquear";
|
||||
"modal_link_previews_title" = "¿Habilitar Previsualizaciones de Enlace?";
|
||||
"modal_link_previews_explanation" = "Activar vista previa de enlaces mostrará las vistas previas para las URL que envíe y reciba. Esto puede ser útil, pero Session tendrá que ponerse en contacto con los sitios web enlazados para generar vistas previas. Siempre puedes desactivar las vistas previas de enlaces en la configuración de Session.";
|
||||
"modal_link_previews_button_title" = "Activar";
|
||||
"modal_share_logs_title" = "Share Logs";
|
||||
"modal_share_logs_explanation" = "Would you like to export your application logs to be able to share for troubleshooting?";
|
||||
"modal_call_title" = "Llamadas de voz / video";
|
||||
"modal_call_explanation" = "La implementación actual de llamadas de voz/video expondrá tu dirección IP a los servidores de Oxen Foundation y al usuario llamado.";
|
||||
"modal_share_logs_title" = "Compartir registros";
|
||||
"modal_share_logs_explanation" = "¿Quiere exportar los registros de su aplicación para poder compartir para la solución de problemas?";
|
||||
"vc_share_title" = "Compartir en Session";
|
||||
"vc_share_loading_message" = "Preparando archivos adjuntos...";
|
||||
"vc_share_sending_message" = "Enviando...";
|
||||
|
@ -573,36 +581,58 @@
|
|||
"vc_share_link_previews_disabled_explanation" = "Enabling link previews will show previews for URLs you share. This can be useful, but Session will need to contact linked websites to generate previews.\n\nYou can enable link previews in Session's settings.";
|
||||
"view_open_group_invitation_description" = "Abrir invitación de grupo";
|
||||
"vc_conversation_settings_invite_button_title" = "Añadir Miembros";
|
||||
"vc_settings_faq_button_title" = "FAQ";
|
||||
"vc_settings_survey_button_title" = "Feedback / Survey";
|
||||
"vc_settings_support_button_title" = "Debug Log";
|
||||
"modal_send_seed_title" = "Warning";
|
||||
"modal_send_seed_explanation" = "This is your recovery phrase. If you send it to someone they'll have full access to your account.";
|
||||
"modal_send_seed_send_button_title" = "Send";
|
||||
"vc_conversation_settings_notify_for_mentions_only_title" = "Notify for Mentions Only";
|
||||
"vc_conversation_settings_notify_for_mentions_only_explanation" = "When enabled, you'll only be notified for messages mentioning you.";
|
||||
"view_conversation_title_notify_for_mentions_only" = "Notifying for Mentions Only";
|
||||
"message_deleted" = "This message has been deleted";
|
||||
"delete_message_for_me" = "Delete just for me";
|
||||
"delete_message_for_everyone" = "Delete for everyone";
|
||||
"delete_message_for_me_and_recipient" = "Delete for me and %@";
|
||||
"context_menu_reply" = "Reply";
|
||||
"context_menu_save" = "Save";
|
||||
"context_menu_ban_user" = "Ban User";
|
||||
"context_menu_ban_and_delete_all" = "Ban and Delete All";
|
||||
"accessibility_expanding_attachments_button" = "Add attachments";
|
||||
"accessibility_gif_button" = "Gif";
|
||||
"accessibility_document_button" = "Document";
|
||||
"accessibility_library_button" = "Photo library";
|
||||
"accessibility_camera_button" = "Camera";
|
||||
"vc_settings_faq_button_title" = "Preguntas Frecuentes";
|
||||
"vc_settings_survey_button_title" = "Encuesta de opinión";
|
||||
"vc_settings_support_button_title" = "Registro de depuración";
|
||||
"modal_send_seed_title" = "Aviso";
|
||||
"modal_send_seed_explanation" = "Esta es tu frase de recuperación. Si se la envias a alguien, tendrá acceso completo a tu cuenta.";
|
||||
"modal_send_seed_send_button_title" = "Enviar";
|
||||
"vc_conversation_settings_notify_for_mentions_only_title" = "Notificar Solo Menciones";
|
||||
"vc_conversation_settings_notify_for_mentions_only_explanation" = "Cuando está activado, sólo se te notificará de mensajes que te mencionen.";
|
||||
"view_conversation_title_notify_for_mentions_only" = "Notificando Solo Menciones";
|
||||
"message_deleted" = "El mensaje se ha eliminado";
|
||||
"delete_message_for_me" = "Eliminar solo para mí";
|
||||
"delete_message_for_everyone" = "Eliminar para todos";
|
||||
"delete_message_for_me_and_recipient" = "Eliminar para mí y para %@";
|
||||
"context_menu_reply" = "Responder";
|
||||
"context_menu_save" = "Guardar";
|
||||
"context_menu_ban_user" = "Banear Usuario";
|
||||
"context_menu_ban_and_delete_all" = "Banear y Eliminar Todo";
|
||||
"context_menu_ban_user_error_alert_message" = "Unable to ban user";
|
||||
"accessibility_expanding_attachments_button" = "Añadir adjuntos Añadir archivo adjunto";
|
||||
"accessibility_gif_button" = "GIF";
|
||||
"accessibility_document_button" = "Documento";
|
||||
"accessibility_library_button" = "Fototeca";
|
||||
"accessibility_camera_button" = "Cámara";
|
||||
"accessibility_main_button_collapse" = "Collapse attachment options";
|
||||
"DISMISS_BUTTON_TEXT" = "Dismiss";
|
||||
"invalid_recovery_phrase" = "Frase de Recuperación Incorrecta";
|
||||
"invalid_recovery_phrase" = "Invalid Recovery Phrase";
|
||||
"DISMISS_BUTTON_TEXT" = "Descartar";
|
||||
/* Button text which opens the settings app */
|
||||
"OPEN_SETTINGS_BUTTON" = "Settings";
|
||||
"APN_Message" = "You've got a new message";
|
||||
"system_mode_theme" = "System";
|
||||
"dark_mode_theme" = "Dark";
|
||||
"light_mode_theme" = "Light";
|
||||
"OPEN_SETTINGS_BUTTON" = "Ajustes";
|
||||
"call_outgoing" = "Has llamado a %@";
|
||||
"call_incoming" = "%@ called you";
|
||||
"call_missed" = "Llamada Perdida de %@";
|
||||
"call_rejected" = "Llamada Rechazada";
|
||||
"call_cancelled" = "Llamada Cancelada";
|
||||
"call_timeout" = "Llamada No Contestada";
|
||||
"voice_call" = "Llamada de Voz";
|
||||
"video_call" = "Videollamada";
|
||||
"APN_Message" = "Tienes un mensaje nuevo";
|
||||
"APN_Collapsed_Messages" = "Tienes un mensaje nuevo.";
|
||||
"system_mode_theme" = "Sistema";
|
||||
"dark_mode_theme" = "Oscuro";
|
||||
"light_mode_theme" = "Claro";
|
||||
"PIN_BUTTON_TEXT" = "Fijar";
|
||||
"UNPIN_BUTTON_TEXT" = "Dejar de fijar";
|
||||
"modal_call_missed_tips_title" = "Llamada perdida";
|
||||
"modal_call_missed_tips_explanation" = "Llamada perdida de '%@' porque necesitas habilitar el permiso de 'Llamadas de voz y video' en la configuración de privacidad.";
|
||||
"meida_saved" = "Media saved by %@.";
|
||||
"screenshot_taken" = "%@ tomó una captura de pantalla.";
|
||||
"SEARCH_SECTION_CONTACTS" = "Contacts and Groups";
|
||||
"SEARCH_SECTION_MESSAGES" = "Messages";
|
||||
"SEARCH_SECTION_RECENT" = "Recent";
|
||||
"RECENT_SEARCH_LAST_MESSAGE_DATETIME" = "last message: %@";
|
||||
"MESSAGE_REQUESTS_TITLE" = "Message Requests";
|
||||
"MESSAGE_REQUESTS_EMPTY_TEXT" = "No pending message requests";
|
||||
"MESSAGE_REQUESTS_CLEAR_ALL" = "Clear All";
|
||||
|
@ -615,9 +645,12 @@
|
|||
"MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request";
|
||||
"TXT_HIDE_TITLE" = "Hide";
|
||||
"TXT_DELETE_ACCEPT" = "Accept";
|
||||
"ALERT_ERROR_TITLE" = "Fallo";
|
||||
"NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group";
|
||||
"NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message";
|
||||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"modal_call_permission_request_title" = "Call Permissions Required";
|
||||
"modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings.";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"ALERT_ERROR_TITLE" = "Fallo";
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
/* placeholder text for an empty captioning field */
|
||||
"ATTACHMENT_APPROVAL_CAPTION_PLACEHOLDER" = "یک عنوان اضافه کنید...";
|
||||
/* Title for 'caption' mode of the attachment approval view. */
|
||||
"ATTACHMENT_APPROVAL_CAPTION_TITLE" = "Caption";
|
||||
"ATTACHMENT_APPROVAL_CAPTION_TITLE" = "عنوان";
|
||||
/* Format string for file extension label in call interstitial view */
|
||||
"ATTACHMENT_APPROVAL_FILE_EXTENSION_FORMAT" = "نوع فايل: %@";
|
||||
/* Format string for file size label in call interstitial view. Embeds: {{file size as 'N mb' or 'N kb'}}. */
|
||||
|
@ -27,7 +27,7 @@
|
|||
/* The title of the 'attachment error' alert. */
|
||||
"ATTACHMENT_ERROR_ALERT_TITLE" = "خطا در ارسال فایل ضمیمه";
|
||||
/* Attachment error message for image attachments which could not be converted to JPEG */
|
||||
"ATTACHMENT_ERROR_COULD_NOT_CONVERT_TO_JPEG" = "Unable to convert image.";
|
||||
"ATTACHMENT_ERROR_COULD_NOT_CONVERT_TO_JPEG" = "امکان تبدیل نگاره وجود ندارد.";
|
||||
/* Attachment error message for video attachments which could not be converted to MP4 */
|
||||
"ATTACHMENT_ERROR_COULD_NOT_CONVERT_TO_MP4" = "Unable to process video.";
|
||||
/* Attachment error message for image attachments which cannot be parsed */
|
||||
|
@ -99,9 +99,9 @@
|
|||
/* Button label for the 'unblock' button */
|
||||
"BLOCK_LIST_UNBLOCK_BUTTON" = "رفع مسدودی";
|
||||
/* The message format of the 'conversation blocked' alert. Embeds the {{conversation title}}. */
|
||||
"BLOCK_LIST_VIEW_BLOCKED_ALERT_MESSAGE_FORMAT" = "%@ has been blocked.";
|
||||
"BLOCK_LIST_VIEW_BLOCKED_ALERT_MESSAGE_FORMAT" = "%@ مسدود شد.";
|
||||
/* The title of the 'user blocked' alert. */
|
||||
"BLOCK_LIST_VIEW_BLOCKED_ALERT_TITLE" = "کاربر مسدود شده است";
|
||||
"BLOCK_LIST_VIEW_BLOCKED_ALERT_TITLE" = "کاربر مسدود شد";
|
||||
/* Alert title after unblocking a group or 1:1 chat. Embeds the {{conversation title}}. */
|
||||
"BLOCK_LIST_VIEW_UNBLOCKED_ALERT_TITLE_FORMAT" = "%@ از حالت مسدودی خارج شد.";
|
||||
/* Alert body after unblocking a group. */
|
||||
|
@ -307,25 +307,25 @@
|
|||
/* label for system photo collections which have no name. */
|
||||
"PHOTO_PICKER_UNNAMED_COLLECTION" = "آلبوم بی نام";
|
||||
/* Notification action button title */
|
||||
"PUSH_MANAGER_MARKREAD" = "Mark as Read";
|
||||
"PUSH_MANAGER_MARKREAD" = "علامتگذاری به عنوان خواندهشده";
|
||||
/* Notification action button title */
|
||||
"PUSH_MANAGER_REPLY" = "Reply";
|
||||
"PUSH_MANAGER_REPLY" = "پاسخ";
|
||||
/* alert body during registration */
|
||||
"REGISTRATION_ERROR_BLANK_VERIFICATION_CODE" = "لطفا برای فعال شدن حساب خود، کدی که به شما فرستادهایم را تائید کنید.";
|
||||
/* Indicates a delay of zero seconds, and that 'screen lock activity' will timeout immediately. */
|
||||
"SCREEN_LOCK_ACTIVITY_TIMEOUT_NONE" = "لحظه";
|
||||
/* Description of how and why Session iOS uses Touch ID/Face ID/Phone Passcode to unlock 'screen lock'. */
|
||||
"SCREEN_LOCK_REASON_UNLOCK_SCREEN_LOCK" = "Authenticate to open Session.";
|
||||
"SCREEN_LOCK_REASON_UNLOCK_SCREEN_LOCK" = "برای باز کردن Session هویت خود را احراز کنید.";
|
||||
/* Title for alert indicating that screen lock could not be unlocked. */
|
||||
"SCREEN_LOCK_UNLOCK_FAILED" = "احراز هویت ناموفق بود";
|
||||
/* alert title when user attempts to leave the send media flow when they have an in-progress album */
|
||||
"SEND_MEDIA_ABANDON_TITLE" = "Discard Media?";
|
||||
"SEND_MEDIA_ABANDON_TITLE" = "رسانه ها کنار گذاشته شوند؟";
|
||||
/* alert action, confirming the user wants to exit the media flow and abandon any photos they've taken */
|
||||
"SEND_MEDIA_CONFIRM_ABANDON_ALBUM" = "Discard Media";
|
||||
"SEND_MEDIA_CONFIRM_ABANDON_ALBUM" = "رسانه ها کنار گذاشته شوند";
|
||||
/* alert action when the user decides not to cancel the media flow after all. */
|
||||
"SEND_MEDIA_RETURN_TO_CAMERA" = "بازگشت به دوربین";
|
||||
/* alert action when the user decides not to cancel the media flow after all. */
|
||||
"SEND_MEDIA_RETURN_TO_MEDIA_LIBRARY" = "Return to Media Library";
|
||||
"SEND_MEDIA_RETURN_TO_MEDIA_LIBRARY" = "برگشت به کتابخانه رسانه";
|
||||
/* Format string for the default 'Note' sound. Embeds the system {{sound name}}. */
|
||||
"SETTINGS_AUDIO_DEFAULT_TONE_LABEL_FORMAT" = "%@ (پیشفرض)";
|
||||
/* Label for the backup view in app settings. */
|
||||
|
@ -341,7 +341,7 @@
|
|||
/* Indicates that the last backup restore failed. */
|
||||
"SETTINGS_BACKUP_IMPORT_STATUS_FAILED" = "ناتوانی در بازگرداندن بکاپ";
|
||||
/* Indicates that app is not restoring up. */
|
||||
"SETTINGS_BACKUP_IMPORT_STATUS_IDLE" = "Backup Restore Idle";
|
||||
"SETTINGS_BACKUP_IMPORT_STATUS_IDLE" = "بازیابی پشتیبان غیرفعال است";
|
||||
/* Indicates that app is restoring up. */
|
||||
"SETTINGS_BACKUP_IMPORT_STATUS_IN_PROGRESS" = "در حال بازگرداندن بکاپ";
|
||||
/* Indicates that the last backup restore succeeded. */
|
||||
|
@ -371,9 +371,15 @@
|
|||
/* Setting for enabling & disabling link previews. */
|
||||
"SETTINGS_LINK_PREVIEWS" = "ارسال پیش نمایش لینک";
|
||||
/* Footer for setting for enabling & disabling link previews. */
|
||||
"SETTINGS_LINK_PREVIEWS_FOOTER" = "پیش نمایش ها برای Imgur، Instagram، Pinterest، Reddit، و لینک های Youtube پشتیبانی می شود.";
|
||||
"SETTINGS_LINK_PREVIEWS_FOOTER" = "پیشنمایشها برای اکثر آدرسهای اینترنتی پشتیبانی میشوند.";
|
||||
/* Header for setting for enabling & disabling link previews. */
|
||||
"SETTINGS_LINK_PREVIEWS_HEADER" = "پیش نمایش های لینک";
|
||||
/* Setting for enabling & disabling voice & video calls. */
|
||||
"SETTINGS_CALLS" = "تماس های صوتی و تصویری";
|
||||
/* Footer for setting for enabling & disabling voice & video calls. */
|
||||
"SETTINGS_CALLS_FOOTER" = "اجازه دسترسی برای پذیرش تماس های صوتی و تصویری از سایر کاربران را بدهید.";
|
||||
/* Header for setting for enabling & disabling voice & video calls. */
|
||||
"SETTINGS_CALLS_HEADER" = "تماس ها";
|
||||
/* table section header */
|
||||
"SETTINGS_NOTIFICATION_CONTENT_TITLE" = "محتوای نوتیفیکیشن";
|
||||
/* Label for the 'read receipts' setting. */
|
||||
|
@ -518,30 +524,30 @@
|
|||
"modal_seed_explanation" = "این عبارت بازیابی شماست. با استفاده از آن میتوانید شناسهی Session خود را به دستگاه جدید بازیابی یا انتقال دهید.";
|
||||
"modal_clear_all_data_title" = "پاک کردن همه دادهها";
|
||||
"modal_clear_all_data_explanation" = "این به طور دائم پیامها، جلسات و مخاطبین شما را حذف میکند.";
|
||||
"modal_clear_all_data_explanation_2" = "Would you like to clear only this device, or delete your entire account?";
|
||||
"dialog_clear_all_data_deletion_failed_1" = "Data not deleted by 1 Service Node. Service Node ID: %@.";
|
||||
"dialog_clear_all_data_deletion_failed_2" = "Data not deleted by %@ Service Nodes. Service Node IDs: %@.";
|
||||
"modal_clear_all_data_device_only_button_title" = "Device Only";
|
||||
"modal_clear_all_data_entire_account_button_title" = "Entire Account";
|
||||
"modal_clear_all_data_explanation_2" = "آیا فقط میخواهید این دستگاه را پاک کنید یا میخواهید این اکانت را پاک کنید؟";
|
||||
"dialog_clear_all_data_deletion_failed_1" = "داده ها توسط ۱ گره سرویس حذف نشده است. شناسه گره سرویس: %@.";
|
||||
"dialog_clear_all_data_deletion_failed_2" = "داده ها توسط گره سرویس %@ حذف نشدند. شناسه گره سرویس: %@.";
|
||||
"modal_clear_all_data_device_only_button_title" = "فقط دستگاه";
|
||||
"modal_clear_all_data_entire_account_button_title" = "تمام حساب";
|
||||
"vc_qr_code_title" = "کد QR";
|
||||
"vc_qr_code_view_my_qr_code_tab_title" = "مشاهده کد QR من";
|
||||
"vc_qr_code_view_scan_qr_code_tab_title" = "اسکن کد QR";
|
||||
"vc_qr_code_view_scan_qr_code_explanation" = "برای شروع مکالمه با دیگران، کد QR شخصی را اسکن کنید";
|
||||
"vc_view_my_qr_code_explanation" = "این کد QR شماست. سایر کاربران میتوانند برای شروع Session با شما آن را اسکن کنند.";
|
||||
// MARK: - Not Yet Translated
|
||||
"fast_mode_explanation" = "You’ll be notified of new messages reliably and immediately using Apple’s notification servers.";
|
||||
"fast_mode" = "Fast Mode";
|
||||
"slow_mode_explanation" = "Session will occasionally check for new messages in the background.";
|
||||
"slow_mode" = "Slow Mode";
|
||||
"vc_pn_mode_title" = "Message Notifications";
|
||||
"fast_mode_explanation" = "با استفاده از سرورهای اطلاع رسانی اپل، شما به صورت سریع و مطمئن از پیامهای جدید مطلع میشوید.";
|
||||
"fast_mode" = "حالت سریع";
|
||||
"slow_mode_explanation" = "Session هرازگاهی در پس زمینه وجود پیامهای جدید را بررسی میکند.";
|
||||
"slow_mode" = "حالت آهسته";
|
||||
"vc_pn_mode_title" = "اعلانهای پیام";
|
||||
"vc_notification_settings_notification_mode_title" = "استفاده از حالت سریع";
|
||||
"vc_link_device_recovery_phrase_tab_title" = "عبارت بازیابی";
|
||||
"vc_link_device_scan_qr_code_explanation" = "Navigate to Settings → Recovery Phrase on your other device to show your QR code.";
|
||||
"vc_link_device_scan_qr_code_explanation" = "برای دیدن کد QR خود، در دستگاه دیگرتان به «تنظیمات ← عبارت بازیابی» مراجعه کنید.";
|
||||
"vc_enter_recovery_phrase_title" = "عبارت بازیابی";
|
||||
"vc_enter_recovery_phrase_explanation" = "To link your device, enter the recovery phrase that was given to you when you signed up.";
|
||||
"vc_enter_public_key_text_field_hint" = "Enter Session ID or ONS name";
|
||||
"vc_enter_recovery_phrase_explanation" = "برای وصل کردن دستگاهتان، عبارت بازیابی که در زمان ثبت نام به شما داده شده بود را وارد کنید.";
|
||||
"vc_enter_public_key_text_field_hint" = "شناسه Session یا اسم سرویس نام Oxen را وارد کنید";
|
||||
"vc_home_title" = "پیام ها";
|
||||
"admin_group_leave_warning" = "Because you are the creator of this group it will be deleted for everyone. This cannot be undone.";
|
||||
"admin_group_leave_warning" = "به دلیل اینکه شما مدیر گروه هستید، این گروه برای همه اعضا ی گروه پاک میشود. این قابل بازگشت نیست.";
|
||||
"vc_join_open_group_suggestions_title" = "یا به یکی از اینها بپیوندید...";
|
||||
"vc_settings_invite_a_friend_button_title" = "دعوت از یک دوست";
|
||||
"vc_settings_help_us_translate_button_title" = "به ما کمک کنید که سشن را ترجمه کنیم";
|
||||
|
@ -549,19 +555,21 @@
|
|||
"vc_conversation_settings_copy_session_id_button_title" = "کپی کردن شناسهی Session شما";
|
||||
"vc_conversation_input_prompt" = "پیام";
|
||||
"vc_conversation_voice_message_cancel_message" = "برای کنسل کردن به کناره بکشید";
|
||||
"modal_download_attachment_title" = "Trust %@?";
|
||||
"modal_download_attachment_explanation" = "Are you sure you want to download media sent by %@?";
|
||||
"modal_download_attachment_title" = "آیا به %@ اعتماد میکنید؟";
|
||||
"modal_download_attachment_explanation" = "آیا مطمئن هستید که میخواهید رسانه ارسال شده توسط %@ را دانلود کنید؟";
|
||||
"modal_download_button_title" = "دریافت";
|
||||
"modal_open_url_title" = "URL باز شود؟";
|
||||
"modal_open_url_explanation" = "آیا مطمئن هستید که میخواهید %@ را باز کنید؟";
|
||||
"modal_open_url_button_title" = "باز کن";
|
||||
"modal_copy_url_button_title" = "Copy Link";
|
||||
"modal_blocked_title" = "Unblock %@?";
|
||||
"modal_blocked_explanation" = "Are you sure you want to unblock %@?";
|
||||
"modal_copy_url_button_title" = "کپی کردن لینک";
|
||||
"modal_blocked_title" = "%@ از حالت مسدود خارج شود؟";
|
||||
"modal_blocked_explanation" = "آیا مطمئن هستید که میخواهید %@ را از حالت مسدود خارج کنید؟";
|
||||
"modal_blocked_button_title" = "رفع مسدودی";
|
||||
"modal_link_previews_title" = "فعالسازی پیشنمایش لینک؟";
|
||||
"modal_link_previews_explanation" = "Enabling link previews will show previews for URLs you send and receive. This can be useful, but Session will need to contact linked websites to generate previews. You can always disable link previews in Session's settings.";
|
||||
"modal_link_previews_explanation" = "با فعال کردن این گزینه، پیشنمایش لینکها در پیامهای ارسالی و دریافتی دیده میشود. این گزینه میتواند مفید باشد اما Session باید با سایت ارتباط برقرار کند تا پیشنمایش را نشان دهد. شما میتوانید همیشه در تنظیمات این پیشنمایش را غیرفعال کنید.";
|
||||
"modal_link_previews_button_title" = "فعال سازی";
|
||||
"modal_call_title" = "تماس های صوتی / تصویری";
|
||||
"modal_call_explanation" = "The current implementation of voice / video calls will expose your IP address to the Oxen Foundation servers and the calling / called user.";
|
||||
"modal_share_logs_title" = "Share Logs";
|
||||
"modal_share_logs_explanation" = "Would you like to export your application logs to be able to share for troubleshooting?";
|
||||
"vc_share_title" = "اشتراک گذاری با Session";
|
||||
|
@ -572,37 +580,59 @@
|
|||
"vc_share_link_previews_disabled_title" = "Link Previews Disabled";
|
||||
"vc_share_link_previews_disabled_explanation" = "Enabling link previews will show previews for URLs you share. This can be useful, but Session will need to contact linked websites to generate previews.\n\nYou can enable link previews in Session's settings.";
|
||||
"view_open_group_invitation_description" = "Open group invitation";
|
||||
"vc_conversation_settings_invite_button_title" = "Add Members";
|
||||
"vc_settings_faq_button_title" = "FAQ";
|
||||
"vc_conversation_settings_invite_button_title" = "اضافهکردن اعضا";
|
||||
"vc_settings_faq_button_title" = "سوالات متداول";
|
||||
"vc_settings_survey_button_title" = "Feedback / Survey";
|
||||
"vc_settings_support_button_title" = "Debug Log";
|
||||
"modal_send_seed_title" = "Warning";
|
||||
"modal_send_seed_explanation" = "This is your recovery phrase. If you send it to someone they'll have full access to your account.";
|
||||
"modal_send_seed_send_button_title" = "Send";
|
||||
"modal_send_seed_title" = "هشدار";
|
||||
"modal_send_seed_explanation" = "این عبارت بازیابی شماست. اگر آن را برای شخصی ارسال کنید ، دسترسی کامل به حساب شما خواهد داشت.";
|
||||
"modal_send_seed_send_button_title" = "ارسال";
|
||||
"vc_conversation_settings_notify_for_mentions_only_title" = "Notify for Mentions Only";
|
||||
"vc_conversation_settings_notify_for_mentions_only_explanation" = "When enabled, you'll only be notified for messages mentioning you.";
|
||||
"view_conversation_title_notify_for_mentions_only" = "Notifying for Mentions Only";
|
||||
"message_deleted" = "This message has been deleted";
|
||||
"delete_message_for_me" = "Delete just for me";
|
||||
"delete_message_for_everyone" = "Delete for everyone";
|
||||
"delete_message_for_me_and_recipient" = "Delete for me and %@";
|
||||
"context_menu_reply" = "Reply";
|
||||
"context_menu_save" = "Save";
|
||||
"context_menu_ban_user" = "Ban User";
|
||||
"message_deleted" = "این پیام حذف شده است";
|
||||
"delete_message_for_me" = "حذف برای من";
|
||||
"delete_message_for_everyone" = "حذف برای همه";
|
||||
"delete_message_for_me_and_recipient" = "حذف برای من و %@";
|
||||
"context_menu_reply" = "پاسخ";
|
||||
"context_menu_save" = "ذخیره";
|
||||
"context_menu_ban_user" = "مسدود کردن کاربر";
|
||||
"context_menu_ban_and_delete_all" = "Ban and Delete All";
|
||||
"context_menu_ban_user_error_alert_message" = "Unable to ban user";
|
||||
"accessibility_expanding_attachments_button" = "Add attachments";
|
||||
"accessibility_gif_button" = "Gif";
|
||||
"accessibility_document_button" = "Document";
|
||||
"accessibility_library_button" = "Photo library";
|
||||
"accessibility_camera_button" = "Camera";
|
||||
"accessibility_main_button_collapse" = "Collapse attachment options";
|
||||
"invalid_recovery_phrase" = "Invalid Recovery Phrase";
|
||||
"invalid_recovery_phrase" = "Invalid Recovery Phrase";
|
||||
"DISMISS_BUTTON_TEXT" = "Dismiss";
|
||||
/* Button text which opens the settings app */
|
||||
"OPEN_SETTINGS_BUTTON" = "Settings";
|
||||
"APN_Message" = "You've got a new message";
|
||||
"call_outgoing" = "You called %@";
|
||||
"call_incoming" = "%@ called you";
|
||||
"call_missed" = "Missed Call from %@";
|
||||
"call_rejected" = "Rejected Call";
|
||||
"call_cancelled" = "Cancelled Call";
|
||||
"call_timeout" = "Unanswered Call";
|
||||
"voice_call" = "Voice Call";
|
||||
"video_call" = "Video Call";
|
||||
"APN_Message" = "You've got a new message.";
|
||||
"APN_Collapsed_Messages" = "You've got %@ new messages.";
|
||||
"system_mode_theme" = "System";
|
||||
"dark_mode_theme" = "Dark";
|
||||
"light_mode_theme" = "Light";
|
||||
"PIN_BUTTON_TEXT" = "Pin";
|
||||
"UNPIN_BUTTON_TEXT" = "Unpin";
|
||||
"modal_call_missed_tips_title" = "Call missed";
|
||||
"modal_call_missed_tips_explanation" = "Call missed from '%@' because you needed to enable the 'Voice and video calls' permission in the Privacy Settings.";
|
||||
"meida_saved" = "Media saved by %@.";
|
||||
"screenshot_taken" = "%@ took a screenshot.";
|
||||
"SEARCH_SECTION_CONTACTS" = "Contacts and Groups";
|
||||
"SEARCH_SECTION_MESSAGES" = "Messages";
|
||||
"SEARCH_SECTION_RECENT" = "Recent";
|
||||
"RECENT_SEARCH_LAST_MESSAGE_DATETIME" = "last message: %@";
|
||||
"MESSAGE_REQUESTS_TITLE" = "Message Requests";
|
||||
"MESSAGE_REQUESTS_EMPTY_TEXT" = "No pending message requests";
|
||||
"MESSAGE_REQUESTS_CLEAR_ALL" = "Clear All";
|
||||
|
@ -615,9 +645,12 @@
|
|||
"MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request";
|
||||
"TXT_HIDE_TITLE" = "Hide";
|
||||
"TXT_DELETE_ACCEPT" = "Accept";
|
||||
"ALERT_ERROR_TITLE" = "خطاء";
|
||||
"NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group";
|
||||
"NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message";
|
||||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"modal_call_permission_request_title" = "Call Permissions Required";
|
||||
"modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings.";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"ALERT_ERROR_TITLE" = "خطاء";
|
||||
|
|
|
@ -1,25 +1,25 @@
|
|||
/* Label for the 'dismiss' button in the 'new app version available' alert. */
|
||||
"APP_UPDATE_NAG_ALERT_DISMISS_BUTTON" = "Ei nyt";
|
||||
"APP_UPDATE_NAG_ALERT_DISMISS_BUTTON" = "Myöhemmin";
|
||||
/* Message format for the 'new app version available' alert. Embeds: {{The latest app version number}} */
|
||||
"APP_UPDATE_NAG_ALERT_MESSAGE_FORMAT" = "Versio %@ on nyt ladattavissa App Storesta.";
|
||||
"APP_UPDATE_NAG_ALERT_MESSAGE_FORMAT" = "Uusin versio on nyt saatavilla App Storesta.";
|
||||
/* Title for the 'new app version available' alert. */
|
||||
"APP_UPDATE_NAG_ALERT_TITLE" = "Uusi versio Sessionista on nyt saatavilla";
|
||||
"APP_UPDATE_NAG_ALERT_TITLE" = "Uusi versio Sessionista on saatavilla";
|
||||
/* Label for the 'update' button in the 'new app version available' alert. */
|
||||
"APP_UPDATE_NAG_ALERT_UPDATE_BUTTON" = "Päivitys";
|
||||
"APP_UPDATE_NAG_ALERT_UPDATE_BUTTON" = "Päivitä";
|
||||
/* No comment provided by engineer. */
|
||||
"ATTACHMENT" = "Liite";
|
||||
"ATTACHMENT" = "Lisää liite";
|
||||
/* One-line label indicating the user can add no more text to the attachment caption. */
|
||||
"ATTACHMENT_APPROVAL_CAPTION_LENGTH_LIMIT_REACHED" = "Otsikon maksimimerkkimäärä saavutettu.";
|
||||
"ATTACHMENT_APPROVAL_CAPTION_LENGTH_LIMIT_REACHED" = "Kuvatekstin enimmäispituus on saavutettu.";
|
||||
/* placeholder text for an empty captioning field */
|
||||
"ATTACHMENT_APPROVAL_CAPTION_PLACEHOLDER" = "Lisää otsikko…";
|
||||
"ATTACHMENT_APPROVAL_CAPTION_PLACEHOLDER" = "Lisää kuvateksti…";
|
||||
/* Title for 'caption' mode of the attachment approval view. */
|
||||
"ATTACHMENT_APPROVAL_CAPTION_TITLE" = "Otsikko";
|
||||
/* Format string for file extension label in call interstitial view */
|
||||
"ATTACHMENT_APPROVAL_FILE_EXTENSION_FORMAT" = "Tiedostotyyppi: %@";
|
||||
"ATTACHMENT_APPROVAL_FILE_EXTENSION_FORMAT" = "Tiedoston formaatti: %@";
|
||||
/* Format string for file size label in call interstitial view. Embeds: {{file size as 'N mb' or 'N kb'}}. */
|
||||
"ATTACHMENT_APPROVAL_FILE_SIZE_FORMAT" = "Koko: %@";
|
||||
"ATTACHMENT_APPROVAL_FILE_SIZE_FORMAT" = "Rajoitettu koko: %@";
|
||||
/* One-line label indicating the user can add no more text to the media message field. */
|
||||
"ATTACHMENT_APPROVAL_MESSAGE_LENGTH_LIMIT_REACHED" = "Viestin maksimimerkkimäärä saavutettu";
|
||||
"ATTACHMENT_APPROVAL_MESSAGE_LENGTH_LIMIT_REACHED" = "Viestin suurin sallittu pituus saavutettu";
|
||||
/* Label for 'send' button in the 'attachment approval' dialog. */
|
||||
"ATTACHMENT_APPROVAL_SEND_BUTTON" = "Lähetä";
|
||||
/* Generic filename for an attachment with no known name */
|
||||
|
@ -33,37 +33,37 @@
|
|||
/* Attachment error message for image attachments which cannot be parsed */
|
||||
"ATTACHMENT_ERROR_COULD_NOT_PARSE_IMAGE" = "Kuvaa ei voitu jäsentää.";
|
||||
/* Attachment error message for image attachments in which metadata could not be removed */
|
||||
"ATTACHMENT_ERROR_COULD_NOT_REMOVE_METADATA" = "Metatietojen poistaminen kuvasta ei onnistunut.";
|
||||
"ATTACHMENT_ERROR_COULD_NOT_REMOVE_METADATA" = "Metadatan poistaminen kuvasta epäonnistui.";
|
||||
/* Attachment error message for image attachments which could not be resized */
|
||||
"ATTACHMENT_ERROR_COULD_NOT_RESIZE_IMAGE" = "Kuvan kokoa ei voitu muuttaa.";
|
||||
/* Attachment error message for attachments whose data exceed file size limits */
|
||||
"ATTACHMENT_ERROR_FILE_SIZE_TOO_LARGE" = "Liitetiedosto on liian suuri.";
|
||||
"ATTACHMENT_ERROR_FILE_SIZE_TOO_LARGE" = "Liite on liian iso.";
|
||||
/* Attachment error message for attachments with invalid data */
|
||||
"ATTACHMENT_ERROR_INVALID_DATA" = "Liitetiedoston sisältö on virheellinen.";
|
||||
"ATTACHMENT_ERROR_INVALID_DATA" = "Liite sisältää virheellistä sisältöä.";
|
||||
/* Attachment error message for attachments with an invalid file format */
|
||||
"ATTACHMENT_ERROR_INVALID_FILE_FORMAT" = "Liitetiedoston muoto on virheellinen.";
|
||||
"ATTACHMENT_ERROR_INVALID_FILE_FORMAT" = "Liitteellä on virheellinen tiedostotyyppi.";
|
||||
/* Attachment error message for attachments without any data */
|
||||
"ATTACHMENT_ERROR_MISSING_DATA" = "Liitetiedosto on tyhjä.";
|
||||
"ATTACHMENT_ERROR_MISSING_DATA" = "Liite on tyhjä.";
|
||||
/* Alert title when picking a document fails for an unknown reason */
|
||||
"ATTACHMENT_PICKER_DOCUMENTS_FAILED_ALERT_TITLE" = "Dokumentin valinta epäonnistui.";
|
||||
/* Alert body when picking a document fails because user picked a directory/bundle */
|
||||
"ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_BODY" = "Ole hyvä ja kokeile kompressoidun arkiston luomista tästä tiedostosta ja lähettää se tiedoston sijaan.";
|
||||
"ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_BODY" = "Ole hyvä ja luo tiivistetty arkisto tästä tiedostosta ja kokeile lähettämistä uudelleen.";
|
||||
/* Alert title when picking a document fails because user picked a directory/bundle */
|
||||
"ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_TITLE" = "Tiedostotyyppiä ei tueta";
|
||||
/* Short text label for a voice message attachment, used for thread preview and on the lock screen */
|
||||
"ATTACHMENT_TYPE_VOICE_MESSAGE" = "Ääniviesti";
|
||||
/* Error indicating the backup export could not export the user's data. */
|
||||
"BACKUP_EXPORT_ERROR_COULD_NOT_EXPORT" = "Varmuuskopiodataa ei pystytty viemään.";
|
||||
"BACKUP_EXPORT_ERROR_COULD_NOT_EXPORT" = "Varmuuskopiodataa ei pystytty siirtämään.";
|
||||
/* Error indicating that the app received an invalid response from CloudKit. */
|
||||
"BACKUP_EXPORT_ERROR_INVALID_CLOUDKIT_RESPONSE" = "Virheellinen vastaus palvelulta";
|
||||
"BACKUP_EXPORT_ERROR_INVALID_CLOUDKIT_RESPONSE" = "Virheellinen palvelun vastaus";
|
||||
/* Indicates that the cloud is being cleaned up. */
|
||||
"BACKUP_EXPORT_PHASE_CLEAN_UP" = "Puhdistetaan varmuuskopiota";
|
||||
"BACKUP_EXPORT_PHASE_CLEAN_UP" = "Puhdistetaan varmuuskopioita";
|
||||
/* Indicates that the backup export is being configured. */
|
||||
"BACKUP_EXPORT_PHASE_CONFIGURATION" = "Alustetaan varmuuskopiota";
|
||||
/* Indicates that the database data is being exported. */
|
||||
"BACKUP_EXPORT_PHASE_DATABASE_EXPORT" = "Viedään dataa";
|
||||
"BACKUP_EXPORT_PHASE_DATABASE_EXPORT" = "Siirretään dataa";
|
||||
/* Indicates that the backup export data is being exported. */
|
||||
"BACKUP_EXPORT_PHASE_EXPORT" = "Varmuuskopiota viedään";
|
||||
"BACKUP_EXPORT_PHASE_EXPORT" = "Siirretään varmuuskopiota";
|
||||
/* Indicates that the backup export data is being uploaded. */
|
||||
"BACKUP_EXPORT_PHASE_UPLOAD" = "Ladataan varmuuskopiota";
|
||||
/* Error indicating the backup import could not import the user's data. */
|
||||
|
@ -73,7 +73,7 @@
|
|||
/* Indicates that the backup import data is being downloaded. */
|
||||
"BACKUP_IMPORT_PHASE_DOWNLOAD" = "Ladataan varmuuskopiota";
|
||||
/* Indicates that the backup import data is being finalized. */
|
||||
"BACKUP_IMPORT_PHASE_FINALIZING" = "Viimeistellään Varmuuskopiota";
|
||||
"BACKUP_IMPORT_PHASE_FINALIZING" = "Viimeistellään varmuuskopiota";
|
||||
/* Indicates that the backup import data is being imported. */
|
||||
"BACKUP_IMPORT_PHASE_IMPORT" = "Tuodaan varmuuskopiota.";
|
||||
/* Indicates that the backup database is being restored. */
|
||||
|
@ -94,8 +94,8 @@
|
|||
"BLOCK_LIST_BLOCK_BUTTON" = "Estä";
|
||||
/* A format for the 'block user' action sheet title. Embeds {{the blocked user's name or phone number}}. */
|
||||
"BLOCK_LIST_BLOCK_USER_TITLE_FORMAT" = "Estä %@?";
|
||||
/* A format for the 'unblock conversation' action sheet title. Embeds the {{conversation title}}. */
|
||||
"BLOCK_LIST_UNBLOCK_TITLE_FORMAT" = "Poista esto yhteystiedolta %@?";
|
||||
/* A format for the 'unblock user' action sheet title. Embeds {{the unblocked user's name or phone number}}. */
|
||||
"BLOCK_LIST_UNBLOCK_TITLE_FORMAT" = "Poista henkilön %@ esto?";
|
||||
/* Button label for the 'unblock' button */
|
||||
"BLOCK_LIST_UNBLOCK_BUTTON" = "Poista esto";
|
||||
/* The message format of the 'conversation blocked' alert. Embeds the {{conversation title}}. */
|
||||
|
@ -103,7 +103,7 @@
|
|||
/* The title of the 'user blocked' alert. */
|
||||
"BLOCK_LIST_VIEW_BLOCKED_ALERT_TITLE" = "Käyttäjä estetty";
|
||||
/* Alert title after unblocking a group or 1:1 chat. Embeds the {{conversation title}}. */
|
||||
"BLOCK_LIST_VIEW_UNBLOCKED_ALERT_TITLE_FORMAT" = "%@ esto on poistettu.";
|
||||
"BLOCK_LIST_VIEW_UNBLOCKED_ALERT_TITLE_FORMAT" = "Henkilön %@ esto on poistettu.";
|
||||
/* Alert body after unblocking a group. */
|
||||
"BLOCK_LIST_VIEW_UNBLOCKED_GROUP_ALERT_BODY" = "Ryhmän jäsenet voivat nyt lisätä sinut takaisin ryhmään.";
|
||||
/* An explanation of the consequences of blocking another user. */
|
||||
|
@ -123,7 +123,7 @@
|
|||
/* Error indicating that the app was prevented from accessing the user's iCloud account. */
|
||||
"CLOUDKIT_STATUS_RESTRICTED" = "Sessionilta estettiin pääsy sinun iCloud-varmuuskopioihin. Myönnä Sessionille lupa iCloud-tilillesi järjestelmäasetuksissa varmuuskopioidaksesi Session-datan.";
|
||||
/* Alert body */
|
||||
"CONFIRM_LEAVE_GROUP_DESCRIPTION" = "Et voi enää lähettää tai vastaanottaa viestejä tässä ryhmässä.";
|
||||
"CONFIRM_LEAVE_GROUP_DESCRIPTION" = "Et pysty enään lähettämään tai vastaanottamaan viestejä tässä ryhmässä.";
|
||||
/* Alert title */
|
||||
"CONFIRM_LEAVE_GROUP_TITLE" = "Haluatko varmasti poistua ryhmästä?";
|
||||
/* Message for the 'conversation delete confirmation' alert. */
|
||||
|
@ -131,43 +131,43 @@
|
|||
/* Title for the 'conversation delete confirmation' alert. */
|
||||
"CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Poistetaanko keskustelu?";
|
||||
/* keyboard toolbar label when no messages match the search string */
|
||||
"CONVERSATION_SEARCH_NO_RESULTS" = "Ei osumia";
|
||||
"CONVERSATION_SEARCH_NO_RESULTS" = "Ei tuloksia";
|
||||
/* keyboard toolbar label when exactly 1 message matches the search string */
|
||||
"CONVERSATION_SEARCH_ONE_RESULT" = "1 osuma";
|
||||
/* keyboard toolbar label when more than 1 message matches the search string. Embeds {{number/position of the 'currently viewed' result}} and the {{total number of results}} */
|
||||
"CONVERSATION_SEARCH_RESULTS_FORMAT" = "%d./%d. hakutuloksesta";
|
||||
"CONVERSATION_SEARCH_RESULTS_FORMAT" = "%d/%d osumasta";
|
||||
/* title for conversation settings screen */
|
||||
"CONVERSATION_SETTINGS" = "Keskusteluasetukset";
|
||||
"CONVERSATION_SETTINGS" = "Keskustelun asetukset";
|
||||
/* table cell label in conversation settings */
|
||||
"CONVERSATION_SETTINGS_BLOCK_THIS_USER" = "Estä tämä käyttäjä";
|
||||
/* Title of the 'mute this thread' action sheet. */
|
||||
"CONVERSATION_SETTINGS_MUTE_ACTION_SHEET_TITLE" = "Mykistä";
|
||||
"CONVERSATION_SETTINGS_MUTE_ACTION_SHEET_TITLE" = "Hiljennä";
|
||||
/* label for 'mute thread' cell in conversation settings */
|
||||
"CONVERSATION_SETTINGS_MUTE_LABEL" = "Mykistä";
|
||||
"CONVERSATION_SETTINGS_MUTE_LABEL" = "Hiljennä";
|
||||
/* Indicates that the current thread is not muted. */
|
||||
"CONVERSATION_SETTINGS_MUTE_NOT_MUTED" = "Hiljentämätön";
|
||||
"CONVERSATION_SETTINGS_MUTE_NOT_MUTED" = "Ei hiljennetty";
|
||||
/* Label for button to mute a thread for a day. */
|
||||
"CONVERSATION_SETTINGS_MUTE_ONE_DAY_ACTION" = "Hiljennä päiväksi";
|
||||
"CONVERSATION_SETTINGS_MUTE_ONE_DAY_ACTION" = "Hiljennä yhdeksi päiväksi";
|
||||
/* Label for button to mute a thread for a hour. */
|
||||
"CONVERSATION_SETTINGS_MUTE_ONE_HOUR_ACTION" = "Hiljennä tunniksi";
|
||||
"CONVERSATION_SETTINGS_MUTE_ONE_HOUR_ACTION" = "Hiljennä tunnin ajan";
|
||||
/* Label for button to mute a thread for a minute. */
|
||||
"CONVERSATION_SETTINGS_MUTE_ONE_MINUTE_ACTION" = "Hiljennä minuutiksi";
|
||||
"CONVERSATION_SETTINGS_MUTE_ONE_MINUTE_ACTION" = "Mykistä minuutiksi";
|
||||
/* Label for button to mute a thread for a week. */
|
||||
"CONVERSATION_SETTINGS_MUTE_ONE_WEEK_ACTION" = "Hiljennä viikoksi";
|
||||
"CONVERSATION_SETTINGS_MUTE_ONE_WEEK_ACTION" = "Hiljennä yhden viikon ajaksi";
|
||||
/* Label for button to mute a thread for a year. */
|
||||
"CONVERSATION_SETTINGS_MUTE_ONE_YEAR_ACTION" = "Hiljennä vuodeksi";
|
||||
"CONVERSATION_SETTINGS_MUTE_ONE_YEAR_ACTION" = "Mykistä vuodeksi";
|
||||
/* Indicates that this thread is muted until a given date or time. Embeds {{The date or time which the thread is muted until}}. */
|
||||
"CONVERSATION_SETTINGS_MUTED_UNTIL_FORMAT" = "%@ asti";
|
||||
/* Table cell label in conversation settings which returns the user to the conversation with 'search mode' activated */
|
||||
"CONVERSATION_SETTINGS_SEARCH" = "Hae keskusteluista";
|
||||
"CONVERSATION_SETTINGS_SEARCH" = "Etsi keskustelusta";
|
||||
/* Label for button to unmute a thread. */
|
||||
"CONVERSATION_SETTINGS_UNMUTE_ACTION" = "Poista hiljennys";
|
||||
"CONVERSATION_SETTINGS_UNMUTE_ACTION" = "Poista mykistys";
|
||||
/* Title for the 'crop/scale image' dialog. */
|
||||
"CROP_SCALE_IMAGE_VIEW_TITLE" = "Siirrä ja muuta kokoa";
|
||||
"CROP_SCALE_IMAGE_VIEW_TITLE" = "Siirrä ja Skaalaa";
|
||||
/* Subtitle shown while the app is updating its database. */
|
||||
"DATABASE_VIEW_OVERLAY_SUBTITLE" = "Tämä saattaa viedä hetken.";
|
||||
/* Title shown while the app is updating its database. */
|
||||
"DATABASE_VIEW_OVERLAY_TITLE" = "Optimoidaan tietokantaa";
|
||||
"DATABASE_VIEW_OVERLAY_TITLE" = "Optimoidaan tietokanta";
|
||||
/* Format string for a relative time, expressed as a certain number of hours in the past. Embeds {{The number of hours}}. */
|
||||
"DATE_HOURS_AGO_FORMAT" = "%@ tuntia sitten";
|
||||
/* Format string for a relative time, expressed as a certain number of minutes in the past. Embeds {{The number of minutes}}. */
|
||||
|
@ -183,27 +183,27 @@
|
|||
/* Info Message when added to a group which has enabled disappearing messages. Embeds {{time amount}} before messages disappear, see the *_TIME_AMOUNT strings for context. */
|
||||
"DISAPPEARING_MESSAGES_CONFIGURATION_GROUP_EXISTING_FORMAT" = "Viestit tässä keskustelussa katoavat %@ jälkeen.";
|
||||
/* table cell label in conversation settings */
|
||||
"EDIT_GROUP_ACTION" = "Muokkaa ryhmää";
|
||||
"EDIT_GROUP_ACTION" = "Muokkaa Ryhmää";
|
||||
/* Label indicating media gallery is empty */
|
||||
"GALLERY_TILES_EMPTY_GALLERY" = "Sinulla ei ole ollenkaan mediaa tässä keskustelussa.";
|
||||
"GALLERY_TILES_EMPTY_GALLERY" = "Sinulla ei ole yhtään mediaa tässä keskustelussa.";
|
||||
/* Label indicating loading is in progress */
|
||||
"GALLERY_TILES_LOADING_MORE_RECENT_LABEL" = "Ladataan uutta mediaa…";
|
||||
"GALLERY_TILES_LOADING_MORE_RECENT_LABEL" = "Ladataan uudempaa mediaa…";
|
||||
/* Label indicating loading is in progress */
|
||||
"GALLERY_TILES_LOADING_OLDER_LABEL" = "Ladataan vanhempaa mediaa…";
|
||||
/* Error displayed when there is a failure fetching a GIF from the remote service. */
|
||||
"GIF_PICKER_ERROR_FETCH_FAILURE" = "Pyydetyn GIF-animaation hakeminen epäonnistui. Ole hyvä ja varmista yhteys internettiin.";
|
||||
"GIF_PICKER_ERROR_FETCH_FAILURE" = "Pyydetyn GIF-animaation hakeminen epäonnistui. Ole hyvä ja varmista yhteytesi.";
|
||||
/* Generic error displayed when picking a GIF */
|
||||
"GIF_PICKER_ERROR_GENERIC" = "Ilmeni tuntematon virhe.";
|
||||
"GIF_PICKER_ERROR_GENERIC" = "Tuntematon virhe ilmeni.";
|
||||
/* Shown when selected GIF couldn't be fetched */
|
||||
"GIF_PICKER_FAILURE_ALERT_TITLE" = "GIF-animaation valinta epäonnistui";
|
||||
"GIF_PICKER_FAILURE_ALERT_TITLE" = "GIF-animaatiota ei pystytä valitsemaan";
|
||||
/* Alert message shown when user tries to search for GIFs without entering any search terms. */
|
||||
"GIF_PICKER_VIEW_MISSING_QUERY" = "Kirjoita hakutermi.";
|
||||
/* Indicates that an error occurred while searching. */
|
||||
"GIF_VIEW_SEARCH_ERROR" = "Virhe. Napauta kokeillaksesi uudelleen.";
|
||||
"GIF_VIEW_SEARCH_ERROR" = "Virhe. Paina kokeillaksesi uudelleen.";
|
||||
/* Indicates that the user's search had no results. */
|
||||
"GIF_VIEW_SEARCH_NO_RESULTS" = "Ei hakutuloksia.";
|
||||
"GIF_VIEW_SEARCH_NO_RESULTS" = "Ei tuloksia.";
|
||||
/* No comment provided by engineer. */
|
||||
"GROUP_CREATED" = "Ryhmä luotu";
|
||||
"GROUP_CREATED" = "Ryhmä on luotu";
|
||||
/* No comment provided by engineer. */
|
||||
"GROUP_MEMBER_JOINED" = "%@ liittyi ryhmään. ";
|
||||
/* No comment provided by engineer. */
|
||||
|
@ -213,7 +213,7 @@
|
|||
/* No comment provided by engineer. */
|
||||
"GROUP_MEMBERS_REMOVED" = "%@ poistettiin ryhmästä. ";
|
||||
/* No comment provided by engineer. */
|
||||
"GROUP_TITLE_CHANGED" = "Ryhmän nimi on nyt ”%@”. ";
|
||||
"GROUP_TITLE_CHANGED" = "Ryhmän kuvaus on nyt ”%@”. ";
|
||||
/* No comment provided by engineer. */
|
||||
"GROUP_UPDATED" = "Ryhmä päivitetty.";
|
||||
/* No comment provided by engineer. */
|
||||
|
@ -221,7 +221,7 @@
|
|||
/* No comment provided by engineer. */
|
||||
"YOU_WERE_REMOVED" = " Sinut poistettiin ryhmästä. ";
|
||||
/* Momentarily shown to the user when attempting to select more images than is allowed. Embeds {{max number of items}} that can be shared. */
|
||||
"IMAGE_PICKER_CAN_SELECT_NO_MORE_TOAST_FORMAT" = "Voit jakaa korkeintaan %@ kohdetta.";
|
||||
"IMAGE_PICKER_CAN_SELECT_NO_MORE_TOAST_FORMAT" = "Et voi jakaa enempää kuin %@ kohdetta.";
|
||||
/* alert title */
|
||||
"IMAGE_PICKER_FAILED_TO_PROCESS_ATTACHMENTS" = "Liitteen valinta epäonnistui.";
|
||||
/* Message for the alert indicating that an audio file is invalid. */
|
||||
|
@ -255,7 +255,7 @@
|
|||
/* status message for failed messages */
|
||||
"MESSAGE_STATUS_FAILED" = "Lähetys epäonnistui.";
|
||||
/* status message for failed messages */
|
||||
"MESSAGE_STATUS_FAILED_SHORT" = "Virhe";
|
||||
"MESSAGE_STATUS_FAILED_SHORT" = "Epäonnistui";
|
||||
/* status message for read messages */
|
||||
"MESSAGE_STATUS_READ" = "Luettu";
|
||||
/* message status if message delivery to a recipient is skipped. We skip delivering group messages to users who have left the group or unregistered their Session account. */
|
||||
|
@ -267,11 +267,11 @@
|
|||
/* status message while attachment is uploading */
|
||||
"MESSAGE_STATUS_UPLOADING" = "Ladataan…";
|
||||
/* Alert body when user has previously denied media library access */
|
||||
"MISSING_MEDIA_LIBRARY_PERMISSION_MESSAGE" = "Voit sallia tämän järjestelmäasetuksissa.";
|
||||
"MISSING_MEDIA_LIBRARY_PERMISSION_MESSAGE" = "Voit sallia tämän iOS-järjestelmän asetuksista.";
|
||||
/* Alert title when user has previously denied media library access */
|
||||
"MISSING_MEDIA_LIBRARY_PERMISSION_TITLE" = "Tämä Sessionin toiminto tarvitsee käyttöluvan kuviisi.";
|
||||
"MISSING_MEDIA_LIBRARY_PERMISSION_TITLE" = "Session tarvitsee oikeuden käyttääkseen kuviasi.";
|
||||
/* An explanation of the consequences of muting a thread. */
|
||||
"MUTE_BEHAVIOR_EXPLANATION" = "Et saa ilmoituksia hiljennetyistä keskusteluista.";
|
||||
"MUTE_BEHAVIOR_EXPLANATION" = "Et saa ilmoituksia mykistetyistä keskusteluista.";
|
||||
/* notification title. Embeds {{author name}} and {{group name}} */
|
||||
"NEW_GROUP_MESSAGE_NOTIFICATION_TITLE" = "%@ ryhmässä %@";
|
||||
/* Label for 1:1 conversation with yourself. */
|
||||
|
@ -279,9 +279,9 @@
|
|||
/* Lock screen notification text presented after user powers on their device without unlocking. Embeds {{device model}} (either 'iPad' or 'iPhone') */
|
||||
"NOTIFICATION_BODY_PHONE_LOCKED_FORMAT" = "Olet saattanut saada viesteja laitteesi %@ käynnistyessä uudelleen.";
|
||||
/* No comment provided by engineer. */
|
||||
"NOTIFICATIONS_FOOTER_WARNING" = "Due to known bugs in Apple's push framework, message previews will only be shown if the message is retrieved within 30 seconds after being sent. The application badge might be inaccurate as a result.";
|
||||
"NOTIFICATIONS_FOOTER_WARNING" = "Tunnettujen Applen ilmoituskehyksessä olevien vikojen vuoksi, viestien esikatselut näytetään vain, jos viesti pystytään saamaan 30 sekuntin kuluessa sen lähettämisestä. Aplikaation ilmoitusmerkki saattaa tästä syystä olla epätarkka.";
|
||||
/* Table cell switch label. When disabled, Session will not play notification sounds while the app is in the foreground. */
|
||||
"NOTIFICATIONS_SECTION_INAPP" = "Soita ilmoitusääni apin olessa avoinna";
|
||||
"NOTIFICATIONS_SECTION_INAPP" = "Älä hiljennä apin ollessa näytöllä";
|
||||
/* Label for settings UI that allows user to change the notification sound. */
|
||||
"NOTIFICATIONS_SECTION_SOUNDS" = "Äänet";
|
||||
/* No comment provided by engineer. */
|
||||
|
@ -293,7 +293,7 @@
|
|||
/* No comment provided by engineer. */
|
||||
"NOTIFICATIONS_SHOW" = "Näytä";
|
||||
/* No comment provided by engineer. */
|
||||
"BUTTON_OK" = "Ok";
|
||||
"BUTTON_OK" = "OK";
|
||||
/* Info Message when {{other user}} disables or doesn't support disappearing messages */
|
||||
"OTHER_DISABLED_DISAPPEARING_MESSAGES_CONFIGURATION" = "%@ poisti katoavat viestit käytöstä.";
|
||||
/* Info Message when {{other user}} updates message expiration to {{time amount}}, see the *_TIME_AMOUNT strings for context. */
|
||||
|
@ -307,17 +307,17 @@
|
|||
/* label for system photo collections which have no name. */
|
||||
"PHOTO_PICKER_UNNAMED_COLLECTION" = "Nimetön albumi";
|
||||
/* Notification action button title */
|
||||
"PUSH_MANAGER_MARKREAD" = "Mark as Read";
|
||||
"PUSH_MANAGER_MARKREAD" = "Merkkaa luetuksi";
|
||||
/* Notification action button title */
|
||||
"PUSH_MANAGER_REPLY" = "Reply";
|
||||
"PUSH_MANAGER_REPLY" = "Vastaa";
|
||||
/* alert body during registration */
|
||||
"REGISTRATION_ERROR_BLANK_VERIFICATION_CODE" = "Emme pysty aktivoimaan käyttäjätiliä ennen kuin vahvistat lähettämämme koodin.";
|
||||
/* Indicates a delay of zero seconds, and that 'screen lock activity' will timeout immediately. */
|
||||
"SCREEN_LOCK_ACTIVITY_TIMEOUT_NONE" = "Heti";
|
||||
/* Description of how and why Session iOS uses Touch ID/Face ID/Phone Passcode to unlock 'screen lock'. */
|
||||
"SCREEN_LOCK_REASON_UNLOCK_SCREEN_LOCK" = "Authenticate to open Session.";
|
||||
"SCREEN_LOCK_REASON_UNLOCK_SCREEN_LOCK" = "Tunnistaudu avataksesi Sessionin.";
|
||||
/* Title for alert indicating that screen lock could not be unlocked. */
|
||||
"SCREEN_LOCK_UNLOCK_FAILED" = "Authentication Failed";
|
||||
"SCREEN_LOCK_UNLOCK_FAILED" = "Tunnistautuminen epäonnistui";
|
||||
/* alert title when user attempts to leave the send media flow when they have an in-progress album */
|
||||
"SEND_MEDIA_ABANDON_TITLE" = "Hylkää media?";
|
||||
/* alert action, confirming the user wants to exit the media flow and abandon any photos they've taken */
|
||||
|
@ -335,17 +335,17 @@
|
|||
/* Label for 'cancel backup' button in the backup settings view. */
|
||||
"SETTINGS_BACKUP_CANCEL_BACKUP" = "Peruuta varmuuskopio";
|
||||
/* Label for switch in settings that controls whether or not backup is enabled. */
|
||||
"SETTINGS_BACKUP_ENABLING_SWITCH" = "Backup Enabled";
|
||||
"SETTINGS_BACKUP_ENABLING_SWITCH" = "Varmuuskopiointi käytössä";
|
||||
/* Label for iCloud status row in the in the backup settings view. */
|
||||
"SETTINGS_BACKUP_ICLOUD_STATUS" = "iCloud Status";
|
||||
"SETTINGS_BACKUP_ICLOUD_STATUS" = "iCloud status";
|
||||
/* Indicates that the last backup restore failed. */
|
||||
"SETTINGS_BACKUP_IMPORT_STATUS_FAILED" = "Backup Restore Failed";
|
||||
"SETTINGS_BACKUP_IMPORT_STATUS_FAILED" = "Varmuuskopion tuominen epäonnistui";
|
||||
/* Indicates that app is not restoring up. */
|
||||
"SETTINGS_BACKUP_IMPORT_STATUS_IDLE" = "Backup Restore Idle";
|
||||
"SETTINGS_BACKUP_IMPORT_STATUS_IDLE" = "Varmuuskopion tuominen on tyhjäkäynnillä";
|
||||
/* Indicates that app is restoring up. */
|
||||
"SETTINGS_BACKUP_IMPORT_STATUS_IN_PROGRESS" = "Backup Restore In Progress";
|
||||
"SETTINGS_BACKUP_IMPORT_STATUS_IN_PROGRESS" = "Varmuuskopiosta tuominen käynnissä";
|
||||
/* Indicates that the last backup restore succeeded. */
|
||||
"SETTINGS_BACKUP_IMPORT_STATUS_SUCCEEDED" = "Backup Restore Succeeded";
|
||||
"SETTINGS_BACKUP_IMPORT_STATUS_SUCCEEDED" = "Varmuuskopiosta tuominen onnistui";
|
||||
/* Label for phase row in the in the backup settings view. */
|
||||
"SETTINGS_BACKUP_PHASE" = "Vaihe";
|
||||
/* Label for phase row in the in the backup settings view. */
|
||||
|
@ -359,39 +359,45 @@
|
|||
/* Indicates that app is backing up. */
|
||||
"SETTINGS_BACKUP_STATUS_IN_PROGRESS" = "Varmuuskopioidaan";
|
||||
/* Indicates that the last backup succeeded. */
|
||||
"SETTINGS_BACKUP_STATUS_SUCCEEDED" = "Backup Successful";
|
||||
"SETTINGS_BACKUP_STATUS_SUCCEEDED" = "Varmuuskopiointi onnistui";
|
||||
/* No comment provided by engineer. */
|
||||
"SETTINGS_CLEAR_HISTORY" = "Clear Conversation History";
|
||||
"SETTINGS_CLEAR_HISTORY" = "Tyhjennä keskusteluhistoria";
|
||||
/* Confirmation text for button which deletes all message, calling, attachments, etc. */
|
||||
"SETTINGS_DELETE_HISTORYLOG_CONFIRMATION_BUTTON" = "Delete Everything";
|
||||
"SETTINGS_DELETE_HISTORYLOG_CONFIRMATION_BUTTON" = "Poista kaikki";
|
||||
/* Section header */
|
||||
"SETTINGS_HISTORYLOG_TITLE" = "Clear Conversation History";
|
||||
"SETTINGS_HISTORYLOG_TITLE" = "Tyhjennä keskusteluhistoria";
|
||||
/* Label for settings view that allows user to change the notification sound. */
|
||||
"SETTINGS_ITEM_NOTIFICATION_SOUND" = "Message Sound";
|
||||
"SETTINGS_ITEM_NOTIFICATION_SOUND" = "Viestien ilmoitusääni";
|
||||
/* Setting for enabling & disabling link previews. */
|
||||
"SETTINGS_LINK_PREVIEWS" = "Send Link Previews";
|
||||
"SETTINGS_LINK_PREVIEWS" = "Lähetä linkeistä esikatselu";
|
||||
/* Footer for setting for enabling & disabling link previews. */
|
||||
"SETTINGS_LINK_PREVIEWS_FOOTER" = "Previews are supported for most urls.";
|
||||
"SETTINGS_LINK_PREVIEWS_FOOTER" = "Useimpien URL-osoitteiden esikatselut ovat tuettuja.";
|
||||
/* Header for setting for enabling & disabling link previews. */
|
||||
"SETTINGS_LINK_PREVIEWS_HEADER" = "Link Previews";
|
||||
"SETTINGS_LINK_PREVIEWS_HEADER" = "Linkkien esikatselu";
|
||||
/* Setting for enabling & disabling voice & video calls. */
|
||||
"SETTINGS_CALLS" = "Ääni- ja videopuhelut";
|
||||
/* Footer for setting for enabling & disabling voice & video calls. */
|
||||
"SETTINGS_CALLS_FOOTER" = "Salli pääsy hyväksyäksesi muilta käyttäjiltä saapuvat ääni- ja videopuhelut.";
|
||||
/* Header for setting for enabling & disabling voice & video calls. */
|
||||
"SETTINGS_CALLS_HEADER" = "Puhelut";
|
||||
/* table section header */
|
||||
"SETTINGS_NOTIFICATION_CONTENT_TITLE" = "Notification Content";
|
||||
"SETTINGS_NOTIFICATION_CONTENT_TITLE" = "Ilmoituksen sisältö";
|
||||
/* Label for the 'read receipts' setting. */
|
||||
"SETTINGS_READ_RECEIPT" = "Read Receipts";
|
||||
"SETTINGS_READ_RECEIPT" = "Lukukuittaukset";
|
||||
/* An explanation of the 'read receipts' setting. */
|
||||
"SETTINGS_READ_RECEIPTS_SECTION_FOOTER" = "See and share when messages have been read. This setting is optional and applies to all conversations.";
|
||||
"SETTINGS_READ_RECEIPTS_SECTION_FOOTER" = "Nää ja jaa lukukuittaukset. Tämä asetus on vapaaehtoinen ja koskee kaikkia keskusteluja.";
|
||||
/* Label for the 'screen lock activity timeout' setting of the privacy settings. */
|
||||
"SETTINGS_SCREEN_LOCK_ACTIVITY_TIMEOUT" = "Screen Lock Timeout";
|
||||
"SETTINGS_SCREEN_LOCK_ACTIVITY_TIMEOUT" = "Näytön lukitus";
|
||||
/* Title for the 'screen lock' section of the privacy settings. */
|
||||
"SETTINGS_SCREEN_LOCK_SECTION_TITLE" = "Screen Lock";
|
||||
"SETTINGS_SCREEN_LOCK_SECTION_TITLE" = "Näytön lukitus";
|
||||
/* Label for the 'enable screen lock' switch of the privacy settings. */
|
||||
"SETTINGS_SCREEN_LOCK_SWITCH_LABEL" = "Screen Lock";
|
||||
"SETTINGS_SCREEN_LOCK_SWITCH_LABEL" = "Näytön lukitus";
|
||||
/* Header Label for the sounds section of settings views. */
|
||||
"SETTINGS_SECTION_SOUNDS" = "Äänet";
|
||||
/* Section header */
|
||||
"SETTINGS_SECURITY_TITLE" = "Screen Security";
|
||||
"SETTINGS_SECURITY_TITLE" = "Näytön suojaus";
|
||||
/* Label for the 'typing indicators' setting. */
|
||||
"SETTINGS_TYPING_INDICATORS" = "Typing Indicators";
|
||||
"SETTINGS_TYPING_INDICATORS" = "Ilmaise aktiivinen kirjoitus";
|
||||
/* Label for the 'no sound' option that allows users to disable sounds for notifications, etc. */
|
||||
"SOUNDS_NONE" = "Ei ääntä";
|
||||
/* {{number of days}} embedded in strings, e.g. 'Alice updated disappearing messages expiration to {{5 days}}'. See other *_TIME_AMOUNT strings */
|
||||
|
@ -447,103 +453,103 @@
|
|||
"your_session_id" = "Sinun Session ID";
|
||||
"vc_landing_title_2" = "Sinun sessio alkaa tästä...";
|
||||
"vc_landing_register_button_title" = "Luo Session ID";
|
||||
"vc_landing_restore_button_title" = "Jatka istuntoasi";
|
||||
"vc_landing_link_button_title" = "Linkkaa laite";
|
||||
"vc_landing_restore_button_title" = "Jatka Sessionin käyttöä";
|
||||
"vc_landing_link_button_title" = "Yhdistä laite";
|
||||
"view_fake_chat_bubble_1" = "Mikä on Session?";
|
||||
"view_fake_chat_bubble_2" = "Se on hajautettu sekä salattu viestipalvelu";
|
||||
"view_fake_chat_bubble_3" = "Joten se ei kerää henkilökohtaisia tietoja tai keskustelujeni metadataa? Miten se sitten toimii?";
|
||||
"view_fake_chat_bubble_3" = "Joten se ei kerää henkilökohtaisia tietojani tai keskustelujeni metadataa? Miten se sitten toimii?";
|
||||
"view_fake_chat_bubble_4" = "Yhdistämällä edistynyttä reititys- ja salausteknologiaa päästä päätyyn.";
|
||||
"view_fake_chat_bubble_5" = "Kaverit eivät jätä kavereita käyttämään vaarantuneita viestipalveluita. Ole hyvä.";
|
||||
"view_fake_chat_bubble_5" = "Kaverit eivät anna kavereiden käyttää vaarantuneita viestipalveluita. Oleppa hyvä.";
|
||||
"vc_register_title" = "Sano terve sinun Session ID:lle";
|
||||
"vc_register_explanation" = "Your Session ID is the unique address people can use to contact you on Session. With no connection to your real identity, your Session ID is totally anonymous and private by design.";
|
||||
"vc_register_explanation" = "Session ID on sinun ainutlaatuinen osoite, jolla ihmiset voivat ottaa sinuun yhteyttä; ilman yhteyttä oikeaan identiteettiisi. Session ID on luonteeltaan täysin anonyymi ja yksityinen.";
|
||||
"vc_restore_title" = "Palauta käyttäjätilisi";
|
||||
"vc_restore_explanation" = "Enter the recovery phrase that was given to you when you signed up to restore your account.";
|
||||
"vc_restore_seed_text_field_hint" = "Anna palautusvirkkeesi";
|
||||
"vc_restore_explanation" = "Palauttaaksesi tilisi, syötä palautusvirke, joka annettiin sinulle kun loit tämän tilin.";
|
||||
"vc_restore_seed_text_field_hint" = "Syötä palautusvirkkeesi";
|
||||
"vc_link_device_title" = "Yhdistä laite";
|
||||
"vc_link_device_scan_qr_code_tab_title" = "Skannaa QR-koodi";
|
||||
"vc_display_name_title_2" = "Valitse julkinen nimi";
|
||||
"vc_display_name_explanation" = "Tämä on nimesi kun käytät Sessionia. Se voi olla oikea nimesi, alias tai mikä tahansa mistä tykkäät.";
|
||||
"vc_display_name_text_field_hint" = "Anna julkinen nimi";
|
||||
"vc_display_name_explanation" = "Tämä on nimesi kun käytät Sessionia. Se voi olla oikea nimesi, alias tai halutessasi jotain ihan muuta.";
|
||||
"vc_display_name_text_field_hint" = "Syötä julkinen nimi";
|
||||
"vc_display_name_display_name_missing_error" = "Ole hyvä ja valitse julkinen nimi";
|
||||
"vc_display_name_display_name_too_long_error" = "Ole hyvä ja valitse lyhyempi julkinen nimi";
|
||||
"vc_pn_mode_recommended_option_tag" = "Suositellut";
|
||||
"vc_pn_mode_recommended_option_tag" = "Suositeltu";
|
||||
"vc_pn_mode_no_option_picked_modal_title" = "Ole hyvä ja valitse vaihtoehto";
|
||||
"vc_home_empty_state_message" = "Sinulla ei ole yhtään kontaktia vielä";
|
||||
"vc_home_empty_state_message" = "Sinulla ei ole vielä yhtään keskustelua";
|
||||
"vc_home_empty_state_button_title" = "Aloita keskustelu";
|
||||
"vc_seed_title" = "Palautusvirkkeesi";
|
||||
"vc_seed_title_2" = "Tapaa palautusvirkkeesi";
|
||||
"vc_seed_explanation" = "Your recovery phrase is the master key to your Session ID — you can use it to restore your Session ID if you lose access to your device. Store your recovery phrase in a safe place, and don’t give it to anyone.";
|
||||
"vc_seed_reveal_button_title" = "Hold to reveal";
|
||||
"view_seed_reminder_subtitle_1" = "Secure your account by saving your recovery phrase";
|
||||
"view_seed_reminder_subtitle_2" = "Tap and hold the redacted words to reveal your recovery phrase, then store it safely to secure your Session ID.";
|
||||
"view_seed_reminder_subtitle_3" = "Make sure to store your recovery phrase in a safe place";
|
||||
"vc_path_title" = "Path";
|
||||
"vc_seed_explanation" = "Palautusvirkkeesi on sinun Session ID:n pääavain — voit palauttaa sillä Session ID:n, jos menetät pääsyn laitteeseesi. Sijoita palautusvirkkeesi turvalliseen paikkaan äläkä anna sitä kellekkään.";
|
||||
"vc_seed_reveal_button_title" = "Pidä pohjassa paljastaaksesi";
|
||||
"view_seed_reminder_subtitle_1" = "Turvaa tilisi ottamalla palautusvirkkeesi ylös";
|
||||
"view_seed_reminder_subtitle_2" = "Paina ja pidä painettuna piilotettuja sanoja paljastaaksesi palautusvirkkeesi. Ota ne ylös ja sijoita turvalliseen paikkaan turvataksesi Session ID:si.";
|
||||
"view_seed_reminder_subtitle_3" = "Varmista, että säilytät palautusvirkkeesi turvallisessa paikassa";
|
||||
"vc_path_title" = "Polku";
|
||||
"vc_path_explanation" = "Session piilottaa IP-osoitteesi ohjaamalla viestisi monen välittäjäreleen läpi Sessionin hajautetussa verkossa. Tässä ovat maat joiden kautta viestisi tällä hetkellä kulkevat:";
|
||||
"vc_path_device_row_title" = "Sinä";
|
||||
"vc_path_guard_node_row_title" = "Tulorele";
|
||||
"vc_path_guard_node_row_title" = "Tulosolmu";
|
||||
"vc_path_service_node_row_title" = "Välittäjärele";
|
||||
"vc_path_destination_row_title" = "Kohde";
|
||||
"vc_path_destination_row_title" = "Määränpää";
|
||||
"vc_path_learn_more_button_title" = "Opi lisää";
|
||||
"vc_create_private_chat_title" = "Uusi istunto";
|
||||
"vc_create_private_chat_title" = "Uusi keskustelu";
|
||||
"vc_create_private_chat_enter_session_id_tab_title" = "Syötä Session ID";
|
||||
"vc_create_private_chat_scan_qr_code_tab_title" = "Skannaa QR-koodi";
|
||||
"vc_create_private_chat_scan_qr_code_explanation" = "Scan a user’s QR code to start a session. QR codes can be found by tapping the QR code icon in account settings.";
|
||||
"vc_enter_public_key_explanation" = "Users can share their Session ID by going into their account settings and tapping \"Share Session ID\", or by sharing their QR code.";
|
||||
"vc_create_private_chat_scan_qr_code_explanation" = "Skannaa käyttäjän QR-koodi aloittaaksesi keskustelu. QR-koodin voit löytää napauttamalla QR-koodi kuvaketta tilin asetuksissa.";
|
||||
"vc_enter_public_key_explanation" = "Käyttäjät voivat jakaa heidän Session ID:n menemällä tilin asetuksiin ja painamalla \"Jaa\" tai jakamalla heidän QR-koodin.";
|
||||
"vc_scan_qr_code_camera_access_explanation" = "Session tarvitsee kameraa skannatakseen QR-koodeja";
|
||||
"vc_scan_qr_code_grant_camera_access_button_title" = "Anna kameran käyttölupa";
|
||||
"vc_create_closed_group_title" = "Uusi suljettu ryhmä";
|
||||
"vc_create_closed_group_title" = "Uusi yksityinen ryhmä";
|
||||
"vc_create_closed_group_text_field_hint" = "Anna ryhmälle nimi";
|
||||
"vc_create_closed_group_empty_state_message" = "Sinulla ei ole vielä yhtään kontaktia";
|
||||
"vc_create_closed_group_empty_state_message" = "Sinulla ei ole vielä yhtään keskustelua";
|
||||
"vc_create_closed_group_empty_state_button_title" = "Aloita keskustelu";
|
||||
"vc_create_closed_group_group_name_missing_error" = "Anna ryhmälle nimi";
|
||||
"vc_create_closed_group_group_name_too_long_error" = "Anna ryhmälle lyhyempi nimi";
|
||||
"vc_create_closed_group_too_many_group_members_error" = "Suljetussa ryhmässä voi olla enintään 100 jäsentä";
|
||||
"vc_join_public_chat_title" = "Liity avoimeen ryhmään";
|
||||
"vc_join_public_chat_enter_group_url_tab_title" = "Open Group URL";
|
||||
"vc_create_closed_group_too_many_group_members_error" = "Yksityisessä ryhmässä voi olla enintään 100 jäsentä";
|
||||
"vc_join_public_chat_title" = "Liity julkiseen ryhmään";
|
||||
"vc_join_public_chat_enter_group_url_tab_title" = "Julkisen ryhmän URL";
|
||||
"vc_join_public_chat_scan_qr_code_tab_title" = "Skannaa QR-koodi";
|
||||
"vc_join_public_chat_scan_qr_code_explanation" = "Skannaa sen avoimen ryhmän QR-koodi, johon haluaisit liittyä";
|
||||
"vc_enter_chat_url_text_field_hint" = "Enter an open group URL";
|
||||
"vc_join_public_chat_scan_qr_code_explanation" = "Skannaa sen julkisen ryhmän QR-koodi, johon haluaisit liittyä";
|
||||
"vc_enter_chat_url_text_field_hint" = "Syötä julkisen ryhmän URL";
|
||||
"vc_settings_title" = "Asetukset";
|
||||
"vc_settings_display_name_text_field_hint" = "Enter a display name";
|
||||
"vc_settings_display_name_missing_error" = "Please pick a display name";
|
||||
"vc_settings_display_name_too_long_error" = "Please pick a shorter display name";
|
||||
"vc_settings_display_name_text_field_hint" = "Anna julkinen nimi";
|
||||
"vc_settings_display_name_missing_error" = "Ole hyvä ja valitse julkinen nimi";
|
||||
"vc_settings_display_name_too_long_error" = "Ole hyvä ja valitse lyhyempi julkinen nimi";
|
||||
"vc_settings_privacy_button_title" = "Yksityisyys";
|
||||
"vc_settings_notifications_button_title" = "Ilmoitukset";
|
||||
"vc_settings_recovery_phrase_button_title" = "Palautusvirke";
|
||||
"vc_settings_clear_all_data_button_title" = "Tyhjennä data";
|
||||
"vc_settings_clear_all_data_button_title" = "Tyhjennä kaikki data";
|
||||
"vc_notification_settings_title" = "Ilmoitukset";
|
||||
"vc_privacy_settings_title" = "Yksityisyys";
|
||||
"preferences_notifications_strategy_category_title" = "Ilmoitustyyli";
|
||||
"modal_seed_title" = "Palatusvirkkeesi";
|
||||
"modal_seed_explanation" = "Tämä on palautusvirkkeesi. Sillä voit palauttaa tai siirtää Session ID:si uuteen laitteeseen.";
|
||||
"modal_clear_all_data_title" = "Tyhjennä kaikki data";
|
||||
"modal_clear_all_data_explanation" = "Tämä poistaa kaikki viestisi, istuntosi sekä kontaktisi lopullisesti.";
|
||||
"modal_clear_all_data_explanation_2" = "Would you like to clear only this device, or delete your entire account?";
|
||||
"dialog_clear_all_data_deletion_failed_1" = "Data not deleted by 1 Service Node. Service Node ID: %@.";
|
||||
"dialog_clear_all_data_deletion_failed_2" = "Data not deleted by %@ Service Nodes. Service Node IDs: %@.";
|
||||
"modal_clear_all_data_device_only_button_title" = "Device Only";
|
||||
"modal_clear_all_data_entire_account_button_title" = "Entire Account";
|
||||
"modal_clear_all_data_explanation" = "Tämä poistaa kaikki viestisi sekä yhteydet lopullisesti.";
|
||||
"modal_clear_all_data_explanation_2" = "Haluatko tyhjentää datan vain tästä laitteesta vai poistaa koko tilin?";
|
||||
"dialog_clear_all_data_deletion_failed_1" = "Dataa ei poistettu yhdestä palvelusolmusta. Palvelusolmun tunnus: %@.";
|
||||
"dialog_clear_all_data_deletion_failed_2" = "Dataa ei poistettu %@ palvelusolmusta. Palvelusolmujen tunnukset: %@.";
|
||||
"modal_clear_all_data_device_only_button_title" = "Vain tämä laite";
|
||||
"modal_clear_all_data_entire_account_button_title" = "Koko tili";
|
||||
"vc_qr_code_title" = "QR-koodi";
|
||||
"vc_qr_code_view_my_qr_code_tab_title" = "Näytä QR-koodini";
|
||||
"vc_qr_code_view_scan_qr_code_tab_title" = "Skannaa QR-koodi";
|
||||
"vc_qr_code_view_scan_qr_code_explanation" = "Skannaa jonkun QR-koodi aloittaaksesi keskustelu heidän kanssaan";
|
||||
"vc_view_my_qr_code_explanation" = "Tämä on QR-koodisi. Toiset käyttäjät voivat skannata sen aloittaakseen keskustelun kanssasi.";
|
||||
"vc_view_my_qr_code_explanation" = "Tämä on QR-koodisi. Muut käyttäjät voivat skannata sen aloittaakseen keskustelun kanssasi.";
|
||||
// MARK: - Not Yet Translated
|
||||
"fast_mode_explanation" = "Sinulle ilmoitetaan uusista viesteistä luotettavasti ja viivyittelemättä Applen ilmoituspalveluja käyttäen.";
|
||||
"fast_mode" = "Pikatila";
|
||||
"fast_mode_explanation" = "Sinulle ilmoitetaan uusista viesteistä luotettavasti ja viivyttelemättä Applen ilmoituspalvelua käyttäen.";
|
||||
"fast_mode" = "Nopeasti";
|
||||
"slow_mode_explanation" = "Session tarkistaa taustalla ajoittain uudet viestit.";
|
||||
"slow_mode" = "Hidas tila";
|
||||
"slow_mode" = "Hitaasti";
|
||||
"vc_pn_mode_title" = "Viesti-ilmoitukset";
|
||||
"vc_notification_settings_notification_mode_title" = "Käytä pikatilaa";
|
||||
"vc_notification_settings_notification_mode_title" = "Toimita nopeasti";
|
||||
"vc_link_device_recovery_phrase_tab_title" = "Palautusvirke";
|
||||
"vc_link_device_scan_qr_code_explanation" = "Siirry asetuksiin ja sitten kohtaan palautusvirke toisella laitteellasi näyttääksesi QR-koodisi.";
|
||||
"vc_link_device_scan_qr_code_explanation" = "Siirry asetuksiin → palautusvirke toisella laitteellasi näyttääksesi QR-koodisi.";
|
||||
"vc_enter_recovery_phrase_title" = "Palautusvirke";
|
||||
"vc_enter_recovery_phrase_explanation" = "Yhdistääksesi laitteesi, anna palautusvirke, jonka sait rekisteröitymisen yhteydessä.";
|
||||
"vc_enter_public_key_text_field_hint" = "Anna Session ID tai ONS-nimi";
|
||||
"vc_enter_public_key_text_field_hint" = "Syötä Session ID tai ONS-nimi";
|
||||
"vc_home_title" = "Viestit";
|
||||
"admin_group_leave_warning" = "Koska teit tämän ryhmän, poistetaan se kaikilta. Tätä ei voi peruuttaa.";
|
||||
"admin_group_leave_warning" = "Koska loit tämän ryhmän, se poistetaan kaikilta. Tätä ei voi peruuttaa.";
|
||||
"vc_join_open_group_suggestions_title" = "Tai liity yhteen näistä...";
|
||||
"vc_settings_invite_a_friend_button_title" = "Kutsu ystävä";
|
||||
"vc_settings_invite_a_friend_button_title" = "Kutsu ystäviä";
|
||||
"vc_settings_help_us_translate_button_title" = "Auta meitä kääntämään Session";
|
||||
"copied" = "Kopioitu";
|
||||
"vc_conversation_settings_copy_session_id_button_title" = "Kopioi Session ID";
|
||||
|
@ -555,69 +561,96 @@
|
|||
"modal_open_url_title" = "Avataanko URL?";
|
||||
"modal_open_url_explanation" = "Oletko varma, että haluat avata linkin %@?";
|
||||
"modal_open_url_button_title" = "Avaa";
|
||||
"modal_copy_url_button_title" = "Copy Link";
|
||||
"modal_blocked_title" = "Poista esto henkilöltä %@?";
|
||||
"modal_copy_url_button_title" = "Kopioi Linkki";
|
||||
"modal_blocked_title" = "Poistetaanko esto henkilöltä %@?";
|
||||
"modal_blocked_explanation" = "Oletko varma, että haluat poistaa eston henkilöltä %@?";
|
||||
"modal_blocked_button_title" = "Poista esto";
|
||||
"modal_link_previews_title" = "Ota linkkien esikatselu käyttöön?";
|
||||
"modal_link_previews_explanation" = "Enabling link previews will show previews for URLs you send and receive. This can be useful, but Session will need to contact linked websites to generate previews. You can always disable link previews in Session's settings.";
|
||||
"modal_link_previews_title" = "Luodaanko linkeistä esikatselut?";
|
||||
"modal_link_previews_explanation" = "Linkkien esikatselu näyttää esikatselun saamiesi ja lähettämiesi linkkien sisällöstä. Tämä voi olla hyödyllistä, mutta Sessionin pitää vierailla linkatulla nettisivulla luodakseen esikatselun. Voit aina ottaa tämän toiminnon pois päältä Sessionin asetuksissa.";
|
||||
"modal_link_previews_button_title" = "Ota käyttöön";
|
||||
"modal_share_logs_title" = "Share Logs";
|
||||
"modal_share_logs_explanation" = "Would you like to export your application logs to be able to share for troubleshooting?";
|
||||
"modal_call_title" = "Ääni-/videopuhelut";
|
||||
"modal_call_explanation" = "Nykyinen ääni-/videopuheluiden toteutus paljastaa IP-osoitteesi Oxen-säätiön palvelimille sekä soittavalle/soitettavalle käyttäjälle.";
|
||||
"modal_share_logs_title" = "Jaa lokitietoja";
|
||||
"modal_share_logs_explanation" = "Haluatko siirtää sovelluksen lokitiedot että ne ovat jaettavissa vianmääritystä varten?";
|
||||
"vc_share_title" = "Jaa Sessioniin";
|
||||
"vc_share_loading_message" = "Valmistellaan liitteitä...";
|
||||
"vc_share_sending_message" = "Lähetetään...";
|
||||
"vc_share_link_previews_unsecure" = "Preview not loaded for unsecure link";
|
||||
"vc_share_link_previews_error" = "Unable to load preview";
|
||||
"vc_share_link_previews_disabled_title" = "Link Previews Disabled";
|
||||
"vc_share_link_previews_unsecure" = "Esikatselua ei ladattu epäturvalliselle linkille";
|
||||
"vc_share_link_previews_error" = "Esikatselun lataaminen epäonnistui";
|
||||
"vc_share_link_previews_disabled_title" = "Linkin esikatselut otettu pois käytöstä";
|
||||
"vc_share_link_previews_disabled_explanation" = "Enabling link previews will show previews for URLs you share. This can be useful, but Session will need to contact linked websites to generate previews.\n\nYou can enable link previews in Session's settings.";
|
||||
"view_open_group_invitation_description" = "Avaa ryhmäkutsu";
|
||||
"vc_conversation_settings_invite_button_title" = "Lisää jäseniä";
|
||||
"vc_settings_faq_button_title" = "FAQ";
|
||||
"vc_settings_survey_button_title" = "Feedback / Survey";
|
||||
"vc_settings_support_button_title" = "Debug Log";
|
||||
"modal_send_seed_title" = "Warning";
|
||||
"modal_send_seed_explanation" = "This is your recovery phrase. If you send it to someone they'll have full access to your account.";
|
||||
"modal_send_seed_send_button_title" = "Send";
|
||||
"vc_conversation_settings_notify_for_mentions_only_title" = "Notify for Mentions Only";
|
||||
"vc_conversation_settings_notify_for_mentions_only_explanation" = "When enabled, you'll only be notified for messages mentioning you.";
|
||||
"view_conversation_title_notify_for_mentions_only" = "Notifying for Mentions Only";
|
||||
"message_deleted" = "This message has been deleted";
|
||||
"delete_message_for_me" = "Delete just for me";
|
||||
"delete_message_for_everyone" = "Delete for everyone";
|
||||
"delete_message_for_me_and_recipient" = "Delete for me and %@";
|
||||
"context_menu_reply" = "Reply";
|
||||
"context_menu_save" = "Save";
|
||||
"context_menu_ban_user" = "Ban User";
|
||||
"context_menu_ban_and_delete_all" = "Ban and Delete All";
|
||||
"accessibility_expanding_attachments_button" = "Add attachments";
|
||||
"vc_settings_faq_button_title" = "UKK";
|
||||
"vc_settings_survey_button_title" = "Palaute / Kysely";
|
||||
"vc_settings_support_button_title" = "Virheenkorjausloki";
|
||||
"modal_send_seed_title" = "Varoitus";
|
||||
"modal_send_seed_explanation" = "Tämä on palautuslauseesi. Jos lähetät sen jollekulle, heillä on täysin vapaa pääsy tililesi.";
|
||||
"modal_send_seed_send_button_title" = "Lähetä";
|
||||
"vc_conversation_settings_notify_for_mentions_only_title" = "Huomioi vain mainitut";
|
||||
"vc_conversation_settings_notify_for_mentions_only_explanation" = "Vain viestit joissa sinut mainitaan, huomioidaan.";
|
||||
"view_conversation_title_notify_for_mentions_only" = "Vain huomioidut ilmoitukset";
|
||||
"message_deleted" = "Tämä viesti on poistettu";
|
||||
"delete_message_for_me" = "Poista vain minun nähtäväksi";
|
||||
"delete_message_for_everyone" = "Poista kaikkien näkyviltä";
|
||||
"delete_message_for_me_and_recipient" = "Poista minulta ja vastaanottajalta";
|
||||
"context_menu_reply" = "Vastaa";
|
||||
"context_menu_save" = "Tallenna";
|
||||
"context_menu_ban_user" = "Estä Käyttäjä";
|
||||
"context_menu_ban_and_delete_all" = "Estä ja Poista kaikki";
|
||||
"context_menu_ban_user_error_alert_message" = "Unable to ban user";
|
||||
"accessibility_expanding_attachments_button" = "Lisää liitteitä";
|
||||
"accessibility_gif_button" = "Gif";
|
||||
"accessibility_document_button" = "Document";
|
||||
"accessibility_library_button" = "Photo library";
|
||||
"accessibility_camera_button" = "Camera";
|
||||
"accessibility_main_button_collapse" = "Collapse attachment options";
|
||||
"DISMISS_BUTTON_TEXT" = "Dismiss";
|
||||
"accessibility_document_button" = "Asiakirja";
|
||||
"accessibility_library_button" = "Kuvakirjasto";
|
||||
"accessibility_camera_button" = "Kamera";
|
||||
"accessibility_main_button_collapse" = "Tiivistä liiteasetukset";
|
||||
"invalid_recovery_phrase" = "Virheellinen Palautuslauseke";
|
||||
"invalid_recovery_phrase" = "Virheellinen palautuslauseke";
|
||||
"DISMISS_BUTTON_TEXT" = "Hylkää";
|
||||
/* Button text which opens the settings app */
|
||||
"OPEN_SETTINGS_BUTTON" = "Settings";
|
||||
"APN_Message" = "You've got a new message";
|
||||
"system_mode_theme" = "System";
|
||||
"dark_mode_theme" = "Dark";
|
||||
"light_mode_theme" = "Light";
|
||||
"MESSAGE_REQUESTS_TITLE" = "Message Requests";
|
||||
"MESSAGE_REQUESTS_EMPTY_TEXT" = "No pending message requests";
|
||||
"MESSAGE_REQUESTS_CLEAR_ALL" = "Clear All";
|
||||
"MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "Are you sure you want to clear all message requests?";
|
||||
"MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON" = "Clear";
|
||||
"MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "Are you sure you want to delete this message request?";
|
||||
"MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE" = "An error occurred when trying to accept this message request";
|
||||
"MESSAGE_REQUESTS_INFO" = "Sending a message to this user will automatically accept their message request.";
|
||||
"MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted.";
|
||||
"MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request";
|
||||
"TXT_HIDE_TITLE" = "Hide";
|
||||
"TXT_DELETE_ACCEPT" = "Accept";
|
||||
"NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group";
|
||||
"NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message";
|
||||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"OPEN_SETTINGS_BUTTON" = "Asetukset";
|
||||
"call_outgoing" = "Soitit käyttäjälle %@";
|
||||
"call_incoming" = "%@ soitti sinulle";
|
||||
"call_missed" = "Vastaamaton puhelu käyttäjältä %@";
|
||||
"call_rejected" = "Hylätty puhelu";
|
||||
"call_cancelled" = "Peruttu puhelu";
|
||||
"call_timeout" = "Vastaamaton puhelu";
|
||||
"voice_call" = "Äänipuhelu";
|
||||
"video_call" = "Videopuhelu";
|
||||
"APN_Message" = "Sinulla on uusi viesti";
|
||||
"APN_Collapsed_Messages" = "Sinulla on %@ uutta viestiä.";
|
||||
"system_mode_theme" = "Järjestelmä";
|
||||
"dark_mode_theme" = "Tumma";
|
||||
"light_mode_theme" = "Vaalea";
|
||||
"PIN_BUTTON_TEXT" = "Kiinnitä";
|
||||
"UNPIN_BUTTON_TEXT" = "Irrota";
|
||||
"modal_call_missed_tips_title" = "Vastaamaton puhelu";
|
||||
"modal_call_missed_tips_explanation" = "Vastaamaton puhelu käyttäjältä '%@', koska pahelut edellyttävät 'Ääni- ja videopuhelut' -käyttöoikeuden yksityisyysasetuksista.";
|
||||
"meida_saved" = "%@ tallensi median.";
|
||||
"screenshot_taken" = "%@ otti kuvankaappauksen.";
|
||||
"SEARCH_SECTION_CONTACTS" = "Henkilöt ja ryhmät";
|
||||
"SEARCH_SECTION_MESSAGES" = "Viestit";
|
||||
"SEARCH_SECTION_RECENT" = "Viimeisimmät";
|
||||
"RECENT_SEARCH_LAST_MESSAGE_DATETIME" = "viimeisin viesti: %@";
|
||||
"MESSAGE_REQUESTS_TITLE" = "Viestipyynnöt";
|
||||
"MESSAGE_REQUESTS_EMPTY_TEXT" = "Ei odottavia viestipyyntöjä";
|
||||
"MESSAGE_REQUESTS_CLEAR_ALL" = "Poista kaikki";
|
||||
"MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "Oletko varma että haluat poistaa kaikki viestipyynnöt?";
|
||||
"MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON" = "Poista";
|
||||
"MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "Oletko varma että haluat poistaa tämän viestipyynnön?";
|
||||
"MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE" = "Viestipyyntöä hyväksyttäessä ilmeni virhe";
|
||||
"MESSAGE_REQUESTS_INFO" = "Viestin lähettäminen tälle henkilölle hyväksyy automaattisesti viestipyynnön.";
|
||||
"MESSAGE_REQUESTS_ACCEPTED" = "Viestipyyntösi hyväksyttiin.";
|
||||
"MESSAGE_REQUESTS_NOTIFICATION" = "Sinulla on uusi viestipyyntö";
|
||||
"TXT_HIDE_TITLE" = "Piilota";
|
||||
"TXT_DELETE_ACCEPT" = "Hyväksy";
|
||||
"ALERT_ERROR_TITLE" = "Virhe";
|
||||
"NEW_CONVERSATION_MENU_OPEN_GROUP" = "Avoin ryhmä";
|
||||
"NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Yksityisviesti";
|
||||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Suljettu ryhmä";
|
||||
"modal_call_permission_request_title" = "Call Permissions Required";
|
||||
"modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings.";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"ALERT_ERROR_TITLE" = "Error";
|
||||
|
|
|
@ -27,23 +27,23 @@
|
|||
/* The title of the 'attachment error' alert. */
|
||||
"ATTACHMENT_ERROR_ALERT_TITLE" = "Erreur d’envoi du fichier joint";
|
||||
/* Attachment error message for image attachments which could not be converted to JPEG */
|
||||
"ATTACHMENT_ERROR_COULD_NOT_CONVERT_TO_JPEG" = "Impossible de convertir l’image.";
|
||||
"ATTACHMENT_ERROR_COULD_NOT_CONVERT_TO_JPEG" = "Impossible de convertir l'image.";
|
||||
/* Attachment error message for video attachments which could not be converted to MP4 */
|
||||
"ATTACHMENT_ERROR_COULD_NOT_CONVERT_TO_MP4" = "Impossible de traiter la vidéo.";
|
||||
"ATTACHMENT_ERROR_COULD_NOT_CONVERT_TO_MP4" = "Impossible de convertir la vidéo.";
|
||||
/* Attachment error message for image attachments which cannot be parsed */
|
||||
"ATTACHMENT_ERROR_COULD_NOT_PARSE_IMAGE" = "Impossible d’analyser l’image.";
|
||||
"ATTACHMENT_ERROR_COULD_NOT_PARSE_IMAGE" = "Impossible d'importer l'image.";
|
||||
/* Attachment error message for image attachments in which metadata could not be removed */
|
||||
"ATTACHMENT_ERROR_COULD_NOT_REMOVE_METADATA" = "Impossible de supprimer les métadonnées de l’image.";
|
||||
"ATTACHMENT_ERROR_COULD_NOT_REMOVE_METADATA" = "Impossible de supprimer les métadonnées de l'image.";
|
||||
/* Attachment error message for image attachments which could not be resized */
|
||||
"ATTACHMENT_ERROR_COULD_NOT_RESIZE_IMAGE" = "Impossible de redimensionner l’image.";
|
||||
/* Attachment error message for attachments whose data exceed file size limits */
|
||||
"ATTACHMENT_ERROR_FILE_SIZE_TOO_LARGE" = "Le fichier joint est trop volumineux.";
|
||||
"ATTACHMENT_ERROR_FILE_SIZE_TOO_LARGE" = "La pièce jointe est trop lourde.";
|
||||
/* Attachment error message for attachments with invalid data */
|
||||
"ATTACHMENT_ERROR_INVALID_DATA" = "Le fichier joint comporte du contenu non valide.";
|
||||
"ATTACHMENT_ERROR_INVALID_DATA" = "La pièce jointe contient du contenu non valide.";
|
||||
/* Attachment error message for attachments with an invalid file format */
|
||||
"ATTACHMENT_ERROR_INVALID_FILE_FORMAT" = "Le fichier joint présente un format de fichier invalide.";
|
||||
"ATTACHMENT_ERROR_INVALID_FILE_FORMAT" = "Le format de la pièce jointe est invalide.";
|
||||
/* Attachment error message for attachments without any data */
|
||||
"ATTACHMENT_ERROR_MISSING_DATA" = "Le fichier joint est vide.";
|
||||
"ATTACHMENT_ERROR_MISSING_DATA" = "La pièce jointe est vide.";
|
||||
/* Alert title when picking a document fails for an unknown reason */
|
||||
"ATTACHMENT_PICKER_DOCUMENTS_FAILED_ALERT_TITLE" = "Échec de sélection du document.";
|
||||
/* Alert body when picking a document fails because user picked a directory/bundle */
|
||||
|
@ -94,7 +94,7 @@
|
|||
"BLOCK_LIST_BLOCK_BUTTON" = "Bloquer";
|
||||
/* A format for the 'block user' action sheet title. Embeds {{the blocked user's name or phone number}}. */
|
||||
"BLOCK_LIST_BLOCK_USER_TITLE_FORMAT" = "Bloquer %@ ?";
|
||||
/* A format for the 'unblock conversation' action sheet title. Embeds the {{conversation title}}. */
|
||||
/* A format for the 'unblock user' action sheet title. Embeds {{the unblocked user's name or phone number}}. */
|
||||
"BLOCK_LIST_UNBLOCK_TITLE_FORMAT" = "Débloquer %@ ?";
|
||||
/* Button label for the 'unblock' button */
|
||||
"BLOCK_LIST_UNBLOCK_BUTTON" = "Débloquer";
|
||||
|
@ -105,7 +105,7 @@
|
|||
/* Alert title after unblocking a group or 1:1 chat. Embeds the {{conversation title}}. */
|
||||
"BLOCK_LIST_VIEW_UNBLOCKED_ALERT_TITLE_FORMAT" = "%@ a été débloqué.";
|
||||
/* Alert body after unblocking a group. */
|
||||
"BLOCK_LIST_VIEW_UNBLOCKED_GROUP_ALERT_BODY" = "Les membres actuels peuvent désormais vous ajouter au groupe de nouveau.";
|
||||
"BLOCK_LIST_VIEW_UNBLOCKED_GROUP_ALERT_BODY" = "Les membres actuels peuvent désormais vous ajouter au groupe à nouveau.";
|
||||
/* An explanation of the consequences of blocking another user. */
|
||||
"BLOCK_USER_BEHAVIOR_EXPLANATION" = "Les utilisateurs bloqués ne pourront ni vous appeler ni vous envoyer des messages.";
|
||||
/* Label for generic done button. */
|
||||
|
@ -307,9 +307,9 @@
|
|||
/* label for system photo collections which have no name. */
|
||||
"PHOTO_PICKER_UNNAMED_COLLECTION" = "Album sans nom";
|
||||
/* Notification action button title */
|
||||
"PUSH_MANAGER_MARKREAD" = "Mark as Read";
|
||||
"PUSH_MANAGER_MARKREAD" = "Marquer comme lu";
|
||||
/* Notification action button title */
|
||||
"PUSH_MANAGER_REPLY" = "Reply";
|
||||
"PUSH_MANAGER_REPLY" = "Répondre";
|
||||
/* alert body during registration */
|
||||
"REGISTRATION_ERROR_BLANK_VERIFICATION_CODE" = "Nous ne pouvons pas activer votre compte tant que vous n’aurez pas vérifié le code que nous vous avons envoyé.";
|
||||
/* Indicates a delay of zero seconds, and that 'screen lock activity' will timeout immediately. */
|
||||
|
@ -371,9 +371,15 @@
|
|||
/* Setting for enabling & disabling link previews. */
|
||||
"SETTINGS_LINK_PREVIEWS" = "Envoyer des aperçus de liens.";
|
||||
/* Footer for setting for enabling & disabling link previews. */
|
||||
"SETTINGS_LINK_PREVIEWS_FOOTER" = "Les aperçus sont pris en charge pour les liens Imgur, Instagram, Pinterest, Reddit et YouTube.";
|
||||
"SETTINGS_LINK_PREVIEWS_FOOTER" = "Les aperçus sont pris en charge pour la plupart des URLs.";
|
||||
/* Header for setting for enabling & disabling link previews. */
|
||||
"SETTINGS_LINK_PREVIEWS_HEADER" = "Aperçus de liens";
|
||||
/* Setting for enabling & disabling voice & video calls. */
|
||||
"SETTINGS_CALLS" = "Appels audio et vidéo";
|
||||
/* Footer for setting for enabling & disabling voice & video calls. */
|
||||
"SETTINGS_CALLS_FOOTER" = "Autoriser les appels audios et vidéos venant des autres utilisateurs.";
|
||||
/* Header for setting for enabling & disabling voice & video calls. */
|
||||
"SETTINGS_CALLS_HEADER" = "Appels";
|
||||
/* table section header */
|
||||
"SETTINGS_NOTIFICATION_CONTENT_TITLE" = "Contenu des notifications";
|
||||
/* Label for the 'read receipts' setting. */
|
||||
|
@ -518,14 +524,14 @@
|
|||
"modal_seed_explanation" = "Ceci est votre phrase de récupération. Elle vous permet de restaurer ou migrer votre Session ID vers un nouvel appareil.";
|
||||
"modal_clear_all_data_title" = "Effacer toutes les données";
|
||||
"modal_clear_all_data_explanation" = "Cela supprimera définitivement vos messages, vos sessions et vos contacts.";
|
||||
"modal_clear_all_data_explanation_2" = "Would you like to clear only this device, or delete your entire account?";
|
||||
"dialog_clear_all_data_deletion_failed_1" = "Data not deleted by 1 Service Node. Service Node ID: %@.";
|
||||
"dialog_clear_all_data_deletion_failed_2" = "Data not deleted by %@ Service Nodes. Service Node IDs: %@.";
|
||||
"modal_clear_all_data_device_only_button_title" = "Device Only";
|
||||
"modal_clear_all_data_entire_account_button_title" = "Entire Account";
|
||||
"modal_clear_all_data_explanation_2" = "Souhaitez-vous effacer seulement cet appareil ou supprimer l’ensemble de votre compte ?";
|
||||
"dialog_clear_all_data_deletion_failed_1" = "Les données n’ont pas été supprimées sur un nœud de service. ID du nœud de service : %@.";
|
||||
"dialog_clear_all_data_deletion_failed_2" = "Les données n’ont pas été supprimées sur %@ nœuds de service. ID des nœuds de service : %@.";
|
||||
"modal_clear_all_data_device_only_button_title" = "L’appareil uniquement";
|
||||
"modal_clear_all_data_entire_account_button_title" = "L’ensemble du compte";
|
||||
"vc_qr_code_title" = "Code QR";
|
||||
"vc_qr_code_view_my_qr_code_tab_title" = "Afficher mon code QR";
|
||||
"vc_qr_code_view_scan_qr_code_tab_title" = "Scanner le code QR";
|
||||
"vc_qr_code_view_scan_qr_code_tab_title" = "Scanner le QR Code";
|
||||
"vc_qr_code_view_scan_qr_code_explanation" = "Scannez le code QR d'un autre utilisateur pour démarrer une session";
|
||||
"vc_view_my_qr_code_explanation" = "Ceci est votre code QR. Les autres utilisateurs peuvent le scanner pour démarrer une session avec vous.";
|
||||
// MARK: - Not Yet Translated
|
||||
|
@ -555,69 +561,96 @@
|
|||
"modal_open_url_title" = "Ouvrir l'URL?";
|
||||
"modal_open_url_explanation" = "Êtes-vous sûr de vouloir ouvrir %@?";
|
||||
"modal_open_url_button_title" = "Ouvrir";
|
||||
"modal_copy_url_button_title" = "Copy Link";
|
||||
"modal_copy_url_button_title" = "Copier le lien";
|
||||
"modal_blocked_title" = "Débloquer %@ ?";
|
||||
"modal_blocked_explanation" = "Confirmez-vous le déblocage de %@ ?";
|
||||
"modal_blocked_button_title" = "Débloquer";
|
||||
"modal_link_previews_title" = "Activer les aperçus de lien?";
|
||||
"modal_link_previews_explanation" = "L'activation des aperçus de lien affichera des aperçus pour les URL que vous envoyez et recevez. Cela peut être utile, mais Session devra contacter les sites Web liés pour générer des aperçus. Vous pouvez toujours désactiver les aperçus de lien dans les paramètres de Session.";
|
||||
"modal_link_previews_button_title" = "Activer";
|
||||
"modal_share_logs_title" = "Share Logs";
|
||||
"modal_share_logs_explanation" = "Would you like to export your application logs to be able to share for troubleshooting?";
|
||||
"modal_call_title" = "Appels audio et vidéo";
|
||||
"modal_call_explanation" = "La mise en œuvre actuelle des appels vocaux/vidéo exposera votre adresse IP aux serveurs de la Fondation Oxen et aux utilisateurs appelés.";
|
||||
"modal_share_logs_title" = "Partager les logs";
|
||||
"modal_share_logs_explanation" = "Voulez-vous exporter les logs de votre application pour pouvoir les partager pour du débogage ?";
|
||||
"vc_share_title" = "Partager en Session";
|
||||
"vc_share_loading_message" = "Préparation des pièces jointes ...";
|
||||
"vc_share_sending_message" = "Envoi...";
|
||||
"vc_share_link_previews_unsecure" = "Preview not loaded for unsecure link";
|
||||
"vc_share_link_previews_error" = "Unable to load preview";
|
||||
"vc_share_link_previews_disabled_title" = "Link Previews Disabled";
|
||||
"vc_share_link_previews_disabled_explanation" = "Enabling link previews will show previews for URLs you share. This can be useful, but Session will need to contact linked websites to generate previews.\n\nYou can enable link previews in Session's settings.";
|
||||
"vc_share_link_previews_unsecure" = "Aperçu non chargé pour les liens non sécurisés";
|
||||
"vc_share_link_previews_error" = "Impossible de charger l'aperçu";
|
||||
"vc_share_link_previews_disabled_title" = "Aperçu des Liens Désactivé";
|
||||
"vc_share_link_previews_disabled_explanation" = "L'activation des aperçus de lien affichera des aperçus pour les URL que vous partagez. Cela peut être utile, mais Session devra contacter les sites Web liés pour générer des aperçus.\n\nVous pouvez toujours activer les aperçus de lien dans les paramètres de Session.";
|
||||
"view_open_group_invitation_description" = "Invitation à un groupe ouvert";
|
||||
"vc_conversation_settings_invite_button_title" = "Ajouter des membres";
|
||||
"vc_settings_faq_button_title" = "FAQ";
|
||||
"vc_settings_survey_button_title" = "Feedback / Survey";
|
||||
"vc_settings_support_button_title" = "Debug Log";
|
||||
"modal_send_seed_title" = "Warning";
|
||||
"modal_send_seed_explanation" = "This is your recovery phrase. If you send it to someone they'll have full access to your account.";
|
||||
"modal_send_seed_send_button_title" = "Send";
|
||||
"vc_conversation_settings_notify_for_mentions_only_title" = "Notify for Mentions Only";
|
||||
"vc_conversation_settings_notify_for_mentions_only_explanation" = "When enabled, you'll only be notified for messages mentioning you.";
|
||||
"view_conversation_title_notify_for_mentions_only" = "Notifying for Mentions Only";
|
||||
"message_deleted" = "This message has been deleted";
|
||||
"delete_message_for_me" = "Delete just for me";
|
||||
"delete_message_for_everyone" = "Delete for everyone";
|
||||
"delete_message_for_me_and_recipient" = "Delete for me and %@";
|
||||
"context_menu_reply" = "Reply";
|
||||
"context_menu_save" = "Save";
|
||||
"context_menu_ban_user" = "Ban User";
|
||||
"context_menu_ban_and_delete_all" = "Ban and Delete All";
|
||||
"accessibility_expanding_attachments_button" = "Add attachments";
|
||||
"vc_settings_survey_button_title" = "Retours / Sondage";
|
||||
"vc_settings_support_button_title" = "Logs de Débug";
|
||||
"modal_send_seed_title" = "Attention";
|
||||
"modal_send_seed_explanation" = "Voici votre phrase de récupération. Si vous l'envoyez à quelqu'un, cette personne aura un accès complet à votre compte.";
|
||||
"modal_send_seed_send_button_title" = "Envoyer";
|
||||
"vc_conversation_settings_notify_for_mentions_only_title" = "Activer les notifications que sur mention";
|
||||
"vc_conversation_settings_notify_for_mentions_only_explanation" = "Quand activer, vous recevrez les notifications d’uniquement les messages vous notifiant.";
|
||||
"view_conversation_title_notify_for_mentions_only" = "Me notifier que si je suis mentionné(e)";
|
||||
"message_deleted" = "Ce message a été supprimé";
|
||||
"delete_message_for_me" = "Supprimer pour moi uniquement";
|
||||
"delete_message_for_everyone" = "Supprimer pour tout le monde";
|
||||
"delete_message_for_me_and_recipient" = "Supprimer pour moi et %@";
|
||||
"context_menu_reply" = "Répondre";
|
||||
"context_menu_save" = "Enregistrer";
|
||||
"context_menu_ban_user" = "Bannir l'utilisateur";
|
||||
"context_menu_ban_and_delete_all" = "Bannir et supprimer tout";
|
||||
"context_menu_ban_user_error_alert_message" = "Unable to ban user";
|
||||
"accessibility_expanding_attachments_button" = "Ajouter une pièce jointe";
|
||||
"accessibility_gif_button" = "Gif";
|
||||
"accessibility_document_button" = "Document";
|
||||
"accessibility_library_button" = "Photo library";
|
||||
"accessibility_camera_button" = "Camera";
|
||||
"accessibility_main_button_collapse" = "Collapse attachment options";
|
||||
"DISMISS_BUTTON_TEXT" = "Dismiss";
|
||||
"accessibility_library_button" = "Bibliothèque photos";
|
||||
"accessibility_camera_button" = "Caméra";
|
||||
"accessibility_main_button_collapse" = "Réduire les options de pièces jointes";
|
||||
"invalid_recovery_phrase" = "Phrase de récupération incorrecte";
|
||||
"invalid_recovery_phrase" = "Phrase de récupération incorrecte";
|
||||
"DISMISS_BUTTON_TEXT" = "Fermer";
|
||||
/* Button text which opens the settings app */
|
||||
"OPEN_SETTINGS_BUTTON" = "Settings";
|
||||
"APN_Message" = "You've got a new message";
|
||||
"system_mode_theme" = "System";
|
||||
"dark_mode_theme" = "Dark";
|
||||
"light_mode_theme" = "Light";
|
||||
"MESSAGE_REQUESTS_TITLE" = "Message Requests";
|
||||
"MESSAGE_REQUESTS_EMPTY_TEXT" = "No pending message requests";
|
||||
"MESSAGE_REQUESTS_CLEAR_ALL" = "Clear All";
|
||||
"MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "Are you sure you want to clear all message requests?";
|
||||
"MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON" = "Clear";
|
||||
"MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "Are you sure you want to delete this message request?";
|
||||
"MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE" = "An error occurred when trying to accept this message request";
|
||||
"MESSAGE_REQUESTS_INFO" = "Sending a message to this user will automatically accept their message request.";
|
||||
"MESSAGE_REQUESTS_ACCEPTED" = "Your message request has been accepted.";
|
||||
"MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request";
|
||||
"TXT_HIDE_TITLE" = "Hide";
|
||||
"TXT_DELETE_ACCEPT" = "Accept";
|
||||
"NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group";
|
||||
"NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message";
|
||||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"OPEN_SETTINGS_BUTTON" = "Paramètres";
|
||||
"call_outgoing" = "Vous avez appelé %@";
|
||||
"call_incoming" = "%@ vous a appelé";
|
||||
"call_missed" = "Appel manqué de %@";
|
||||
"call_rejected" = "Appel refusé";
|
||||
"call_cancelled" = "Appel annulé";
|
||||
"call_timeout" = "Appels sans réponse";
|
||||
"voice_call" = "Appel vocal";
|
||||
"video_call" = "Appel vidéo";
|
||||
"APN_Message" = "Vous avez un nouveau message.";
|
||||
"APN_Collapsed_Messages" = "Vous avez %@ nouveaux messages.";
|
||||
"system_mode_theme" = "Système";
|
||||
"dark_mode_theme" = "Sombre";
|
||||
"light_mode_theme" = "Clair";
|
||||
"PIN_BUTTON_TEXT" = "Épingler";
|
||||
"UNPIN_BUTTON_TEXT" = "Désépingler";
|
||||
"modal_call_missed_tips_title" = "Appel manqué";
|
||||
"modal_call_missed_tips_explanation" = "Appel manqué de '%@' car vous devez activer la permission 'Appels vocaux et vidéo' dans les paramètres de confidentialité.";
|
||||
"meida_saved" = "%@ a enregistré le média.";
|
||||
"screenshot_taken" = "%@ a pris une capture d'écran.";
|
||||
"SEARCH_SECTION_CONTACTS" = "Contacts et Groupes";
|
||||
"SEARCH_SECTION_MESSAGES" = "Messages";
|
||||
"SEARCH_SECTION_RECENT" = "Récent";
|
||||
"RECENT_SEARCH_LAST_MESSAGE_DATETIME" = "dernier message : %@";
|
||||
"MESSAGE_REQUESTS_TITLE" = "Demandes de message";
|
||||
"MESSAGE_REQUESTS_EMPTY_TEXT" = "Aucune demande de message en attente";
|
||||
"MESSAGE_REQUESTS_CLEAR_ALL" = "Effacer tous";
|
||||
"MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "Êtes-vous sûr de vouloir supprimer toutes les demandes de messages ?";
|
||||
"MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON" = "Effacer";
|
||||
"MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "Êtes-vous sûr de vouloir supprimer cette demande de message ?";
|
||||
"MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE" = "Une erreur s'est produite en acceptant cette demande de message";
|
||||
"MESSAGE_REQUESTS_INFO" = "Envoyer un message à cet utilisateur acceptera automatiquement sa demande de message.";
|
||||
"MESSAGE_REQUESTS_ACCEPTED" = "Votre demande de message a été réceptionnée.";
|
||||
"MESSAGE_REQUESTS_NOTIFICATION" = "Vous avez une nouvelle demande de message";
|
||||
"TXT_HIDE_TITLE" = "Masquer";
|
||||
"TXT_DELETE_ACCEPT" = "Accepter";
|
||||
"ALERT_ERROR_TITLE" = "Erreur";
|
||||
"NEW_CONVERSATION_MENU_OPEN_GROUP" = "Groupe public";
|
||||
"NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Message privé";
|
||||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Groupe privé";
|
||||
"modal_call_permission_request_title" = "Autorisations d'appel requises";
|
||||
"modal_call_permission_request_explanation" = "Vous pouvez activer la permission \"Appels vocaux et vidéo\" dans les paramètres de confidentialité.";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"ALERT_ERROR_TITLE" = "Erreur";
|
||||
|
|
|
@ -123,9 +123,9 @@
|
|||
/* Error indicating that the app was prevented from accessing the user's iCloud account. */
|
||||
"CLOUDKIT_STATUS_RESTRICTED" = "Session को बैकअप के लिए आपके iCloud खाते तक पहुंच से वंचित कर दिया गया था। अपने Session डेटा का बैकअप लेने के लिए iOS सेटिंग ऐप में अपने iCloud खाते में Session की पहुंच प्रदान करें।";
|
||||
/* Alert body */
|
||||
"CONFIRM_LEAVE_GROUP_DESCRIPTION" = "You will no longer be able to send or receive messages in this group.";
|
||||
"CONFIRM_LEAVE_GROUP_DESCRIPTION" = "अब आप इस समूह में संदेश भेजने या प्राप्त करने में सक्षम नहीं होंगे।";
|
||||
/* Alert title */
|
||||
"CONFIRM_LEAVE_GROUP_TITLE" = "Do you really want to leave?";
|
||||
"CONFIRM_LEAVE_GROUP_TITLE" = "क्या आप वाकई छोड़ना चाहते हैं?";
|
||||
/* Message for the 'conversation delete confirmation' alert. */
|
||||
"CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "This cannot be undone.";
|
||||
/* Title for the 'conversation delete confirmation' alert. */
|
||||
|
@ -141,45 +141,45 @@
|
|||
/* table cell label in conversation settings */
|
||||
"CONVERSATION_SETTINGS_BLOCK_THIS_USER" = "Block This User";
|
||||
/* Title of the 'mute this thread' action sheet. */
|
||||
"CONVERSATION_SETTINGS_MUTE_ACTION_SHEET_TITLE" = "Mute";
|
||||
"CONVERSATION_SETTINGS_MUTE_ACTION_SHEET_TITLE" = "म्यूट";
|
||||
/* label for 'mute thread' cell in conversation settings */
|
||||
"CONVERSATION_SETTINGS_MUTE_LABEL" = "Mute";
|
||||
"CONVERSATION_SETTINGS_MUTE_LABEL" = "म्यूट";
|
||||
/* Indicates that the current thread is not muted. */
|
||||
"CONVERSATION_SETTINGS_MUTE_NOT_MUTED" = "Not muted";
|
||||
"CONVERSATION_SETTINGS_MUTE_NOT_MUTED" = "म्यूट नहीं";
|
||||
/* Label for button to mute a thread for a day. */
|
||||
"CONVERSATION_SETTINGS_MUTE_ONE_DAY_ACTION" = "Mute for one day";
|
||||
"CONVERSATION_SETTINGS_MUTE_ONE_DAY_ACTION" = "एक दिन के लिए म्यूट करें";
|
||||
/* Label for button to mute a thread for a hour. */
|
||||
"CONVERSATION_SETTINGS_MUTE_ONE_HOUR_ACTION" = "Mute for one hour";
|
||||
"CONVERSATION_SETTINGS_MUTE_ONE_HOUR_ACTION" = "एक घंटे के लिए म्यूट करें";
|
||||
/* Label for button to mute a thread for a minute. */
|
||||
"CONVERSATION_SETTINGS_MUTE_ONE_MINUTE_ACTION" = "Mute for one minute";
|
||||
"CONVERSATION_SETTINGS_MUTE_ONE_MINUTE_ACTION" = "एक मिनट के लिए म्यूट करें";
|
||||
/* Label for button to mute a thread for a week. */
|
||||
"CONVERSATION_SETTINGS_MUTE_ONE_WEEK_ACTION" = "Mute for one week";
|
||||
"CONVERSATION_SETTINGS_MUTE_ONE_WEEK_ACTION" = "एक हफ़्ते के लिए म्यूट करें";
|
||||
/* Label for button to mute a thread for a year. */
|
||||
"CONVERSATION_SETTINGS_MUTE_ONE_YEAR_ACTION" = "Mute for one year";
|
||||
"CONVERSATION_SETTINGS_MUTE_ONE_YEAR_ACTION" = "एक साल के लिए म्यूट करें";
|
||||
/* Indicates that this thread is muted until a given date or time. Embeds {{The date or time which the thread is muted until}}. */
|
||||
"CONVERSATION_SETTINGS_MUTED_UNTIL_FORMAT" = "until %@";
|
||||
"CONVERSATION_SETTINGS_MUTED_UNTIL_FORMAT" = "जब तक %@";
|
||||
/* Table cell label in conversation settings which returns the user to the conversation with 'search mode' activated */
|
||||
"CONVERSATION_SETTINGS_SEARCH" = "Search Conversation";
|
||||
"CONVERSATION_SETTINGS_SEARCH" = "बातचीत खोजें";
|
||||
/* Label for button to unmute a thread. */
|
||||
"CONVERSATION_SETTINGS_UNMUTE_ACTION" = "Unmute";
|
||||
"CONVERSATION_SETTINGS_UNMUTE_ACTION" = "अनम्यूट";
|
||||
/* Title for the 'crop/scale image' dialog. */
|
||||
"CROP_SCALE_IMAGE_VIEW_TITLE" = "Move and Scale";
|
||||
/* Subtitle shown while the app is updating its database. */
|
||||
"DATABASE_VIEW_OVERLAY_SUBTITLE" = "This can take a few minutes.";
|
||||
"DATABASE_VIEW_OVERLAY_SUBTITLE" = "इसमें कुछ मिनटों का समय लगेगा ।";
|
||||
/* Title shown while the app is updating its database. */
|
||||
"DATABASE_VIEW_OVERLAY_TITLE" = "Optimizing Database";
|
||||
"DATABASE_VIEW_OVERLAY_TITLE" = "डाटाबेस का अनुकूलन";
|
||||
/* Format string for a relative time, expressed as a certain number of hours in the past. Embeds {{The number of hours}}. */
|
||||
"DATE_HOURS_AGO_FORMAT" = "%@ Hr Ago";
|
||||
"DATE_HOURS_AGO_FORMAT" = "%@ घंटा पहले";
|
||||
/* Format string for a relative time, expressed as a certain number of minutes in the past. Embeds {{The number of minutes}}. */
|
||||
"DATE_MINUTES_AGO_FORMAT" = "%@ Min Ago";
|
||||
"DATE_MINUTES_AGO_FORMAT" = "%@ मिनट पहले";
|
||||
/* The present; the current time. */
|
||||
"DATE_NOW" = "Now";
|
||||
"DATE_NOW" = "अभी";
|
||||
/* The current day. */
|
||||
"DATE_TODAY" = "Today";
|
||||
"DATE_TODAY" = "आज";
|
||||
/* The day before today. */
|
||||
"DATE_YESTERDAY" = "Yesterday";
|
||||
"DATE_YESTERDAY" = "कल";
|
||||
/* table cell label in conversation settings */
|
||||
"DISAPPEARING_MESSAGES" = "Disappearing Messages";
|
||||
"DISAPPEARING_MESSAGES" = "गायब होने वाले संदेश";
|
||||
/* Info Message when added to a group which has enabled disappearing messages. Embeds {{time amount}} before messages disappear, see the *_TIME_AMOUNT strings for context. */
|
||||
"DISAPPEARING_MESSAGES_CONFIGURATION_GROUP_EXISTING_FORMAT" = "Messages in this conversation will disappear after %@.";
|
||||
/* table cell label in conversation settings */
|
||||
|
@ -211,13 +211,13 @@
|
|||
/* No comment provided by engineer. */
|
||||
"GROUP_MEMBER_REMOVED" = "%@ was removed from the group. ";
|
||||
/* No comment provided by engineer. */
|
||||
"GROUP_MEMBERS_REMOVED" = "%@ were removed from the group. ";
|
||||
"GROUP_MEMBERS_REMOVED" = " %@ समूह से हटा दिए गये हैं ";
|
||||
/* No comment provided by engineer. */
|
||||
"GROUP_TITLE_CHANGED" = "Title is now '%@'. ";
|
||||
/* No comment provided by engineer. */
|
||||
"GROUP_UPDATED" = "Group updated.";
|
||||
/* No comment provided by engineer. */
|
||||
"GROUP_YOU_LEFT" = "You have left the group.";
|
||||
"GROUP_YOU_LEFT" = "आपने समूह छोड़ दिया है";
|
||||
/* No comment provided by engineer. */
|
||||
"YOU_WERE_REMOVED" = " You were removed from the group. ";
|
||||
/* Momentarily shown to the user when attempting to select more images than is allowed. Embeds {{max number of items}} that can be shared. */
|
||||
|
@ -374,6 +374,12 @@
|
|||
"SETTINGS_LINK_PREVIEWS_FOOTER" = "Previews are supported for most urls.";
|
||||
/* Header for setting for enabling & disabling link previews. */
|
||||
"SETTINGS_LINK_PREVIEWS_HEADER" = "Link Previews";
|
||||
/* Setting for enabling & disabling voice & video calls. */
|
||||
"SETTINGS_CALLS" = "Voice and video calls";
|
||||
/* Footer for setting for enabling & disabling voice & video calls. */
|
||||
"SETTINGS_CALLS_FOOTER" = "Allow access to accept voice and video calls from other users.";
|
||||
/* Header for setting for enabling & disabling voice & video calls. */
|
||||
"SETTINGS_CALLS_HEADER" = "Calls";
|
||||
/* table section header */
|
||||
"SETTINGS_NOTIFICATION_CONTENT_TITLE" = "Notification Content";
|
||||
/* Label for the 'read receipts' setting. */
|
||||
|
@ -562,6 +568,8 @@
|
|||
"modal_link_previews_title" = "लिंक पूर्वावलोकन सक्षम करें?";
|
||||
"modal_link_previews_explanation" = "लिंक पूर्वावलोकन सक्षम करने से आपके द्वारा भेजे और प्राप्त किए जाने वाले URL के पूर्वावलोकन दिखाई देंगे. यह उपयोगी हो सकता है, लेकिन Session को पूर्वावलोकन उत्पन्न करने के लिए लिंक की गई वेबसाइटों से संपर्क करने की आवश्यकता होगी। आप कभी भी Session की सेटिंग में लिंक पूर्वावलोकन अक्षम कर सकते हैं।";
|
||||
"modal_link_previews_button_title" = "सक्षम करें";
|
||||
"modal_call_title" = "Voice / video calls";
|
||||
"modal_call_explanation" = "The current implementation of voice / video calls will expose your IP address to the Oxen Foundation servers and the calling / called user.";
|
||||
"modal_share_logs_title" = "Share Logs";
|
||||
"modal_share_logs_explanation" = "Would you like to export your application logs to be able to share for troubleshooting?";
|
||||
"vc_share_title" = "सत्र में साझा करें";
|
||||
|
@ -590,19 +598,41 @@
|
|||
"context_menu_save" = "Save";
|
||||
"context_menu_ban_user" = "Ban User";
|
||||
"context_menu_ban_and_delete_all" = "Ban and Delete All";
|
||||
"context_menu_ban_user_error_alert_message" = "Unable to ban user";
|
||||
"accessibility_expanding_attachments_button" = "Add attachments";
|
||||
"accessibility_gif_button" = "Gif";
|
||||
"accessibility_document_button" = "Document";
|
||||
"accessibility_library_button" = "Photo library";
|
||||
"accessibility_camera_button" = "Camera";
|
||||
"accessibility_main_button_collapse" = "Collapse attachment options";
|
||||
"invalid_recovery_phrase" = "Invalid Recovery Phrase";
|
||||
"invalid_recovery_phrase" = "Invalid Recovery Phrase";
|
||||
"DISMISS_BUTTON_TEXT" = "Dismiss";
|
||||
/* Button text which opens the settings app */
|
||||
"OPEN_SETTINGS_BUTTON" = "Settings";
|
||||
"APN_Message" = "You've got a new message";
|
||||
"call_outgoing" = "You called %@";
|
||||
"call_incoming" = "%@ called you";
|
||||
"call_missed" = "Missed Call from %@";
|
||||
"call_rejected" = "Rejected Call";
|
||||
"call_cancelled" = "Cancelled Call";
|
||||
"call_timeout" = "Unanswered Call";
|
||||
"voice_call" = "Voice Call";
|
||||
"video_call" = "Video Call";
|
||||
"APN_Message" = "You've got a new message.";
|
||||
"APN_Collapsed_Messages" = "You've got %@ new messages.";
|
||||
"system_mode_theme" = "System";
|
||||
"dark_mode_theme" = "Dark";
|
||||
"light_mode_theme" = "Light";
|
||||
"PIN_BUTTON_TEXT" = "Pin";
|
||||
"UNPIN_BUTTON_TEXT" = "Unpin";
|
||||
"modal_call_missed_tips_title" = "Call missed";
|
||||
"modal_call_missed_tips_explanation" = "Call missed from '%@' because you needed to enable the 'Voice and video calls' permission in the Privacy Settings.";
|
||||
"meida_saved" = "Media saved by %@.";
|
||||
"screenshot_taken" = "%@ took a screenshot.";
|
||||
"SEARCH_SECTION_CONTACTS" = "Contacts and Groups";
|
||||
"SEARCH_SECTION_MESSAGES" = "Messages";
|
||||
"SEARCH_SECTION_RECENT" = "Recent";
|
||||
"RECENT_SEARCH_LAST_MESSAGE_DATETIME" = "last message: %@";
|
||||
"MESSAGE_REQUESTS_TITLE" = "Message Requests";
|
||||
"MESSAGE_REQUESTS_EMPTY_TEXT" = "No pending message requests";
|
||||
"MESSAGE_REQUESTS_CLEAR_ALL" = "Clear All";
|
||||
|
@ -615,9 +645,12 @@
|
|||
"MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request";
|
||||
"TXT_HIDE_TITLE" = "Hide";
|
||||
"TXT_DELETE_ACCEPT" = "Accept";
|
||||
"ALERT_ERROR_TITLE" = "Error";
|
||||
"NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group";
|
||||
"NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message";
|
||||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"modal_call_permission_request_title" = "Call Permissions Required";
|
||||
"modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings.";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"ALERT_ERROR_TITLE" = "Error";
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue