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:
Morgan Pretty 2022-06-08 14:29:51 +10:00
commit 290bce5ce0
330 changed files with 27923 additions and 5958 deletions

1
.gitignore vendored
View File

@ -27,7 +27,6 @@ DerivedData
*.ipa
*.xcuserstate
Index/
Session-Turn-Server
# CocoaPods
Pods

21
Podfile
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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">

View File

@ -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">

View File

@ -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>

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1210"
LastUpgradeVersion = "1320"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"

View File

@ -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

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

550
Session/Calls/CallVC.swift Normal file
View File

@ -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
}
}
}

View File

@ -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.")
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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
}
}
}

View File

@ -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()
})
}
}

View File

@ -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
}
}
}

View File

@ -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)
}
}

View File

@ -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) {

View File

@ -132,4 +132,5 @@ protocol ContextMenuActionDelegate {
func save(_ cellViewModel: MessageViewModel)
func ban(_ cellViewModel: MessageViewModel)
func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel)
func contextMenuDismissed()
}

View File

@ -176,6 +176,7 @@ final class ContextMenuVC: UIViewController {
},
completion: { [weak self] _ in
self?.dismiss()
self.delegate?.contextMenuDismissed()
}
)
}

View File

@ -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

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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()
}
}
}

View File

@ -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

View File

@ -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
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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)
}

View File

@ -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()

View File

@ -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;

View File

@ -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

View File

@ -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()
}
}

View File

@ -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)
}
})
}
}

View File

@ -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) {

View File

@ -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

View File

@ -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)
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)
}

View File

@ -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

View File

@ -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)
}
}

View File

@ -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 }

View File

@ -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
}

View File

@ -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,

View File

@ -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()
}
}
}

View File

@ -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.

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "Airpods.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "AnswerCall.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "audio_off_fill.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "Bluetooth.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "CallIncoming.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "CallMissed.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "CallOutgoing.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "check.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "Path.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "Headsets.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "minimize.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "Phone.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "speaker.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "switch_camera_fill.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "Tips.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "video_call_fill.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -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>

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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" = "Youll be notified of new messages reliably and immediately using Apples 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" = "خطاء";

View File

@ -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 dont 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 users 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";

View File

@ -27,23 +27,23 @@
/* The title of the 'attachment error' alert. */
"ATTACHMENT_ERROR_ALERT_TITLE" = "Erreur denvoi 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 limage.";
"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 danalyser limage.";
"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 limage.";
"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 limage.";
/* 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 naurez 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 lensemble de votre compte ?";
"dialog_clear_all_data_deletion_failed_1" = "Les données nont 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 nont pas été supprimées sur %@ nœuds de service. ID des nœuds de service : %@.";
"modal_clear_all_data_device_only_button_title" = "Lappareil uniquement";
"modal_clear_all_data_entire_account_button_title" = "Lensemble 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 duniquement 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";

View File

@ -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