Merge branch 'develop' into doug/update_addressable_gem
@@ -1,6 +1,57 @@
|
||||
Changes to be released in next version
|
||||
=================================================
|
||||
|
||||
✨ Features
|
||||
*
|
||||
|
||||
🙌 Improvements
|
||||
* Room: Added support for Voice Messages (#4090, #4091, #4092, #4094, #4095, #4096)
|
||||
|
||||
🐛 Bugfix
|
||||
* Room: Fixed mentioning users from room info member details (#4583)
|
||||
|
||||
⚠️ API Changes
|
||||
*
|
||||
|
||||
🗣 Translations
|
||||
*
|
||||
|
||||
🧱 Build
|
||||
*
|
||||
|
||||
Others
|
||||
*
|
||||
|
||||
Changes in 1.4.7 (2021-07-22)
|
||||
=================================================
|
||||
|
||||
✨ Features
|
||||
*
|
||||
|
||||
🙌 Improvements
|
||||
*
|
||||
|
||||
🐛 Bugfix
|
||||
*
|
||||
|
||||
⚠️ API Changes
|
||||
*
|
||||
|
||||
🗣 Translations
|
||||
*
|
||||
|
||||
🧱 Build
|
||||
*
|
||||
|
||||
Others
|
||||
*
|
||||
|
||||
Improvements:
|
||||
* Upgrade MatrixKit version ([v0.15.5](https://github.com/matrix-org/matrix-ios-kit/releases/tag/v0.15.5)).
|
||||
|
||||
Changes in 1.4.6 (2021-07-16)
|
||||
=================================================
|
||||
|
||||
✨ Features
|
||||
*
|
||||
|
||||
@@ -17,6 +68,8 @@ Changes to be released in next version
|
||||
* Use different title for scan button for self verification (#4525).
|
||||
* it's easy for the back button to trigger a leftpanel reveal (#4438).
|
||||
* Show / hide reset button in secrets recovery screen (#4546).
|
||||
* Share Extension: Fix layout when searching (#4258).
|
||||
* Timeline: Fix incorrect crop of media thumbnails (#4552).
|
||||
|
||||
⚠️ API Changes
|
||||
*
|
||||
@@ -29,6 +82,33 @@ Changes to be released in next version
|
||||
|
||||
Others
|
||||
* Silenced some documentation, deprecations and SwiftLint warnings.
|
||||
|
||||
Improvements:
|
||||
* Upgrade MatrixKit version ([v0.15.4](https://github.com/matrix-org/matrix-ios-kit/releases/tag/v0.15.4)).
|
||||
|
||||
Changes in 1.4.5 (2021-07-07)
|
||||
=================================================
|
||||
|
||||
✨ Features
|
||||
*
|
||||
|
||||
🙌 Improvements
|
||||
*
|
||||
|
||||
🐛 Bugfix
|
||||
* Notifications: Fix an issue where the app is unresponsive after getting some notifications (#4534).
|
||||
|
||||
⚠️ API Changes
|
||||
*
|
||||
|
||||
🗣 Translations
|
||||
*
|
||||
|
||||
🧱 Build
|
||||
*
|
||||
|
||||
Others
|
||||
*
|
||||
|
||||
Changes in 1.4.4 (2021-06-30)
|
||||
=================================================
|
||||
|
||||
@@ -22,8 +22,8 @@ APPLICATION_GROUP_IDENTIFIER = group.im.vector
|
||||
APPLICATION_SCHEME = element
|
||||
|
||||
// Version
|
||||
MARKETING_VERSION = 1.4.5
|
||||
CURRENT_PROJECT_VERSION = 1.4.5
|
||||
MARKETING_VERSION = 1.4.8
|
||||
CURRENT_PROJECT_VERSION = 1.4.8
|
||||
|
||||
|
||||
// Team
|
||||
|
||||
@@ -309,6 +309,10 @@ final class BuildSettings: NSObject {
|
||||
static let messageDetailsAllowCopyMedia: Bool = true
|
||||
static let messageDetailsAllowPasteMedia: Bool = true
|
||||
|
||||
// MARK: - Voice Message
|
||||
|
||||
static let voiceMessagesEnabled = false
|
||||
|
||||
// MARK: - HTTP
|
||||
/// Additional HTTP headers will be sent by all requests. Not recommended to use request-specific headers, like `Authorization`.
|
||||
/// Empty dictionary by default.
|
||||
|
||||
@@ -45,6 +45,10 @@ import UIKit
|
||||
/// - Icons
|
||||
var quarterlyContent: UIColor { get }
|
||||
|
||||
/// - Text
|
||||
/// - Icons
|
||||
var quinaryContent: UIColor { get }
|
||||
|
||||
/// Separating line
|
||||
var separator: UIColor { get }
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ public class DarkColors: Colors {
|
||||
|
||||
public let quarterlyContent: UIColor = UIColor(rgb: 0x6F7882)
|
||||
|
||||
public let quinaryContent: UIColor = UIColor(rgb: 0x394049)
|
||||
|
||||
public let separator: UIColor = UIColor(rgb: 0x21262C)
|
||||
|
||||
public let tile: UIColor = UIColor(rgb: 0x394049)
|
||||
|
||||
@@ -32,6 +32,8 @@ public class LightColors: Colors {
|
||||
|
||||
public let quarterlyContent: UIColor = UIColor(rgb: 0xC1C6CD)
|
||||
|
||||
public let quinaryContent: UIColor = UIColor(rgb: 0xE3E8F0)
|
||||
|
||||
public let separator: UIColor = UIColor(rgb: 0xE3E8F0)
|
||||
|
||||
public let tile: UIColor = UIColor(rgb: 0xF3F8FD)
|
||||
|
||||
@@ -11,7 +11,7 @@ use_frameworks!
|
||||
# - `{ {kit spec hash} => {sdk spec hash}` to depend on specific pod options (:git => …, :podspec => …) for each repo. Used by Fastfile during CI
|
||||
#
|
||||
# Warning: our internal tooling depends on the name of this variable name, so be sure not to change it
|
||||
$matrixKitVersion = '= 0.15.3'
|
||||
$matrixKitVersion = '= 0.15.5'
|
||||
# $matrixKitVersion = :local
|
||||
# $matrixKitVersion = {'develop' => 'develop'}
|
||||
|
||||
@@ -69,6 +69,8 @@ abstract_target 'RiotPods' do
|
||||
pod 'SwiftBase32', '~> 0.9.0'
|
||||
pod 'SwiftJWT', '~> 3.6.200'
|
||||
pod 'SideMenu', '~> 6.5'
|
||||
pod 'DSWaveformImage', '~> 6.1.1'
|
||||
pod 'ffmpeg-kit-ios-audio', '~> 4.4.LTS'
|
||||
|
||||
pod 'FLEX', '~> 4.4.1', :configurations => ['Debug']
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ PODS:
|
||||
- BlueRSA (1.0.34)
|
||||
- DGCollectionViewLeftAlignFlowLayout (1.0.4)
|
||||
- Down (0.11.0)
|
||||
- DSWaveformImage (6.1.1)
|
||||
- DTCoreText (1.6.26):
|
||||
- DTCoreText/Core (= 1.6.26)
|
||||
- DTFoundation/Core (~> 1.7.5)
|
||||
@@ -36,6 +37,7 @@ PODS:
|
||||
- DTFoundation/Core
|
||||
- DTFoundation/UIKit (1.7.18):
|
||||
- DTFoundation/Core
|
||||
- ffmpeg-kit-ios-audio (4.4)
|
||||
- FLEX (4.4.1)
|
||||
- FlowCommoniOS (1.10.0)
|
||||
- GBDeviceInfo (6.6.0):
|
||||
@@ -56,29 +58,29 @@ PODS:
|
||||
- MatomoTracker (7.4.1):
|
||||
- MatomoTracker/Core (= 7.4.1)
|
||||
- MatomoTracker/Core (7.4.1)
|
||||
- MatrixKit (0.15.3):
|
||||
- MatrixKit (0.15.5):
|
||||
- Down (~> 0.11.0)
|
||||
- DTCoreText (~> 1.6.25)
|
||||
- HPGrowingTextView (~> 1.1)
|
||||
- libPhoneNumber-iOS (~> 0.9.13)
|
||||
- MatrixKit/Core (= 0.15.3)
|
||||
- MatrixSDK (= 0.19.3)
|
||||
- MatrixKit/Core (0.15.3):
|
||||
- MatrixKit/Core (= 0.15.5)
|
||||
- MatrixSDK (= 0.19.5)
|
||||
- MatrixKit/Core (0.15.5):
|
||||
- Down (~> 0.11.0)
|
||||
- DTCoreText (~> 1.6.25)
|
||||
- HPGrowingTextView (~> 1.1)
|
||||
- libPhoneNumber-iOS (~> 0.9.13)
|
||||
- MatrixSDK (= 0.19.3)
|
||||
- MatrixSDK (0.19.3):
|
||||
- MatrixSDK/Core (= 0.19.3)
|
||||
- MatrixSDK/Core (0.19.3):
|
||||
- MatrixSDK (= 0.19.5)
|
||||
- MatrixSDK (0.19.5):
|
||||
- MatrixSDK/Core (= 0.19.5)
|
||||
- MatrixSDK/Core (0.19.5):
|
||||
- AFNetworking (~> 4.0.0)
|
||||
- GZIP (~> 1.3.0)
|
||||
- libbase58 (~> 0.1.4)
|
||||
- OLMKit (~> 3.2.4)
|
||||
- Realm (= 10.7.6)
|
||||
- SwiftyBeaver (= 1.9.5)
|
||||
- MatrixSDK/JingleCallStack (0.19.3):
|
||||
- MatrixSDK/JingleCallStack (0.19.5):
|
||||
- JitsiMeetSDK (= 3.5.0)
|
||||
- MatrixSDK/Core
|
||||
- OLMKit (3.2.4):
|
||||
@@ -104,7 +106,7 @@ PODS:
|
||||
- BlueRSA (~> 1.0)
|
||||
- KituraContracts (~> 1.2)
|
||||
- LoggerAPI (~> 1.7)
|
||||
- SwiftLint (0.43.0)
|
||||
- SwiftLint (0.43.1)
|
||||
- SwiftyBeaver (1.9.5)
|
||||
- zxcvbn-ios (1.0.4)
|
||||
- ZXingObjC (3.6.5):
|
||||
@@ -113,6 +115,8 @@ PODS:
|
||||
|
||||
DEPENDENCIES:
|
||||
- DGCollectionViewLeftAlignFlowLayout (~> 1.0.4)
|
||||
- DSWaveformImage (~> 6.1.1)
|
||||
- ffmpeg-kit-ios-audio (~> 4.4.LTS)
|
||||
- FLEX (~> 4.4.1)
|
||||
- FlowCommoniOS (~> 1.10.0)
|
||||
- GBDeviceInfo (~> 6.6.0)
|
||||
@@ -120,7 +124,7 @@ DEPENDENCIES:
|
||||
- KeychainAccess (~> 4.2.2)
|
||||
- KTCenterFlowLayout (~> 1.3.1)
|
||||
- MatomoTracker (~> 7.4.1)
|
||||
- MatrixKit (= 0.15.3)
|
||||
- MatrixKit (= 0.15.5)
|
||||
- MatrixSDK
|
||||
- MatrixSDK/JingleCallStack
|
||||
- OLMKit
|
||||
@@ -142,8 +146,10 @@ SPEC REPOS:
|
||||
- BlueRSA
|
||||
- DGCollectionViewLeftAlignFlowLayout
|
||||
- Down
|
||||
- DSWaveformImage
|
||||
- DTCoreText
|
||||
- DTFoundation
|
||||
- ffmpeg-kit-ios-audio
|
||||
- FLEX
|
||||
- FlowCommoniOS
|
||||
- GBDeviceInfo
|
||||
@@ -180,8 +186,10 @@ SPEC CHECKSUMS:
|
||||
BlueRSA: 6f9776d62d9773502415a7db3bcbb2bbb3f71fc3
|
||||
DGCollectionViewLeftAlignFlowLayout: a0fa58797373ded039cafba8133e79373d048399
|
||||
Down: b6ba1bc985c9d2f4e15e3b293d2207766fa12612
|
||||
DSWaveformImage: 3c718a0cf99291887ee70d1d0c18d80101d3d9ce
|
||||
DTCoreText: ec749e013f2e1f76de5e7c7634642e600a7467ce
|
||||
DTFoundation: a53f8cda2489208cbc71c648be177f902ee17536
|
||||
ffmpeg-kit-ios-audio: ddfc3dac6f574e83d53f8ae33586711162685d3e
|
||||
FLEX: 7ca2c8cd3a435ff501ff6d2f2141e9bdc934eaab
|
||||
FlowCommoniOS: bcdf81a5f30717e711af08a8c812eb045411ba94
|
||||
GBDeviceInfo: ed0db16230d2fa280e1cbb39a5a7f60f6946aaec
|
||||
@@ -196,8 +204,8 @@ SPEC CHECKSUMS:
|
||||
LoggerAPI: ad9c4a6f1e32f518fdb43a1347ac14d765ab5e3d
|
||||
Logging: beeb016c9c80cf77042d62e83495816847ef108b
|
||||
MatomoTracker: 24a846c9d3aa76933183fe9d47fd62c9efa863fb
|
||||
MatrixKit: 6cbe65db11a5450ec8cc02d51660f43b5e95a141
|
||||
MatrixSDK: c15663c67bfd2991d897d973c1551ba4de900e25
|
||||
MatrixKit: 7606227237cf58c1a1a2235547222c5d75b464c4
|
||||
MatrixSDK: 9fa30f9ca2504c4251b99212dcf4ff569bbf45b1
|
||||
OLMKit: 2d73cd67d149b5c3e3a8eb8ecae93d0b429d8a02
|
||||
ReadMoreTextView: 19147adf93abce6d7271e14031a00303fe28720d
|
||||
Realm: ed860452717c8db8f4bf832b6807f7f2ce708839
|
||||
@@ -206,11 +214,11 @@ SPEC CHECKSUMS:
|
||||
SwiftBase32: 9399c25a80666dc66b51e10076bf591e3bbb8f17
|
||||
SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108
|
||||
SwiftJWT: 88c412708f58c169d431d344c87bc79a87c830ae
|
||||
SwiftLint: 0c645fdc6feed3e390c1701ab3cc669f88b42752
|
||||
SwiftLint: 99f82d07b837b942dd563c668de129a03fc3fb52
|
||||
SwiftyBeaver: 84069991dd5dca07d7069100985badaca7f0ce82
|
||||
zxcvbn-ios: fef98b7c80f1512ff0eec47ac1fa399fc00f7e3c
|
||||
ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb
|
||||
|
||||
PODFILE CHECKSUM: c39d88adc5ec2df412af32b64ceb99a9a1ee92a8
|
||||
PODFILE CHECKSUM: c7386ecfb38fc4302613c915aef79eebdb98a53d
|
||||
|
||||
COCOAPODS: 1.10.1
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
buildImplicitDependencies = "YES"
|
||||
runPostActionsOnFailure = "NO">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
|
||||
@@ -19,5 +19,8 @@
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 823 B After Width: | Height: | Size: 765 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "voice_message_cancel_gradient.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_cancel_gradient@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_cancel_gradient@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "voice_message_lock_chevron.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_lock_chevron@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_lock_chevron@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 245 B |
|
After Width: | Height: | Size: 364 B |
|
After Width: | Height: | Size: 451 B |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "voice_message_lock_icon_locked.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_lock_icon_locked@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_lock_icon_locked@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 387 B |
|
After Width: | Height: | Size: 596 B |
|
After Width: | Height: | Size: 844 B |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "voice_message_lock_icon_unlocked.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_lock_icon_unlocked@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_lock_icon_unlocked@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 346 B |
|
After Width: | Height: | Size: 553 B |
|
After Width: | Height: | Size: 712 B |
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "voice_message_pause_button.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_pause_button@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_pause_button@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 171 B |
|
After Width: | Height: | Size: 224 B |
|
After Width: | Height: | Size: 285 B |
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "voice_message_play_button.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_play_button@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_play_button@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 412 B |
|
After Width: | Height: | Size: 634 B |
|
After Width: | Height: | Size: 748 B |
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "action_voice_message.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "action_voice_message@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "action_voice_message@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 694 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "voice_message_record_button_recording.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_record_button_recording@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_record_button_recording@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "voice_message_record_icon.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_record_icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "voice_message_record_icon@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 247 B |
|
After Width: | Height: | Size: 427 B |
|
After Width: | Height: | Size: 576 B |
@@ -261,3 +261,88 @@
|
||||
"room_participants_filter_room_members_for_dm" = "تَصفِيَةُ الأعضَاء";
|
||||
"room_participants_filter_room_members" = "تَصفِيَةُ أعضَاءِ الغُرفَة";
|
||||
"room_participants_invite_prompt_msg" = "هَل أنتَ مُتَأكِّدٌ أنَّكَ تٌريدُ دَعوةَ %@ إلَى هَذِهِ المُحادَثَة؟";
|
||||
"room_event_action_reaction_show_less" = "إظهَارُ أقَل";
|
||||
"room_event_action_reaction_show_all" = "إظهَارُ الكُل";
|
||||
"room_event_action_edit" = "تَحرِير";
|
||||
"room_event_action_reply" = "الرَّدّ";
|
||||
"room_event_action_view_encryption" = "مَعلُومَاتُ التَّعمِيَة";
|
||||
"room_event_action_cancel_download" = "إلغَاءُ التَّنزيل";
|
||||
"room_event_action_cancel_send" = "إلغَاءُ الإرسَال";
|
||||
"room_event_action_delete_confirmation_message" = "هَل أنتَ مُتَأكِّدٌ أنَّكَ تٌريدُ حَذفَ هَذِهِ الرِّسَالَةَ غَيرِ المُرسَلَة؟";
|
||||
"room_event_action_delete_confirmation_title" = "حَذفُ الرَّسَائِلِ غَيرِ المُرسَلَة";
|
||||
"room_event_action_delete" = "حَذف";
|
||||
"room_event_action_resend" = "إعادَةُ الإرسَال";
|
||||
"room_event_action_save" = "حِفظ";
|
||||
"room_event_action_report_prompt_ignore_user" = "هَل تُريدُ إخفَاءَ جَميعِ الرَّسَائِلِ مِن هَذَا المُستَخدِم؟";
|
||||
"room_event_action_ban_prompt_reason" = "سَبَبُ حَظْرِ هَذَا المُستَخدِم";
|
||||
"room_event_action_kick_prompt_reason" = "سَبَبُ طَردِ هَذَا المُستَخدِم";
|
||||
"room_event_action_report_prompt_reason" = "سَبَبُ الإبلَاغِ عَن هَذَا المُحتَوى";
|
||||
"room_event_action_report" = "التَّبلِيغُ عَنِ المُحتَوَى";
|
||||
"room_event_action_view_decrypted_source" = "الاِطِّلاعُ عَلَى المَصدَرِ مَفكُوكِ التَّعميَة";
|
||||
"room_event_action_view_source" = "الاِطِّلاعُ عَلَى المَصدَر";
|
||||
"room_event_action_permalink" = "رَابِطٌ دَائِم";
|
||||
"room_event_action_share" = "مُشارَكَة";
|
||||
"room_event_action_more" = "مَزيد";
|
||||
"room_event_action_redact" = "إزالَة";
|
||||
"room_event_action_quote" = "اِقتِبَاس";
|
||||
"room_event_action_copy" = "نَسخ";
|
||||
"room_delete_unsent_messages" = "حَذفُ الرَّسائِلِ غَيرِ المُرسَلَة";
|
||||
"room_resend_unsent_messages" = "إعادَةُ إرسَالِ الرَّسائِلِ غَيرِ المُرسَلَة";
|
||||
"room_prompt_cancel" = "إلغَاءُ الكُل";
|
||||
"room_prompt_resend" = "إعادَةُ إرسَالِ الكُل";
|
||||
"room_conference_call_no_power" = "تَحتَاجُ إلَى إذنٍ لِإدَارَةِ مُكالَمَةِ اِجتِمَاعٍ فِي هَذِهِ الغُرفَة";
|
||||
"room_ongoing_conference_call_with_close" = "مُكالَمَةُ اِجتِمَاعٍ مُستَمِرَّة. الاِنضِمَامُ كَـ%@ أَو %@. %@ها.";
|
||||
"room_ongoing_conference_call_close" = "إِغلَاق";
|
||||
"room_ongoing_conference_call" = "مُكالَمَةُ اِجتِمَاعٍ مُستَمِرَّة. الاِنضِمَامُ كَـ%@ أَو %@.";
|
||||
"room_unsent_messages_cancel_message" = "هَل أنتَ مُتَأكِّدٌ أنَّكَ تٌريدُ حَذفَ جَميعِ الرَّسائِلِ غَيرِ المُرسَلَةِ فِي هَذِهِ الغُرفَة؟";
|
||||
"room_unsent_messages_cancel_title" = "حَذفُ الرَّسائِلِ غَيرِ المُرسَلَة";
|
||||
"room_unsent_messages_unknown_devices_notification" = "فَشَلَ إرسَالُ الرَّسائِلِ بِسَبَبِ جَلسَاتٍ حَاليةٍ غَيرِ مَعرُوفَة.";
|
||||
"room_unsent_messages_notification" = "فَشَلَ إرسَالُ الرَّسائِل.";
|
||||
"room_offline_notification" = "فُقِدَ الاِتِّصَالُ بِالخَادِم.";
|
||||
"room_message_reply_to_short_placeholder" = "إرسَالُ رَدّ…";
|
||||
"room_message_short_placeholder" = "إرسَالُ رِسَالَة…";
|
||||
"room_participants_security_information_room_encrypted_for_dm" = "الرَّسائِلُ هُنا مُعمَاةٌ مِنَ النِّهايَةِ إلَى النِّهايَةِ. \n\nيَتِمُ تَأمينُ رَسائِلَكَ بِأقفَال، فَقَط أنتَ وَالمُستَلِمُ مَن لَديهِم مِفتَاحَينِ فَريدَينِ لِفتحِهَا.";
|
||||
"room_participants_security_information_room_encrypted" = "الرَّسائِلُ فِي هَذِهِ الغُرفَة مُعمَاةٌ مِنَ النِّهايَةِ إلَى النِّهايَةِ. \n\nيَتِمُ تَأمينُ رَسائِلَكَ بِأقفَال، فَقَط أنتَ وَالمُستَلِمُ مَن لَديهِم مِفتَاحَينِ فَريدَينِ لِفتحِهَا.";
|
||||
"room_participants_security_information_room_not_encrypted_for_dm" = "الرَّسائِلُ هُنا لَيسَت مُعمَاةً مِنَ النِّهايَةِ إلَى النِّهايَةِ.";
|
||||
"room_participants_security_information_room_not_encrypted" = "الرَّسائِلُ فِي هَذِهِ الغُرفَة لَيسَت مُعمَاةً مِنَ النِّهايَةِ إلَى النِّهايَةِ.";
|
||||
"encrypted_room_message_reply_to_placeholder" = "إرسَالُ رَدٍّ مُعمَى…";
|
||||
"encrypted_room_message_placeholder" = "إرسَالُ رِسَالَةٍ مُعمَاة…";
|
||||
"room_do_not_have_permission_to_post" = "لَيسَ لَديكَ إذنٌ بِالنَّشرِ فِي هَذِهِ الغُرفَة";
|
||||
"room_message_editing" = "التَّحرِير";
|
||||
"room_message_replying_to" = "الرَّدُّ عَلَى %@";
|
||||
"room_message_unable_open_link_error_message" = "يَتَعَذَّرُ فَتحُ الرَّابِط.";
|
||||
"room_message_reply_to_placeholder" = "إرسَالُ رَدّ (غَيرُ مُعَمَى)…";
|
||||
"room_message_placeholder" = "إرسَالُ رِسَالَة (غَيرُ مُعَمَاة)…";
|
||||
"room_many_users_are_typing" = "إنَّ %@، %@ وَآخَرُونَ يَكتُبُون…";
|
||||
"room_two_users_are_typing" = "إنَّ %@ وَ %@ يَكتُبَان…";
|
||||
"room_one_user_is_typing" = "إنَّ %@ يَكتُب…";
|
||||
"room_new_messages_notification" = "عَدَدُ %d رَسَائِلَ جَدِيدَة";
|
||||
"room_new_message_notification" = "عَدَدُ %d رِسَالَةً جَدِيدَة";
|
||||
"room_accessiblity_scroll_to_bottom" = "التَّمرِيرُ إلَى الأَسفَل";
|
||||
"room_jump_to_first_unread" = "القَفزُ إلَى غَيرِ المَقرُوء";
|
||||
|
||||
// Chat
|
||||
"room_slide_to_end_group_call" = "مَرِّرِ لِإنهَاءِ المُكالَمَةِ لِلجَّميع";
|
||||
"room_member_power_level_short_custom" = "مُتَخَصِّص";
|
||||
"room_member_power_level_short_moderator" = "مُشرِف";
|
||||
"room_member_power_level_short_admin" = "مُدير";
|
||||
"room_member_power_level_custom_in" = "مُتَخَصِّصٌ (%@) في %@";
|
||||
"room_member_power_level_moderator_in" = "مُشرِفٌ فِي %@";
|
||||
"room_member_power_level_admin_in" = "مُديرٌ فِي %@";
|
||||
"room_participants_security_loading" = "التَّحمِيلُ جَارٍ…";
|
||||
"room_participants_action_security_status_loading" = "التَّحمِيلُ جَارٍ…";
|
||||
"room_participants_action_security_status_warning" = "تَحذِير";
|
||||
"room_participants_action_security_status_complete_security" = "الأمَانُ الكَامِل";
|
||||
"room_participants_action_security_status_verify" = "تَأكِيدُ التَّحَقُّق";
|
||||
"room_participants_action_security_status_verified" = "مُتَحَقَّقٌ مِنه";
|
||||
"room_participants_action_mention" = "ذِكْر";
|
||||
"room_participants_action_start_video_call" = "بَدْءُ مُكالَمَةٍ مَرئيَّة";
|
||||
"room_participants_action_start_voice_call" = "بَدْءُ مُكالَمَةٍ صَوتيَّة";
|
||||
"room_participants_action_start_new_chat" = "بَدْءُ مُحادَثَةٍ جَدِيدَة";
|
||||
"room_participants_action_set_admin" = "جَعْلَهُ مُدِير";
|
||||
"room_participants_action_set_moderator" = "جَعْلَهُ مُشرِف";
|
||||
"room_participants_action_set_default_power_level" = "إعَادَةُ الضَّبطِ إلَى مُستَخدِمٍ عَادِيّ";
|
||||
"room_participants_action_unignore" = "إِظْهَارُ جَمِيعِ الرَّسائِلِ مِن هَذَا المُستَخدِم";
|
||||
"room_participants_action_ignore" = "إخفَاءُ جَمِيعِ الرَّسائِلِ مِن هَذَا المُستَخدِم";
|
||||
"room_recents_unknown_room_error_message" = "يَتَعَذَّر العُثُور عَلَى هَذِهِ الغُرفَة. تأكَّد مِن وجودِهَا";
|
||||
"room_creation_dm_error" = "يَتَعَذَّرُ عَلينَا إنشَاء المُحادَثَة المُباشِرَة الخَّاصَةِ بِك. يُرجَى التَّحَقُقُ مِنَ المُستَخدِمِيَنَ اللَّذِينَ تُريدُ دَعوَتَهُم ثُمَّ المُحاوَلَةُ مَرةً أُخرَى.";
|
||||
|
||||
@@ -506,7 +506,7 @@
|
||||
"group_rooms_filter_rooms" = "Filtere Community-Räume";
|
||||
"e2e_room_key_request_message_new_device" = "Du hast die neue Sitzung '%@' hinzugefügt, welche Verschlüsselungs-Schlüssel anfordert.";
|
||||
"room_do_not_have_permission_to_post" = "Du hast keine Berechtigung Nachrichten in diesem Raum zu senden";
|
||||
"room_event_action_kick_prompt_reason" = "Grund für den Rauswurf des Benutzers";
|
||||
"room_event_action_kick_prompt_reason" = "Grund für das Entfernen des Benutzers";
|
||||
"room_event_action_ban_prompt_reason" = "Grund für die Verbannung des Benutzers";
|
||||
"room_action_send_photo_or_video" = "Foto oder Video senden";
|
||||
"room_action_send_sticker" = "Aufkleber senden";
|
||||
@@ -1389,3 +1389,16 @@
|
||||
"room_recents_unknown_room_error_message" = "Raum kann nicht gefunden werden. Überprüfe bitte, dass er existiert";
|
||||
"room_creation_dm_error" = "Fehler beim Erstellen der Direktnachricht. Bitte überprüfe die eingeladenen Leute und versuche es erneut.";
|
||||
"settings_ui_theme_picker_message_invert_colours" = "\"Auto\" verwendet die Farbinvertierungseinstellung deines Geräts";
|
||||
"key_verification_verify_qr_code_scan_code_other_device_action" = "Mit diesem Gerät scannen";
|
||||
"room_notifs_settings_encrypted_room_notice" = "Am Handy sind Benachrichtigungen bei Erwähnungen und Schlüsselwörtern in verschlüsselten Räumen nicht verfügbar.";
|
||||
"room_notifs_settings_account_settings" = "Kontoeinstellungen";
|
||||
"room_notifs_settings_manage_notifications" = "Benachrichtigungen kannst du in %@ verwalten";
|
||||
"room_notifs_settings_cancel_action" = "Abbrechen";
|
||||
"room_notifs_settings_done_action" = "Fertig";
|
||||
"room_notifs_settings_none" = "Nichts";
|
||||
"room_notifs_settings_mentions_and_keywords" = "Nur Erwähnungen und Schlüsselwörtern";
|
||||
"room_notifs_settings_all_messages" = "Allen Nachrichten";
|
||||
|
||||
// Room Notification Settings
|
||||
"room_notifs_settings_notify_me_for" = "Benachrichtige mich bei";
|
||||
"room_details_notifs" = "Benachrichtigungen";
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
// Permissions usage explanations
|
||||
"NSCameraUsageDescription" = "The camera is used to take photos and videos, make video calls.";
|
||||
"NSPhotoLibraryUsageDescription" = "The photo library is used to send photos and videos.";
|
||||
"NSMicrophoneUsageDescription" = "The microphone is used to take videos, make calls.";
|
||||
"NSMicrophoneUsageDescription" = "Element needs to access your microphone to make and receive calls, take videos, and record voice messages.";
|
||||
"NSContactsUsageDescription" = "To discover contacts already using Matrix, Element can send email addresses and phone numbers in your address book to your chosen Matrix identity server. Where supported, personal data is hashed before sending - please check your identity server's privacy policy for more details.";
|
||||
"NSCalendarsUsageDescription" = "See your scheduled meetings in the app.";
|
||||
"NSFaceIDUsageDescription" = "Face ID is used to access your app.";
|
||||
|
||||
@@ -534,6 +534,7 @@ Tap the + to start adding people.";
|
||||
"settings_labs_create_conference_with_jitsi" = "Create conference calls with jitsi";
|
||||
"settings_labs_message_reaction" = "React to messages with emoji";
|
||||
"settings_labs_enable_ringing_for_group_calls" = "Ring for group calls";
|
||||
"settings_labs_voice_messages" = "Voice messages";
|
||||
|
||||
"settings_version" = "Version %@";
|
||||
"settings_olm_version" = "Olm Version %@";
|
||||
@@ -1681,3 +1682,9 @@ Tap the + to start adding people.";
|
||||
"side_menu_action_help" = "Help";
|
||||
"side_menu_action_feedback" = "Feedback";
|
||||
"side_menu_app_version" = "Version %@";
|
||||
|
||||
// Mark: - Voice Messages
|
||||
|
||||
"voice_message_release_to_send" = "Hold to record, release to send";
|
||||
"voice_message_remaining_recording_time" = "%@s left";
|
||||
"voice_message_stop_locked_mode_recording" = "Tap on the wavelength to stop and playback";
|
||||
|
||||
@@ -1349,3 +1349,16 @@
|
||||
"settings_ui_theme_picker_message_invert_colours" = "Automaatne valik kasutab sinu seadme pööratud värvide seadistust";
|
||||
"room_recents_unknown_room_error_message" = "Ei leia sellist jututuba. Palun kontrolli, et ta ikka olemas on";
|
||||
"room_creation_dm_error" = "Otsevestluse loomine ei õnnestunud. Palun kontrolli, et kasutajanimed oleks õiged ja proovi uuesti.";
|
||||
"key_verification_verify_qr_code_scan_code_other_device_action" = "Skaneeri selle seadmega";
|
||||
"room_notifs_settings_encrypted_room_notice" = "Teavitused mainimiste ja võtmesõnade esinemise puhul pole mobiilirakenduses krüptitud jututoas saadaval.";
|
||||
"room_notifs_settings_account_settings" = "Kasutajakonto seadistused";
|
||||
"room_notifs_settings_manage_notifications" = "Sa võid hallata teavitusi %@ jututoas";
|
||||
"room_notifs_settings_cancel_action" = "Katkesta";
|
||||
"room_notifs_settings_done_action" = "Valmis";
|
||||
"room_notifs_settings_none" = "mitte ühelgi juhul";
|
||||
"room_notifs_settings_mentions_and_keywords" = "mainimiste ja võtmesõnade leidumise puhul";
|
||||
"room_notifs_settings_all_messages" = "kõikide sõnumite puhul";
|
||||
|
||||
// Room Notification Settings
|
||||
"room_notifs_settings_notify_me_for" = "Teavita mind";
|
||||
"room_details_notifs" = "Teavitused";
|
||||
|
||||
@@ -1426,3 +1426,16 @@
|
||||
"settings_ui_theme_picker_message_invert_colours" = "« Auto » utilise le paramètre « Inverser les couleurs » de l’appreil";
|
||||
"room_recents_unknown_room_error_message" = "Aucun résultat dans ce salon. Assurez vous de son existence";
|
||||
"room_creation_dm_error" = "Nous n’avons pas pu créer votre message direct. Merci de vérifier les utilisateurs que vous voulez inviter et réessayer.";
|
||||
"key_verification_verify_qr_code_scan_code_other_device_action" = "Scanner avec cet appareil";
|
||||
"room_notifs_settings_encrypted_room_notice" = "Veuillez noter que les mentions et mots-clés ne sont pas disponibles dans les salons chiffrés sur mobile.";
|
||||
"room_notifs_settings_account_settings" = "Paramètres du compte";
|
||||
"room_notifs_settings_manage_notifications" = "Vous pouvez gérer les notifications dans %@";
|
||||
"room_notifs_settings_cancel_action" = "Annuler";
|
||||
"room_notifs_settings_done_action" = "Terminé";
|
||||
"room_notifs_settings_none" = "Aucun";
|
||||
"room_notifs_settings_mentions_and_keywords" = "Seulement les mentions et les mots-clés";
|
||||
"room_notifs_settings_all_messages" = "Tous les messages";
|
||||
|
||||
// Room Notification Settings
|
||||
"room_notifs_settings_notify_me_for" = "Me notifier pour";
|
||||
"room_details_notifs" = "Notifications";
|
||||
|
||||
@@ -1383,3 +1383,16 @@
|
||||
"settings_ui_theme_picker_message_invert_colours" = "\"Automatico\" usa l'impostazione \"Inverti Colori\" del tuo dispositivo";
|
||||
"room_recents_unknown_room_error_message" = "Impossibile trovare questa stanza. Assicurati che esista";
|
||||
"room_creation_dm_error" = "Impossibile creare il messaggio diretto. Ricontrolla gli utenti che vuoi invitare e riprova.";
|
||||
"key_verification_verify_qr_code_scan_code_other_device_action" = "Scansiona con questo dispositivo";
|
||||
"room_notifs_settings_encrypted_room_notice" = "Nota che le notifiche per menzioni e parole chiave non sono disponibili nelle stanze cifrate su mobile.";
|
||||
"room_notifs_settings_account_settings" = "impostazioni dell'account";
|
||||
"room_notifs_settings_manage_notifications" = "Puoi gestire le notifiche nelle %@";
|
||||
"room_notifs_settings_cancel_action" = "Annulla";
|
||||
"room_notifs_settings_done_action" = "Fatto";
|
||||
"room_notifs_settings_none" = "Niente";
|
||||
"room_notifs_settings_mentions_and_keywords" = "Solo menzioni e parole chiave";
|
||||
"room_notifs_settings_all_messages" = "Tutti i messaggi";
|
||||
|
||||
// Room Notification Settings
|
||||
"room_notifs_settings_notify_me_for" = "Inviami notifiche per";
|
||||
"room_details_notifs" = "Notifiche";
|
||||
|
||||
@@ -50,3 +50,27 @@
|
||||
"SINGLE_UNREAD_IN_ROOM" = "%@にメッセージを受け取りました";
|
||||
/* A single unread message */
|
||||
"SINGLE_UNREAD" = "あなたはメッセージを受け取りました";
|
||||
|
||||
/** Key verification **/
|
||||
|
||||
"KEY_VERIFICATION_REQUEST_FROM_USER" = "%@は検証したい";
|
||||
|
||||
/* New message indicator on a room */
|
||||
"MESSAGE_IN_X" = "%@ 内のメッセージ";
|
||||
|
||||
/* Sticker from a specific person, not referencing a room. */
|
||||
"STICKER_FROM_USER" = "%@ さんからのスタンプ";
|
||||
/* Message title for a specific person in a named room */
|
||||
"MSG_FROM_USER_IN_ROOM_TITLE" = "%@(%@ から)";
|
||||
|
||||
/* Group call from user, CallKit caller name */
|
||||
"GROUP_CALL_FROM_USER" = "%@ (グループ通話)";
|
||||
"MESSAGE_PROTECTED" = "新しいメッセージ";
|
||||
|
||||
/* New message indicator from a DM */
|
||||
"MESSAGE_FROM_X" = "%@ からのメッセージ";
|
||||
|
||||
/** Notification messages **/
|
||||
|
||||
/* New message indicator on unknown room */
|
||||
"MESSAGE" = "メッセージ";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"title_home" = "ホーム";
|
||||
"title_favourites" = "お気に入り";
|
||||
"title_people" = "対話";
|
||||
"title_rooms" = "部屋";
|
||||
"title_rooms" = "ルーム";
|
||||
"warning" = "警告";
|
||||
// Actions
|
||||
"view" = "表示";
|
||||
@@ -92,39 +92,39 @@
|
||||
"room_creation_account" = "アカウント";
|
||||
"room_creation_appearance" = "外観";
|
||||
"room_creation_appearance_name" = "名前";
|
||||
"room_creation_appearance_picture" = "部屋のアイコン画像 (任意で)";
|
||||
"room_creation_appearance_picture" = "チャット画像 (任意)";
|
||||
"room_creation_privacy" = "個人情報保護";
|
||||
"room_creation_private_room" = "この会話は非公開です";
|
||||
"room_creation_public_room" = "この会話は公開されます";
|
||||
"room_creation_make_public" = "公開部屋作成";
|
||||
"room_creation_make_public_prompt_title" = "この部屋を公開しますか?";
|
||||
"room_creation_make_public_prompt_msg" = "この部屋を一般公開してもよろしいですか?誰でもあなたの発言を読み、部屋に参加できます。";
|
||||
"room_creation_keep_private" = "非公開部屋";
|
||||
"room_creation_make_private" = "非公開部屋を作成";
|
||||
"room_creation_wait_for_creation" = "部屋はすでに作成されています。お待ちください。";
|
||||
"room_creation_make_public" = "パブリックにする";
|
||||
"room_creation_make_public_prompt_title" = "このチャットをパブリックしますか?";
|
||||
"room_creation_make_public_prompt_msg" = "このチャットをパブリックしてもよろしいですか? 誰でもあなたのメッセージを読んでチャットに参加できます。";
|
||||
"room_creation_keep_private" = "プライベートに保つ";
|
||||
"room_creation_make_private" = "プライベートにする";
|
||||
"room_creation_wait_for_creation" = "ルームはすでに作成されています。 お待ちください。";
|
||||
"room_creation_invite_another_user" = "ユーザID, 表示名, 電子メールで検索と招待";
|
||||
// Room recents
|
||||
"room_recents_directory_section" = "部屋一覧";
|
||||
"room_recents_directory_section" = "ルーム一覧";
|
||||
"room_recents_directory_section_network" = "通信回線";
|
||||
"room_recents_favourites_section" = "お気に入り";
|
||||
"room_recents_people_section" = "対話";
|
||||
"room_recents_conversations_section" = "部屋";
|
||||
"room_recents_no_conversation" = "部屋がありません";
|
||||
"room_recents_conversations_section" = "ルーム";
|
||||
"room_recents_no_conversation" = "ルームがありません";
|
||||
"room_recents_low_priority_section" = "低優先度";
|
||||
"room_recents_invites_section" = "招待中";
|
||||
"room_recents_start_chat_with" = "対話を開始";
|
||||
"room_recents_create_empty_room" = "部屋を作成";
|
||||
"room_recents_join_room" = "部屋へ参加";
|
||||
"room_recents_join_room_title" = "部屋へ参加";
|
||||
"room_recents_join_room_prompt" = "部屋の固有IDまたは住所表記を入力";
|
||||
"room_recents_create_empty_room" = "ルームを作成";
|
||||
"room_recents_join_room" = "ルームへ参加";
|
||||
"room_recents_join_room_title" = "ルームへ参加";
|
||||
"room_recents_join_room_prompt" = "ルームIDまたはルームのエイリアスを入力します";
|
||||
// People tab
|
||||
"people_invites_section" = "招待中";
|
||||
"people_conversation_section" = "会話";
|
||||
"people_no_conversation" = "会話なし";
|
||||
// Rooms tab
|
||||
"room_directory_no_public_room" = "公開された部屋がありません";
|
||||
"room_directory_no_public_room" = "利用可能なパブリックのルームはありません";
|
||||
// Search
|
||||
"search_rooms" = "部屋";
|
||||
"search_rooms" = "ルーム";
|
||||
"search_messages" = "発言";
|
||||
"search_people" = "対話";
|
||||
"search_files" = "添付ファイル";
|
||||
@@ -133,12 +133,12 @@
|
||||
"search_no_result" = "結果なし";
|
||||
"search_in_progress" = "検索中…";
|
||||
// Directory
|
||||
"directory_cell_title" = "部屋一覧を見る";
|
||||
"directory_cell_description" = "%tu 部屋";
|
||||
"directory_search_results_title" = "部屋一覧検索結果";
|
||||
"directory_cell_title" = "ルーム一覧を見る";
|
||||
"directory_cell_description" = "%tu ルーム";
|
||||
"directory_search_results_title" = "ルーム一覧検索結果";
|
||||
"directory_search_results" = "%tu 件の検索結果 for %@";
|
||||
"directory_search_results_more_than" = ">%tu 件の検索結果 for %@";
|
||||
"directory_searching_title" = "部屋一覧を検索中…";
|
||||
"directory_searching_title" = "ルーム一覧を検索中…";
|
||||
"directory_search_fail" = "一覧を取得できませんでした";
|
||||
// Contacts
|
||||
"contacts_address_book_section" = "端末の電話帳";
|
||||
@@ -153,13 +153,13 @@
|
||||
"room_participants_add_participant" = "参加者を追加";
|
||||
"room_participants_one_participant" = "参加者 1名";
|
||||
"room_participants_multi_participants" = "参加者 %d名";
|
||||
"room_participants_leave_prompt_title" = "部屋を退室";
|
||||
"room_participants_leave_prompt_msg" = "部屋を退室して本当によろしいですか?";
|
||||
"room_participants_leave_prompt_title" = "ルームを出る";
|
||||
"room_participants_leave_prompt_msg" = "ルームを退室して本当によろしいですか?";
|
||||
"room_participants_remove_prompt_title" = "確認";
|
||||
"room_participants_remove_prompt_msg" = "本当に %@ さんを部屋から退去させますか?";
|
||||
"room_participants_remove_prompt_msg" = "本当に %@ さんをチャットから退去させますか?";
|
||||
"room_participants_remove_third_party_invite_msg" = "サードパーティの招待を削除することは、APIが存在するまでサポートされていません";
|
||||
"room_participants_invite_prompt_title" = "確認";
|
||||
"room_participants_invite_prompt_msg" = "本当に %@ さんを部屋へ招待しますか?";
|
||||
"room_participants_invite_prompt_msg" = "本当に %@ さんをチャットへ招待しますか?";
|
||||
"room_participants_filter_room_members" = "参加者を検索";
|
||||
"room_participants_invite_another_user" = "ユーザID, 表示名, 電子メールで検索と招待";
|
||||
"room_participants_invite_malformed_id_title" = "招待エラー";
|
||||
@@ -176,9 +176,9 @@
|
||||
"room_participants_action_section_devices" = "セッション";
|
||||
"room_participants_action_section_other" = "オプション";
|
||||
"room_participants_action_invite" = "招待";
|
||||
"room_participants_action_leave" = "部屋を退室";
|
||||
"room_participants_action_remove" = "部屋から退室させる";
|
||||
"room_participants_action_ban" = "この部屋からブロックする";
|
||||
"room_participants_action_leave" = "このルームを出る";
|
||||
"room_participants_action_remove" = "このルームから退室させる";
|
||||
"room_participants_action_ban" = "このルームからブロックする";
|
||||
"room_participants_action_unban" = "ブロック解除";
|
||||
"room_participants_action_ignore" = "この参加者の発言を全て非表示にする";
|
||||
"room_participants_action_unignore" = "この参加者の発言を全て表示する";
|
||||
@@ -205,7 +205,7 @@
|
||||
"room_ongoing_conference_call" = "会議通話実施中。 %@ または %@で参加してください。";
|
||||
"room_ongoing_conference_call_with_close" = "会議通話実施中。%@または%@で参加してください。 %@。";
|
||||
"room_ongoing_conference_call_close" = "閉じる";
|
||||
"room_conference_call_no_power" = "この部屋で会議通話を管理する権限が必要です";
|
||||
"room_conference_call_no_power" = "このルームで会議通話を管理する権限が必要です";
|
||||
"room_prompt_resend" = "全て再送信";
|
||||
"room_prompt_cancel" = "全て中止";
|
||||
"room_resend_unsent_messages" = "未送信の文を再送信";
|
||||
@@ -307,7 +307,7 @@
|
||||
"settings_labs_matrix_apps" = "Matrixアプリ";
|
||||
"settings_labs_create_conference_with_jitsi" = "jitsiの会議通話を作成する";
|
||||
"settings_version" = "Version %@";
|
||||
"settings_olm_version" = "Olm Version %@";
|
||||
"settings_olm_version" = "Olm バージョン %@";
|
||||
"settings_copyright" = "著作権";
|
||||
"settings_term_conditions" = "利用規約";
|
||||
"settings_privacy_policy" = "個人情報保護方針";
|
||||
@@ -323,7 +323,7 @@
|
||||
"settings_password_updated" = "あなたのパスワードは更新されました";
|
||||
"settings_crypto_device_name" = "セッション名: ";
|
||||
"settings_crypto_device_id" = "\nセッションID: ";
|
||||
"settings_crypto_device_key" = "\n端末鍵: ";
|
||||
"settings_crypto_device_key" = "\nセッションキー:\n";
|
||||
"settings_crypto_export" = "暗号鍵を外部へ保存";
|
||||
"settings_crypto_blacklist_unverified_devices" = "検証されたセッションのみで暗号化";
|
||||
// Room Details
|
||||
@@ -354,7 +354,7 @@
|
||||
"room_details_addresses_section" = "住所表記";
|
||||
"room_details_no_local_addresses" = "この部屋はサーバ内住所表記がありません";
|
||||
"room_details_new_address" = "新しい住所表記を追加";
|
||||
"room_details_new_address_placeholder" = "新しい住所表記を追加 (例 #foo%@)";
|
||||
"room_details_new_address_placeholder" = "新しいアドレスを追加(例 #foo%@)";
|
||||
"room_details_addresses_invalid_address_prompt_title" = "住所表記が正しくありません";
|
||||
"room_details_addresses_invalid_address_prompt_msg" = "%@ は正しくない形式の住所表記です";
|
||||
"room_details_addresses_disable_main_address_prompt_title" = "代表住所表記の警告";
|
||||
@@ -473,14 +473,14 @@
|
||||
"group_invite_section" = "招待";
|
||||
"group_section" = "コミュニティ";
|
||||
"room_message_reply_to_placeholder" = "返信を送る (暗号化されていない)…";
|
||||
"room_do_not_have_permission_to_post" = "この部屋に投稿する権限がありません";
|
||||
"room_do_not_have_permission_to_post" = "このルームに投稿する権限がありません";
|
||||
"encrypted_room_message_reply_to_placeholder" = "暗号化された返信を送る…";
|
||||
"room_message_reply_to_short_placeholder" = "返信を送る…";
|
||||
"room_event_action_view_decrypted_source" = "復号化されたソースを見る";
|
||||
"room_event_action_kick_prompt_reason" = "このユーザーを追放する理由";
|
||||
"room_action_send_photo_or_video" = "写真か動画を送る";
|
||||
"room_action_send_sticker" = "スタンプ送信";
|
||||
"room_replacement_information" = "この部屋は交換されており、もうアクティブではありません。";
|
||||
"room_replacement_information" = "このルームは交換されており、もうアクティブではありません。";
|
||||
"room_replacement_link" = "会話はここで続けられます。";
|
||||
"room_predecessor_information" = "この部屋は別の会話の続きです。";
|
||||
"room_predecessor_link" = "より古いメッセージを見るにはここをタップしてください。";
|
||||
@@ -534,7 +534,7 @@
|
||||
"e2e_room_key_request_title" = "暗号化キー要求";
|
||||
"e2e_room_key_request_message_new_device" = "暗号化キーを要求している新しい端末 '%@'を追加しました。";
|
||||
"e2e_room_key_request_message" = "検証されていない端末 '%@'が暗号化キーを要求しています。";
|
||||
"e2e_room_key_request_start_verification" = "検証開始...";
|
||||
"e2e_room_key_request_start_verification" = "検証を始めます…";
|
||||
"e2e_room_key_request_share_without_verifying" = "検証せずに共有";
|
||||
"e2e_room_key_request_ignore_request" = "要求を無視";
|
||||
// GDPR
|
||||
@@ -556,7 +556,7 @@
|
||||
"rerequest_keys_alert_title" = "リクエスト送信";
|
||||
"rerequest_keys_alert_message" = "この端末にキーを送信できるように、メッセージを復号化できる別の端末でElementを起動してください。";
|
||||
"room_event_action_ban_prompt_reason" = "このユーザーをブロックする理由";
|
||||
"room_resource_limit_exceeded_message_contact_1" = " Please ";
|
||||
"room_resource_limit_exceeded_message_contact_1" = " お願い ";
|
||||
"settings_ui_theme_black" = "Black";
|
||||
"settings_flair" = "特色を表示する";
|
||||
// String for App Store
|
||||
@@ -599,8 +599,8 @@
|
||||
// Mark: - Room creation introduction cell
|
||||
|
||||
"room_intro_cell_add_participants_action" = "参加者を追加";
|
||||
"room_participants_security_information_room_encrypted" = "この部屋で送受信されるメッセージはエンドツーエンド暗号化されます。\n\nメッセージは安全に保護されており、この部屋の参加者のみがメッセージの閲覧に必要な鍵を所持します。";
|
||||
"room_participants_security_information_room_not_encrypted" = "この部屋で送受信されるメッセージはエンドツーエンド暗号化されません。";
|
||||
"room_participants_security_information_room_encrypted" = "このルームで送受信されるメッセージはエンドツーエンド暗号化されます。\n\nメッセージは安全に保護されており、このルームの参加者のみがメッセージの閲覧に必要な鍵を所持します。";
|
||||
"room_participants_security_information_room_not_encrypted" = "このルームのメッセージはエンドツーエンド暗号化されていません。";
|
||||
"room_intro_cell_information_dm_sentence1_part3" = ". ";
|
||||
"callbar_active_and_single_paused" = "ひとつのアクティブな通話 (%@) · ひとつの一時停止された通話";
|
||||
|
||||
@@ -729,7 +729,7 @@
|
||||
"room_multiple_typing_notification" = "%@とその他のユーザーが入力中です";
|
||||
"external_link_confirmation_message" = "リンク%@は別のサイトに移動します: %@\n\n本当に続けますか?";
|
||||
"room_event_action_delete_confirmation_title" = "未送信メッセージを削除";
|
||||
"room_unsent_messages_cancel_message" = "この部屋にある未送信のメッセージをすべて削除してもよろしいですか?";
|
||||
"room_unsent_messages_cancel_message" = "このルームにある未送信のメッセージをすべて削除してもよろしいですか?";
|
||||
"room_unsent_messages_cancel_title" = "未送信メッセージを削除";
|
||||
"room_message_replying_to" = "%@に返信中";
|
||||
"room_message_editing" = "編集中";
|
||||
@@ -753,7 +753,7 @@
|
||||
"room_participants_leave_prompt_msg_for_dm" = "退出してよろしいですか?";
|
||||
"room_participants_leave_prompt_title_for_dm" = "退出する";
|
||||
"contacts_address_book_no_identity_server" = "IDサーバーが設定されていません";
|
||||
"rooms_empty_view_information" = "ルームはプライベートでもパブリックでも、あらゆるグループチャットに最適です。+をタップすると既にある部屋を探したり、新しい部屋を作ることができます。";
|
||||
"rooms_empty_view_information" = "ルームはプライベートでもパブリックでも、あらゆるグループチャットに最適です。+をタップすると、既にあるルームを見つけたり、新しいルームを作ることができます。";
|
||||
"rooms_empty_view_title" = "ルーム";
|
||||
"people_empty_view_information" = "誰とでも安全にチャットできます。+をタップすると会話相手を追加できます。";
|
||||
"people_empty_view_title" = "人々";
|
||||
|
||||
@@ -111,3 +111,9 @@
|
||||
"MSG_FROM_USER" = "%@ sendte en melding";
|
||||
/* Message title for a specific person in a named room */
|
||||
"MSG_FROM_USER_IN_ROOM_TITLE" = "%@ i %@";
|
||||
|
||||
/* Group call from user, CallKit caller name */
|
||||
"GROUP_CALL_FROM_USER" = "%@ (Gruppeanrop)";
|
||||
|
||||
/* A user added a Jitsi call to a room */
|
||||
"GROUP_CALL_STARTED" = "Gruppeanrop startet";
|
||||
|
||||
@@ -479,7 +479,7 @@
|
||||
"settings_discovery_error_message" = "Det oppstod en feil. Vennligst prøv igjen.";
|
||||
"security_settings_crypto_sessions" = "MINE ØKTER";
|
||||
"security_settings_secure_backup_setup" = "Sett opp";
|
||||
"security_settings_secure_backup_delete" = "Slett";
|
||||
"security_settings_secure_backup_delete" = "Slett sikkerhetskopi";
|
||||
"security_settings_crosssigning_complete_security" = "Komplett sikkerhet";
|
||||
"security_settings_cryptography" = "KRYPTOGRAFI";
|
||||
"security_settings_complete_security_alert_title" = "Komplett sikkerhet";
|
||||
@@ -513,7 +513,7 @@
|
||||
"key_backup_setup_passphrase_confirm_passphrase_placeholder" = "Bekreft passordfrasen";
|
||||
"key_backup_setup_passphrase_confirm_passphrase_invalid" = "Passordfrasen samsvarer ikke";
|
||||
"key_backup_setup_passphrase_set_passphrase_action" = "Velg passordfrase";
|
||||
"key_backup_setup_success_from_recovery_key_recovery_key_title" = "Gjenopprettingsnøkkel";
|
||||
"key_backup_setup_success_from_recovery_key_recovery_key_title" = "Sikkerhetsnøkkel";
|
||||
"key_backup_setup_success_from_recovery_key_make_copy_action" = "Lag en kpi";
|
||||
"key_backup_setup_success_from_recovery_key_made_copy_action" = "Jeg har laget en kopi";
|
||||
"key_backup_recover_from_recovery_key_recovery_key_placeholder" = "Skriv inn gjenopprettingsnøkkelen";
|
||||
@@ -834,7 +834,7 @@
|
||||
"settings_key_backup_info_not_valid" = "Denne økten sikkerhetskopierer ikke dine nøkler, men du har en eksisterende sikkerhetskopi du kan gjenopprette fra og legge til, for å gå videre.";
|
||||
"settings_key_backup_info_valid" = "Denne økten sikkerhetskopierer dine nøkler.";
|
||||
"settings_key_backup_info_version" = "Sikkerhetskopi av nøkler versjon : %@";
|
||||
"settings_key_backup_info_signout_warning" = "Før du logger ut, koble denne sesjonen til sikkerhetskopi av nøkler for å unngå tap av nøkler som kanskje bare er lagret på denne enheten.";
|
||||
"settings_key_backup_info_signout_warning" = "Sikkerhetskopier nøklene dine før du logger av for å unngå å miste dem.";
|
||||
"settings_key_backup_info_none" = "Nøklene dine for denne sesjonen blir ikke sikkerhetskopiert.";
|
||||
"settings_third_party_notices" = "Tredjepartsmerknader";
|
||||
"settings_labs_e2e_encryption_prompt_message" = "Vennligst logg inn igjen for å ferdigstille oppsett av kryptering.";
|
||||
@@ -864,7 +864,7 @@
|
||||
"pin_protection_not_allowed_pin" = "Av sikkerhetsårsaker er denne PIN-koden ikke tilgjengelig. Prøv en annen PIN-kode";
|
||||
"secrets_setup_recovery_passphrase_information" = "Skriv inn en sikkerhetsfrase bare du kjenner, brukes til å sikre hemmeligheter på serveren.";
|
||||
"secrets_recovery_with_passphrase_lost_passphrase_action_part2" = "bruke gjenopprettingsnøkkelen din";
|
||||
"key_backup_recover_from_passphrase_lost_passphrase_action_part2" = "bruke gjenopprettingsnøkkelen";
|
||||
"key_backup_recover_from_passphrase_lost_passphrase_action_part2" = "bruk sikkerhetsnøkkelen";
|
||||
"room_details_access_section_for_dm" = "Hvem har tilgang til dette?";
|
||||
"identity_server_settings_place_holder" = "Legg inn en identitetsserver";
|
||||
"identity_server_settings_description" = "Du bruker for øyeblikket %@ for å finne og bli funnet av dine eksisterende kontakter.";
|
||||
@@ -920,13 +920,13 @@
|
||||
"security_settings_crosssigning" = "KRYSS-SIGNERING";
|
||||
"security_settings_backup" = "SIKKERHETSKOPI-MELDINGER";
|
||||
"security_settings_secure_backup_synchronise" = "Synkroniser";
|
||||
"security_settings_secure_backup_description" = "Sikre deg mot å miste tilgang til krypterte meldinger og data ved å lagre sikkerhetskopi av krypteringsnøkler på din server.";
|
||||
"security_settings_secure_backup_description" = "Sikkerhetskopier krypteringsnøklene med kontodataene dine hvis du mister tilgangen til øktene dine. Nøklene dine blir sikret med en unik sikkerhetsnøkkel.";
|
||||
"security_settings_secure_backup" = "SIKKERHETSKOPI";
|
||||
"security_settings_crosssigning_info_trusted" = "Kryss-signering er aktivert. Du kan stole på andre brukere og dine andre økter basert på kryss-signering, men du kan ikke kryss-signere fra denne økten fordi den ikke har private nøkler for kryss-signering. Fullfør sikkerheten for denne økten.";
|
||||
"security_settings_export_keys_manually" = "Eksporter nøkler manuelt";
|
||||
"security_settings_crosssigning_reset" = "Tilbakestill kryss-signering";
|
||||
"security_settings_crosssigning_bootstrap" = "Bootstrap kryss-signering";
|
||||
"security_settings_crosssigning_info_ok" = "Kryss-signering er aktivert.";
|
||||
"security_settings_crosssigning_reset" = "Nullstill";
|
||||
"security_settings_crosssigning_bootstrap" = "Sett opp";
|
||||
"security_settings_crosssigning_info_ok" = "Kryss-signering er klar til bruk.";
|
||||
"security_settings_blacklist_unverified_devices" = "Aldri send meldinger til ikke-klarerte økter";
|
||||
|
||||
// AuthenticatedSessionViewControllerFactory
|
||||
@@ -1052,7 +1052,7 @@
|
||||
"bug_report_prompt" = "Applikasjonen krasjet sist gang. Vil du sende inn en krasj-rapport?";
|
||||
"public_room_section_title" = "Offentlige rom (på %@):";
|
||||
"call_no_stun_server_error_message_2" = "Alternativt kan du prøve å bruke den offentlige serveren ved %@, men denne vil være mindre pålitelig, og vil dele din IP-adresse med serveren. Du kan også administrere dette i innstillinger";
|
||||
"e2e_key_backup_wrong_version" = "Det har blitt oppdaget en ny sikkerhetskopi av meldingsnøkler .\n\nHvis dette ikke ble intitiert av deg bør du endre passordfrase i innstillinger.";
|
||||
"e2e_key_backup_wrong_version" = "Det har blitt oppdaget en ny sikkerhetskopi av meldingsnøkler .\n\nHvis dette ikke var deg, angir du en ny sikkerhetsfrase i Innstillinger.";
|
||||
|
||||
// Key backup wrong version
|
||||
"e2e_key_backup_wrong_version_title" = "Ny sikkerhetskopi av nøkler";
|
||||
@@ -1136,7 +1136,7 @@
|
||||
"secure_key_backup_setup_intro_title" = "Sikkert lagringsområde";
|
||||
"secure_key_backup_setup_intro_use_security_key_info" = "Generer en sikkerhetsnøkkel og lagre den på et trygt sted som i en passordadministrator eller en safe.";
|
||||
"secure_key_backup_setup_intro_use_security_key_title" = "Bruk en sikkerhetsnøkkel";
|
||||
"secure_key_backup_setup_intro_use_security_passphrase_title" = "Bruk en passordfrase";
|
||||
"secure_key_backup_setup_intro_use_security_passphrase_title" = "Bruk en sikkerhetsfrase";
|
||||
"key_backup_setup_intro_manual_export_action" = "Eksporter nøkler manuelt";
|
||||
"key_backup_setup_intro_setup_connect_action_with_existing_backup" = "Koble denne enheten til sikkerhetskopi av meldingsnøkler";
|
||||
"key_backup_setup_skip_alert_message" = "Du kan miste dine krypterte meldinger dersom du logger ut eller mister enheten.";
|
||||
@@ -1154,12 +1154,12 @@
|
||||
"secure_key_backup_setup_existing_backup_error_info" = "Lås den opp for å gjenbruke den på sikkert lagringsområde, eller slett den for å opprette en ny sikkerhetskopi av meldinger på sikkert lagringsområde.";
|
||||
"secure_key_backup_setup_existing_backup_error_title" = "Det finnes allerede en sikkerhetskopi for meldinger";
|
||||
"secure_key_backup_setup_intro_use_security_passphrase_info" = "Skriv inn en hemmelig frase bare du vet, og generer en nøkkel for sikkerhetskopiering.";
|
||||
"key_backup_setup_passphrase_setup_recovery_key_info" = "Eller sikre sikkerhetskopien din med en gjenopprettingsnøkkel, og lagre den på et trygt sted.";
|
||||
"key_backup_setup_passphrase_info" = "Vi lagrer en kryptert kopi av nøklene dine på serveren vår. Beskytt sikkerhetskopien din med en passordfrase for å holde den sikker.\n\nFor maksimal sikkerhet bør dette være forskjellig fra kontopassordet ditt.";
|
||||
"key_backup_setup_passphrase_setup_recovery_key_info" = "Eller, sikre sikkerhetskopien med en sikkerhetsnøkkel, og lagre den et trygt sted.";
|
||||
"key_backup_setup_passphrase_info" = "Vi lagrer en kryptert kopi av nøklene dine på serveren vår. Beskytt sikkerhetskopien med en setning for å holde den sikker.\n\nFor maksimal sikkerhet bør dette være forskjellig fra kontopassordet ditt.";
|
||||
|
||||
// Passphrase
|
||||
|
||||
"key_backup_setup_passphrase_title" = "Gjør sikkerhetskopien din sikker med en passordfrase";
|
||||
"key_backup_setup_passphrase_title" = "Sikre sikkerhetskopien din med en sikkerhetsfrase";
|
||||
"key_backup_setup_intro_info" = "Meldinger i krypterte rom er sikret med ende-til-ende-kryptering. Bare du og mottakeren(e) har nøklene til å lese disse meldingene.\n\nLagre nøklene dine på et trygt sted for å unngå å miste dem.";
|
||||
|
||||
// MARK: Key backup recover
|
||||
@@ -1168,29 +1168,29 @@
|
||||
|
||||
// Success from recovery key
|
||||
"key_backup_setup_success_from_recovery_key_info" = "Nøklene dine blir sikkerhetskopiert.\n\nKopier denne gjenopprettingsnøkkelen og lagre den på et trygt sted.";
|
||||
"key_backup_setup_success_from_passphrase_save_recovery_key_action" = "Lagre gjenopprettingsnøkkel";
|
||||
"key_backup_setup_success_from_passphrase_save_recovery_key_action" = "Lagre sikkerhetsnøkkel";
|
||||
|
||||
// Success from passphrase
|
||||
"key_backup_setup_success_from_passphrase_info" = "Nøklene dine blir sikkerhetskopiert.\n\nGjenopprettingsnøkkelen din er et sikkerhetsnett - du kan bruke den til å gjenopprette tilgangen til de krypterte meldingene dine hvis du glemmer passordfrasen.\n\nLagre gjenopprettingsnøkkelen din på en trygg måte, f.eks. ved hjelp av en passordadministrator (eller i en safe).";
|
||||
"key_backup_setup_passphrase_setup_recovery_key_action" = "(Avansert) Sett opp med gjenopprettingsnøkkel";
|
||||
"key_backup_recover_invalid_passphrase" = "Sikkerhetskopi kunne ikke dekrypteres med denne passordfrasen: Vennligst sjekk at du har angitt riktig passordfrase.";
|
||||
"key_backup_recover_invalid_passphrase_title" = "Feil gjenopprettingsfrase";
|
||||
"key_backup_recover_invalid_recovery_key" = "Sikkerhetskopi kunne ikke dekrypteres med denne nøkkelen: bekreft at du skrev inn riktig gjenopprettingsnøkkel.";
|
||||
"key_backup_setup_passphrase_setup_recovery_key_action" = "(Avansert) Sett opp med sikkerhetsnøkkel";
|
||||
"key_backup_recover_invalid_passphrase" = "Sikkerhetskopiering kunne ikke dekrypteres med denne setningen: bekreft at du har skrevet riktig sikkerhetsfrase.";
|
||||
"key_backup_recover_invalid_passphrase_title" = "Feil sikkerhetsfrase";
|
||||
"key_backup_recover_invalid_recovery_key" = "Sikkerhetskopiering kunne ikke dekrypteres med denne nøkkelen: bekreft at du har angitt riktig sikkerhetsnøkkel.";
|
||||
"key_backup_recover_invalid_recovery_key_title" = "Feil i gjenopprettingsnøkkel";
|
||||
|
||||
// Recover from passphrase
|
||||
|
||||
"key_backup_recover_from_passphrase_info" = "Bruk gjenopprettingspassordet for å låse opp historikken for dine sikrede meldinger";
|
||||
"key_backup_recover_from_passphrase_info" = "Bruk sikkerhetsfrasen for å låse opp den sikre meldingsloggen";
|
||||
|
||||
// Recover from private key
|
||||
"key_backup_recover_from_private_key_info" = "Gjenoppretter sikkerhetskopi …";
|
||||
"key_backup_recover_from_passphrase_lost_passphrase_action_part1" = "Kjenner du ikke gjenopprettingspassordet ditt? Du kan ";
|
||||
"key_backup_recover_from_passphrase_lost_passphrase_action_part1" = "Kjenner du ikke sikkerhetsfrasen din? Du kan ";
|
||||
"key_backup_recover_from_passphrase_recover_action" = "Lås opp historikk";
|
||||
"key_backup_recover_from_passphrase_passphrase_placeholder" = "Skriv inn passordfrase";
|
||||
|
||||
// Recover from recovery key
|
||||
|
||||
"key_backup_recover_from_recovery_key_info" = "Bruk gjenopprettingsnøkkel for å låse opp historikken for sikrede meldinger";
|
||||
"key_backup_recover_from_recovery_key_info" = "Bruk sikkerhetsnøkkelen til å låse opp den sikre meldingsloggen";
|
||||
"key_backup_recover_from_passphrase_lost_passphrase_action_part3" = ".";
|
||||
"sign_out_non_existing_key_backup_alert_setup_secure_backup_action" = "Begynn å bruke Sikkert lagringsområde";
|
||||
"sign_out_non_existing_key_backup_alert_title" = "Du mister tilgangen til de krypterte meldingene dine hvis du logger ut nå";
|
||||
@@ -1418,3 +1418,50 @@
|
||||
"room_intro_cell_information_multiple_dm_sentence2" = "Bare dere er i denne samtalen, med mindre noen av dere inviterer andre til å bli med.";
|
||||
"room_intro_cell_information_dm_sentence2" = "Bare dere to er i denne samtalen, ingen andre kan bli med.";
|
||||
"room_intro_cell_information_dm_sentence1_part3" = ". ";
|
||||
"side_menu_app_version" = "Versjon %@";
|
||||
"side_menu_action_feedback" = "Tilbakemelding";
|
||||
"side_menu_action_help" = "Hjelp";
|
||||
"side_menu_action_settings" = "Innstillinger";
|
||||
"side_menu_action_invite_friends" = "Inviter venner";
|
||||
|
||||
// Mark: - Side menu
|
||||
|
||||
"side_menu_reveal_action_accessibility_label" = "Venstre panel";
|
||||
"user_avatar_view_accessibility_hint" = "Endre bruker avatar";
|
||||
|
||||
// Mark: - User avatar view
|
||||
|
||||
"user_avatar_view_accessibility_label" = "avatar";
|
||||
"space_beta_announce_information" = "Plasser er en ny måte å gruppere rom og mennesker på. De er ikke på iOS ennå, men du kan bruke dem nå på nettet og på skrivebordet.";
|
||||
"space_beta_announce_subtitle" = "Den nye versjonen av lokalsamfunn";
|
||||
"space_beta_announce_title" = "Plasser kommer snart";
|
||||
"space_beta_announce_badge" = "BETA";
|
||||
"space_feature_unavailable_information" = "Plasser er en ny måte å gruppere rom og mennesker på.\n\nDe kommer snart. For nå, hvis du blir med på en annen plattform, vil du kunne få tilgang til alle rom du blir med her.";
|
||||
"space_feature_unavailable_subtitle" = "Plasser er ikke på iOS ennå, men du kan bruke dem nå på nettet og på skrivebordet";
|
||||
|
||||
// Mark: - Spaces
|
||||
|
||||
"space_feature_unavailable_title" = "Plasser er ikke her ennå";
|
||||
"secrets_recovery_with_key_information_unlock_secure_backup_with_key" = "Skriv inn sikkerhetsnøkkelen din for å fortsette.";
|
||||
"secrets_recovery_with_key_information_unlock_secure_backup_with_phrase" = "Skriv inn sikkerhetsfrasen for å fortsette.";
|
||||
|
||||
// Success from secure backup
|
||||
"key_backup_setup_success_from_secure_backup_info" = "Nøklene dine blir sikkerhetskopiert.";
|
||||
"event_formatter_group_call_incoming" = "%@ i %@";
|
||||
"event_formatter_group_call_leave" = "Forlat";
|
||||
"event_formatter_group_call_join" = "Bli med";
|
||||
"event_formatter_group_call" = "Gruppeanrop";
|
||||
"event_formatter_call_end_call" = "Avslutt samtale";
|
||||
"event_formatter_call_retry" = "Prøv på nytt";
|
||||
"event_formatter_call_answer" = "Svar";
|
||||
"security_settings_secure_backup_restore" = "Gjenopprett fra sikkerhetskopi";
|
||||
"security_settings_secure_backup_reset" = "Nullstill";
|
||||
"security_settings_secure_backup_info_valid" = "Denne økten tar sikkerhetskopi av nøklene dine.";
|
||||
"security_settings_secure_backup_info_checking" = "Sjekker…";
|
||||
"settings_ui_theme_picker_message_match_system_theme" = "\"Auto\" samsvarer med enhetens systemtema";
|
||||
"settings_ui_theme_picker_message_invert_colours" = "\"Auto\" bruker enhetens \"Inverter farger\" innstillinger";
|
||||
|
||||
// Chat
|
||||
"room_slide_to_end_group_call" = "Skyv for å avslutte samtalen for alle";
|
||||
"room_recents_unknown_room_error_message" = "Finner ikke dette rommet. Forsikre deg om at den eksisterer";
|
||||
"room_creation_dm_error" = "Vi kunne ikke opprette DM. Kontroller brukerne du vil invitere, og prøv på nytt.";
|
||||
|
||||
@@ -611,7 +611,7 @@
|
||||
"room_does_not_exist" = "%@ bestaat niet";
|
||||
// Key backup wrong version
|
||||
"e2e_key_backup_wrong_version_title" = "Nieuwe sleutelback-up";
|
||||
"e2e_key_backup_wrong_version" = "Er is een nieuwe sleutelback-up voor versleutelde berichten gedetecteerd.\n\nIndien deze niet van u komt, stel dan een nieuw wachtwoord in in de instellingen.";
|
||||
"e2e_key_backup_wrong_version" = "Er is een nieuwe sleutelback-up voor versleutelde berichten gedetecteerd.\n\nIndien deze niet van u komt, stel dan een nieuw veiligheidswachtwoord in in de instellingen.";
|
||||
"e2e_key_backup_wrong_version_button_settings" = "Instellingen";
|
||||
"e2e_key_backup_wrong_version_button_wasme" = "Ik was het";
|
||||
"key_backup_setup_title" = "Sleutelback-up";
|
||||
@@ -623,10 +623,10 @@
|
||||
"key_backup_setup_intro_setup_action_without_existing_backup" = "Begin sleutelback-up te gebruiken";
|
||||
"key_backup_setup_intro_manual_export_info" = "(Geavanceerd)";
|
||||
"key_backup_setup_intro_manual_export_action" = "Sleutels handmatig exporteren";
|
||||
"key_backup_setup_passphrase_title" = "Beveilig uw back-up met een wachtwoord";
|
||||
"key_backup_setup_passphrase_info" = "We bewaren een versleutelde kopie van uw sleutels op onze server. Bescherm uw back-up met een wachtwoord om deze veilig te houden.\n\nVoor maximale beveiliging zou dit moeten verschillen van uw accountwachtwoord.";
|
||||
"key_backup_setup_passphrase_title" = "Beveilig uw back-up met een veiligheidswachtwoord";
|
||||
"key_backup_setup_passphrase_info" = "We bewaren een versleutelde kopie van uw sleutels op onze server. Bescherm uw back-up met een veiligheidswachtwoord om deze veilig te houden.\n\nVoor maximale beveiliging zou dit moeten verschillen van uw accountwachtwoord.";
|
||||
"key_backup_setup_passphrase_passphrase_title" = "Invoeren";
|
||||
"key_backup_setup_passphrase_passphrase_placeholder" = "Voer wachtwoord in";
|
||||
"key_backup_setup_passphrase_passphrase_placeholder" = "Wachtwoord invoeren";
|
||||
"key_backup_setup_passphrase_passphrase_valid" = "Top!";
|
||||
"key_backup_setup_passphrase_passphrase_invalid" = "Probeer nog een woord toe te voegen";
|
||||
"key_backup_setup_passphrase_confirm_passphrase_title" = "Bevestigen";
|
||||
@@ -634,35 +634,35 @@
|
||||
"key_backup_setup_passphrase_confirm_passphrase_valid" = "Top!";
|
||||
"key_backup_setup_passphrase_confirm_passphrase_invalid" = "Wachtwoorden komen niet overeen";
|
||||
"key_backup_setup_passphrase_set_passphrase_action" = "Wachtwoord instellen";
|
||||
"key_backup_setup_passphrase_setup_recovery_key_info" = "Of beveilig uw back-up met een herstelsleutel, en bewaar deze op een veilige plaats.";
|
||||
"key_backup_setup_passphrase_setup_recovery_key_action" = "(Geavanceerd) Instellen met herstelsleutel";
|
||||
"key_backup_setup_passphrase_setup_recovery_key_info" = "Of beveilig uw back-up met een veiligheidssleutel en bewaar deze op een veilige plaats.";
|
||||
"key_backup_setup_passphrase_setup_recovery_key_action" = "(Geavanceerd) Instellen met veiligheidssleutel";
|
||||
"key_backup_setup_success_title" = "Klaar!";
|
||||
// Success from passphrase
|
||||
"key_backup_setup_success_from_passphrase_info" = "Er wordt een back-up van uw sleutels gemaakt.\n\nUw herstelsleutel is een veiligheidsnet - u kunt deze gebruiken om de toegang tot uw versleutelde berichten te herstellen als u uw wachtwoord zou vergeten.\n\nBewaar uw herstelsleutel op een heel veilig plaats, zoals een wachtwoordbeheerder (of een kluis).";
|
||||
"key_backup_setup_success_from_passphrase_save_recovery_key_action" = "Herstelsleutel opslaan";
|
||||
"key_backup_setup_success_from_passphrase_info" = "Er wordt een back-up van uw sleutels gemaakt.\n\nUw veiligheidssleutel is een veiligheidsnet - u kunt deze gebruiken om de toegang tot uw versleutelde berichten te herstellen als u uw wachtwoord zou vergeten.\n\nBewaar uw veiligheidssleutel op een heel veilige plaats, zoals een wachtwoordbeheerder (of een kluis).";
|
||||
"key_backup_setup_success_from_passphrase_save_recovery_key_action" = "Veiligheidssleutel opslaan";
|
||||
"key_backup_setup_success_from_passphrase_done_action" = "Klaar";
|
||||
// Success from recovery key
|
||||
"key_backup_setup_success_from_recovery_key_info" = "Er wordt een back-up van uw sleutels gemaakt.\n\nMaak een kopie van deze herstelsleutel en bewaar deze op een veilige plaats.";
|
||||
"key_backup_setup_success_from_recovery_key_recovery_key_title" = "Herstelsleutel";
|
||||
"key_backup_setup_success_from_recovery_key_info" = "Er wordt een back-up van uw sleutels gemaakt.\n\nMaak een kopie van deze veiligheidssleutel en bewaar deze op een veilige plaats.";
|
||||
"key_backup_setup_success_from_recovery_key_recovery_key_title" = "Veiligheidssleutel";
|
||||
"key_backup_setup_success_from_recovery_key_make_copy_action" = "Maak een kopie";
|
||||
"key_backup_setup_success_from_recovery_key_made_copy_action" = "Ik heb een kopie gemaakt";
|
||||
"key_backup_recover_title" = "Versleutelde berichten";
|
||||
"key_backup_recover_invalid_passphrase_title" = "Onjuist herstelwachtwoord";
|
||||
"key_backup_recover_invalid_passphrase" = "De back-up kon niet ontsleuteld worden met dit wachtwoord: controleer of u het herstelwachtwoord juist hebt ingevoerd.";
|
||||
"key_backup_recover_invalid_recovery_key_title" = "Herstelsleutel komt niet overeen";
|
||||
"key_backup_recover_invalid_recovery_key" = "De back-up kon niet ontsleuteld worden met deze sleutel: controleer of u de juiste herstelsleutel hebt ingevoerd.";
|
||||
"key_backup_recover_from_passphrase_info" = "Gebruik uw herstelwachtwoord om uw versleutelde berichtgeschiedenis te ontgrendelen";
|
||||
"key_backup_recover_invalid_passphrase_title" = "Onjuist veiligheidswachtwoord";
|
||||
"key_backup_recover_invalid_passphrase" = "De back-up kon niet ontsleuteld worden met dit wachtwoord: controleer of u het veiligheidswachtwoord juist hebt ingevoerd.";
|
||||
"key_backup_recover_invalid_recovery_key_title" = "Veiligheidssleutel komt niet overeen";
|
||||
"key_backup_recover_invalid_recovery_key" = "De back-up kon niet ontsleuteld worden met deze sleutel: controleer of u de juiste veiligheidssleutel hebt ingevoerd.";
|
||||
"key_backup_recover_from_passphrase_info" = "Gebruik uw veiligheidswachtwoord om uw versleutelde berichtengeschiedenis te ontgrendelen";
|
||||
"key_backup_recover_from_passphrase_passphrase_title" = "Invoeren";
|
||||
"key_backup_recover_from_passphrase_passphrase_placeholder" = "Voer wachtwoord in";
|
||||
"key_backup_recover_from_passphrase_passphrase_placeholder" = "Wachtwoord invoeren";
|
||||
"key_backup_recover_from_passphrase_recover_action" = "Geschiedenis ontgrendelen";
|
||||
"key_backup_recover_from_passphrase_lost_passphrase_action_part1" = "Herstelwachtwoord vergeten? Dan kunt u ";
|
||||
"key_backup_recover_from_passphrase_lost_passphrase_action_part2" = "uw herstelsleutel gebruiken";
|
||||
"key_backup_recover_from_passphrase_lost_passphrase_action_part1" = "Veiligheidswachtwoord vergeten? Dan kunt u ";
|
||||
"key_backup_recover_from_passphrase_lost_passphrase_action_part2" = "uw veiligheidssleutel gebruiken";
|
||||
"key_backup_recover_from_passphrase_lost_passphrase_action_part3" = ".";
|
||||
"key_backup_recover_from_recovery_key_info" = "Gebruik uw herstelsleutel om uw versleutelde berichtgeschiedenis te ontgrendelen";
|
||||
"key_backup_recover_from_recovery_key_info" = "Gebruik uw veiligheidssleutel om uw versleutelde berichtengeschiedenis te ontgrendelen";
|
||||
"key_backup_recover_from_recovery_key_recovery_key_title" = "Invoeren";
|
||||
"key_backup_recover_from_recovery_key_recovery_key_placeholder" = "Voer herstelsleutel in";
|
||||
"key_backup_recover_from_recovery_key_recovery_key_placeholder" = "Veiligheidssleutel invoeren";
|
||||
"key_backup_recover_from_recovery_key_recover_action" = "Geschiedenis ontgrendelen";
|
||||
"key_backup_recover_from_recovery_key_lost_recovery_key_action" = "Herstelsleutel verloren? U kunt er een nieuwe aanmaken in de instellingen.";
|
||||
"key_backup_recover_from_recovery_key_lost_recovery_key_action" = "Veiligheidssleutel verloren? U kunt er een nieuwe aanmaken in de instellingen.";
|
||||
"key_backup_recover_success_info" = "Back-up hersteld!";
|
||||
"key_backup_recover_done_action" = "Klaar";
|
||||
"key_backup_setup_banner_title" = "Verlies nooit uw versleutelde berichten";
|
||||
@@ -913,7 +913,7 @@
|
||||
// MARK: - Favourites
|
||||
|
||||
"favourites_empty_view_title" = "Favoriete gesprekken en personen";
|
||||
"home_empty_view_information" = "De alles-in-één veilige chat app voor teams, vrienden en organisaties. Klik op de onderstaande + knop om gesprekken te starten met personen en groepen.";
|
||||
"home_empty_view_information" = "De alles-in-één veilige chat-app voor teams, vrienden en organisaties. Druk op de + knop hieronder om personen en gesprekken toe te voegen.";
|
||||
|
||||
// MARK: - Home
|
||||
|
||||
@@ -1032,32 +1032,32 @@
|
||||
"secrets_setup_recovery_key_done_action" = "Klaar";
|
||||
"secrets_setup_recovery_key_export_action" = "Opslaan";
|
||||
"secrets_setup_recovery_key_loading" = "Laden…";
|
||||
"secrets_setup_recovery_key_information" = "Bewaar uw Herstelsleutel op een veilige plek. Het kan gebruikt worden voor het ontgrendelen van uw versleutelde berichten en data.";
|
||||
"secrets_recovery_with_key_invalid_recovery_key_message" = "Verifieer dat u de juiste herstelsleutel heeft ingevoerd.";
|
||||
"secrets_setup_recovery_key_information" = "Bewaar uw veiligheidssleutel op een veilige plek. Deze kan gebruikt worden om uw versleutelde berichten en data te ontsleutelen.";
|
||||
"secrets_recovery_with_key_invalid_recovery_key_message" = "Verifieer dat u de juiste veiligheidssleutel heeft ingevoerd.";
|
||||
"secrets_recovery_with_key_invalid_recovery_key_title" = "Geen toegang tot geheime opslag";
|
||||
"secrets_recovery_with_key_recover_action" = "Gebruik sleutel";
|
||||
"secrets_recovery_with_key_recovery_key_placeholder" = "Voer de herstelsleutel in";
|
||||
"secrets_recovery_with_key_recovery_key_placeholder" = "Veiligheidssleutel invoeren";
|
||||
"secrets_recovery_with_key_recovery_key_title" = "Invoeren";
|
||||
"secrets_recovery_with_key_information_verify_device" = "Gebruik uw herstelsleutel om dit apparaat te verifiëren.";
|
||||
"secrets_recovery_with_key_information_default" = "Ontvang toegang tot uw versleutelde berichtengeschiedenis en uw kruislings ondertekenen ID voor het verifiëren van andere sessie door het invoeren van uw Herstelsleutel.";
|
||||
"secrets_recovery_with_key_information_verify_device" = "Gebruik uw veiligheidssleutel om dit apparaat te verifiëren.";
|
||||
"secrets_recovery_with_key_information_default" = "Ontvang toegang tot uw versleutelde berichtengeschiedenis en kruislings ondertekenen voor het verifiëren van andere sessie door het invoeren van uw veiligheidssleutel.";
|
||||
|
||||
// Recover with key
|
||||
|
||||
"secrets_recovery_with_key_title" = "Herstelsleutel";
|
||||
"secrets_recovery_with_passphrase_invalid_passphrase_message" = "Verifieer dat u het juiste Herstelwachtwoord heeft ingevoerd.";
|
||||
"secrets_recovery_with_key_title" = "Veiligheidssleutel";
|
||||
"secrets_recovery_with_passphrase_invalid_passphrase_message" = "Verifieer dat u het juiste veiligheidswachtwoord heeft ingevoerd.";
|
||||
"secrets_recovery_with_passphrase_invalid_passphrase_title" = "Geen toegang tot geheime opslag";
|
||||
"secrets_recovery_with_passphrase_lost_passphrase_action_part3" = ".";
|
||||
"secrets_recovery_with_passphrase_lost_passphrase_action_part2" = "uw Herstelsleutel gebruiken";
|
||||
"secrets_recovery_with_passphrase_lost_passphrase_action_part1" = "Herstelwachtwoord vergeten? Dan kunt u ";
|
||||
"secrets_recovery_with_passphrase_recover_action" = "Gebruik Wachtwoord";
|
||||
"secrets_recovery_with_passphrase_passphrase_placeholder" = "Voer uw Herstelwachtwoord in";
|
||||
"secrets_recovery_with_passphrase_lost_passphrase_action_part2" = "uw veiligheidssleutel gebruiken";
|
||||
"secrets_recovery_with_passphrase_lost_passphrase_action_part1" = "Veiligheidswachtwoord vergeten? Dan kunt u ";
|
||||
"secrets_recovery_with_passphrase_recover_action" = "Gebruik wachtwoord";
|
||||
"secrets_recovery_with_passphrase_passphrase_placeholder" = "Voer uw veiligheidswachtwoord in";
|
||||
"secrets_recovery_with_passphrase_passphrase_title" = "Invoeren";
|
||||
"secrets_recovery_with_passphrase_information_verify_device" = "Gebruik uw Herstelwachtwoord om dit apparaat te verifiëren.";
|
||||
"secrets_recovery_with_passphrase_information_default" = "Ontvang toegang tot uw versleutelde berichtengeschiedenis en uw kruislings ondertekenen ID voor het verifiëren van andere sessies door het invoeren van uw Herstelwachtwoord.";
|
||||
"secrets_recovery_with_passphrase_information_verify_device" = "Gebruik uw veiligheidswachtwoord om dit apparaat te verifiëren.";
|
||||
"secrets_recovery_with_passphrase_information_default" = "Ontvang toegang tot uw versleutelde berichtengeschiedenis en kruislings ondertekenen voor het verifiëren van andere sessies door het invoeren van uw veiligheidswachtwoord.";
|
||||
|
||||
// Recover with passphrase
|
||||
|
||||
"secrets_recovery_with_passphrase_title" = "Herstelwachtwoord";
|
||||
"secrets_recovery_with_passphrase_title" = "Veiligheidswachtwoord";
|
||||
"secrets_recovery_reset_action_part_2" = "Alles opnieuw instellen";
|
||||
|
||||
// MARK: - Secrets recovery
|
||||
@@ -1065,9 +1065,9 @@
|
||||
"secrets_recovery_reset_action_part_1" = "Alle herstelopties vergeten of verloren? ";
|
||||
"user_verification_session_details_verify_action_other_user" = "Handmatig verifiëren";
|
||||
"user_verification_session_details_verify_action_current_user_manually" = "Handmatig middels een tekst";
|
||||
"user_verification_session_details_verify_action_current_user" = "Interactief Verifiëren";
|
||||
"user_verification_session_details_verify_action_current_user" = "Interactief verifiëren";
|
||||
"user_verification_session_details_additional_information_untrusted_current_user" = "Als u zich niet heeft aangemeld bij deze sessie, is uw account wellicht geschonden.";
|
||||
"user_verification_session_details_additional_information_untrusted_other_user" = "Totdat deze persoon de sessie vertrouwd zijn berichten gelabeld met een waarschuwing. Een alternatief is handmatig verifiëren.";
|
||||
"user_verification_session_details_additional_information_untrusted_other_user" = "Totdat deze persoon deze sessie vertrouwd, zijn berichten gelabeld met waarschuwingen. Een andere mogelijkheid is om de persoon handmatig te verifiëren.";
|
||||
"user_verification_session_details_information_untrusted_other_user" = " heeft zich in een nieuwe sessie aangemeld:";
|
||||
"user_verification_session_details_information_untrusted_current_user" = "Verifieer deze sessie om het als vertrouwd te markeren en het toegang te geven tot versleutelde berichten:";
|
||||
"user_verification_session_details_information_trusted_other_user_part2" = " verifieer het:";
|
||||
@@ -1180,8 +1180,8 @@
|
||||
"key_verification_verify_sas_title_emoji" = "Vergelijk de emoji's";
|
||||
"device_verification_self_verify_wait_recover_secrets_checking_availability" = "Controleren op andere verificatie mogelijkheden...";
|
||||
"device_verification_self_verify_wait_recover_secrets_additional_information" = "Wanneer u geen toegang meer heeft tot een bestaande sessie";
|
||||
"device_verification_self_verify_wait_recover_secrets_with_passphrase" = "Uw Herstelwachtwoord of -sleutel gebruiken";
|
||||
"device_verification_self_verify_wait_recover_secrets_without_passphrase" = "Herstelsleutel gebruiken";
|
||||
"device_verification_self_verify_wait_recover_secrets_with_passphrase" = "Uw veiligheidswachtwoord of -sleutel gebruiken";
|
||||
"device_verification_self_verify_wait_recover_secrets_without_passphrase" = "Veiligheidssleutel gebruiken";
|
||||
"device_verification_self_verify_wait_additional_information" = "Dit werkt met Element en andere Matrix-apps die kruislings ondertekenen ondersteunen.";
|
||||
"device_verification_self_verify_wait_information" = "Verifieer deze sessie vanaf een van uw andere sessies, om toegang te krijgen tot de versleutelde berichten.\n\nGebruik de laatste versie van Element op uw andere apparaten:";
|
||||
"device_verification_self_verify_wait_new_sign_in_title" = "Verifieer deze login";
|
||||
@@ -1266,7 +1266,7 @@
|
||||
// Room widget permissions
|
||||
"room_widget_permission_title" = "Widget laden";
|
||||
"widget_picker_manage_integrations" = "Beheer integraties…";
|
||||
"widget_integration_manager_disabled" = "U moet een integratebeheerder inschakelen in uw instellingen";
|
||||
"widget_integration_manager_disabled" = "U moet integratiebeheer inschakelen in de instellingen";
|
||||
"widget_menu_remove" = "Verwijderen voor iedereen";
|
||||
"widget_menu_revoke_permission" = "Toegang intrekken voor mij";
|
||||
"widget_menu_open_outside" = "Openen in browser";
|
||||
@@ -1292,11 +1292,11 @@
|
||||
"room_details_advanced_e2e_encryption_disabled_for_dm" = "Versleuteling is hier niet ingeschakeld.";
|
||||
"room_details_advanced_e2e_encryption_enabled_for_dm" = "Versleuteling is hier ingeschakeld";
|
||||
"room_details_advanced_room_id_for_dm" = "ID:";
|
||||
"room_details_no_local_addresses_for_dm" = "Dit heeft geen lokaaladres";
|
||||
"room_details_no_local_addresses_for_dm" = "Geen lokaaladres bekend";
|
||||
"room_details_access_section_directory_toggle_for_dm" = "Weergeven in publieke groepsgesprekkencatalogus";
|
||||
"room_details_access_section_anyone_for_dm" = "Iedereen die de koppeling kent, inclusief gasten";
|
||||
"room_details_access_section_anyone_apart_from_guest_for_dm" = "Iedereen die de koppeling kent, behalve gasten";
|
||||
"room_details_access_section_for_dm" = "Wie mag toegang hebben?";
|
||||
"room_details_access_section_for_dm" = "Wie heeft toegang?";
|
||||
"room_details_room_name_for_dm" = "Naam";
|
||||
"room_details_photo_for_dm" = "Foto";
|
||||
"room_details_title_for_dm" = "Details";
|
||||
@@ -1343,17 +1343,17 @@
|
||||
"security_settings_cryptography" = "CRYPTOGRAFIE";
|
||||
"security_settings_crosssigning_complete_security" = "Beveiliging afronden";
|
||||
"security_settings_crosssigning_reset" = "Reset";
|
||||
"security_settings_crosssigning_bootstrap" = "Stel in";
|
||||
"security_settings_crosssigning_info_ok" = "Cross-signing is klaar voor gebruik.";
|
||||
"security_settings_crosssigning_bootstrap" = "Instellen";
|
||||
"security_settings_crosssigning_info_ok" = "Kruiselings ondertekenen is klaar voor gebruik.";
|
||||
"security_settings_crosssigning_info_trusted" = "Kruislings ondertekenen is ingeschakeld. U kunt andere personen en sessies verifiëren met kruislings ondertekenen, maar u kunt dit nog niet vanaf deze sessie doordat de versleutelingssleutel ontbreekt. Rond de beveiliging van deze sessie af.";
|
||||
"security_settings_crosssigning_info_exists" = "Uw account heeft een kruislings ondertekenen ID, maar is nog niet geverifieerd door deze sessie. Rond de beveiliging van deze sessie af.";
|
||||
"security_settings_crosssigning_info_not_bootstrapped" = "Kruislings ondertekenen is nog niet ingesteld.";
|
||||
"security_settings_crosssigning" = "KRUISLINGS ONDERTEKENEN";
|
||||
"security_settings_backup" = "BERICHTENBACK-UP";
|
||||
"security_settings_secure_backup_delete" = "Verwijder backup";
|
||||
"security_settings_secure_backup_delete" = "Back-up verwijderen";
|
||||
"security_settings_secure_backup_synchronise" = "Synchroniseren";
|
||||
"security_settings_secure_backup_setup" = "Instellen";
|
||||
"security_settings_secure_backup_description" = "Waarborg uw toegang tot uw versleutelde berichten & data door de versleutelingssleutels op te slaan. Uw sleutels zullen worden beveiligd met een unieke beveiligingssleutel.";
|
||||
"security_settings_secure_backup_description" = "Maak een back-up van uw versleutelingssleutel bij uw account data voor het geval u toegang verliest tot uw sessies. Uw sleutels zullen worden beveiligd met een unieke veiligheidssleutel.";
|
||||
"security_settings_secure_backup" = "VEILIGE BACK-UP";
|
||||
"security_settings_crypto_sessions_description_2" = "Als u deze inlog niet herkent, verander uw wachtwoord en reset uw Veilige Back-up.";
|
||||
"security_settings_crypto_sessions_loading" = "Sessies laden…";
|
||||
@@ -1512,3 +1512,16 @@
|
||||
"settings_ui_theme_picker_message_invert_colours" = "‘Automatisch’ gebruikt de instelling ‘Kleurweergave omkeren’ van uw apparaat";
|
||||
"room_recents_unknown_room_error_message" = "Dit gesprek is niet gevonden. Controleer of het bestaat";
|
||||
"room_creation_dm_error" = "Uw direct gesprek kon niet aangemaakt worden. Controleer de gebruikers die u wilt uitnodigen en probeer het opnieuw.";
|
||||
"key_verification_verify_qr_code_scan_code_other_device_action" = "Scan met dit apparaat";
|
||||
"room_notifs_settings_encrypted_room_notice" = "Let op dat vermeldingen & trefwoorden-meldingen niet beschikbaar zijn in versleutelde gesprekken op mobiel.";
|
||||
"room_notifs_settings_account_settings" = "Accountinstellingen";
|
||||
"room_notifs_settings_manage_notifications" = "U kunt uw meldingen beheren in %@";
|
||||
"room_notifs_settings_cancel_action" = "Annuleer";
|
||||
"room_notifs_settings_done_action" = "Klaar";
|
||||
"room_notifs_settings_none" = "Geen";
|
||||
"room_notifs_settings_mentions_and_keywords" = "Alleen vermeldingen en trefwoorden";
|
||||
"room_notifs_settings_all_messages" = "Alle berichten";
|
||||
|
||||
// Room Notification Settings
|
||||
"room_notifs_settings_notify_me_for" = "Stuur een melding voor";
|
||||
"room_details_notifs" = "Meldingen";
|
||||
|
||||
@@ -1380,3 +1380,16 @@
|
||||
"settings_ui_theme_picker_message_invert_colours" = "\"Auto\" usa as configurações \"Inverter Cores\" de seu dispositivo";
|
||||
"room_recents_unknown_room_error_message" = "Não dá para encontrar esta sala. Assegure que ela existe";
|
||||
"room_creation_dm_error" = "Nós não conseguimos criar sua DM. Por favor cheque as/os usuárias(os) que você quer convidar e tente de novo.";
|
||||
"key_verification_verify_qr_code_scan_code_other_device_action" = "Scannar com este dispositivo";
|
||||
"room_notifs_settings_encrypted_room_notice" = "Por favor note que notificações de menções & palavrachave não estão disponíveis em salas encriptadas no celular.";
|
||||
"room_notifs_settings_account_settings" = "Configurações de conta";
|
||||
"room_notifs_settings_manage_notifications" = "Você pode gerenciar notificações em %@";
|
||||
"room_notifs_settings_cancel_action" = "Cancelar";
|
||||
"room_notifs_settings_done_action" = "Feito";
|
||||
"room_notifs_settings_none" = "Nenhuma";
|
||||
"room_notifs_settings_mentions_and_keywords" = "Menções e Palavrachaves somente";
|
||||
"room_notifs_settings_all_messages" = "Todas as Mensagens";
|
||||
|
||||
// Room Notification Settings
|
||||
"room_notifs_settings_notify_me_for" = "Notifique-me para";
|
||||
"room_details_notifs" = "Notificações";
|
||||
|
||||
@@ -17,3 +17,6 @@
|
||||
|
||||
/* New message from a specific person, not referencing a room. Content included. */
|
||||
"MSG_FROM_USER_WITH_CONTENT" = "%@: %@";
|
||||
|
||||
/* Group call from user, CallKit caller name */
|
||||
"GROUP_CALL_FROM_USER" = "%@ (සමූහ ඇමතුම)";
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
// Titles
|
||||
"title_home" = "මුල් පිටුව";
|
||||
"warning" = "අවවාදයයි";
|
||||
"join" = "එක්වන්න";
|
||||
"save" = "සුරකින්න";
|
||||
"cancel" = "අවලංගු කරන්න";
|
||||
"remove" = "ඉවත් කරන්න";
|
||||
"leave" = "හැරයන්න";
|
||||
"start" = "අරඹන්න";
|
||||
"create" = "සාදන්න";
|
||||
"continue" = "ඉදිරියට";
|
||||
"back" = "ආපසු";
|
||||
"next" = "ඊලඟ";
|
||||
"title_rooms" = "කාමර";
|
||||
"title_people" = "මිනිසුන්";
|
||||
"title_favourites" = "ප්රියතමයින්";
|
||||
|
||||
@@ -1401,3 +1401,16 @@
|
||||
"settings_ui_theme_picker_message_invert_colours" = "“Auto” përdor rregullimet “Ktheji Së Prapthi Ngjyrat”";
|
||||
"room_recents_unknown_room_error_message" = "S’gjendet dot kjo dhomë. Sigurohuni se ekziston";
|
||||
"room_creation_dm_error" = "S’mundëm të krijojmë dot MD-në tuaj. Ju lutemi, kontrolloni përdoruesit të cilëve doni t’u dërgohet dhe riprovoni.";
|
||||
"key_verification_verify_qr_code_scan_code_other_device_action" = "Skanoje me këtë pajisje";
|
||||
"room_notifs_settings_encrypted_room_notice" = "Ju lutemi, kini parasysh se njoftimet për përmendje & fjalëkyçe s’mund të kihen në celular për dhoma të fshehtëzuara.";
|
||||
"room_notifs_settings_account_settings" = "Rregullime llogarie";
|
||||
"room_notifs_settings_manage_notifications" = "Njoftimet mund t’i administroni që nga %@";
|
||||
"room_notifs_settings_cancel_action" = "Anuloje";
|
||||
"room_notifs_settings_done_action" = "U bë";
|
||||
"room_notifs_settings_none" = "Asnjë";
|
||||
"room_notifs_settings_mentions_and_keywords" = "Vetëm Përmendje dhe Fjalëkyçe";
|
||||
"room_notifs_settings_all_messages" = "Krejt Mesazhet";
|
||||
|
||||
// Room Notification Settings
|
||||
"room_notifs_settings_notify_me_for" = "Njoftomëni për";
|
||||
"room_details_notifs" = "Njoftime";
|
||||
|
||||
@@ -1688,7 +1688,203 @@
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
<br/><br/>
|
||||
</li>
|
||||
</li>
|
||||
<li>
|
||||
<b>DSWaveformImage</b> (<a href="https://github.com/dmrschmidt/DSWaveformImage">https://github.com/dmrschmidt/DSWaveformImage</a>)
|
||||
<br/><br/>
|
||||
The MIT License (MIT)
|
||||
<br/><br/>
|
||||
Copyright (c) 2013 Dennis Schmidt
|
||||
<br/><br/>
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
<br/><br/>
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
<br/><br/>
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
<br/><br/>
|
||||
</li>
|
||||
<li>
|
||||
<b>ffmpeg-kit-ios-audio</b> (<a href="https://github.com/tanersener/ffmpeg-kit">https://github.com/tanersener/ffmpeg-kit</a>)
|
||||
<br/><br/>
|
||||
<pre>
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
|
||||
This version of the GNU Lesser General Public License incorporates
|
||||
the terms and conditions of version 3 of the GNU General Public
|
||||
License, supplemented by the additional permissions listed below.
|
||||
|
||||
0. Additional Definitions.
|
||||
|
||||
As used herein, "this License" refers to version 3 of the GNU Lesser
|
||||
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
||||
General Public License.
|
||||
|
||||
"The Library" refers to a covered work governed by this License,
|
||||
other than an Application or a Combined Work as defined below.
|
||||
|
||||
An "Application" is any work that makes use of an interface provided
|
||||
by the Library, but which is not otherwise based on the Library.
|
||||
Defining a subclass of a class defined by the Library is deemed a mode
|
||||
of using an interface provided by the Library.
|
||||
|
||||
A "Combined Work" is a work produced by combining or linking an
|
||||
Application with the Library. The particular version of the Library
|
||||
with which the Combined Work was made is also called the "Linked
|
||||
Version".
|
||||
|
||||
The "Minimal Corresponding Source" for a Combined Work means the
|
||||
Corresponding Source for the Combined Work, excluding any source code
|
||||
for portions of the Combined Work that, considered in isolation, are
|
||||
based on the Application, and not on the Linked Version.
|
||||
|
||||
The "Corresponding Application Code" for a Combined Work means the
|
||||
object code and/or source code for the Application, including any data
|
||||
and utility programs needed for reproducing the Combined Work from the
|
||||
Application, but excluding the System Libraries of the Combined Work.
|
||||
|
||||
1. Exception to Section 3 of the GNU GPL.
|
||||
|
||||
You may convey a covered work under sections 3 and 4 of this License
|
||||
without being bound by section 3 of the GNU GPL.
|
||||
|
||||
2. Conveying Modified Versions.
|
||||
|
||||
If you modify a copy of the Library, and, in your modifications, a
|
||||
facility refers to a function or data to be supplied by an Application
|
||||
that uses the facility (other than as an argument passed when the
|
||||
facility is invoked), then you may convey a copy of the modified
|
||||
version:
|
||||
|
||||
a) under this License, provided that you make a good faith effort to
|
||||
ensure that, in the event an Application does not supply the
|
||||
function or data, the facility still operates, and performs
|
||||
whatever part of its purpose remains meaningful, or
|
||||
|
||||
b) under the GNU GPL, with none of the additional permissions of
|
||||
this License applicable to that copy.
|
||||
|
||||
3. Object Code Incorporating Material from Library Header Files.
|
||||
|
||||
The object code form of an Application may incorporate material from
|
||||
a header file that is part of the Library. You may convey such object
|
||||
code under terms of your choice, provided that, if the incorporated
|
||||
material is not limited to numerical parameters, data structure
|
||||
layouts and accessors, or small macros, inline functions and templates
|
||||
(ten or fewer lines in length), you do both of the following:
|
||||
|
||||
a) Give prominent notice with each copy of the object code that the
|
||||
Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the object code with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
4. Combined Works.
|
||||
|
||||
You may convey a Combined Work under terms of your choice that,
|
||||
taken together, effectively do not restrict modification of the
|
||||
portions of the Library contained in the Combined Work and reverse
|
||||
engineering for debugging such modifications, if you also do each of
|
||||
the following:
|
||||
|
||||
a) Give prominent notice with each copy of the Combined Work that
|
||||
the Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
c) For a Combined Work that displays copyright notices during
|
||||
execution, include the copyright notice for the Library among
|
||||
these notices, as well as a reference directing the user to the
|
||||
copies of the GNU GPL and this license document.
|
||||
|
||||
d) Do one of the following:
|
||||
|
||||
0) Convey the Minimal Corresponding Source under the terms of this
|
||||
License, and the Corresponding Application Code in a form
|
||||
suitable for, and under terms that permit, the user to
|
||||
recombine or relink the Application with a modified version of
|
||||
the Linked Version to produce a modified Combined Work, in the
|
||||
manner specified by section 6 of the GNU GPL for conveying
|
||||
Corresponding Source.
|
||||
|
||||
1) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (a) uses at run time
|
||||
a copy of the Library already present on the user's computer
|
||||
system, and (b) will operate properly with a modified version
|
||||
of the Library that is interface-compatible with the Linked
|
||||
Version.
|
||||
|
||||
e) Provide Installation Information, but only if you would otherwise
|
||||
be required to provide such information under section 6 of the
|
||||
GNU GPL, and only to the extent that such information is
|
||||
necessary to install and execute a modified version of the
|
||||
Combined Work produced by recombining or relinking the
|
||||
Application with a modified version of the Linked Version. (If
|
||||
you use option 4d0, the Installation Information must accompany
|
||||
the Minimal Corresponding Source and Corresponding Application
|
||||
Code. If you use option 4d1, you must provide the Installation
|
||||
Information in the manner specified by section 6 of the GNU GPL
|
||||
for conveying Corresponding Source.)
|
||||
|
||||
5. Combined Libraries.
|
||||
|
||||
You may place library facilities that are a work based on the
|
||||
Library side by side in a single library together with other library
|
||||
facilities that are not Applications and are not covered by this
|
||||
License, and convey such a combined library under terms of your
|
||||
choice, if you do both of the following:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work based
|
||||
on the Library, uncombined with any other library facilities,
|
||||
conveyed under the terms of this License.
|
||||
|
||||
b) Give prominent notice with the combined library that part of it
|
||||
is a work based on the Library, and explaining where to find the
|
||||
accompanying uncombined form of the same work.
|
||||
|
||||
6. Revised Versions of the GNU Lesser General Public License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Lesser General Public License from time to time. Such new
|
||||
versions will be similar in spirit to the present version, but may
|
||||
differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Library as you received it specifies that a certain numbered version
|
||||
of the GNU Lesser General Public License "or any later version"
|
||||
applies to it, you have the option of following the terms and
|
||||
conditions either of that published version or of any later version
|
||||
published by the Free Software Foundation. If the Library as you
|
||||
received it does not specify a version number of the GNU Lesser
|
||||
General Public License, you may choose any version of the GNU Lesser
|
||||
General Public License ever published by the Free Software Foundation.
|
||||
|
||||
If the Library as you received it specifies that a proxy can decide
|
||||
whether future versions of the GNU Lesser General Public License shall
|
||||
apply, that proxy's public statement of acceptance of any version is
|
||||
permanent authorization for you to choose that version for the
|
||||
Library.
|
||||
</pre>
|
||||
</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -397,7 +397,7 @@ NSString *const kMXKRoomBubbleCellKeyVerificationIncomingRequestDeclinePressed =
|
||||
}
|
||||
|
||||
// Move this view in front
|
||||
[self.contentView bringSubviewToFront:self.bubbleOverlayContainer];
|
||||
[self.bubbleOverlayContainer.superview bringSubviewToFront:self.bubbleOverlayContainer];
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -135,6 +135,15 @@ internal enum Asset {
|
||||
internal static let videoCall = ImageAsset(name: "video_call")
|
||||
internal static let voiceCallHangonIcon = ImageAsset(name: "voice_call_hangon_icon")
|
||||
internal static let voiceCallHangupIcon = ImageAsset(name: "voice_call_hangup_icon")
|
||||
internal static let voiceMessageCancelGradient = ImageAsset(name: "voice_message_cancel_gradient")
|
||||
internal static let voiceMessageLockChevron = ImageAsset(name: "voice_message_lock_chevron")
|
||||
internal static let voiceMessageLockIconLocked = ImageAsset(name: "voice_message_lock_icon_locked")
|
||||
internal static let voiceMessageLockIconUnlocked = ImageAsset(name: "voice_message_lock_icon_unlocked")
|
||||
internal static let voiceMessagePauseButton = ImageAsset(name: "voice_message_pause_button")
|
||||
internal static let voiceMessagePlayButton = ImageAsset(name: "voice_message_play_button")
|
||||
internal static let voiceMessageRecordButtonDefault = ImageAsset(name: "voice_message_record_button_default")
|
||||
internal static let voiceMessageRecordButtonRecording = ImageAsset(name: "voice_message_record_button_recording")
|
||||
internal static let voiceMessageRecordIcon = ImageAsset(name: "voice_message_record_icon")
|
||||
internal static let addMemberFloatingAction = ImageAsset(name: "add_member_floating_action")
|
||||
internal static let addParticipant = ImageAsset(name: "add_participant")
|
||||
internal static let addParticipants = ImageAsset(name: "add_participants")
|
||||
|
||||
@@ -4378,6 +4378,10 @@ internal enum VectorL10n {
|
||||
internal static var settingsLabsMessageReaction: String {
|
||||
return VectorL10n.tr("Vector", "settings_labs_message_reaction")
|
||||
}
|
||||
/// Voice messages
|
||||
internal static var settingsLabsVoiceMessages: String {
|
||||
return VectorL10n.tr("Vector", "settings_labs_voice_messages")
|
||||
}
|
||||
/// Mark all messages as read
|
||||
internal static var settingsMarkAllAsRead: String {
|
||||
return VectorL10n.tr("Vector", "settings_mark_all_as_read")
|
||||
@@ -4882,6 +4886,18 @@ internal enum VectorL10n {
|
||||
internal static var voice: String {
|
||||
return VectorL10n.tr("Vector", "voice")
|
||||
}
|
||||
/// Hold to record, release to send
|
||||
internal static var voiceMessageReleaseToSend: String {
|
||||
return VectorL10n.tr("Vector", "voice_message_release_to_send")
|
||||
}
|
||||
/// %@s left
|
||||
internal static func voiceMessageRemainingRecordingTime(_ p1: String) -> String {
|
||||
return VectorL10n.tr("Vector", "voice_message_remaining_recording_time", p1)
|
||||
}
|
||||
/// Tap on the wavelength to stop and playback
|
||||
internal static var voiceMessageStopLockedModeRecording: String {
|
||||
return VectorL10n.tr("Vector", "voice_message_stop_locked_mode_recording")
|
||||
}
|
||||
/// Warning
|
||||
internal static var warning: String {
|
||||
return VectorL10n.tr("Vector", "warning")
|
||||
|
||||
@@ -52,6 +52,7 @@ final class RiotSettings: NSObject {
|
||||
static let roomCreationScreenRoomIsPublic = "roomCreationScreenRoomIsPublic"
|
||||
static let allowInviteExernalUsers = "allowInviteExernalUsers"
|
||||
static let enableRingingForGroupCalls = "enableRingingForGroupCalls"
|
||||
static let enableVoiceMessages = "enableVoiceMessages"
|
||||
static let roomSettingsScreenShowLowPriorityOption = "roomSettingsScreenShowLowPriorityOption"
|
||||
static let roomSettingsScreenShowDirectChatOption = "roomSettingsScreenShowDirectChatOption"
|
||||
static let roomSettingsScreenAllowChangingAccessSettings = "roomSettingsScreenAllowChangingAccessSettings"
|
||||
@@ -93,6 +94,11 @@ final class RiotSettings: NSObject {
|
||||
return userDefaults
|
||||
}()
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
defaults.register(defaults: [UserDefaultsKeys.enableVoiceMessages: BuildSettings.voiceMessagesEnabled])
|
||||
}
|
||||
|
||||
// MARK: Servers
|
||||
|
||||
var homeserverUrlString: String {
|
||||
@@ -215,6 +221,14 @@ final class RiotSettings: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
var enableVoiceMessages: Bool {
|
||||
get {
|
||||
return defaults.bool(forKey: UserDefaultsKeys.enableVoiceMessages)
|
||||
} set {
|
||||
defaults.set(newValue, forKey: UserDefaultsKeys.enableVoiceMessages)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Calls
|
||||
|
||||
/// Indicate if `allowStunServerFallback` settings has been set once.
|
||||
|
||||
@@ -112,6 +112,9 @@
|
||||
case MXKAttachmentTypeAudio:
|
||||
image = [UIImage imageNamed:@"file_music_icon"];
|
||||
break;
|
||||
case MXKAttachmentTypeVoiceMessage:
|
||||
image = [UIImage imageNamed:@"file_music_icon"];
|
||||
break;
|
||||
case MXKAttachmentTypeVideo:
|
||||
image = [UIImage imageNamed:@"file_video_icon"];
|
||||
break;
|
||||
|
||||
@@ -986,6 +986,9 @@ static NSAttributedString *timestampVerticalWhitespace = nil;
|
||||
case MXKAttachmentTypeAudio:
|
||||
accessibilityLabel = NSLocalizedStringFromTable(@"media_type_accessibility_audio", @"Vector", nil);
|
||||
break;
|
||||
case MXKAttachmentTypeVoiceMessage:
|
||||
accessibilityLabel = NSLocalizedStringFromTable(@"media_type_accessibility_audio", @"Vector", nil);
|
||||
break;
|
||||
case MXKAttachmentTypeVideo:
|
||||
accessibilityLabel = NSLocalizedStringFromTable(@"media_type_accessibility_video", @"Vector", nil);
|
||||
break;
|
||||
|
||||
@@ -1039,12 +1039,10 @@ const CGFloat kTypingCellHeight = 24;
|
||||
|
||||
- (void)applyMaskToAttachmentViewOfBubbleCell:(MXKRoomBubbleTableViewCell *)cell
|
||||
{
|
||||
if (cell.attachmentView && !cell.attachmentView.layer.mask)
|
||||
if (cell.attachmentView)
|
||||
{
|
||||
UIBezierPath *myClippingPath = [UIBezierPath bezierPathWithRoundedRect:cell.attachmentView.bounds cornerRadius:6];
|
||||
CAShapeLayer *mask = [CAShapeLayer layer];
|
||||
mask.path = myClippingPath.CGPath;
|
||||
cell.attachmentView.layer.mask = mask;
|
||||
cell.attachmentView.layer.cornerRadius = 6;
|
||||
cell.attachmentView.layer.masksToBounds = YES;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -194,7 +194,8 @@ extension RoomInfoCoordinator: RoomInfoListCoordinatorDelegate {
|
||||
extension RoomInfoCoordinator: RoomParticipantsViewControllerDelegate {
|
||||
|
||||
func roomParticipantsViewController(_ roomParticipantsViewController: RoomParticipantsViewController!, mention member: MXRoomMember!) {
|
||||
|
||||
self.navigationRouter.popToRootModule(animated: true)
|
||||
self.delegate?.roomInfoCoordinator(self, didRequestMentionForMember: member)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import Foundation
|
||||
|
||||
@objc protocol RoomInfoCoordinatorBridgePresenterDelegate {
|
||||
func roomInfoCoordinatorBridgePresenterDelegateDidComplete(_ coordinatorBridgePresenter: RoomInfoCoordinatorBridgePresenter)
|
||||
func roomInfoCoordinatorBridgePresenter(_ coordinatorBridgePresenter: RoomInfoCoordinatorBridgePresenter, didRequestMentionForMember member: MXRoomMember)
|
||||
}
|
||||
|
||||
/// RoomInfoCoordinatorBridgePresenter enables to start RoomInfoCoordinator from a view controller.
|
||||
@@ -115,6 +116,10 @@ extension RoomInfoCoordinatorBridgePresenter: RoomInfoCoordinatorDelegate {
|
||||
self.delegate?.roomInfoCoordinatorBridgePresenterDelegateDidComplete(self)
|
||||
}
|
||||
|
||||
func roomInfoCoordinator(_ coordinator: RoomInfoCoordinatorType, didRequestMentionForMember member: MXRoomMember) {
|
||||
self.delegate?.roomInfoCoordinatorBridgePresenter(self, didRequestMentionForMember: member)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UIAdaptivePresentationControllerDelegate
|
||||
|
||||
@@ -20,6 +20,7 @@ import Foundation
|
||||
|
||||
protocol RoomInfoCoordinatorDelegate: AnyObject {
|
||||
func roomInfoCoordinatorDidComplete(_ coordinator: RoomInfoCoordinatorType)
|
||||
func roomInfoCoordinator(_ coordinator: RoomInfoCoordinatorType, didRequestMentionForMember member: MXRoomMember)
|
||||
}
|
||||
|
||||
/// `RoomInfoCoordinatorType` is a protocol describing a Coordinator that handle keybackup setup navigation flow.
|
||||
|
||||
@@ -135,7 +135,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
|
||||
@interface RoomViewController () <UISearchBarDelegate, UIGestureRecognizerDelegate, UIScrollViewAccessibilityDelegate, RoomTitleViewTapGestureDelegate, RoomParticipantsViewControllerDelegate, MXKRoomMemberDetailsViewControllerDelegate, ContactsTableViewControllerDelegate, MXServerNoticesDelegate, RoomContextualMenuViewControllerDelegate,
|
||||
ReactionsMenuViewModelCoordinatorDelegate, EditHistoryCoordinatorBridgePresenterDelegate, MXKDocumentPickerPresenterDelegate, EmojiPickerCoordinatorBridgePresenterDelegate,
|
||||
ReactionHistoryCoordinatorBridgePresenterDelegate, CameraPresenterDelegate, MediaPickerCoordinatorBridgePresenterDelegate,
|
||||
RoomDataSourceDelegate, RoomCreationModalCoordinatorBridgePresenterDelegate, RoomInfoCoordinatorBridgePresenterDelegate, DialpadViewControllerDelegate, RemoveJitsiWidgetViewDelegate>
|
||||
RoomDataSourceDelegate, RoomCreationModalCoordinatorBridgePresenterDelegate, RoomInfoCoordinatorBridgePresenterDelegate, DialpadViewControllerDelegate, RemoveJitsiWidgetViewDelegate, VoiceMessageControllerDelegate>
|
||||
{
|
||||
|
||||
// The preview header
|
||||
@@ -240,6 +240,8 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
|
||||
@property (nonatomic, getter=isActivitiesViewExpanded) BOOL activitiesViewExpanded;
|
||||
@property (nonatomic, getter=isScrollToBottomHidden) BOOL scrollToBottomHidden;
|
||||
|
||||
@property (nonatomic, strong) VoiceMessageController *voiceMessageController;
|
||||
|
||||
@end
|
||||
|
||||
@implementation RoomViewController
|
||||
@@ -313,6 +315,9 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
|
||||
|
||||
// Show / hide actions button in document preview according BuildSettings
|
||||
self.allowActionsInDocumentPreview = BuildSettings.messageDetailsAllowShare;
|
||||
|
||||
_voiceMessageController = [[VoiceMessageController alloc] initWithThemeService:ThemeService.shared mediaServiceProvider:VoiceMessageMediaServiceProvider.sharedProvider];
|
||||
self.voiceMessageController.delegate = self;
|
||||
}
|
||||
|
||||
- (void)viewDidLoad
|
||||
@@ -386,6 +391,10 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
|
||||
|
||||
[self.bubblesTableView registerNib:RoomTypingBubbleCell.nib forCellReuseIdentifier:RoomTypingBubbleCell.defaultReuseIdentifier];
|
||||
|
||||
[self.bubblesTableView registerClass:VoiceMessageBubbleCell.class forCellReuseIdentifier:VoiceMessageBubbleCell.defaultReuseIdentifier];
|
||||
[self.bubblesTableView registerClass:VoiceMessageWithoutSenderInfoBubbleCell.class forCellReuseIdentifier:VoiceMessageWithoutSenderInfoBubbleCell.defaultReuseIdentifier];
|
||||
[self.bubblesTableView registerClass:VoiceMessageWithPaginationTitleBubbleCell.class forCellReuseIdentifier:VoiceMessageWithPaginationTitleBubbleCell.defaultReuseIdentifier];
|
||||
|
||||
[self vc_removeBackTitle];
|
||||
|
||||
[self setupRemoveJitsiWidgetRemoveView];
|
||||
@@ -607,6 +616,8 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
|
||||
self.roomDataSource.showReadMarker = YES;
|
||||
self.updateRoomReadMarker = NO;
|
||||
isAppeared = NO;
|
||||
|
||||
[VoiceMessageMediaServiceProvider.sharedProvider stopAllServices];
|
||||
}
|
||||
|
||||
- (void)viewDidAppear:(BOOL)animated
|
||||
@@ -1114,6 +1125,9 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
|
||||
if (!self.inputToolbarView || ![self.inputToolbarView isMemberOfClass:roomInputToolbarViewClass])
|
||||
{
|
||||
[super setRoomInputToolbarViewClass:roomInputToolbarViewClass];
|
||||
|
||||
[(RoomInputToolbarView *)self.inputToolbarView setVoiceMessageToolbarView:self.voiceMessageController.voiceMessageToolbarView];
|
||||
|
||||
[self updateInputToolBarViewHeight];
|
||||
}
|
||||
}
|
||||
@@ -2359,6 +2373,16 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
|
||||
{
|
||||
cellViewClass = RoomGroupCallStatusBubbleCell.class;
|
||||
}
|
||||
else if (bubbleData.attachment.type == MXKAttachmentTypeVoiceMessage)
|
||||
{
|
||||
if (bubbleData.isPaginationFirstBubble) {
|
||||
cellViewClass = VoiceMessageWithPaginationTitleBubbleCell.class;
|
||||
} else if (bubbleData.shouldHideSenderInformation) {
|
||||
cellViewClass = VoiceMessageWithoutSenderInfoBubbleCell.class;
|
||||
} else {
|
||||
cellViewClass = VoiceMessageBubbleCell.class;
|
||||
}
|
||||
}
|
||||
else if (bubbleData.isIncoming)
|
||||
{
|
||||
if (bubbleData.isAttachmentWithThumbnail)
|
||||
@@ -2718,12 +2742,11 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
|
||||
[actionIdentifier isEqualToString:RoomGroupCallStatusBubbleCell.answerAction])
|
||||
{
|
||||
MXWeakify(self);
|
||||
NSString *appDisplayName = [[NSBundle mainBundle] infoDictionary][@"CFBundleDisplayName"];
|
||||
|
||||
// Check app permissions first
|
||||
[MXKTools checkAccessForCall:YES
|
||||
manualChangeMessageForAudio:[NSString stringWithFormat:[NSBundle mxk_localizedStringForKey:@"microphone_access_not_granted_for_call"], appDisplayName]
|
||||
manualChangeMessageForVideo:[NSString stringWithFormat:[NSBundle mxk_localizedStringForKey:@"camera_access_not_granted_for_call"], appDisplayName]
|
||||
manualChangeMessageForAudio:[NSString stringWithFormat:[NSBundle mxk_localizedStringForKey:@"microphone_access_not_granted_for_call"], AppInfo.current.displayName]
|
||||
manualChangeMessageForVideo:[NSString stringWithFormat:[NSBundle mxk_localizedStringForKey:@"camera_access_not_granted_for_call"], AppInfo.current.displayName]
|
||||
showPopUpInViewController:self completionHandler:^(BOOL granted) {
|
||||
|
||||
MXStrongifyAndReturnIfNil(self);
|
||||
@@ -3715,12 +3738,10 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
|
||||
{
|
||||
__weak __typeof(self) weakSelf = self;
|
||||
|
||||
NSString *appDisplayName = [[NSBundle mainBundle] infoDictionary][@"CFBundleDisplayName"];
|
||||
|
||||
// Check app permissions first
|
||||
[MXKTools checkAccessForCall:video
|
||||
manualChangeMessageForAudio:[NSString stringWithFormat:[NSBundle mxk_localizedStringForKey:@"microphone_access_not_granted_for_call"], appDisplayName]
|
||||
manualChangeMessageForVideo:[NSString stringWithFormat:[NSBundle mxk_localizedStringForKey:@"camera_access_not_granted_for_call"], appDisplayName]
|
||||
manualChangeMessageForAudio:[NSString stringWithFormat:[NSBundle mxk_localizedStringForKey:@"microphone_access_not_granted_for_call"], AppInfo.current.displayName]
|
||||
manualChangeMessageForVideo:[NSString stringWithFormat:[NSBundle mxk_localizedStringForKey:@"camera_access_not_granted_for_call"], AppInfo.current.displayName]
|
||||
showPopUpInViewController:self completionHandler:^(BOOL granted) {
|
||||
|
||||
if (weakSelf)
|
||||
@@ -3936,13 +3957,6 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
|
||||
[self cancelEventSelection];
|
||||
}
|
||||
|
||||
#pragma mark - RoomParticipantsViewControllerDelegate
|
||||
|
||||
- (void)roomParticipantsViewController:(RoomParticipantsViewController *)roomParticipantsViewController mention:(MXRoomMember*)member
|
||||
{
|
||||
[self mention:member];
|
||||
}
|
||||
|
||||
#pragma mark - MXKRoomMemberDetailsViewControllerDelegate
|
||||
|
||||
- (void)roomMemberDetailsViewController:(MXKRoomMemberDetailsViewController *)roomMemberDetailsViewController startChatWithMemberId:(NSString *)matrixId completion:(void (^)(void))completion
|
||||
@@ -5810,7 +5824,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
|
||||
MXWeakify(self);
|
||||
|
||||
RoomContextualMenuItem *replyMenuItem = [[RoomContextualMenuItem alloc] initWithMenuAction:RoomContextualMenuActionReply];
|
||||
replyMenuItem.isEnabled = [self.roomDataSource canReplyToEventWithId:event.eventId];
|
||||
replyMenuItem.isEnabled = [self.roomDataSource canReplyToEventWithId:event.eventId] && !self.voiceMessageController.isRecordingAudio;
|
||||
replyMenuItem.action = ^{
|
||||
MXStrongifyAndReturnIfNil(self);
|
||||
|
||||
@@ -6121,6 +6135,11 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
|
||||
self.roomInfoCoordinatorBridgePresenter = nil;
|
||||
}
|
||||
|
||||
- (void)roomInfoCoordinatorBridgePresenter:(RoomInfoCoordinatorBridgePresenter *)coordinatorBridgePresenter didRequestMentionForMember:(MXRoomMember *)member
|
||||
{
|
||||
[self mention:member];
|
||||
}
|
||||
|
||||
#pragma mark - RemoveJitsiWidgetViewDelegate
|
||||
|
||||
- (void)removeJitsiWidgetViewDidCompleteSliding:(RemoveJitsiWidgetView *)view
|
||||
@@ -6154,4 +6173,32 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
|
||||
}];
|
||||
}
|
||||
|
||||
#pragma mark - VoiceMessageControllerDelegate
|
||||
|
||||
- (void)voiceMessageControllerDidRequestMicrophonePermission:(VoiceMessageController *)voiceMessageController
|
||||
{
|
||||
NSString *message = [NSString stringWithFormat:[NSBundle mxk_localizedStringForKey:@"microphone_access_not_granted_for_voice_message"], AppInfo.current.displayName];
|
||||
|
||||
[MXKTools checkAccessForMediaType:AVMediaTypeAudio
|
||||
manualChangeMessage: message
|
||||
showPopUpInViewController:self completionHandler:^(BOOL granted) {
|
||||
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)voiceMessageController:(VoiceMessageController *)voiceMessageController
|
||||
didRequestSendForFileAtURL:(NSURL *)url
|
||||
duration:(NSUInteger)duration
|
||||
samples:(NSArray<NSNumber *> *)samples
|
||||
completion:(void (^)(BOOL))completion
|
||||
{
|
||||
[self.roomDataSource sendVoiceMessage:url mimeType:nil duration:duration samples:samples success:^(NSString *eventId) {
|
||||
MXLogDebug(@"Success with event id %@", eventId);
|
||||
completion(YES);
|
||||
} failure:^(NSError *error) {
|
||||
MXLogError(@"Failed sending voice message");
|
||||
completion(NO);
|
||||
}];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class VoiceMessageBubbleCell: SizableBaseBubbleCell, BubbleCellReactionsDisplayable {
|
||||
|
||||
private var playbackController: VoiceMessagePlaybackController!
|
||||
|
||||
override func render(_ cellData: MXKCellData!) {
|
||||
super.render(cellData)
|
||||
|
||||
guard let data = cellData as? RoomBubbleCellData else {
|
||||
return
|
||||
}
|
||||
|
||||
guard data.attachment.type == MXKAttachmentTypeVoiceMessage else {
|
||||
fatalError("Invalid attachment type passed to a voice message cell.")
|
||||
}
|
||||
|
||||
if playbackController.attachment != data.attachment {
|
||||
playbackController.attachment = data.attachment
|
||||
}
|
||||
}
|
||||
|
||||
override func setupViews() {
|
||||
super.setupViews()
|
||||
|
||||
bubbleCellContentView?.backgroundColor = .clear
|
||||
bubbleCellContentView?.showSenderInfo = true
|
||||
bubbleCellContentView?.showPaginationTitle = false
|
||||
|
||||
guard let contentView = bubbleCellContentView?.innerContentView else {
|
||||
return
|
||||
}
|
||||
|
||||
playbackController = VoiceMessagePlaybackController(mediaServiceProvider: VoiceMessageMediaServiceProvider.sharedProvider,
|
||||
cacheManager: VoiceMessageAttachmentCacheManager.sharedManager)
|
||||
|
||||
contentView.vc_addSubViewMatchingParent(playbackController.playbackView)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class VoiceMessageWithPaginationTitleBubbleCell: VoiceMessageBubbleCell {
|
||||
override func setupViews() {
|
||||
super.setupViews()
|
||||
|
||||
bubbleCellContentView?.showPaginationTitle = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class VoiceMessageWithoutSenderInfoBubbleCell: VoiceMessageBubbleCell {
|
||||
override func setupViews() {
|
||||
super.setupViews()
|
||||
|
||||
bubbleCellContentView?.showSenderInfo = false
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,6 @@ typedef enum : NSUInteger
|
||||
@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mainToolbarMinHeightConstraint;
|
||||
@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mainToolbarHeightConstraint;
|
||||
|
||||
@property (weak, nonatomic) IBOutlet NSLayoutConstraint *messageComposerContainerLeadingConstraint;
|
||||
@property (weak, nonatomic) IBOutlet NSLayoutConstraint *messageComposerContainerTrailingConstraint;
|
||||
|
||||
@property (weak, nonatomic) IBOutlet UIButton *attachMediaButton;
|
||||
@@ -70,6 +69,7 @@ typedef enum : NSUInteger
|
||||
@property (weak, nonatomic) IBOutlet UILabel *inputContextLabel;
|
||||
@property (weak, nonatomic) IBOutlet UIButton *inputContextButton;
|
||||
@property (weak, nonatomic) IBOutlet RoomActionsBar *actionsBar;
|
||||
@property (weak, nonatomic) UIView *voiceMessageToolbarView;
|
||||
|
||||
/**
|
||||
Tell whether the filled data will be sent encrypted. NO by default.
|
||||
|
||||
@@ -34,6 +34,7 @@ const CGFloat kActionMenuAttachButtonSpringVelocity = 7;
|
||||
const CGFloat kActionMenuAttachButtonSpringDamping = .45;
|
||||
const NSTimeInterval kActionMenuContentAlphaAnimationDuration = .2;
|
||||
const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3;
|
||||
const CGFloat kComposerContainerTrailingPadding = 12;
|
||||
|
||||
@interface RoomInputToolbarView()
|
||||
{
|
||||
@@ -75,6 +76,24 @@ const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3;
|
||||
[self.rightInputToolbarButton setTitle:nil forState:UIControlStateHighlighted];
|
||||
|
||||
self.isEncryptionEnabled = _isEncryptionEnabled;
|
||||
|
||||
[self updateUIWithTextMessage:nil animated:NO];
|
||||
}
|
||||
|
||||
- (void)setVoiceMessageToolbarView:(UIView *)voiceMessageToolbarView
|
||||
{
|
||||
if (RiotSettings.shared.enableVoiceMessages == NO) {
|
||||
return;
|
||||
}
|
||||
|
||||
_voiceMessageToolbarView = voiceMessageToolbarView;
|
||||
self.voiceMessageToolbarView.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self addSubview:self.voiceMessageToolbarView];
|
||||
|
||||
[NSLayoutConstraint activateConstraints:@[[self.mainToolbarView.topAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.topAnchor],
|
||||
[self.mainToolbarView.leftAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.leftAnchor],
|
||||
[self.mainToolbarView.bottomAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.bottomAnchor],
|
||||
[self.mainToolbarView.rightAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.rightAnchor]]];
|
||||
}
|
||||
|
||||
#pragma mark - Override MXKView
|
||||
@@ -133,7 +152,7 @@ const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3;
|
||||
|
||||
- (void)setTextMessage:(NSString *)textMessage
|
||||
{
|
||||
[self updateSendButtonWithMessage:textMessage];
|
||||
[self updateUIWithTextMessage:textMessage animated:YES];
|
||||
[super setTextMessage:textMessage];
|
||||
}
|
||||
|
||||
@@ -290,7 +309,7 @@ const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3;
|
||||
- (BOOL)growingTextView:(HPGrowingTextView *)growingTextView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text
|
||||
{
|
||||
NSString *newText = [growingTextView.text stringByReplacingCharactersInRange:range withString:text];
|
||||
[self updateSendButtonWithMessage:newText];
|
||||
[self updateUIWithTextMessage:newText animated:YES];
|
||||
|
||||
return YES;
|
||||
}
|
||||
@@ -354,24 +373,6 @@ const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3;
|
||||
[super destroy];
|
||||
}
|
||||
|
||||
- (void)updateSendButtonWithMessage:(NSString *)textMessage
|
||||
{
|
||||
self.actionMenuOpened = NO;
|
||||
|
||||
if (textMessage.length)
|
||||
{
|
||||
self.rightInputToolbarButton.alpha = 1;
|
||||
self.messageComposerContainerTrailingConstraint.constant = self.frame.size.width - self.rightInputToolbarButton.frame.origin.x + 12;
|
||||
}
|
||||
else
|
||||
{
|
||||
self.rightInputToolbarButton.alpha = 0;
|
||||
self.messageComposerContainerTrailingConstraint.constant = 12;
|
||||
}
|
||||
|
||||
[self layoutIfNeeded];
|
||||
}
|
||||
|
||||
#pragma mark - properties
|
||||
|
||||
- (void)setActionMenuOpened:(BOOL)actionMenuOpened
|
||||
@@ -406,6 +407,10 @@ const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3;
|
||||
[UIView animateWithDuration:kActionMenuContentAlphaAnimationDuration delay:_actionMenuOpened ? 0 : .1 options:UIViewAnimationOptionCurveEaseIn animations:^{
|
||||
self->messageComposerContainer.alpha = actionMenuOpened ? 0 : 1;
|
||||
self.rightInputToolbarButton.alpha = self->growingTextView.text.length == 0 || actionMenuOpened ? 0 : 1;
|
||||
if (RiotSettings.shared.enableVoiceMessages)
|
||||
{
|
||||
self.voiceMessageToolbarView.alpha = self->growingTextView.text.length > 0 || actionMenuOpened ? 0 : 1;
|
||||
}
|
||||
} completion:nil];
|
||||
|
||||
[UIView animateWithDuration:kActionMenuComposerHeightAnimationDuration animations:^{
|
||||
@@ -432,4 +437,25 @@ const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3;
|
||||
[super paste:sender];
|
||||
}
|
||||
|
||||
#pragma mark - Private
|
||||
|
||||
- (void)updateUIWithTextMessage:(NSString *)textMessage animated:(BOOL)animated
|
||||
{
|
||||
self.actionMenuOpened = NO;
|
||||
|
||||
if (RiotSettings.shared.enableVoiceMessages == NO) {
|
||||
self.rightInputToolbarButton.alpha = textMessage.length ? 1.0f : 0.0f;
|
||||
self.messageComposerContainerTrailingConstraint.constant = (textMessage.length ? self.frame.size.width - self.rightInputToolbarButton.frame.origin.x : 0.0f) + kComposerContainerTrailingPadding;
|
||||
|
||||
[self layoutIfNeeded];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
[UIView animateWithDuration:(animated ? 0.15f : 0.0f) animations:^{
|
||||
self.rightInputToolbarButton.alpha = textMessage.length ? 1.0f : 0.0f;
|
||||
self.voiceMessageToolbarView.alpha = textMessage.length ? 0.0f : 1.0;
|
||||
}];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="a84-Vc-6ud" userLabel="MainToolBar View">
|
||||
<rect key="frame" x="0.0" y="2" width="600" height="58"/>
|
||||
<subviews>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="Hga-l8-Wua" userLabel="attach Button">
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="Hga-l8-Wua">
|
||||
<rect key="frame" x="12" y="10" width="36" height="36"/>
|
||||
<accessibility key="accessibilityConfiguration" identifier="AttachButton"/>
|
||||
<constraints>
|
||||
@@ -35,26 +35,26 @@
|
||||
<viewLayoutGuide key="contentLayoutGuide" id="F6O-76-cZl"/>
|
||||
<viewLayoutGuide key="frameLayoutGuide" id="rZR-Bv-AqG"/>
|
||||
</scrollView>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="QWp-NV-uh5" userLabel="Message Composer Container">
|
||||
<rect key="frame" x="60" y="9" width="528" height="36"/>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="QWp-NV-uh5">
|
||||
<rect key="frame" x="60" y="9" width="484" height="36"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="input_text_background" translatesAutoresizingMaskIntoConstraints="NO" id="uH7-Q7-hpZ">
|
||||
<rect key="frame" x="0.0" y="0.0" width="528" height="36"/>
|
||||
<rect key="frame" x="0.0" y="0.0" width="484" height="36"/>
|
||||
</imageView>
|
||||
<view clipsSubviews="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="jXI-9E-Bgl">
|
||||
<rect key="frame" x="0.0" y="0.0" width="528" height="32"/>
|
||||
<rect key="frame" x="0.0" y="0.0" width="484" height="32"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="input_edit_icon" translatesAutoresizingMaskIntoConstraints="NO" id="PZ4-0Y-TmL">
|
||||
<rect key="frame" x="8" y="16" width="10.5" height="10"/>
|
||||
</imageView>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="dVr-ZM-kkX">
|
||||
<rect key="frame" x="22.5" y="13.5" width="471.5" height="14.5"/>
|
||||
<rect key="frame" x="22.5" y="13.5" width="427.5" height="14.5"/>
|
||||
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="12"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="48y-kn-7b5">
|
||||
<rect key="frame" x="498" y="6" width="30" height="30"/>
|
||||
<rect key="frame" x="454" y="6" width="30" height="30"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="30" id="I17-S0-9fp"/>
|
||||
<constraint firstAttribute="width" constant="30" id="cCe-RB-ET2"/>
|
||||
@@ -78,7 +78,7 @@
|
||||
</constraints>
|
||||
</view>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="wgb-ON-N29" customClass="KeyboardGrowingTextView">
|
||||
<rect key="frame" x="5" y="33" width="518" height="4"/>
|
||||
<rect key="frame" x="5" y="33" width="474" height="4"/>
|
||||
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<accessibility key="accessibilityConfiguration" identifier="GrowingTextView"/>
|
||||
</view>
|
||||
@@ -98,7 +98,7 @@
|
||||
<constraint firstAttribute="trailing" secondItem="uH7-Q7-hpZ" secondAttribute="trailing" id="wS9-oU-alv"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<button opaque="NO" alpha="0.0" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="G8Z-CM-tGs" userLabel="send Button">
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="G8Z-CM-tGs">
|
||||
<rect key="frame" x="552" y="10" width="36" height="36"/>
|
||||
<accessibility key="accessibilityConfiguration" identifier="SendButton"/>
|
||||
<state key="normal" image="send_icon"/>
|
||||
@@ -118,7 +118,7 @@
|
||||
<constraint firstAttribute="bottom" secondItem="G8Z-CM-tGs" secondAttribute="bottom" constant="12" id="Yam-dS-zwr"/>
|
||||
<constraint firstAttribute="height" constant="58" id="Yjj-ua-rbe"/>
|
||||
<constraint firstAttribute="bottom" secondItem="Hga-l8-Wua" secondAttribute="bottom" constant="12" id="b0G-CY-AmP"/>
|
||||
<constraint firstAttribute="trailing" secondItem="QWp-NV-uh5" secondAttribute="trailing" constant="12" id="hXO-cY-Jgz"/>
|
||||
<constraint firstAttribute="trailing" secondItem="QWp-NV-uh5" secondAttribute="trailing" constant="56" id="hXO-cY-Jgz"/>
|
||||
<constraint firstAttribute="trailing" secondItem="ESv-9w-KJF" secondAttribute="trailing" id="jCS-Tf-vxr"/>
|
||||
<constraint firstAttribute="bottom" secondItem="ESv-9w-KJF" secondAttribute="bottom" constant="12" id="v8r-ac-MKn"/>
|
||||
</constraints>
|
||||
@@ -150,7 +150,7 @@
|
||||
<outlet property="messageComposerContainer" destination="QWp-NV-uh5" id="APR-B5-ogC"/>
|
||||
<outlet property="messageComposerContainerBottomConstraint" destination="NGr-2o-sOP" id="oez-6D-IKA"/>
|
||||
<outlet property="messageComposerContainerTopConstraint" destination="WyZ-3i-OHi" id="OcO-1f-bNA"/>
|
||||
<outlet property="messageComposerContainerTrailingConstraint" destination="hXO-cY-Jgz" id="lHZ-MU-vyC"/>
|
||||
<outlet property="messageComposerContainerTrailingConstraint" destination="hXO-cY-Jgz" id="0m7-AB-90i"/>
|
||||
<outlet property="rightInputToolbarButton" destination="G8Z-CM-tGs" id="NCk-5m-aNF"/>
|
||||
</connections>
|
||||
<point key="canvasLocation" x="137.59999999999999" y="151.12443778110946"/>
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import DSWaveformImage
|
||||
|
||||
enum VoiceMessageAttachmentCacheManagerError: Error {
|
||||
case invalidEventId
|
||||
case invalidAttachmentType
|
||||
case decryptionError(Error)
|
||||
case preparationError(Error)
|
||||
case conversionError(Error)
|
||||
case invalidNumberOfSamples
|
||||
case samplingError
|
||||
}
|
||||
|
||||
/**
|
||||
Swift optimizes the callbacks to be the same instance. Wrap them so we can store them in an array.
|
||||
*/
|
||||
private class CompletionWrapper {
|
||||
let completion: (Result<VoiceMessageAttachmentCacheManagerLoadResult, Error>) -> Void
|
||||
|
||||
init(_ completion: @escaping (Result<VoiceMessageAttachmentCacheManagerLoadResult, Error>) -> Void) {
|
||||
self.completion = completion
|
||||
}
|
||||
}
|
||||
|
||||
private struct CompletionCallbackKey: Hashable {
|
||||
let eventIdentifier: String
|
||||
let requiredNumberOfSamples: Int
|
||||
}
|
||||
|
||||
struct VoiceMessageAttachmentCacheManagerLoadResult {
|
||||
let eventIdentifier: String
|
||||
let url: URL
|
||||
let duration: TimeInterval
|
||||
let samples: [Float]
|
||||
}
|
||||
|
||||
class VoiceMessageAttachmentCacheManager {
|
||||
|
||||
static let sharedManager = VoiceMessageAttachmentCacheManager()
|
||||
|
||||
private var completionCallbacks = [CompletionCallbackKey: [CompletionWrapper]]()
|
||||
private var samples = [String: [Int: [Float]]]()
|
||||
private var durations = [String: TimeInterval]()
|
||||
private var finalURLs = [String: URL]()
|
||||
|
||||
private let workQueue: DispatchQueue
|
||||
|
||||
private init() {
|
||||
workQueue = DispatchQueue(label: "io.element.VoiceMessageAttachmentCacheManager.queue", qos: .userInitiated)
|
||||
}
|
||||
|
||||
func loadAttachment(_ attachment: MXKAttachment, numberOfSamples: Int, completion: @escaping (Result<VoiceMessageAttachmentCacheManagerLoadResult, Error>) -> Void) {
|
||||
guard attachment.type == MXKAttachmentTypeVoiceMessage else {
|
||||
completion(Result.failure(VoiceMessageAttachmentCacheManagerError.invalidAttachmentType))
|
||||
return
|
||||
}
|
||||
|
||||
guard let identifier = attachment.eventId else {
|
||||
completion(Result.failure(VoiceMessageAttachmentCacheManagerError.invalidEventId))
|
||||
return
|
||||
}
|
||||
|
||||
guard numberOfSamples > 0 else {
|
||||
completion(Result.failure(VoiceMessageAttachmentCacheManagerError.invalidNumberOfSamples))
|
||||
return
|
||||
}
|
||||
|
||||
workQueue.async {
|
||||
// Run this in the work queue to preserve order
|
||||
if let finalURL = self.finalURLs[identifier], let duration = self.durations[identifier], let samples = self.samples[identifier]?[numberOfSamples] {
|
||||
let result = VoiceMessageAttachmentCacheManagerLoadResult(eventIdentifier: identifier, url: finalURL, duration: duration, samples: samples)
|
||||
DispatchQueue.main.async {
|
||||
completion(Result.success(result))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
self.enqueueLoadAttachment(attachment, identifier: identifier, numberOfSamples: numberOfSamples, completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
private func enqueueLoadAttachment(_ attachment: MXKAttachment, identifier: String, numberOfSamples: Int, completion: @escaping (Result<VoiceMessageAttachmentCacheManagerLoadResult, Error>) -> Void) {
|
||||
MXLog.debug("[VoiceMessageAttachmentCacheManager] Started task")
|
||||
|
||||
let callbackKey = CompletionCallbackKey(eventIdentifier: identifier, requiredNumberOfSamples: numberOfSamples)
|
||||
|
||||
if var callbacks = completionCallbacks[callbackKey] {
|
||||
MXLog.debug("[VoiceMessageAttachmentCacheManager] Finished task - cached completion callback")
|
||||
callbacks.append(CompletionWrapper(completion))
|
||||
completionCallbacks[callbackKey] = callbacks
|
||||
return
|
||||
} else {
|
||||
completionCallbacks[callbackKey] = [CompletionWrapper(completion)]
|
||||
}
|
||||
|
||||
let dispatchGroup = DispatchGroup()
|
||||
|
||||
func sampleFileAtURL(_ url: URL, duration: TimeInterval) {
|
||||
let analyser = WaveformAnalyzer(audioAssetURL: url)
|
||||
|
||||
dispatchGroup.enter()
|
||||
analyser?.samples(count: numberOfSamples, completionHandler: { samples in
|
||||
MXLog.debug("[VoiceMessageAttachmentCacheManager] Finished sampling voice message")
|
||||
|
||||
dispatchGroup.leave()
|
||||
|
||||
guard let samples = samples else {
|
||||
self.invokeFailureCallbacksForIdentifier(identifier, error: VoiceMessageAttachmentCacheManagerError.samplingError)
|
||||
return
|
||||
}
|
||||
|
||||
if var existingSamples = self.samples[identifier] {
|
||||
existingSamples[numberOfSamples] = samples
|
||||
self.samples[identifier] = existingSamples
|
||||
} else {
|
||||
self.samples[identifier] = [numberOfSamples: samples]
|
||||
}
|
||||
|
||||
self.invokeSuccessCallbacksForIdentifier(identifier, url: url, duration: duration, samples: samples)
|
||||
})
|
||||
}
|
||||
|
||||
if let finalURL = finalURLs[identifier], let duration = durations[identifier] {
|
||||
sampleFileAtURL(finalURL, duration: duration)
|
||||
dispatchGroup.wait()
|
||||
MXLog.debug("[VoiceMessageAttachmentCacheManager] Finished task")
|
||||
return
|
||||
}
|
||||
|
||||
func convertFileAtPath(_ path: String?) {
|
||||
guard let filePath = path else {
|
||||
return
|
||||
}
|
||||
|
||||
let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
let newURL = temporaryDirectoryURL.appendingPathComponent(ProcessInfo().globallyUniqueString).appendingPathExtension("m4a")
|
||||
|
||||
dispatchGroup.enter()
|
||||
VoiceMessageAudioConverter.convertToMPEG4AAC(sourceURL: URL(fileURLWithPath: filePath), destinationURL: newURL) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.finalURLs[identifier] = newURL
|
||||
VoiceMessageAudioConverter.mediaDurationAt(newURL) { result in
|
||||
MXLog.debug("[VoiceMessageAttachmentCacheManager] Finished converting voice message")
|
||||
|
||||
switch result {
|
||||
case .success:
|
||||
if let duration = try? result.get() {
|
||||
self.durations[identifier] = duration
|
||||
sampleFileAtURL(newURL, duration: duration)
|
||||
} else {
|
||||
MXLog.error("[VoiceMessageAttachmentCacheManager] enqueueLoadAttachment: Failed to retrieve media duration")
|
||||
}
|
||||
case .failure(let error):
|
||||
MXLog.error("[VoiceMessageAttachmentCacheManager] enqueueLoadAttachment: failed getting audio duration with: \(error)")
|
||||
}
|
||||
|
||||
dispatchGroup.leave()
|
||||
}
|
||||
case .failure(let error):
|
||||
self.invokeFailureCallbacksForIdentifier(identifier, error: VoiceMessageAttachmentCacheManagerError.conversionError(error))
|
||||
MXLog.error("[VoiceMessageAttachmentCacheManager] enqueueLoadAttachment: failed decoding audio message with: \(error)")
|
||||
dispatchGroup.leave()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispatchGroup.enter()
|
||||
DispatchQueue.main.async { // These don't behave accordingly if called from a background thread
|
||||
if attachment.isEncrypted {
|
||||
attachment.decrypt(toTempFile: { filePath in
|
||||
convertFileAtPath(filePath)
|
||||
dispatchGroup.leave()
|
||||
}, failure: { error in
|
||||
// A nil error in this case is a cancellation on the MXMediaLoader
|
||||
if let error = error {
|
||||
MXLog.error("Failed decrypting attachment with error: \(String(describing: error))")
|
||||
self.invokeFailureCallbacksForIdentifier(identifier, error: VoiceMessageAttachmentCacheManagerError.decryptionError(error))
|
||||
}
|
||||
dispatchGroup.leave()
|
||||
})
|
||||
} else {
|
||||
attachment.prepare({
|
||||
convertFileAtPath(attachment.cacheFilePath)
|
||||
dispatchGroup.leave()
|
||||
}, failure: { error in
|
||||
// A nil error in this case is a cancellation on the MXMediaLoader
|
||||
if let error = error {
|
||||
MXLog.error("Failed preparing attachment with error: \(String(describing: error))")
|
||||
self.invokeFailureCallbacksForIdentifier(identifier, error: VoiceMessageAttachmentCacheManagerError.preparationError(error))
|
||||
}
|
||||
dispatchGroup.leave()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
dispatchGroup.wait()
|
||||
|
||||
MXLog.debug("[VoiceMessageAttachmentCacheManager] Finished task")
|
||||
}
|
||||
|
||||
private func invokeSuccessCallbacksForIdentifier(_ identifier: String, url: URL, duration: TimeInterval, samples: [Float]) {
|
||||
let callbackKey = CompletionCallbackKey(eventIdentifier: identifier, requiredNumberOfSamples: samples.count)
|
||||
|
||||
guard let callbacks = completionCallbacks[callbackKey] else {
|
||||
return
|
||||
}
|
||||
|
||||
let result = VoiceMessageAttachmentCacheManagerLoadResult(eventIdentifier: identifier, url: url, duration: duration, samples: samples)
|
||||
|
||||
let copy = callbacks.map { $0 }
|
||||
DispatchQueue.main.async {
|
||||
for wrapper in copy {
|
||||
wrapper.completion(Result.success(result))
|
||||
}
|
||||
}
|
||||
|
||||
self.completionCallbacks[callbackKey] = nil
|
||||
}
|
||||
|
||||
private func invokeFailureCallbacksForIdentifier(_ identifier: String, error: Error) {
|
||||
let callbackKey = CompletionCallbackKey(eventIdentifier: identifier, requiredNumberOfSamples: samples.count)
|
||||
|
||||
guard let callbacks = completionCallbacks[callbackKey] else {
|
||||
return
|
||||
}
|
||||
|
||||
let copy = callbacks.map { $0 }
|
||||
DispatchQueue.main.async {
|
||||
for wrapper in copy {
|
||||
wrapper.completion(Result.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
self.completionCallbacks[callbackKey] = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ffmpegkit
|
||||
|
||||
enum VoiceMessageAudioConverterError: Error {
|
||||
case generic(String)
|
||||
case cancelled
|
||||
}
|
||||
|
||||
struct VoiceMessageAudioConverter {
|
||||
static func convertToOpusOgg(sourceURL: URL, destinationURL: URL, completion: @escaping (Result<Void, VoiceMessageAudioConverterError>) -> Void) {
|
||||
let command = "-hide_banner -y -i \"\(sourceURL.path)\" -c:a libopus \"\(destinationURL.path)\""
|
||||
executeCommand(command, completion: completion)
|
||||
}
|
||||
|
||||
static func convertToMPEG4AAC(sourceURL: URL, destinationURL: URL, completion: @escaping (Result<Void, VoiceMessageAudioConverterError>) -> Void) {
|
||||
let command = "-hide_banner -y -i \"\(sourceURL.path)\" -c:a aac_at -b:a 192k \"\(destinationURL.path)\""
|
||||
executeCommand(command, completion: completion)
|
||||
}
|
||||
|
||||
static func mediaDurationAt(_ sourceURL: URL, completion: @escaping (Result<TimeInterval, VoiceMessageAudioConverterError>) -> Void) {
|
||||
FFprobeKit.getMediaInformationAsync(sourceURL.path) { session in
|
||||
guard let session = session as? MediaInformationSession else {
|
||||
completion(.failure(.generic("Invalid session")))
|
||||
return
|
||||
}
|
||||
|
||||
guard let returnCode = session.getReturnCode() else {
|
||||
completion(.failure(.generic("Invalid return code")))
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if returnCode.isSuccess() {
|
||||
let mediaInfo = session.getMediaInformation()
|
||||
if let duration = try? TimeInterval(value: mediaInfo?.getDuration() ?? "0") {
|
||||
completion(.success(duration))
|
||||
} else {
|
||||
completion(.failure(.generic("Failed to get media duration")))
|
||||
}
|
||||
} else if returnCode.isCancel() {
|
||||
completion(.failure(.cancelled))
|
||||
} else {
|
||||
completion(.failure(.generic(String(returnCode.getValue()))))
|
||||
MXLog.error("""
|
||||
getMediaInformationAsync failed with state: \(String(describing: FFmpegKitConfig.sessionState(toString: session.getState()))), \
|
||||
returnCode: \(String(describing: returnCode)), \
|
||||
stackTrace: \(String(describing: session.getFailStackTrace()))
|
||||
""")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static private func executeCommand(_ command: String, completion: @escaping (Result<Void, VoiceMessageAudioConverterError>) -> Void) {
|
||||
FFmpegKitConfig.setLogLevel(0)
|
||||
|
||||
FFmpegKit.executeAsync(command) { session in
|
||||
guard let session = session else {
|
||||
completion(.failure(.generic("Invalid session")))
|
||||
return
|
||||
}
|
||||
|
||||
guard let returnCode = session.getReturnCode() else {
|
||||
completion(.failure(.generic("Invalid return code")))
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if returnCode.isSuccess() {
|
||||
completion(.success(()))
|
||||
} else if returnCode.isCancel() {
|
||||
completion(.failure(.cancelled))
|
||||
} else {
|
||||
completion(.failure(.generic(String(returnCode.getValue()))))
|
||||
MXLog.error("""
|
||||
Failed converting voice message with state: \(String(describing: FFmpegKitConfig.sessionState(toString: session.getState()))), \
|
||||
returnCode: \(String(describing: returnCode)), \
|
||||
stackTrace: \(String(describing: session.getFailStackTrace()))
|
||||
""")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol VoiceMessageAudioPlayerDelegate: AnyObject {
|
||||
func audioPlayerDidStartLoading(_ audioPlayer: VoiceMessageAudioPlayer)
|
||||
func audioPlayerDidFinishLoading(_ audioPlayer: VoiceMessageAudioPlayer)
|
||||
|
||||
func audioPlayerDidStartPlaying(_ audioPlayer: VoiceMessageAudioPlayer)
|
||||
func audioPlayerDidPausePlaying(_ audioPlayer: VoiceMessageAudioPlayer)
|
||||
func audioPlayerDidStopPlaying(_ audioPlayer: VoiceMessageAudioPlayer)
|
||||
func audioPlayerDidFinishPlaying(_ audioPlayer: VoiceMessageAudioPlayer)
|
||||
|
||||
func audioPlayer(_ audioPlayer: VoiceMessageAudioPlayer, didFailWithError: Error)
|
||||
}
|
||||
|
||||
enum VoiceMessageAudioPlayerError: Error {
|
||||
case genericError
|
||||
}
|
||||
|
||||
class VoiceMessageAudioPlayer: NSObject {
|
||||
|
||||
private var playerItem: AVPlayerItem?
|
||||
private var audioPlayer: AVPlayer?
|
||||
|
||||
private var statusObserver: NSKeyValueObservation?
|
||||
private var playbackBufferEmptyObserver: NSKeyValueObservation?
|
||||
private var rateObserver: NSKeyValueObservation?
|
||||
private var playToEndObserver: NSObjectProtocol?
|
||||
|
||||
private let delegateContainer = DelegateContainer()
|
||||
|
||||
private(set) var url: URL?
|
||||
|
||||
var isPlaying: Bool {
|
||||
guard let audioPlayer = audioPlayer else {
|
||||
return false
|
||||
}
|
||||
|
||||
return (audioPlayer.rate > 0)
|
||||
}
|
||||
|
||||
var duration: TimeInterval {
|
||||
return abs(CMTimeGetSeconds(self.audioPlayer?.currentItem?.duration ?? .zero))
|
||||
}
|
||||
|
||||
var currentTime: TimeInterval {
|
||||
return abs(CMTimeGetSeconds(audioPlayer?.currentTime() ?? .zero))
|
||||
}
|
||||
|
||||
private(set) var isStopped = true
|
||||
|
||||
deinit {
|
||||
removeObservers()
|
||||
}
|
||||
|
||||
func loadContentFromURL(_ url: URL) {
|
||||
if self.url == url {
|
||||
return
|
||||
}
|
||||
|
||||
self.url = url
|
||||
|
||||
removeObservers()
|
||||
|
||||
delegateContainer.notifyDelegatesWithBlock { delegate in
|
||||
(delegate as? VoiceMessageAudioPlayerDelegate)?.audioPlayerDidStartLoading(self)
|
||||
}
|
||||
|
||||
playerItem = AVPlayerItem(url: url)
|
||||
audioPlayer = AVPlayer(playerItem: playerItem)
|
||||
|
||||
addObservers()
|
||||
}
|
||||
|
||||
func unloadContent() {
|
||||
url = nil
|
||||
audioPlayer?.replaceCurrentItem(with: nil)
|
||||
}
|
||||
|
||||
func play() {
|
||||
isStopped = false
|
||||
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
} catch {
|
||||
MXLog.error("Could not redirect audio playback to speakers.")
|
||||
}
|
||||
|
||||
audioPlayer?.play()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
audioPlayer?.pause()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
if isStopped {
|
||||
return
|
||||
}
|
||||
|
||||
isStopped = true
|
||||
audioPlayer?.pause()
|
||||
audioPlayer?.seek(to: .zero)
|
||||
}
|
||||
|
||||
func seekToTime(_ time: TimeInterval) {
|
||||
audioPlayer?.seek(to: CMTime(seconds: time, preferredTimescale: 60000))
|
||||
}
|
||||
|
||||
func registerDelegate(_ delegate: VoiceMessageAudioPlayerDelegate) {
|
||||
delegateContainer.registerDelegate(delegate)
|
||||
}
|
||||
|
||||
func deregisterDelegate(_ delegate: VoiceMessageAudioPlayerDelegate) {
|
||||
delegateContainer.deregisterDelegate(delegate)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func addObservers() {
|
||||
guard let audioPlayer = audioPlayer, let playerItem = playerItem else {
|
||||
return
|
||||
}
|
||||
|
||||
statusObserver = playerItem.observe(\.status, options: [.old, .new]) { [weak self] item, change in
|
||||
guard let self = self else { return }
|
||||
|
||||
switch playerItem.status {
|
||||
case .failed:
|
||||
self.delegateContainer.notifyDelegatesWithBlock { delegate in
|
||||
(delegate as? VoiceMessageAudioPlayerDelegate)?.audioPlayer(self, didFailWithError: playerItem.error ?? VoiceMessageAudioPlayerError.genericError)
|
||||
}
|
||||
case .readyToPlay:
|
||||
self.delegateContainer.notifyDelegatesWithBlock { delegate in
|
||||
(delegate as? VoiceMessageAudioPlayerDelegate)?.audioPlayerDidFinishLoading(self)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
playbackBufferEmptyObserver = playerItem.observe(\.isPlaybackBufferEmpty, options: [.old, .new]) { [weak self] item, change in
|
||||
guard let self = self else { return }
|
||||
|
||||
if playerItem.isPlaybackBufferEmpty {
|
||||
self.delegateContainer.notifyDelegatesWithBlock { delegate in
|
||||
(delegate as? VoiceMessageAudioPlayerDelegate)?.audioPlayerDidStartLoading(self)
|
||||
}
|
||||
} else {
|
||||
self.delegateContainer.notifyDelegatesWithBlock { delegate in
|
||||
(delegate as? VoiceMessageAudioPlayerDelegate)?.audioPlayerDidFinishLoading(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rateObserver = audioPlayer.observe(\.rate, options: [.old, .new]) { [weak self] player, change in
|
||||
guard let self = self else { return }
|
||||
|
||||
if audioPlayer.rate == 0.0 {
|
||||
if self.isStopped {
|
||||
self.delegateContainer.notifyDelegatesWithBlock { delegate in
|
||||
(delegate as? VoiceMessageAudioPlayerDelegate)?.audioPlayerDidStopPlaying(self)
|
||||
}
|
||||
} else {
|
||||
self.delegateContainer.notifyDelegatesWithBlock { delegate in
|
||||
(delegate as? VoiceMessageAudioPlayerDelegate)?.audioPlayerDidPausePlaying(self)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.delegateContainer.notifyDelegatesWithBlock { delegate in
|
||||
(delegate as? VoiceMessageAudioPlayerDelegate)?.audioPlayerDidStartPlaying(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
playToEndObserver = NotificationCenter.default.addObserver(forName: Notification.Name.AVPlayerItemDidPlayToEndTime, object: playerItem, queue: nil) { [weak self] notification in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.delegateContainer.notifyDelegatesWithBlock { delegate in
|
||||
(delegate as? VoiceMessageAudioPlayerDelegate)?.audioPlayerDidFinishPlaying(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeObservers() {
|
||||
statusObserver?.invalidate()
|
||||
playbackBufferEmptyObserver?.invalidate()
|
||||
rateObserver?.invalidate()
|
||||
NotificationCenter.default.removeObserver(playToEndObserver as Any)
|
||||
}
|
||||
}
|
||||
|
||||
extension VoiceMessageAudioPlayerDelegate {
|
||||
func audioPlayerDidStartLoading(_ audioPlayer: VoiceMessageAudioPlayer) { }
|
||||
|
||||
func audioPlayerDidFinishLoading(_ audioPlayer: VoiceMessageAudioPlayer) { }
|
||||
|
||||
func audioPlayerDidStartPlaying(_ audioPlayer: VoiceMessageAudioPlayer) { }
|
||||
|
||||
func audioPlayerDidPausePlaying(_ audioPlayer: VoiceMessageAudioPlayer) { }
|
||||
|
||||
func audioPlayerDidStopPlaying(_ audioPlayer: VoiceMessageAudioPlayer) { }
|
||||
|
||||
func audioPlayerDidFinishPlaying(_ audioPlayer: VoiceMessageAudioPlayer) { }
|
||||
|
||||
func audioPlayer(_ audioPlayer: VoiceMessageAudioPlayer, didFailWithError: Error) { }
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
protocol VoiceMessageAudioRecorderDelegate: AnyObject {
|
||||
func audioRecorderDidStartRecording(_ audioRecorder: VoiceMessageAudioRecorder)
|
||||
func audioRecorderDidFinishRecording(_ audioRecorder: VoiceMessageAudioRecorder)
|
||||
func audioRecorder(_ audioRecorder: VoiceMessageAudioRecorder, didFailWithError: Error)
|
||||
}
|
||||
|
||||
enum VoiceMessageAudioRecorderError: Error {
|
||||
case genericError
|
||||
}
|
||||
|
||||
class VoiceMessageAudioRecorder: NSObject, AVAudioRecorderDelegate {
|
||||
|
||||
private enum Constants {
|
||||
static let silenceThreshold: Float = -50.0
|
||||
}
|
||||
|
||||
private var audioRecorder: AVAudioRecorder?
|
||||
private let delegateContainer = DelegateContainer()
|
||||
|
||||
var url: URL? {
|
||||
return audioRecorder?.url
|
||||
}
|
||||
|
||||
var currentTime: TimeInterval {
|
||||
return audioRecorder?.currentTime ?? 0
|
||||
}
|
||||
|
||||
var isRecording: Bool {
|
||||
return audioRecorder?.isRecording ?? false
|
||||
}
|
||||
|
||||
func recordWithOutputURL(_ url: URL) {
|
||||
|
||||
let settings = [AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
|
||||
AVSampleRateKey: 12000,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue]
|
||||
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .default)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
audioRecorder = try AVAudioRecorder(url: url, settings: settings)
|
||||
audioRecorder?.delegate = self
|
||||
audioRecorder?.isMeteringEnabled = true
|
||||
audioRecorder?.record()
|
||||
delegateContainer.notifyDelegatesWithBlock { delegate in
|
||||
(delegate as? VoiceMessageAudioRecorderDelegate)?.audioRecorderDidStartRecording(self)
|
||||
}
|
||||
} catch {
|
||||
delegateContainer.notifyDelegatesWithBlock { delegate in
|
||||
(delegate as? VoiceMessageAudioRecorderDelegate)?.audioRecorder(self, didFailWithError: VoiceMessageAudioRecorderError.genericError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopRecording() {
|
||||
audioRecorder?.stop()
|
||||
}
|
||||
|
||||
func peakPowerForChannelNumber(_ channelNumber: Int) -> Float {
|
||||
guard self.isRecording, let audioRecorder = audioRecorder else {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
audioRecorder.updateMeters()
|
||||
|
||||
return self.normalizedPowerLevelFromDecibels(audioRecorder.peakPower(forChannel: channelNumber))
|
||||
}
|
||||
|
||||
func averagePowerForChannelNumber(_ channelNumber: Int) -> Float {
|
||||
guard self.isRecording, let audioRecorder = audioRecorder else {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
audioRecorder.updateMeters()
|
||||
|
||||
return self.normalizedPowerLevelFromDecibels(audioRecorder.averagePower(forChannel: channelNumber))
|
||||
}
|
||||
|
||||
func registerDelegate(_ delegate: VoiceMessageAudioPlayerDelegate) {
|
||||
delegateContainer.registerDelegate(delegate)
|
||||
}
|
||||
|
||||
func deregisterDelegate(_ delegate: VoiceMessageAudioPlayerDelegate) {
|
||||
delegateContainer.deregisterDelegate(delegate)
|
||||
}
|
||||
|
||||
// MARK: - AVAudioRecorderDelegate
|
||||
|
||||
func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully success: Bool) {
|
||||
if success {
|
||||
delegateContainer.notifyDelegatesWithBlock { delegate in
|
||||
(delegate as? VoiceMessageAudioRecorderDelegate)?.audioRecorderDidFinishRecording(self)
|
||||
}
|
||||
} else {
|
||||
delegateContainer.notifyDelegatesWithBlock { delegate in
|
||||
(delegate as? VoiceMessageAudioRecorderDelegate)?.audioRecorder(self, didFailWithError: VoiceMessageAudioRecorderError.genericError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) {
|
||||
delegateContainer.notifyDelegatesWithBlock { delegate in
|
||||
(delegate as? VoiceMessageAudioRecorderDelegate)?.audioRecorder(self, didFailWithError: VoiceMessageAudioRecorderError.genericError)
|
||||
}
|
||||
}
|
||||
|
||||
private func normalizedPowerLevelFromDecibels(_ decibels: Float) -> Float {
|
||||
return decibels / Constants.silenceThreshold
|
||||
}
|
||||
}
|
||||
|
||||
extension String: LocalizedError {
|
||||
public var errorDescription: String? { return self }
|
||||
}
|
||||
|
||||
extension VoiceMessageAudioRecorderDelegate {
|
||||
func audioRecorderDidStartRecording(_ audioRecorder: VoiceMessageAudioRecorder) { }
|
||||
|
||||
func audioRecorderDidFinishRecording(_ audioRecorder: VoiceMessageAudioRecorder) { }
|
||||
|
||||
func audioRecorder(_ audioRecorder: VoiceMessageAudioRecorder, didFailWithError: Error) { }
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import DSWaveformImage
|
||||
|
||||
@objc public protocol VoiceMessageControllerDelegate: AnyObject {
|
||||
func voiceMessageControllerDidRequestMicrophonePermission(_ voiceMessageController: VoiceMessageController)
|
||||
func voiceMessageController(_ voiceMessageController: VoiceMessageController, didRequestSendForFileAtURL url: URL, duration: UInt, samples: [Float]?, completion: @escaping (Bool) -> Void)
|
||||
}
|
||||
|
||||
public class VoiceMessageController: NSObject, VoiceMessageToolbarViewDelegate, VoiceMessageAudioRecorderDelegate, VoiceMessageAudioPlayerDelegate {
|
||||
|
||||
private enum Constants {
|
||||
static let maximumAudioRecordingDuration: TimeInterval = 120.0
|
||||
static let maximumAudioRecordingLengthReachedThreshold: TimeInterval = 10.0
|
||||
static let elapsedTimeFormat = "m:ss"
|
||||
static let minimumRecordingDuration = 1.0
|
||||
}
|
||||
|
||||
private let themeService: ThemeService
|
||||
private let mediaServiceProvider: VoiceMessageMediaServiceProvider
|
||||
private let temporaryFileURL: URL
|
||||
|
||||
private let _voiceMessageToolbarView: VoiceMessageToolbarView
|
||||
private var displayLink: CADisplayLink!
|
||||
|
||||
private var audioRecorder: VoiceMessageAudioRecorder?
|
||||
|
||||
private var audioPlayer: VoiceMessageAudioPlayer?
|
||||
private var waveformAnalyser: WaveformAnalyzer?
|
||||
|
||||
private var audioSamples: [Float] = []
|
||||
private var isInLockedMode: Bool = false
|
||||
private var notifiedRemainingTime = false
|
||||
|
||||
private static let timeFormatter: DateFormatter = {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = Constants.elapsedTimeFormat
|
||||
return dateFormatter
|
||||
}()
|
||||
|
||||
@objc public weak var delegate: VoiceMessageControllerDelegate?
|
||||
|
||||
@objc public var isRecordingAudio: Bool {
|
||||
return audioRecorder?.isRecording ?? false || isInLockedMode
|
||||
}
|
||||
|
||||
@objc public var voiceMessageToolbarView: UIView {
|
||||
return _voiceMessageToolbarView
|
||||
}
|
||||
|
||||
@objc public init(themeService: ThemeService, mediaServiceProvider: VoiceMessageMediaServiceProvider) {
|
||||
self.themeService = themeService
|
||||
self.mediaServiceProvider = mediaServiceProvider
|
||||
|
||||
let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
temporaryFileURL = temporaryDirectoryURL.appendingPathComponent(ProcessInfo().globallyUniqueString).appendingPathExtension("m4a")
|
||||
|
||||
_voiceMessageToolbarView = VoiceMessageToolbarView.loadFromNib()
|
||||
|
||||
super.init()
|
||||
|
||||
_voiceMessageToolbarView.delegate = self
|
||||
|
||||
displayLink = CADisplayLink(target: WeakTarget(self, selector: #selector(handleDisplayLinkTick)), selector: WeakTarget.triggerSelector)
|
||||
displayLink.isPaused = true
|
||||
displayLink.add(to: .current, forMode: .common)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(updateTheme), name: .themeServiceDidChangeTheme, object: nil)
|
||||
updateTheme()
|
||||
|
||||
updateUI()
|
||||
}
|
||||
|
||||
// MARK: - VoiceMessageToolbarViewDelegate
|
||||
|
||||
func voiceMessageToolbarViewDidRequestRecordingStart(_ toolbarView: VoiceMessageToolbarView) {
|
||||
guard AVAudioSession.sharedInstance().recordPermission == .granted else {
|
||||
delegate?.voiceMessageControllerDidRequestMicrophonePermission(self)
|
||||
return
|
||||
}
|
||||
|
||||
// Haptic are not played during record on iOS by default. This fix works
|
||||
// only since iOS 13. A workaround for iOS 12 and earlier would be to
|
||||
// dispatch after at least 100ms recordWithOutputURL call
|
||||
if #available(iOS 13.0, *) {
|
||||
try? AVAudioSession.sharedInstance().setCategory(.playAndRecord)
|
||||
try? AVAudioSession.sharedInstance().setAllowHapticsAndSystemSoundsDuringRecording(true)
|
||||
}
|
||||
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
|
||||
audioRecorder = mediaServiceProvider.audioRecorder()
|
||||
audioRecorder?.registerDelegate(self)
|
||||
audioRecorder?.recordWithOutputURL(temporaryFileURL)
|
||||
}
|
||||
|
||||
func voiceMessageToolbarViewDidRequestRecordingFinish(_ toolbarView: VoiceMessageToolbarView) {
|
||||
finishRecording()
|
||||
}
|
||||
|
||||
func voiceMessageToolbarViewDidRequestRecordingCancel(_ toolbarView: VoiceMessageToolbarView) {
|
||||
isInLockedMode = false
|
||||
audioPlayer?.stop()
|
||||
audioRecorder?.stopRecording()
|
||||
deleteRecordingAtURL(temporaryFileURL)
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
||||
updateUI()
|
||||
}
|
||||
|
||||
func voiceMessageToolbarViewDidRequestLockedModeRecording(_ toolbarView: VoiceMessageToolbarView) {
|
||||
isInLockedMode = true
|
||||
updateUI()
|
||||
}
|
||||
|
||||
func voiceMessageToolbarViewDidRequestPlaybackToggle(_ toolbarView: VoiceMessageToolbarView) {
|
||||
guard let audioPlayer = audioPlayer else {
|
||||
return
|
||||
}
|
||||
|
||||
if audioPlayer.url != nil {
|
||||
if audioPlayer.isPlaying {
|
||||
audioPlayer.pause()
|
||||
} else {
|
||||
audioPlayer.play()
|
||||
}
|
||||
} else {
|
||||
audioPlayer.loadContentFromURL(temporaryFileURL)
|
||||
audioPlayer.play()
|
||||
}
|
||||
}
|
||||
|
||||
func voiceMessageToolbarViewDidRequestSend(_ toolbarView: VoiceMessageToolbarView) {
|
||||
audioPlayer?.stop()
|
||||
audioRecorder?.stopRecording()
|
||||
|
||||
sendRecordingAtURL(temporaryFileURL)
|
||||
|
||||
isInLockedMode = false
|
||||
updateUI()
|
||||
}
|
||||
|
||||
// MARK: - AudioRecorderDelegate
|
||||
|
||||
func audioRecorderDidStartRecording(_ audioRecorder: VoiceMessageAudioRecorder) {
|
||||
notifiedRemainingTime = false
|
||||
updateUI()
|
||||
}
|
||||
|
||||
func audioRecorderDidFinishRecording(_ audioRecorder: VoiceMessageAudioRecorder) {
|
||||
updateUI()
|
||||
}
|
||||
|
||||
func audioRecorder(_ audioRecorder: VoiceMessageAudioRecorder, didFailWithError: Error) {
|
||||
isInLockedMode = false
|
||||
updateUI()
|
||||
|
||||
MXLog.error("Failed recording voice message.")
|
||||
}
|
||||
|
||||
// MARK: - VoiceMessageAudioPlayerDelegate
|
||||
|
||||
func audioPlayerDidStartPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
updateUI()
|
||||
}
|
||||
|
||||
func audioPlayerDidPausePlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
updateUI()
|
||||
}
|
||||
|
||||
func audioPlayerDidStopPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
updateUI()
|
||||
}
|
||||
|
||||
func audioPlayerDidFinishPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
audioPlayer.seekToTime(0.0)
|
||||
updateUI()
|
||||
}
|
||||
|
||||
func audioPlayer(_ audioPlayer: VoiceMessageAudioPlayer, didFailWithError: Error) {
|
||||
updateUI()
|
||||
|
||||
MXLog.error("Failed playing voice message.")
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func finishRecording() {
|
||||
let recordDuration = audioRecorder?.currentTime
|
||||
audioRecorder?.stopRecording()
|
||||
|
||||
guard isInLockedMode else {
|
||||
if recordDuration ?? 0 >= Constants.minimumRecordingDuration {
|
||||
sendRecordingAtURL(temporaryFileURL)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
audioPlayer = mediaServiceProvider.audioPlayerForIdentifier(UUID().uuidString)
|
||||
audioPlayer?.registerDelegate(self)
|
||||
audioPlayer?.loadContentFromURL(temporaryFileURL)
|
||||
|
||||
audioSamples = []
|
||||
|
||||
updateUI()
|
||||
}
|
||||
|
||||
private func sendRecordingAtURL(_ sourceURL: URL) {
|
||||
|
||||
let dispatchGroup = DispatchGroup()
|
||||
|
||||
var duration = 0.0
|
||||
var invertedSamples: [Float]?
|
||||
var finalURL: URL?
|
||||
|
||||
dispatchGroup.enter()
|
||||
VoiceMessageAudioConverter.mediaDurationAt(sourceURL) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
if let someDuration = try? result.get() {
|
||||
duration = someDuration
|
||||
} else {
|
||||
MXLog.error("[VoiceMessageController] Failed retrieving media duration")
|
||||
}
|
||||
case .failure(let error):
|
||||
MXLog.error("[VoiceMessageController] Failed getting audio duration with: \(error)")
|
||||
}
|
||||
|
||||
dispatchGroup.leave()
|
||||
}
|
||||
|
||||
dispatchGroup.enter()
|
||||
let analyser = WaveformAnalyzer(audioAssetURL: sourceURL)
|
||||
analyser?.samples(count: 100, completionHandler: { samples in
|
||||
// Dispatch back from the WaveformAnalyzer's internal queue
|
||||
DispatchQueue.main.async {
|
||||
if let samples = samples {
|
||||
invertedSamples = samples.compactMap { return 1.0 - $0 } // linearly normalized to [0, 1] (1 -> -50 dB)
|
||||
} else {
|
||||
MXLog.error("[VoiceMessageController] Failed sampling recorder voice message.")
|
||||
}
|
||||
|
||||
dispatchGroup.leave()
|
||||
}
|
||||
})
|
||||
|
||||
dispatchGroup.enter()
|
||||
let destinationURL = sourceURL.deletingPathExtension().appendingPathExtension("opus")
|
||||
VoiceMessageAudioConverter.convertToOpusOgg(sourceURL: sourceURL, destinationURL: destinationURL) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
finalURL = destinationURL
|
||||
case .failure(let error):
|
||||
MXLog.error("Failed failed encoding audio message with: \(error)")
|
||||
}
|
||||
|
||||
dispatchGroup.leave()
|
||||
}
|
||||
|
||||
dispatchGroup.notify(queue: .main) {
|
||||
guard let url = finalURL else {
|
||||
return
|
||||
}
|
||||
|
||||
self.delegate?.voiceMessageController(self, didRequestSendForFileAtURL: url,
|
||||
duration: UInt(duration * 1000),
|
||||
samples: invertedSamples) { [weak self] success in
|
||||
UINotificationFeedbackGenerator().notificationOccurred((success ? .success : .error))
|
||||
self?.deleteRecordingAtURL(sourceURL)
|
||||
self?.deleteRecordingAtURL(destinationURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteRecordingAtURL(_ url: URL?) {
|
||||
guard let url = url else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager.default.removeItem(at: url)
|
||||
} catch {
|
||||
MXLog.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func updateTheme() {
|
||||
_voiceMessageToolbarView.update(theme: themeService.theme)
|
||||
}
|
||||
|
||||
@objc private func handleDisplayLinkTick() {
|
||||
updateUI()
|
||||
}
|
||||
|
||||
private func updateUI() {
|
||||
|
||||
let shouldUpdateFromAudioPlayer = isInLockedMode && !(audioRecorder?.isRecording ?? false)
|
||||
|
||||
if shouldUpdateFromAudioPlayer {
|
||||
updateUIFromAudioPlayer()
|
||||
} else {
|
||||
updateUIFromAudioRecorder()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateUIFromAudioRecorder() {
|
||||
let isRecording = audioRecorder?.isRecording ?? false
|
||||
|
||||
displayLink.isPaused = !isRecording
|
||||
|
||||
let requiredNumberOfSamples = _voiceMessageToolbarView.getRequiredNumberOfSamples()
|
||||
|
||||
if audioSamples.count != requiredNumberOfSamples {
|
||||
padSamplesArrayToSize(requiredNumberOfSamples)
|
||||
}
|
||||
|
||||
let sample = audioRecorder?.averagePowerForChannelNumber(0) ?? 0.0
|
||||
audioSamples.insert(sample, at: 0)
|
||||
audioSamples.removeLast()
|
||||
|
||||
let currentTime = audioRecorder?.currentTime ?? 0.0
|
||||
|
||||
if currentTime >= Constants.maximumAudioRecordingDuration {
|
||||
finishRecording()
|
||||
return
|
||||
}
|
||||
|
||||
var details = VoiceMessageToolbarViewDetails()
|
||||
details.state = (isRecording ? (isInLockedMode ? .lockedModeRecord : .record) : (isInLockedMode ? .lockedModePlayback : .idle))
|
||||
details.elapsedTime = VoiceMessageController.timeFormatter.string(from: Date(timeIntervalSinceReferenceDate: currentTime))
|
||||
details.audioSamples = audioSamples
|
||||
|
||||
if isRecording {
|
||||
if currentTime >= Constants.maximumAudioRecordingDuration - Constants.maximumAudioRecordingLengthReachedThreshold {
|
||||
|
||||
if !self.notifiedRemainingTime {
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
}
|
||||
|
||||
notifiedRemainingTime = true
|
||||
|
||||
let remainingTime = ceil(Constants.maximumAudioRecordingDuration - currentTime)
|
||||
details.toastMessage = VectorL10n.voiceMessageRemainingRecordingTime(String(remainingTime))
|
||||
} else {
|
||||
details.toastMessage = (isInLockedMode ? VectorL10n.voiceMessageStopLockedModeRecording : VectorL10n.voiceMessageReleaseToSend)
|
||||
}
|
||||
}
|
||||
|
||||
_voiceMessageToolbarView.configureWithDetails(details)
|
||||
}
|
||||
|
||||
private func updateUIFromAudioPlayer() {
|
||||
guard let audioPlayer = audioPlayer else {
|
||||
return
|
||||
}
|
||||
|
||||
displayLink.isPaused = !audioPlayer.isPlaying
|
||||
|
||||
let requiredNumberOfSamples = _voiceMessageToolbarView.getRequiredNumberOfSamples()
|
||||
if audioSamples.count != requiredNumberOfSamples && requiredNumberOfSamples > 0 {
|
||||
padSamplesArrayToSize(requiredNumberOfSamples)
|
||||
|
||||
waveformAnalyser = WaveformAnalyzer(audioAssetURL: temporaryFileURL)
|
||||
waveformAnalyser?.samples(count: requiredNumberOfSamples, completionHandler: { [weak self] samples in
|
||||
guard let samples = samples else {
|
||||
MXLog.error("Could not sample audio recording.")
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self?.audioSamples = samples
|
||||
self?.updateUIFromAudioPlayer()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var details = VoiceMessageToolbarViewDetails()
|
||||
details.state = (audioRecorder?.isRecording ?? false ? (isInLockedMode ? .lockedModeRecord : .record) : (isInLockedMode ? .lockedModePlayback : .idle))
|
||||
details.elapsedTime = VoiceMessageController.timeFormatter.string(from: Date(timeIntervalSinceReferenceDate: (audioPlayer.isPlaying ? audioPlayer.currentTime : audioPlayer.duration)))
|
||||
details.audioSamples = audioSamples
|
||||
details.isPlaying = audioPlayer.isPlaying
|
||||
details.progress = (audioPlayer.isPlaying ? (audioPlayer.duration > 0.0 ? audioPlayer.currentTime / audioPlayer.duration : 0.0) : 0.0)
|
||||
_voiceMessageToolbarView.configureWithDetails(details)
|
||||
}
|
||||
|
||||
private func padSamplesArrayToSize(_ size: Int) {
|
||||
let delta = size - audioSamples.count
|
||||
guard delta > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
audioSamples = audioSamples + [Float](repeating: 0.0, count: delta)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@objc public class VoiceMessageMediaServiceProvider: NSObject, VoiceMessageAudioPlayerDelegate, VoiceMessageAudioRecorderDelegate {
|
||||
|
||||
private let audioPlayers: NSMapTable<NSString, VoiceMessageAudioPlayer>
|
||||
private let audioRecorders: NSHashTable<VoiceMessageAudioRecorder>
|
||||
|
||||
// Retain currently playing audio player so it doesn't stop playing on timeline cell reusage
|
||||
private var currentlyPlayingAudioPlayer: VoiceMessageAudioPlayer?
|
||||
|
||||
@objc public static let sharedProvider = VoiceMessageMediaServiceProvider()
|
||||
|
||||
private override init() {
|
||||
audioPlayers = NSMapTable<NSString, VoiceMessageAudioPlayer>(valueOptions: .weakMemory)
|
||||
audioRecorders = NSHashTable<VoiceMessageAudioRecorder>(options: .weakMemory)
|
||||
}
|
||||
|
||||
@objc func audioPlayerForIdentifier(_ identifier: String) -> VoiceMessageAudioPlayer {
|
||||
if let audioPlayer = audioPlayers.object(forKey: identifier as NSString) {
|
||||
return audioPlayer
|
||||
}
|
||||
|
||||
let audioPlayer = VoiceMessageAudioPlayer()
|
||||
audioPlayer.registerDelegate(self)
|
||||
audioPlayers.setObject(audioPlayer, forKey: identifier as NSString)
|
||||
return audioPlayer
|
||||
}
|
||||
|
||||
@objc func audioRecorder() -> VoiceMessageAudioRecorder {
|
||||
let audioRecorder = VoiceMessageAudioRecorder()
|
||||
audioRecorder.registerDelegate(self)
|
||||
audioRecorders.add(audioRecorder)
|
||||
return audioRecorder
|
||||
}
|
||||
|
||||
@objc func stopAllServices() {
|
||||
stopAllServicesExcept(nil)
|
||||
}
|
||||
|
||||
// MARK: - VoiceMessageAudioPlayerDelegate
|
||||
|
||||
func audioPlayerDidStartPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
currentlyPlayingAudioPlayer = audioPlayer
|
||||
stopAllServicesExcept(audioPlayer)
|
||||
}
|
||||
|
||||
func audioPlayerDidStopPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
if currentlyPlayingAudioPlayer == audioPlayer {
|
||||
currentlyPlayingAudioPlayer = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VoiceMessageAudioRecorderDelegate
|
||||
|
||||
func audioRecorderDidStartRecording(_ audioRecorder: VoiceMessageAudioRecorder) {
|
||||
stopAllServicesExcept(audioRecorder)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func stopAllServicesExcept(_ service: AnyObject?) {
|
||||
for audioRecorder in audioRecorders.allObjects {
|
||||
if audioRecorder === service {
|
||||
continue
|
||||
}
|
||||
|
||||
audioRecorder.stopRecording()
|
||||
}
|
||||
|
||||
guard let audioPlayersEnumerator = audioPlayers.objectEnumerator() else {
|
||||
return
|
||||
}
|
||||
|
||||
for case let audioPlayer as VoiceMessageAudioPlayer in audioPlayersEnumerator {
|
||||
if audioPlayer === service {
|
||||
continue
|
||||
}
|
||||
|
||||
audioPlayer.stop()
|
||||
audioPlayer.unloadContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import DSWaveformImage
|
||||
|
||||
enum VoiceMessagePlaybackControllerState {
|
||||
case stopped
|
||||
case playing
|
||||
case paused
|
||||
case error
|
||||
}
|
||||
|
||||
class VoiceMessagePlaybackController: VoiceMessageAudioPlayerDelegate, VoiceMessagePlaybackViewDelegate {
|
||||
|
||||
private enum Constants {
|
||||
static let elapsedTimeFormat = "m:ss"
|
||||
}
|
||||
|
||||
private let mediaServiceProvider: VoiceMessageMediaServiceProvider
|
||||
private let cacheManager: VoiceMessageAttachmentCacheManager
|
||||
|
||||
private var audioPlayer: VoiceMessageAudioPlayer?
|
||||
private var displayLink: CADisplayLink!
|
||||
private var samples: [Float] = []
|
||||
private var duration: TimeInterval = 0
|
||||
private var urlToLoad: URL?
|
||||
private var loading: Bool = false
|
||||
|
||||
private var state: VoiceMessagePlaybackControllerState = .stopped {
|
||||
didSet {
|
||||
updateUI()
|
||||
displayLink.isPaused = (state != .playing)
|
||||
}
|
||||
}
|
||||
|
||||
private static let timeFormatter: DateFormatter = {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = Constants.elapsedTimeFormat
|
||||
return dateFormatter
|
||||
}()
|
||||
|
||||
|
||||
let playbackView: VoiceMessagePlaybackView
|
||||
|
||||
init(mediaServiceProvider: VoiceMessageMediaServiceProvider,
|
||||
cacheManager: VoiceMessageAttachmentCacheManager) {
|
||||
self.mediaServiceProvider = mediaServiceProvider
|
||||
self.cacheManager = cacheManager
|
||||
|
||||
playbackView = VoiceMessagePlaybackView.loadFromNib()
|
||||
playbackView.delegate = self
|
||||
|
||||
displayLink = CADisplayLink(target: WeakTarget(self, selector: #selector(handleDisplayLinkTick)), selector: WeakTarget.triggerSelector)
|
||||
displayLink.isPaused = true
|
||||
displayLink.add(to: .current, forMode: .common)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(updateTheme), name: .themeServiceDidChangeTheme, object: nil)
|
||||
updateTheme()
|
||||
updateUI()
|
||||
}
|
||||
|
||||
var attachment: MXKAttachment? {
|
||||
didSet {
|
||||
loadAttachmentData()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VoiceMessagePlaybackViewDelegate
|
||||
|
||||
func voiceMessagePlaybackViewDidRequestPlaybackToggle() {
|
||||
guard let audioPlayer = audioPlayer else {
|
||||
return
|
||||
}
|
||||
|
||||
if audioPlayer.url != nil {
|
||||
if audioPlayer.isPlaying {
|
||||
audioPlayer.pause()
|
||||
} else {
|
||||
audioPlayer.play()
|
||||
}
|
||||
} else if let url = urlToLoad {
|
||||
audioPlayer.loadContentFromURL(url)
|
||||
audioPlayer.play()
|
||||
}
|
||||
}
|
||||
|
||||
func voiceMessagePlaybackViewDidChangeWidth() {
|
||||
loadAttachmentData()
|
||||
}
|
||||
|
||||
// MARK: - VoiceMessageAudioPlayerDelegate
|
||||
|
||||
func audioPlayerDidFinishLoading(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
updateUI()
|
||||
}
|
||||
|
||||
func audioPlayerDidStartPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
state = .playing
|
||||
}
|
||||
|
||||
func audioPlayerDidPausePlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
state = .paused
|
||||
}
|
||||
|
||||
func audioPlayerDidStopPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
state = .stopped
|
||||
}
|
||||
|
||||
func audioPlayer(_ audioPlayer: VoiceMessageAudioPlayer, didFailWithError error: Error) {
|
||||
state = .error
|
||||
MXLog.error("Failed playing voice message with error: \(error)")
|
||||
}
|
||||
|
||||
func audioPlayerDidFinishPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
audioPlayer.seekToTime(0.0)
|
||||
state = .stopped
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
@objc private func handleDisplayLinkTick() {
|
||||
updateUI()
|
||||
}
|
||||
|
||||
private func updateUI() {
|
||||
var details = VoiceMessagePlaybackViewDetails()
|
||||
|
||||
details.playbackEnabled = (state != .error)
|
||||
details.playing = (state == .playing)
|
||||
details.samples = samples
|
||||
|
||||
switch state {
|
||||
case .stopped:
|
||||
details.currentTime = VoiceMessagePlaybackController.timeFormatter.string(from: Date(timeIntervalSinceReferenceDate: self.duration))
|
||||
details.progress = 0.0
|
||||
default:
|
||||
if let audioPlayer = audioPlayer {
|
||||
details.currentTime = VoiceMessagePlaybackController.timeFormatter.string(from: Date(timeIntervalSinceReferenceDate: audioPlayer.currentTime))
|
||||
details.progress = (audioPlayer.duration > 0.0 ? audioPlayer.currentTime / audioPlayer.duration : 0.0)
|
||||
}
|
||||
}
|
||||
details.loading = self.loading
|
||||
|
||||
playbackView.configureWithDetails(details)
|
||||
}
|
||||
|
||||
private func loadAttachmentData() {
|
||||
guard let attachment = attachment else {
|
||||
return
|
||||
}
|
||||
|
||||
self.state = .stopped
|
||||
self.loading = true
|
||||
self.samples = []
|
||||
updateUI()
|
||||
|
||||
let requiredNumberOfSamples = playbackView.getRequiredNumberOfSamples()
|
||||
|
||||
cacheManager.loadAttachment(attachment, numberOfSamples: requiredNumberOfSamples) { [weak self] result in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let result):
|
||||
guard result.eventIdentifier == attachment.eventId else {
|
||||
return
|
||||
}
|
||||
|
||||
// Avoid listening to old audio player delegates if the attachment for this playbackController/cell changes
|
||||
self.audioPlayer?.deregisterDelegate(self)
|
||||
|
||||
self.audioPlayer = self.mediaServiceProvider.audioPlayerForIdentifier(result.eventIdentifier)
|
||||
self.audioPlayer?.registerDelegate(self)
|
||||
|
||||
self.loading = false
|
||||
self.urlToLoad = result.url
|
||||
self.duration = result.duration
|
||||
self.samples = result.samples
|
||||
|
||||
if let audioPlayer = self.audioPlayer {
|
||||
if audioPlayer.isPlaying {
|
||||
self.state = .playing
|
||||
} else if audioPlayer.currentTime > 0 {
|
||||
self.state = .paused
|
||||
} else {
|
||||
self.state = .stopped
|
||||
}
|
||||
}
|
||||
case .failure:
|
||||
self.state = .error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func updateTheme() {
|
||||
playbackView.update(theme: ThemeService.shared().theme)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Reusable
|
||||
|
||||
protocol VoiceMessagePlaybackViewDelegate: AnyObject {
|
||||
func voiceMessagePlaybackViewDidRequestPlaybackToggle()
|
||||
func voiceMessagePlaybackViewDidChangeWidth()
|
||||
}
|
||||
|
||||
struct VoiceMessagePlaybackViewDetails {
|
||||
var currentTime: String = ""
|
||||
var progress = 0.0
|
||||
var samples: [Float] = []
|
||||
var playing: Bool = false
|
||||
var playbackEnabled = false
|
||||
var recording: Bool = false
|
||||
var loading: Bool = false
|
||||
}
|
||||
|
||||
class VoiceMessagePlaybackView: UIView, NibLoadable, Themable {
|
||||
|
||||
private enum Constants {
|
||||
static let backgroundCornerRadius: CGFloat = 12.0
|
||||
}
|
||||
|
||||
private var _waveformView: VoiceMessageWaveformView!
|
||||
private var currentTheme: Theme?
|
||||
|
||||
@IBOutlet private var backgroundView: UIView!
|
||||
@IBOutlet private var recordingIcon: UIView!
|
||||
@IBOutlet private var playButton: UIButton!
|
||||
@IBOutlet private var elapsedTimeLabel: UILabel!
|
||||
@IBOutlet private var waveformContainerView: UIView!
|
||||
|
||||
weak var delegate: VoiceMessagePlaybackViewDelegate?
|
||||
|
||||
var details: VoiceMessagePlaybackViewDetails?
|
||||
|
||||
var waveformView: UIView {
|
||||
return _waveformView
|
||||
}
|
||||
|
||||
override var bounds: CGRect {
|
||||
didSet {
|
||||
if oldValue.width != bounds.width {
|
||||
delegate?.voiceMessagePlaybackViewDidChangeWidth()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
backgroundView.layer.cornerRadius = Constants.backgroundCornerRadius
|
||||
playButton.layer.cornerRadius = playButton.bounds.width / 2.0
|
||||
|
||||
_waveformView = VoiceMessageWaveformView(frame: waveformContainerView.bounds)
|
||||
waveformContainerView.vc_addSubViewMatchingParent(_waveformView)
|
||||
}
|
||||
|
||||
func configureWithDetails(_ details: VoiceMessagePlaybackViewDetails?) {
|
||||
guard let details = details else {
|
||||
return
|
||||
}
|
||||
|
||||
playButton.isEnabled = details.playbackEnabled
|
||||
playButton.setImage((details.playing ? Asset.Images.voiceMessagePauseButton.image : Asset.Images.voiceMessagePlayButton.image), for: .normal)
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
// UIStackView doesn't respond well to re-setting hidden states https://openradar.appspot.com/22819594
|
||||
if playButton.isHidden != details.recording {
|
||||
playButton.isHidden = details.recording
|
||||
}
|
||||
|
||||
// UIStackView doesn't respond well to re-setting hidden states https://openradar.appspot.com/22819594
|
||||
if recordingIcon.isHidden != !details.recording {
|
||||
recordingIcon.isHidden = !details.recording
|
||||
}
|
||||
}
|
||||
|
||||
if details.loading {
|
||||
elapsedTimeLabel.text = "--:--"
|
||||
_waveformView.progress = 0
|
||||
_waveformView.samples = []
|
||||
_waveformView.alpha = 0.3
|
||||
} else {
|
||||
elapsedTimeLabel.text = details.currentTime
|
||||
_waveformView.progress = details.progress
|
||||
_waveformView.samples = details.samples
|
||||
_waveformView.alpha = 1.0
|
||||
}
|
||||
|
||||
self.details = details
|
||||
|
||||
guard let theme = currentTheme else {
|
||||
return
|
||||
}
|
||||
|
||||
self.backgroundColor = theme.colors.background
|
||||
playButton.backgroundColor = theme.colors.background
|
||||
playButton.tintColor = theme.colors.secondaryContent
|
||||
backgroundView.backgroundColor = theme.colors.quinaryContent
|
||||
_waveformView.primaryLineColor = theme.colors.quarterlyContent
|
||||
_waveformView.secondaryLineColor = theme.colors.secondaryContent
|
||||
elapsedTimeLabel.textColor = theme.colors.tertiaryContent
|
||||
}
|
||||
|
||||
func getRequiredNumberOfSamples() -> Int {
|
||||
_waveformView.setNeedsLayout()
|
||||
_waveformView.layoutIfNeeded()
|
||||
return _waveformView.requiredNumberOfSamples
|
||||
}
|
||||
|
||||
// MARK: - Themable
|
||||
|
||||
func update(theme: Theme) {
|
||||
currentTheme = theme
|
||||
configureWithDetails(details)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
@IBAction private func onPlayButtonTap() {
|
||||
delegate?.voiceMessagePlaybackViewDidRequestPlaybackToggle()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
||||
<view contentMode="scaleToFill" id="cGR-49-HWB" customClass="VoiceMessagePlaybackView" customModule="Riot" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="427" height="44"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<subviews>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="LPc-i8-8UC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="427" height="44"/>
|
||||
<color key="backgroundColor" red="0.8901960784313725" green="0.90980392156862744" blue="0.94117647058823528" alpha="1" colorSpace="calibratedRGB"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" priority="999" constant="44" id="RFF-Im-d7x"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="ZQ2-Ij-mYr">
|
||||
<rect key="frame" x="8" y="0.0" width="411" height="44"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="voice_message_record_icon" translatesAutoresizingMaskIntoConstraints="NO" id="REB-gl-h0h">
|
||||
<rect key="frame" x="0.0" y="17" width="10" height="10"/>
|
||||
</imageView>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="GL1-b8-dZK">
|
||||
<rect key="frame" x="14" y="6" width="32" height="32"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="32" id="5Pl-ej-HIg"/>
|
||||
<constraint firstAttribute="width" constant="32" id="dXM-KA-xzM"/>
|
||||
</constraints>
|
||||
<state key="normal" image="voice_message_play_button"/>
|
||||
<connections>
|
||||
<action selector="onPlayButtonTap" destination="cGR-49-HWB" eventType="touchUpInside" id="B5j-st-pUp"/>
|
||||
</connections>
|
||||
</button>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="eAi-HM-Wvj">
|
||||
<rect key="frame" x="50" y="0.0" width="40" height="44"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="40" id="iuv-MD-XYg"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="16"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="7Fl-yZ-dZB">
|
||||
<rect key="frame" x="94" y="7" width="317" height="30"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</view>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="7Fl-yZ-dZB" firstAttribute="height" secondItem="ZQ2-Ij-mYr" secondAttribute="height" constant="-14" id="PiL-fv-hP1"/>
|
||||
</constraints>
|
||||
</stackView>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="Ugy-Dx-gcs"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<constraints>
|
||||
<constraint firstItem="Ugy-Dx-gcs" firstAttribute="trailing" secondItem="LPc-i8-8UC" secondAttribute="trailing" id="2AH-VU-Kcc"/>
|
||||
<constraint firstAttribute="bottom" secondItem="ZQ2-Ij-mYr" secondAttribute="bottom" id="BSe-tM-f0V"/>
|
||||
<constraint firstItem="LPc-i8-8UC" firstAttribute="leading" secondItem="Ugy-Dx-gcs" secondAttribute="leading" id="FnY-Ab-FVL"/>
|
||||
<constraint firstItem="ZQ2-Ij-mYr" firstAttribute="top" secondItem="cGR-49-HWB" secondAttribute="top" id="KRu-5w-kGE"/>
|
||||
<constraint firstAttribute="bottom" secondItem="LPc-i8-8UC" secondAttribute="bottom" id="apf-b1-yIb"/>
|
||||
<constraint firstItem="ZQ2-Ij-mYr" firstAttribute="leading" secondItem="cGR-49-HWB" secondAttribute="leading" constant="8" id="fDO-rh-Jbl"/>
|
||||
<constraint firstAttribute="trailing" secondItem="ZQ2-Ij-mYr" secondAttribute="trailing" constant="8" id="fM3-nY-rDV"/>
|
||||
<constraint firstItem="LPc-i8-8UC" firstAttribute="top" secondItem="cGR-49-HWB" secondAttribute="top" id="zl5-Sf-qSF"/>
|
||||
</constraints>
|
||||
<nil key="simulatedTopBarMetrics"/>
|
||||
<nil key="simulatedBottomBarMetrics"/>
|
||||
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
|
||||
<connections>
|
||||
<outlet property="backgroundView" destination="LPc-i8-8UC" id="mfD-md-nTj"/>
|
||||
<outlet property="elapsedTimeLabel" destination="eAi-HM-Wvj" id="z70-aJ-O90"/>
|
||||
<outlet property="playButton" destination="GL1-b8-dZK" id="5u7-CG-d99"/>
|
||||
<outlet property="recordingIcon" destination="REB-gl-h0h" id="uL1-nI-bhF"/>
|
||||
<outlet property="waveformContainerView" destination="7Fl-yZ-dZB" id="f9u-wS-jvG"/>
|
||||
</connections>
|
||||
<point key="canvasLocation" x="-1742.753623188406" y="-299.33035714285711"/>
|
||||
</view>
|
||||
</objects>
|
||||
<resources>
|
||||
<image name="voice_message_play_button" width="12.5" height="15"/>
|
||||
<image name="voice_message_record_icon" width="10" height="10"/>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -0,0 +1,398 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Reusable
|
||||
|
||||
protocol VoiceMessageToolbarViewDelegate: AnyObject {
|
||||
func voiceMessageToolbarViewDidRequestRecordingStart(_ toolbarView: VoiceMessageToolbarView)
|
||||
func voiceMessageToolbarViewDidRequestRecordingCancel(_ toolbarView: VoiceMessageToolbarView)
|
||||
func voiceMessageToolbarViewDidRequestRecordingFinish(_ toolbarView: VoiceMessageToolbarView)
|
||||
func voiceMessageToolbarViewDidRequestLockedModeRecording(_ toolbarView: VoiceMessageToolbarView)
|
||||
func voiceMessageToolbarViewDidRequestPlaybackToggle(_ toolbarView: VoiceMessageToolbarView)
|
||||
func voiceMessageToolbarViewDidRequestSend(_ toolbarView: VoiceMessageToolbarView)
|
||||
}
|
||||
|
||||
enum VoiceMessageToolbarViewUIState {
|
||||
case idle
|
||||
case record
|
||||
case lockedModeRecord
|
||||
case lockedModePlayback
|
||||
}
|
||||
|
||||
struct VoiceMessageToolbarViewDetails {
|
||||
var state: VoiceMessageToolbarViewUIState = .idle
|
||||
var elapsedTime: String = ""
|
||||
var audioSamples: [Float] = []
|
||||
var isPlaying: Bool = false
|
||||
var progress: Double = 0.0
|
||||
var toastMessage: String?
|
||||
}
|
||||
|
||||
class VoiceMessageToolbarView: PassthroughView, NibLoadable, Themable, UIGestureRecognizerDelegate, VoiceMessagePlaybackViewDelegate {
|
||||
|
||||
private enum Constants {
|
||||
static let longPressMinimumDuration: TimeInterval = 1.0
|
||||
static let animationDuration: TimeInterval = 0.25
|
||||
static let lockModeTransitionAnimationDuration: TimeInterval = 0.5
|
||||
static let panDirectionChangeThreshold: CGFloat = 20.0
|
||||
static let toastContainerCornerRadii: CGFloat = 8.0
|
||||
static let toastDisplayTimeout: TimeInterval = 5.0
|
||||
}
|
||||
|
||||
@IBOutlet private var backgroundView: UIView!
|
||||
|
||||
@IBOutlet private var recordingContainerView: UIView!
|
||||
|
||||
@IBOutlet private var recordButtonsContainerView: UIView!
|
||||
@IBOutlet private var primaryRecordButton: UIButton!
|
||||
@IBOutlet private var secondaryRecordButton: UIButton!
|
||||
|
||||
@IBOutlet private var recordingChromeContainerView: UIView!
|
||||
@IBOutlet private var recordingIndicatorView: UIView!
|
||||
|
||||
@IBOutlet private var elapsedTimeLabel: UILabel!
|
||||
|
||||
@IBOutlet private var slideToCancelContainerView: UIView!
|
||||
@IBOutlet private var slideToCancelLabel: UILabel!
|
||||
@IBOutlet private var slideToCancelChevron: UIImageView!
|
||||
@IBOutlet private var slideToCancelGradient: UIImageView!
|
||||
|
||||
@IBOutlet private var lockContainerView: UIView!
|
||||
@IBOutlet private var lockContainerBackgroundView: UIView!
|
||||
|
||||
@IBOutlet private var lockButtonsContainerView: UIView!
|
||||
@IBOutlet private var primaryLockButton: UIButton!
|
||||
@IBOutlet private var secondaryLockButton: UIButton!
|
||||
@IBOutlet private var lockChevron: UIView!
|
||||
|
||||
@IBOutlet private var lockedModeContainerView: UIView!
|
||||
@IBOutlet private var deleteButton: UIButton!
|
||||
@IBOutlet private var playbackViewContainerView: UIView!
|
||||
@IBOutlet private var sendButton: UIButton!
|
||||
|
||||
@IBOutlet private var toastNotificationContainerView: UIView!
|
||||
@IBOutlet private var toastNotificationLabel: UILabel!
|
||||
|
||||
private var playbackView: VoiceMessagePlaybackView!
|
||||
|
||||
private var cancelLabelToRecordButtonDistance: CGFloat = 0.0
|
||||
private var lockChevronToRecordButtonDistance: CGFloat = 0.0
|
||||
private var lockChevronToLockButtonDistance: CGFloat = 0.0
|
||||
private var panDirection: UISwipeGestureRecognizer.Direction?
|
||||
|
||||
private var details: VoiceMessageToolbarViewDetails?
|
||||
|
||||
private var currentTheme: Theme? {
|
||||
didSet {
|
||||
updateUIWithDetails(details, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
weak var delegate: VoiceMessageToolbarViewDelegate?
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
lockContainerBackgroundView.layer.cornerRadius = lockContainerBackgroundView.bounds.width / 2.0
|
||||
lockButtonsContainerView.layer.cornerRadius = lockButtonsContainerView.bounds.width / 2.0
|
||||
toastNotificationContainerView.layer.cornerRadius = Constants.toastContainerCornerRadii
|
||||
|
||||
let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress))
|
||||
longPressGesture.delegate = self
|
||||
longPressGesture.minimumPressDuration = Constants.longPressMinimumDuration
|
||||
recordButtonsContainerView.addGestureRecognizer(longPressGesture)
|
||||
|
||||
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
|
||||
longPressGesture.delegate = self
|
||||
recordButtonsContainerView.addGestureRecognizer(panGesture)
|
||||
|
||||
playbackView = VoiceMessagePlaybackView.loadFromNib()
|
||||
playbackView.delegate = self
|
||||
playbackViewContainerView.vc_addSubViewMatchingParent(playbackView)
|
||||
|
||||
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleWaveformTap))
|
||||
playbackView.waveformView.addGestureRecognizer(tapGesture)
|
||||
|
||||
updateUIWithDetails(VoiceMessageToolbarViewDetails(), animated: false)
|
||||
}
|
||||
|
||||
func configureWithDetails(_ details: VoiceMessageToolbarViewDetails) {
|
||||
elapsedTimeLabel.text = details.elapsedTime
|
||||
|
||||
self.updateToastNotificationsWithDetails(details)
|
||||
self.updatePlaybackViewWithDetails(details)
|
||||
|
||||
if self.details?.state != details.state {
|
||||
switch details.state {
|
||||
case .record:
|
||||
var convertedFrame = self.convert(slideToCancelLabel.frame, from: slideToCancelContainerView)
|
||||
cancelLabelToRecordButtonDistance = recordButtonsContainerView.frame.minX - convertedFrame.maxX
|
||||
|
||||
convertedFrame = self.convert(lockChevron.frame, from: lockContainerView)
|
||||
lockChevronToRecordButtonDistance = recordButtonsContainerView.frame.midY + convertedFrame.maxY
|
||||
|
||||
lockChevronToLockButtonDistance = lockChevron.frame.minY - lockButtonsContainerView.frame.midY
|
||||
|
||||
startAnimatingRecordingIndicator()
|
||||
default:
|
||||
cancelDrag()
|
||||
}
|
||||
|
||||
if details.state == .lockedModeRecord && self.details?.state == .record {
|
||||
UIView.animate(withDuration: Constants.animationDuration) {
|
||||
self.lockButtonsContainerView.transform = CGAffineTransform(scaleX: 0.1, y: 0.1)
|
||||
} completion: { _ in
|
||||
self.updateUIWithDetails(details, animated: true)
|
||||
}
|
||||
} else {
|
||||
updateUIWithDetails(details, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
self.details = details
|
||||
}
|
||||
|
||||
func getRequiredNumberOfSamples() -> Int {
|
||||
return playbackView.getRequiredNumberOfSamples()
|
||||
}
|
||||
|
||||
// MARK: - Themable
|
||||
|
||||
func update(theme: Theme) {
|
||||
currentTheme = theme
|
||||
playbackView.update(theme: theme)
|
||||
}
|
||||
|
||||
// MARK: - UIGestureRecognizerDelegate
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - VoiceMessagePlaybackViewDelegate
|
||||
|
||||
func voiceMessagePlaybackViewDidRequestPlaybackToggle() {
|
||||
delegate?.voiceMessageToolbarViewDidRequestPlaybackToggle(self)
|
||||
}
|
||||
|
||||
func voiceMessagePlaybackViewDidChangeWidth() {
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
@objc private func handleLongPress(_ gestureRecognizer: UILongPressGestureRecognizer) {
|
||||
switch gestureRecognizer.state {
|
||||
case UIGestureRecognizer.State.began:
|
||||
delegate?.voiceMessageToolbarViewDidRequestRecordingStart(self)
|
||||
case UIGestureRecognizer.State.ended:
|
||||
delegate?.voiceMessageToolbarViewDidRequestRecordingFinish(self)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) {
|
||||
guard details?.state == .record && gestureRecognizer.state == .changed else {
|
||||
return
|
||||
}
|
||||
|
||||
let translation = gestureRecognizer.translation(in: self)
|
||||
|
||||
if abs(translation.x) <= Constants.panDirectionChangeThreshold && abs(translation.y) <= Constants.panDirectionChangeThreshold {
|
||||
panDirection = nil
|
||||
} else if panDirection == nil {
|
||||
if abs(translation.x) >= abs(translation.y) {
|
||||
panDirection = .left
|
||||
} else {
|
||||
panDirection = .up
|
||||
}
|
||||
}
|
||||
|
||||
if panDirection == .left {
|
||||
secondaryRecordButton.transform = CGAffineTransform(translationX: min(translation.x, 0.0), y: 0.0)
|
||||
slideToCancelContainerView.transform = CGAffineTransform(translationX: min(translation.x + cancelLabelToRecordButtonDistance, 0.0), y: 0.0)
|
||||
|
||||
if abs(translation.x - recordButtonsContainerView.frame.width / 2.0) > self.bounds.width / 2.0 {
|
||||
delegate?.voiceMessageToolbarViewDidRequestRecordingCancel(self)
|
||||
}
|
||||
} else if panDirection == .up {
|
||||
secondaryRecordButton.transform = CGAffineTransform(translationX: 0.0, y: min(0.0, translation.y))
|
||||
|
||||
let yTranslation = min(max(translation.y + lockChevronToRecordButtonDistance, -lockChevronToLockButtonDistance), 0.0)
|
||||
lockChevron.transform = CGAffineTransform(translationX: 0.0, y: yTranslation)
|
||||
|
||||
let transitionPercentage = abs(yTranslation) / lockChevronToLockButtonDistance
|
||||
|
||||
lockChevron.alpha = 1.0 - transitionPercentage
|
||||
secondaryRecordButton.alpha = 1.0 - transitionPercentage
|
||||
primaryLockButton.alpha = 1.0 - transitionPercentage
|
||||
lockContainerBackgroundView.alpha = 1.0 - transitionPercentage
|
||||
secondaryLockButton.alpha = transitionPercentage
|
||||
|
||||
if transitionPercentage >= 1.0 {
|
||||
self.delegate?.voiceMessageToolbarViewDidRequestLockedModeRecording(self)
|
||||
}
|
||||
|
||||
} else {
|
||||
secondaryRecordButton.transform = CGAffineTransform(translationX: min(0.0, translation.x), y: min(0.0, translation.y))
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelDrag() {
|
||||
recordButtonsContainerView.gestureRecognizers?.forEach { gestureRecognizer in
|
||||
gestureRecognizer.isEnabled = false
|
||||
gestureRecognizer.isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
private func updateUIWithDetails(_ details: VoiceMessageToolbarViewDetails?, animated: Bool) {
|
||||
guard let details = details else {
|
||||
return
|
||||
}
|
||||
|
||||
UIView.animate(withDuration: (animated ? Constants.animationDuration : 0.0), delay: 0.0, options: .beginFromCurrentState) {
|
||||
switch details.state {
|
||||
case .record:
|
||||
self.lockContainerBackgroundView.alpha = 1.0
|
||||
case .idle:
|
||||
self.lockContainerBackgroundView.alpha = 1.0
|
||||
self.primaryLockButton.alpha = 1.0
|
||||
self.secondaryLockButton.alpha = 0.0
|
||||
self.lockChevron.alpha = 1.0
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
self.backgroundView.alpha = (details.state == .idle ? 0.0 : 1.0)
|
||||
self.primaryRecordButton.alpha = (details.state == .idle ? 1.0 : 0.0)
|
||||
self.secondaryRecordButton.alpha = (details.state == .record ? 1.0 : 0.0)
|
||||
self.recordingChromeContainerView.alpha = (details.state == .record ? 1.0 : 0.0)
|
||||
self.lockContainerView.alpha = (details.state == .record ? 1.0 : 0.0)
|
||||
self.lockedModeContainerView.alpha = (details.state == .lockedModePlayback || details.state == .lockedModeRecord ? 1.0 : 0.0)
|
||||
self.recordingContainerView.alpha = (details.state == .idle || details.state == .record ? 1.0 : 0.0)
|
||||
|
||||
guard let theme = self.currentTheme else {
|
||||
return
|
||||
}
|
||||
|
||||
self.backgroundView.backgroundColor = theme.colors.background
|
||||
self.slideToCancelGradient.tintColor = theme.colors.background
|
||||
|
||||
self.primaryRecordButton.tintColor = theme.colors.tertiaryContent
|
||||
self.slideToCancelLabel.textColor = theme.colors.secondaryContent
|
||||
self.slideToCancelChevron.tintColor = theme.colors.secondaryContent
|
||||
self.elapsedTimeLabel.textColor = theme.colors.secondaryContent
|
||||
|
||||
self.lockContainerBackgroundView.backgroundColor = theme.colors.navigation
|
||||
self.lockButtonsContainerView.backgroundColor = theme.colors.navigation
|
||||
|
||||
} completion: { _ in
|
||||
switch details.state {
|
||||
case .idle:
|
||||
self.secondaryRecordButton.transform = .identity
|
||||
self.slideToCancelContainerView.transform = .identity
|
||||
self.lockChevron.transform = .identity
|
||||
self.lockButtonsContainerView.transform = .identity
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var toastIdleTimer: Timer?
|
||||
private var lastUIState: VoiceMessageToolbarViewUIState = .idle
|
||||
|
||||
private func updateToastNotificationsWithDetails(_ details: VoiceMessageToolbarViewDetails, animated: Bool = true) {
|
||||
|
||||
guard self.toastNotificationLabel.text != details.toastMessage || lastUIState != details.state else {
|
||||
return
|
||||
}
|
||||
|
||||
lastUIState = details.state
|
||||
|
||||
let shouldShowNotification = details.state != .idle && details.toastMessage != nil
|
||||
let requiredAlpha: CGFloat = shouldShowNotification ? 1.0 : 0.0
|
||||
|
||||
toastIdleTimer?.invalidate()
|
||||
toastIdleTimer = nil
|
||||
|
||||
if shouldShowNotification {
|
||||
self.toastNotificationLabel.text = details.toastMessage
|
||||
}
|
||||
|
||||
UIView.animate(withDuration: (animated ? Constants.animationDuration : 0.0)) {
|
||||
self.toastNotificationContainerView.alpha = requiredAlpha
|
||||
}
|
||||
|
||||
if shouldShowNotification {
|
||||
toastIdleTimer = Timer.scheduledTimer(withTimeInterval: Constants.toastDisplayTimeout, repeats: false) { [weak self] timer in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.toastIdleTimer?.invalidate()
|
||||
self.toastIdleTimer = nil
|
||||
|
||||
UIView.animate(withDuration: Constants.animationDuration) {
|
||||
self.toastNotificationContainerView.alpha = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updatePlaybackViewWithDetails(_ details: VoiceMessageToolbarViewDetails, animated: Bool = true) {
|
||||
UIView.animate(withDuration: (animated ? Constants.animationDuration : 0.0)) {
|
||||
var playbackViewDetails = VoiceMessagePlaybackViewDetails()
|
||||
playbackViewDetails.recording = (details.state == .record || details.state == .lockedModeRecord)
|
||||
playbackViewDetails.playing = details.isPlaying
|
||||
playbackViewDetails.progress = details.progress
|
||||
playbackViewDetails.currentTime = details.elapsedTime
|
||||
playbackViewDetails.samples = details.audioSamples
|
||||
playbackViewDetails.playbackEnabled = true
|
||||
self.playbackView.configureWithDetails(playbackViewDetails)
|
||||
}
|
||||
}
|
||||
|
||||
private func startAnimatingRecordingIndicator() {
|
||||
if self.details?.state != .record {
|
||||
return
|
||||
}
|
||||
|
||||
UIView.animate(withDuration: Constants.lockModeTransitionAnimationDuration) {
|
||||
if self.recordingIndicatorView.alpha > 0.0 {
|
||||
self.recordingIndicatorView.alpha = 0.0
|
||||
} else {
|
||||
self.recordingIndicatorView.alpha = 1.0
|
||||
}
|
||||
} completion: { [weak self] _ in
|
||||
self?.startAnimatingRecordingIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction private func onTrashButtonTap(_ sender: UIBarItem) {
|
||||
delegate?.voiceMessageToolbarViewDidRequestRecordingCancel(self)
|
||||
}
|
||||
|
||||
@IBAction private func onSendButtonTap(_ sender: UIBarItem) {
|
||||
delegate?.voiceMessageToolbarViewDidRequestSend(self)
|
||||
}
|
||||
|
||||
@objc private func handleWaveformTap(_ gestureRecognizer: UITapGestureRecognizer) {
|
||||
delegate?.voiceMessageToolbarViewDidRequestRecordingFinish(self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
||||
<view contentMode="scaleToFill" id="iN0-l3-epB" customClass="VoiceMessageToolbarView" customModule="Riot" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="544" height="72"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<view contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="FqE-3x-NQ9">
|
||||
<rect key="frame" x="0.0" y="0.0" width="544" height="72"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</view>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="XRB-CY-ijK" customClass="PassthroughView" customModule="Riot" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="544" height="72"/>
|
||||
<subviews>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="8fP-9K-WTa">
|
||||
<rect key="frame" x="492" y="-90" width="44" height="152"/>
|
||||
<subviews>
|
||||
<view contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="kvc-OZ-peC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="44" height="152"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" red="0.95686274510000002" green="0.97647058819999999" blue="0.99215686270000003" alpha="1" colorSpace="custom" customColorSpace="calibratedRGB"/>
|
||||
</view>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="C9P-A3-Vew">
|
||||
<rect key="frame" x="0.0" y="0.0" width="44" height="44"/>
|
||||
<subviews>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="YF2-5s-q5S">
|
||||
<rect key="frame" x="0.0" y="0.0" width="44" height="44"/>
|
||||
<state key="normal" image="voice_message_lock_icon_unlocked"/>
|
||||
</button>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="vm7-e1-VJ8">
|
||||
<rect key="frame" x="0.0" y="0.0" width="44" height="44"/>
|
||||
<state key="normal" image="voice_message_lock_icon_locked"/>
|
||||
</button>
|
||||
</subviews>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="trailing" secondItem="YF2-5s-q5S" secondAttribute="trailing" id="3a5-Dn-gjn"/>
|
||||
<constraint firstAttribute="bottom" secondItem="vm7-e1-VJ8" secondAttribute="bottom" id="87x-rV-hNr"/>
|
||||
<constraint firstItem="vm7-e1-VJ8" firstAttribute="leading" secondItem="C9P-A3-Vew" secondAttribute="leading" id="BeS-hI-uDa"/>
|
||||
<constraint firstAttribute="width" secondItem="C9P-A3-Vew" secondAttribute="height" multiplier="1:1" id="fuO-oh-g8I"/>
|
||||
<constraint firstAttribute="bottom" secondItem="YF2-5s-q5S" secondAttribute="bottom" id="rMf-if-5c3"/>
|
||||
<constraint firstItem="YF2-5s-q5S" firstAttribute="leading" secondItem="C9P-A3-Vew" secondAttribute="leading" id="rZq-pO-0OY"/>
|
||||
<constraint firstAttribute="trailing" secondItem="vm7-e1-VJ8" secondAttribute="trailing" id="rwd-uO-nu6"/>
|
||||
<constraint firstItem="YF2-5s-q5S" firstAttribute="top" secondItem="C9P-A3-Vew" secondAttribute="top" id="xJC-WF-SKy"/>
|
||||
<constraint firstItem="vm7-e1-VJ8" firstAttribute="top" secondItem="C9P-A3-Vew" secondAttribute="top" id="xzW-aL-alz"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="voice_message_lock_chevron" translatesAutoresizingMaskIntoConstraints="NO" id="c8y-xb-2nh">
|
||||
<rect key="frame" x="0.0" y="64" width="44" height="24"/>
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="trailing" secondItem="c8y-xb-2nh" secondAttribute="trailing" id="7HA-jr-fUD"/>
|
||||
<constraint firstItem="C9P-A3-Vew" firstAttribute="top" secondItem="8fP-9K-WTa" secondAttribute="top" id="7qH-6G-oAq"/>
|
||||
<constraint firstItem="c8y-xb-2nh" firstAttribute="centerY" secondItem="8fP-9K-WTa" secondAttribute="centerY" id="9x0-mO-M0V"/>
|
||||
<constraint firstAttribute="trailing" secondItem="C9P-A3-Vew" secondAttribute="trailing" id="DJV-ib-qiR"/>
|
||||
<constraint firstItem="C9P-A3-Vew" firstAttribute="leading" secondItem="8fP-9K-WTa" secondAttribute="leading" id="F2q-RQ-dgi"/>
|
||||
<constraint firstItem="c8y-xb-2nh" firstAttribute="leading" secondItem="8fP-9K-WTa" secondAttribute="leading" id="U4g-Vq-hJB"/>
|
||||
<constraint firstAttribute="width" constant="44" id="iwn-h5-ilH"/>
|
||||
<constraint firstAttribute="height" constant="152" id="li1-Bd-px2"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="dyu-ha-046" customClass="PassthroughView" customModule="Riot" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="544" height="72"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="6FH-4Q-Z5e">
|
||||
<rect key="frame" x="205" y="26" width="134.5" height="20.5"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="chevron.left" catalog="system" translatesAutoresizingMaskIntoConstraints="NO" id="82A-vC-KEp">
|
||||
<rect key="frame" x="0.0" y="2" width="12.5" height="17"/>
|
||||
</imageView>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Slide to cancel" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Ydw-Nb-zP6">
|
||||
<rect key="frame" x="20.5" y="0.0" width="114" height="20.5"/>
|
||||
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="17"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
</stackView>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="voice_message_cancel_gradient" translatesAutoresizingMaskIntoConstraints="NO" id="BYJ-HN-opT">
|
||||
<rect key="frame" x="0.0" y="0.0" width="217.5" height="72"/>
|
||||
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</imageView>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="K6L-me-5EJ">
|
||||
<rect key="frame" x="20" y="11" width="64" height="50"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="voice_message_record_icon" translatesAutoresizingMaskIntoConstraints="NO" id="miF-pM-B9J">
|
||||
<rect key="frame" x="0.0" y="0.0" width="10" height="50"/>
|
||||
</imageView>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="QBp-TZ-h5s">
|
||||
<rect key="frame" x="14" y="0.0" width="50" height="50"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="14"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
</stackView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<constraints>
|
||||
<constraint firstItem="K6L-me-5EJ" firstAttribute="leading" secondItem="dyu-ha-046" secondAttribute="leading" constant="20" id="0CB-EV-XDb"/>
|
||||
<constraint firstItem="BYJ-HN-opT" firstAttribute="top" secondItem="dyu-ha-046" secondAttribute="top" id="3Mq-1a-iKc"/>
|
||||
<constraint firstItem="BYJ-HN-opT" firstAttribute="width" secondItem="dyu-ha-046" secondAttribute="width" multiplier="0.4" id="4R3-5v-p6s"/>
|
||||
<constraint firstItem="K6L-me-5EJ" firstAttribute="centerY" secondItem="dyu-ha-046" secondAttribute="centerY" id="Eyq-fW-20D"/>
|
||||
<constraint firstItem="6FH-4Q-Z5e" firstAttribute="centerX" secondItem="dyu-ha-046" secondAttribute="centerX" id="IZ1-Dr-yrw"/>
|
||||
<constraint firstItem="6FH-4Q-Z5e" firstAttribute="centerY" secondItem="dyu-ha-046" secondAttribute="centerY" id="NAu-5j-4Yg"/>
|
||||
<constraint firstItem="BYJ-HN-opT" firstAttribute="leading" secondItem="dyu-ha-046" secondAttribute="leading" id="lXc-5e-Ssj"/>
|
||||
<constraint firstAttribute="bottom" secondItem="BYJ-HN-opT" secondAttribute="bottom" id="yNQ-wC-4iD"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="7OQ-1F-5qT">
|
||||
<rect key="frame" x="488" y="10" width="52" height="52"/>
|
||||
<subviews>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="BDj-Sw-VQ5">
|
||||
<rect key="frame" x="0.0" y="0.0" width="52" height="52"/>
|
||||
<state key="normal" image="voice_message_record_button_default"/>
|
||||
</button>
|
||||
<button opaque="NO" contentMode="scaleToFill" fixedFrame="YES" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="rel-Fo-ROL">
|
||||
<rect key="frame" x="0.0" y="0.0" width="52" height="52"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<state key="normal" image="voice_message_record_button_recording"/>
|
||||
</button>
|
||||
</subviews>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<constraints>
|
||||
<constraint firstItem="BDj-Sw-VQ5" firstAttribute="leading" secondItem="7OQ-1F-5qT" secondAttribute="leading" id="2Xv-EI-etf"/>
|
||||
<constraint firstAttribute="trailing" secondItem="BDj-Sw-VQ5" secondAttribute="trailing" id="2ZQ-3v-0W7"/>
|
||||
<constraint firstAttribute="height" constant="52" id="4XA-Gb-5NO"/>
|
||||
<constraint firstAttribute="trailing" secondItem="BDj-Sw-VQ5" secondAttribute="trailing" id="Dki-cT-7xX"/>
|
||||
<constraint firstAttribute="bottom" secondItem="BDj-Sw-VQ5" secondAttribute="bottom" id="fzv-iX-c1Y"/>
|
||||
<constraint firstItem="BDj-Sw-VQ5" firstAttribute="top" secondItem="7OQ-1F-5qT" secondAttribute="top" id="mNa-EU-ZKQ"/>
|
||||
<constraint firstAttribute="bottom" secondItem="BDj-Sw-VQ5" secondAttribute="bottom" id="phX-gD-B2H"/>
|
||||
<constraint firstItem="BDj-Sw-VQ5" firstAttribute="top" secondItem="7OQ-1F-5qT" secondAttribute="top" id="pv8-li-wP8"/>
|
||||
<constraint firstItem="BDj-Sw-VQ5" firstAttribute="leading" secondItem="7OQ-1F-5qT" secondAttribute="leading" id="ynJ-4x-1jv"/>
|
||||
<constraint firstAttribute="width" constant="52" id="zPb-1B-JyA"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</subviews>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<constraints>
|
||||
<constraint firstItem="dyu-ha-046" firstAttribute="leading" secondItem="XRB-CY-ijK" secondAttribute="leading" id="BoC-Ut-chI"/>
|
||||
<constraint firstAttribute="bottom" secondItem="dyu-ha-046" secondAttribute="bottom" id="U4h-FY-D3W"/>
|
||||
<constraint firstItem="8fP-9K-WTa" firstAttribute="bottom" secondItem="7OQ-1F-5qT" secondAttribute="bottom" id="X4v-7T-LgP"/>
|
||||
<constraint firstAttribute="trailing" secondItem="7OQ-1F-5qT" secondAttribute="trailing" constant="4" id="giC-4J-EUL"/>
|
||||
<constraint firstItem="dyu-ha-046" firstAttribute="top" secondItem="XRB-CY-ijK" secondAttribute="top" id="ra2-Me-23b"/>
|
||||
<constraint firstItem="8fP-9K-WTa" firstAttribute="centerX" secondItem="7OQ-1F-5qT" secondAttribute="centerX" id="xL5-g3-aHb"/>
|
||||
<constraint firstAttribute="trailing" secondItem="dyu-ha-046" secondAttribute="trailing" id="xME-WZ-OMX"/>
|
||||
<constraint firstItem="7OQ-1F-5qT" firstAttribute="centerY" secondItem="XRB-CY-ijK" secondAttribute="centerY" id="yLc-Ke-vBU"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="pkc-LT-lE6">
|
||||
<rect key="frame" x="0.0" y="0.0" width="544" height="72"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="wL2-0Z-cvF">
|
||||
<rect key="frame" x="8" y="0.0" width="528" height="72"/>
|
||||
<subviews>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="U4V-EC-Ffy">
|
||||
<rect key="frame" x="0.0" y="14" width="44" height="44"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="44" id="rXL-fN-mn1"/>
|
||||
<constraint firstAttribute="height" constant="44" id="sMv-uS-G8f"/>
|
||||
</constraints>
|
||||
<color key="tintColor" red="0.55686274509803924" green="0.59999999999999998" blue="0.64313725490196072" alpha="1" colorSpace="calibratedRGB"/>
|
||||
<state key="normal" image="room_context_menu_delete"/>
|
||||
<connections>
|
||||
<action selector="onTrashButtonTap:" destination="iN0-l3-epB" eventType="touchUpInside" id="G3W-VG-evO"/>
|
||||
</connections>
|
||||
</button>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="RWp-zw-zVq">
|
||||
<rect key="frame" x="52" y="14" width="424" height="44"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="44" id="H6t-Lp-spE"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="UuF-HN-cAU">
|
||||
<rect key="frame" x="484" y="14" width="44" height="44"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="44" id="HKq-XS-LDC"/>
|
||||
<constraint firstAttribute="height" constant="44" id="ZuT-pR-osp"/>
|
||||
</constraints>
|
||||
<state key="normal" image="send_icon"/>
|
||||
<connections>
|
||||
<action selector="onSendButtonTap:" destination="iN0-l3-epB" eventType="touchUpInside" id="8IQ-s2-AnY"/>
|
||||
</connections>
|
||||
</button>
|
||||
</subviews>
|
||||
</stackView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<constraints>
|
||||
<constraint firstItem="wL2-0Z-cvF" firstAttribute="top" secondItem="pkc-LT-lE6" secondAttribute="top" id="2Na-3x-Ri6"/>
|
||||
<constraint firstAttribute="trailing" secondItem="wL2-0Z-cvF" secondAttribute="trailing" constant="8" id="7oK-QU-5uP"/>
|
||||
<constraint firstAttribute="bottom" secondItem="wL2-0Z-cvF" secondAttribute="bottom" id="IKw-iw-tWg"/>
|
||||
<constraint firstItem="wL2-0Z-cvF" firstAttribute="leading" secondItem="pkc-LT-lE6" secondAttribute="leading" constant="8" id="cG3-Fr-Auu"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="HDF-2Z-UHZ">
|
||||
<rect key="frame" x="260" y="-45" width="24" height="16"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="gZJ-ep-9Bz">
|
||||
<rect key="frame" x="12" y="8" width="0.0" height="0.0"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="12"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="0.090196078430000007" green="0.098039215690000001" blue="0.10980392160000001" alpha="1" colorSpace="calibratedRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="gZJ-ep-9Bz" firstAttribute="leading" secondItem="HDF-2Z-UHZ" secondAttribute="leading" constant="12" id="OtO-8K-aVX"/>
|
||||
<constraint firstAttribute="trailing" secondItem="gZJ-ep-9Bz" secondAttribute="trailing" constant="12" id="avM-Fg-VOH"/>
|
||||
<constraint firstAttribute="bottom" secondItem="gZJ-ep-9Bz" secondAttribute="bottom" constant="8" id="cab-E0-Xdu"/>
|
||||
<constraint firstItem="gZJ-ep-9Bz" firstAttribute="top" secondItem="HDF-2Z-UHZ" secondAttribute="top" constant="8" id="fwT-X9-jWY"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<constraints>
|
||||
<constraint firstItem="HDF-2Z-UHZ" firstAttribute="centerX" secondItem="iN0-l3-epB" secondAttribute="centerX" id="JaY-uF-uSj"/>
|
||||
<constraint firstAttribute="trailing" secondItem="XRB-CY-ijK" secondAttribute="trailing" id="Utk-t1-anP"/>
|
||||
<constraint firstAttribute="trailing" secondItem="pkc-LT-lE6" secondAttribute="trailing" id="VNU-5V-O6I"/>
|
||||
<constraint firstAttribute="bottom" secondItem="XRB-CY-ijK" secondAttribute="bottom" id="VT1-7g-OYr"/>
|
||||
<constraint firstItem="XRB-CY-ijK" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" id="VWe-l9-ZqO"/>
|
||||
<constraint firstItem="pkc-LT-lE6" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" id="X9R-lc-F52"/>
|
||||
<constraint firstAttribute="top" secondItem="XRB-CY-ijK" secondAttribute="top" id="XRb-zW-xdf"/>
|
||||
<constraint firstAttribute="top" secondItem="HDF-2Z-UHZ" secondAttribute="top" constant="45" id="h5P-gd-sf3"/>
|
||||
<constraint firstItem="pkc-LT-lE6" firstAttribute="bottom" secondItem="iN0-l3-epB" secondAttribute="bottom" id="ppT-PL-6Jg"/>
|
||||
<constraint firstAttribute="top" secondItem="pkc-LT-lE6" secondAttribute="top" id="tEJ-94-MLM"/>
|
||||
</constraints>
|
||||
<nil key="simulatedTopBarMetrics"/>
|
||||
<nil key="simulatedBottomBarMetrics"/>
|
||||
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
|
||||
<connections>
|
||||
<outlet property="backgroundView" destination="FqE-3x-NQ9" id="RFR-SQ-s21"/>
|
||||
<outlet property="deleteButton" destination="U4V-EC-Ffy" id="Op3-oN-2vG"/>
|
||||
<outlet property="elapsedTimeLabel" destination="QBp-TZ-h5s" id="qC9-BQ-8RA"/>
|
||||
<outlet property="lockButtonsContainerView" destination="C9P-A3-Vew" id="ebu-OR-VXw"/>
|
||||
<outlet property="lockChevron" destination="c8y-xb-2nh" id="p6S-mB-C1U"/>
|
||||
<outlet property="lockContainerBackgroundView" destination="kvc-OZ-peC" id="ke4-gM-LQV"/>
|
||||
<outlet property="lockContainerView" destination="8fP-9K-WTa" id="mFH-Va-74i"/>
|
||||
<outlet property="lockedModeContainerView" destination="pkc-LT-lE6" id="bbY-iP-th3"/>
|
||||
<outlet property="playbackViewContainerView" destination="RWp-zw-zVq" id="X0h-z8-9CA"/>
|
||||
<outlet property="primaryLockButton" destination="YF2-5s-q5S" id="zsO-cM-wBY"/>
|
||||
<outlet property="primaryRecordButton" destination="BDj-Sw-VQ5" id="dg3-fG-Bym"/>
|
||||
<outlet property="recordButtonsContainerView" destination="7OQ-1F-5qT" id="HDQ-r9-2Tu"/>
|
||||
<outlet property="recordingChromeContainerView" destination="dyu-ha-046" id="u7O-Vb-T2W"/>
|
||||
<outlet property="recordingContainerView" destination="XRB-CY-ijK" id="czS-WC-dqS"/>
|
||||
<outlet property="recordingIndicatorView" destination="miF-pM-B9J" id="zNy-ms-awL"/>
|
||||
<outlet property="secondaryLockButton" destination="vm7-e1-VJ8" id="XDw-nX-Aef"/>
|
||||
<outlet property="secondaryRecordButton" destination="rel-Fo-ROL" id="KXM-gt-9hS"/>
|
||||
<outlet property="sendButton" destination="UuF-HN-cAU" id="bBT-hM-c9E"/>
|
||||
<outlet property="slideToCancelChevron" destination="82A-vC-KEp" id="Chg-EH-UBv"/>
|
||||
<outlet property="slideToCancelContainerView" destination="6FH-4Q-Z5e" id="qCc-rl-vQX"/>
|
||||
<outlet property="slideToCancelGradient" destination="BYJ-HN-opT" id="qbb-Q9-xSo"/>
|
||||
<outlet property="slideToCancelLabel" destination="Ydw-Nb-zP6" id="l4Y-Eg-Qwc"/>
|
||||
<outlet property="toastNotificationContainerView" destination="HDF-2Z-UHZ" id="8Ty-Gl-XnP"/>
|
||||
<outlet property="toastNotificationLabel" destination="gZJ-ep-9Bz" id="soa-bs-C37"/>
|
||||
</connections>
|
||||
<point key="canvasLocation" x="10.144927536231885" y="456.69642857142856"/>
|
||||
</view>
|
||||
</objects>
|
||||
<resources>
|
||||
<image name="chevron.left" catalog="system" width="96" height="128"/>
|
||||
<image name="room_context_menu_delete" width="24" height="24"/>
|
||||
<image name="send_icon" width="36" height="36"/>
|
||||
<image name="voice_message_cancel_gradient" width="104" height="47"/>
|
||||
<image name="voice_message_lock_chevron" width="24" height="24"/>
|
||||
<image name="voice_message_lock_icon_locked" width="24" height="24"/>
|
||||
<image name="voice_message_lock_icon_unlocked" width="16" height="16"/>
|
||||
<image name="voice_message_record_button_default" width="22" height="26.5"/>
|
||||
<image name="voice_message_record_button_recording" width="52" height="52"/>
|
||||
<image name="voice_message_record_icon" width="10" height="10"/>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -0,0 +1,122 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class VoiceMessageWaveformView: UIView {
|
||||
|
||||
private let lineWidth: CGFloat = 2.0
|
||||
private let linePadding: CGFloat = 2.0
|
||||
private let renderingQueue: DispatchQueue = DispatchQueue(label: "io.element.VoiceMessageWaveformView.queue", qos: .userInitiated)
|
||||
|
||||
var samples: [Float] = [] {
|
||||
didSet {
|
||||
computeWaveForm()
|
||||
}
|
||||
}
|
||||
|
||||
var primaryLineColor = UIColor.lightGray {
|
||||
didSet {
|
||||
backgroundLayer.strokeColor = primaryLineColor.cgColor
|
||||
backgroundLayer.fillColor = primaryLineColor.cgColor
|
||||
}
|
||||
}
|
||||
var secondaryLineColor = UIColor.darkGray {
|
||||
didSet {
|
||||
progressLayer.strokeColor = secondaryLineColor.cgColor
|
||||
progressLayer.fillColor = secondaryLineColor.cgColor
|
||||
}
|
||||
}
|
||||
|
||||
private let backgroundLayer = CAShapeLayer()
|
||||
private let progressLayer = CAShapeLayer()
|
||||
|
||||
var progress = 0.0 {
|
||||
didSet {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
progressLayer.frame = CGRect(origin: self.bounds.origin, size: CGSize(width: self.bounds.width * CGFloat(self.progress), height: self.bounds.height))
|
||||
CATransaction.commit()
|
||||
}
|
||||
}
|
||||
|
||||
var requiredNumberOfSamples: Int {
|
||||
return Int(self.bounds.size.width / (lineWidth + linePadding))
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
setupAndAdd(backgroundLayer, with: primaryLineColor)
|
||||
setupAndAdd(progressLayer, with: secondaryLineColor)
|
||||
progressLayer.masksToBounds = true
|
||||
|
||||
computeWaveForm()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
backgroundLayer.frame = self.bounds
|
||||
progressLayer.frame = CGRect(origin: self.bounds.origin, size: CGSize(width: self.bounds.width * CGFloat(self.progress), height: self.bounds.height))
|
||||
computeWaveForm()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func computeWaveForm() {
|
||||
renderingQueue.async { [samples] in // Capture the current samples as a way to provide atomicity
|
||||
let path = UIBezierPath()
|
||||
|
||||
let drawMappingFactor = self.bounds.size.height
|
||||
let minimumGraphAmplitude: CGFloat = 1
|
||||
|
||||
var xOffset: CGFloat = self.lineWidth / 2
|
||||
var index = 0
|
||||
|
||||
while xOffset < self.bounds.width - self.lineWidth {
|
||||
let sample = CGFloat(index >= samples.count ? 1 : samples[index])
|
||||
let invertedDbSample = 1 - sample // sample is in dB, linearly normalized to [0, 1] (1 -> -50 dB)
|
||||
let drawingAmplitude = max(minimumGraphAmplitude, invertedDbSample * drawMappingFactor)
|
||||
|
||||
path.move(to: CGPoint(x: xOffset, y: self.bounds.midY - drawingAmplitude / 2))
|
||||
path.addLine(to: CGPoint(x: xOffset, y: self.bounds.midY + drawingAmplitude / 2))
|
||||
|
||||
xOffset += self.lineWidth + self.linePadding
|
||||
|
||||
index += 1
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.backgroundLayer.path = path.cgPath
|
||||
self.progressLayer.path = path.cgPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupAndAdd(_ shapeLayer: CAShapeLayer, with color: UIColor) {
|
||||
shapeLayer.frame = self.bounds
|
||||
shapeLayer.strokeColor = color.cgColor
|
||||
shapeLayer.fillColor = color.cgColor
|
||||
shapeLayer.lineCap = .round
|
||||
shapeLayer.lineWidth = lineWidth
|
||||
self.layer.addSublayer(shapeLayer)
|
||||
}
|
||||
}
|
||||
@@ -142,7 +142,8 @@ enum
|
||||
|
||||
enum
|
||||
{
|
||||
LABS_ENABLE_RINGING_FOR_GROUP_CALLS_INDEX = 0
|
||||
LABS_ENABLE_RINGING_FOR_GROUP_CALLS_INDEX = 0,
|
||||
LABS_ENABLE_VOICE_MESSAGES = 1
|
||||
};
|
||||
|
||||
enum
|
||||
@@ -487,6 +488,7 @@ TableViewSectionsDelegate>
|
||||
{
|
||||
Section *sectionLabs = [Section sectionWithTag:SECTION_TAG_LABS];
|
||||
[sectionLabs addRowWithTag:LABS_ENABLE_RINGING_FOR_GROUP_CALLS_INDEX];
|
||||
[sectionLabs addRowWithTag:LABS_ENABLE_VOICE_MESSAGES];
|
||||
sectionLabs.headerTitle = NSLocalizedStringFromTable(@"settings_labs", @"Vector", nil);
|
||||
if (sectionLabs.hasAnyRows)
|
||||
{
|
||||
@@ -2263,6 +2265,17 @@ TableViewSectionsDelegate>
|
||||
|
||||
[labelAndSwitchCell.mxkSwitch addTarget:self action:@selector(toggleEnableRingingForGroupCalls:) forControlEvents:UIControlEventValueChanged];
|
||||
|
||||
cell = labelAndSwitchCell;
|
||||
} else if (row == LABS_ENABLE_VOICE_MESSAGES)
|
||||
{
|
||||
MXKTableViewCellWithLabelAndSwitch *labelAndSwitchCell = [self getLabelAndSwitchCell:tableView forIndexPath:indexPath];
|
||||
|
||||
labelAndSwitchCell.mxkLabel.text = NSLocalizedStringFromTable(@"settings_labs_voice_messages", @"Vector", nil);
|
||||
labelAndSwitchCell.mxkSwitch.on = RiotSettings.shared.enableVoiceMessages;
|
||||
labelAndSwitchCell.mxkSwitch.onTintColor = ThemeService.shared.theme.tintColor;
|
||||
|
||||
[labelAndSwitchCell.mxkSwitch addTarget:self action:@selector(toggleEnableVoiceMessages:) forControlEvents:UIControlEventValueChanged];
|
||||
|
||||
cell = labelAndSwitchCell;
|
||||
}
|
||||
}
|
||||
@@ -2789,7 +2802,7 @@ TableViewSectionsDelegate>
|
||||
}
|
||||
}
|
||||
|
||||
- (void)togglePushNotifications:(id)sender
|
||||
- (void)togglePushNotifications:(UISwitch *)sender
|
||||
{
|
||||
// Check first whether the user allow notification from device settings
|
||||
UIUserNotificationType currentUserNotificationTypes = UIApplication.sharedApplication.currentUserNotificationSettings.types;
|
||||
@@ -2819,7 +2832,7 @@ TableViewSectionsDelegate>
|
||||
[self presentViewController:currentAlert animated:YES completion:nil];
|
||||
|
||||
// Keep off the switch
|
||||
((UISwitch*)sender).on = NO;
|
||||
sender.on = NO;
|
||||
}
|
||||
else if ([MXKAccountManager sharedManager].activeAccounts.count)
|
||||
{
|
||||
@@ -2842,7 +2855,7 @@ TableViewSectionsDelegate>
|
||||
[[AppDelegate theDelegate] registerForRemoteNotificationsWithCompletion:^(NSError * error) {
|
||||
if (error)
|
||||
{
|
||||
[(UISwitch *)sender setOn:NO animated:YES];
|
||||
[sender setOn:NO animated:YES];
|
||||
[self stopActivityIndicator];
|
||||
}
|
||||
else
|
||||
@@ -2858,49 +2871,42 @@ TableViewSectionsDelegate>
|
||||
}
|
||||
}
|
||||
|
||||
- (void)toggleCallKit:(id)sender
|
||||
- (void)toggleCallKit:(UISwitch *)sender
|
||||
{
|
||||
UISwitch *switchButton = (UISwitch*)sender;
|
||||
[MXKAppSettings standardAppSettings].enableCallKit = switchButton.isOn;
|
||||
[MXKAppSettings standardAppSettings].enableCallKit = sender.isOn;
|
||||
}
|
||||
|
||||
- (void)toggleStunServerFallback:(id)sender
|
||||
- (void)toggleStunServerFallback:(UISwitch *)sender
|
||||
{
|
||||
UISwitch *switchButton = (UISwitch*)sender;
|
||||
RiotSettings.shared.allowStunServerFallback = switchButton.isOn;
|
||||
RiotSettings.shared.allowStunServerFallback = sender.isOn;
|
||||
|
||||
self.mainSession.callManager.fallbackSTUNServer = RiotSettings.shared.allowStunServerFallback ? BuildSettings.stunServerFallbackUrlString : nil;
|
||||
}
|
||||
|
||||
- (void)toggleAllowIntegrations:(id)sender
|
||||
- (void)toggleAllowIntegrations:(UISwitch *)sender
|
||||
{
|
||||
UISwitch *switchButton = (UISwitch*)sender;
|
||||
|
||||
MXSession *session = self.mainSession;
|
||||
[self startActivityIndicator];
|
||||
|
||||
|
||||
__block RiotSharedSettings *sharedSettings = [[RiotSharedSettings alloc] initWithSession:session];
|
||||
[sharedSettings setIntegrationProvisioningWithEnabled:switchButton.on success:^{
|
||||
[sharedSettings setIntegrationProvisioningWithEnabled:sender.isOn success:^{
|
||||
sharedSettings = nil;
|
||||
[self stopActivityIndicator];
|
||||
} failure:^(NSError * _Nullable error) {
|
||||
sharedSettings = nil;
|
||||
[switchButton setOn:!switchButton.on animated:YES];
|
||||
[sender setOn:!sender.isOn animated:YES];
|
||||
[self stopActivityIndicator];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)toggleShowDecodedContent:(id)sender
|
||||
- (void)toggleShowDecodedContent:(UISwitch *)sender
|
||||
{
|
||||
UISwitch *switchButton = (UISwitch*)sender;
|
||||
RiotSettings.shared.showDecryptedContentInNotifications = switchButton.isOn;
|
||||
RiotSettings.shared.showDecryptedContentInNotifications = sender.isOn;
|
||||
}
|
||||
|
||||
- (void)toggleLocalContactsSync:(id)sender
|
||||
- (void)toggleLocalContactsSync:(UISwitch *)sender
|
||||
{
|
||||
UISwitch *switchButton = (UISwitch*)sender;
|
||||
|
||||
if (switchButton.on)
|
||||
if (sender.on)
|
||||
{
|
||||
[MXKContactManager requestUserConfirmationForLocalContactsSyncInViewController:self completionHandler:^(BOOL granted) {
|
||||
|
||||
@@ -2941,47 +2947,36 @@ TableViewSectionsDelegate>
|
||||
}
|
||||
}
|
||||
|
||||
- (void)toggleEnableRageShake:(id)sender
|
||||
- (void)toggleEnableRageShake:(UISwitch *)sender
|
||||
{
|
||||
if (sender && [sender isKindOfClass:UISwitch.class])
|
||||
{
|
||||
UISwitch *switchButton = (UISwitch*)sender;
|
||||
|
||||
RiotSettings.shared.enableRageShake = switchButton.isOn;
|
||||
|
||||
[self updateSections];
|
||||
}
|
||||
RiotSettings.shared.enableRageShake = sender.isOn;
|
||||
|
||||
[self updateSections];
|
||||
}
|
||||
|
||||
- (void)toggleEnableRingingForGroupCalls:(UISwitch *)sender
|
||||
{
|
||||
if (sender)
|
||||
{
|
||||
RiotSettings.shared.enableRingingForGroupCalls = sender.isOn;
|
||||
|
||||
[self.tableView reloadData];
|
||||
}
|
||||
RiotSettings.shared.enableRingingForGroupCalls = sender.isOn;
|
||||
}
|
||||
|
||||
- (void)togglePinRoomsWithMissedNotif:(id)sender
|
||||
- (void)toggleEnableVoiceMessages:(UISwitch *)sender
|
||||
{
|
||||
UISwitch *switchButton = (UISwitch*)sender;
|
||||
|
||||
RiotSettings.shared.pinRoomsWithMissedNotificationsOnHome = switchButton.on;
|
||||
RiotSettings.shared.enableVoiceMessages = sender.isOn;
|
||||
}
|
||||
|
||||
- (void)togglePinRoomsWithUnread:(id)sender
|
||||
- (void)togglePinRoomsWithMissedNotif:(UISwitch *)sender
|
||||
{
|
||||
UISwitch *switchButton = (UISwitch*)sender;
|
||||
|
||||
RiotSettings.shared.pinRoomsWithUnreadMessagesOnHome = switchButton.on;
|
||||
RiotSettings.shared.pinRoomsWithMissedNotificationsOnHome = sender.isOn;
|
||||
}
|
||||
|
||||
- (void)toggleCommunityFlair:(id)sender
|
||||
- (void)togglePinRoomsWithUnread:(UISwitch *)sender
|
||||
{
|
||||
UISwitch *switchButton = (UISwitch*)sender;
|
||||
|
||||
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:switchButton.tag inSection:groupsDataSource.joinedGroupsSection];
|
||||
RiotSettings.shared.pinRoomsWithUnreadMessagesOnHome = sender.on;
|
||||
}
|
||||
|
||||
- (void)toggleCommunityFlair:(UISwitch *)sender
|
||||
{
|
||||
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:sender.tag inSection:groupsDataSource.joinedGroupsSection];
|
||||
id<MXKGroupCellDataStoring> groupCellData = [groupsDataSource cellDataAtIndex:indexPath];
|
||||
MXGroup *group = groupCellData.group;
|
||||
|
||||
@@ -2991,7 +2986,7 @@ TableViewSectionsDelegate>
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
|
||||
[self.mainSession updateGroupPublicity:group isPublicised:switchButton.on success:^{
|
||||
[self.mainSession updateGroupPublicity:group isPublicised:sender.isOn success:^{
|
||||
|
||||
if (weakSelf)
|
||||
{
|
||||
@@ -3007,7 +3002,7 @@ TableViewSectionsDelegate>
|
||||
[self stopActivityIndicator];
|
||||
|
||||
// Come back to previous state button
|
||||
[switchButton setOn:!switchButton.isOn animated:YES];
|
||||
[sender setOn:!sender.isOn animated:YES];
|
||||
|
||||
// Notify user
|
||||
[[AppDelegate theDelegate] showErrorAsAlert:error];
|
||||
@@ -3653,16 +3648,9 @@ TableViewSectionsDelegate>
|
||||
animated:YES];
|
||||
}
|
||||
|
||||
- (void)toggleNSFWPublicRoomsFiltering:(id)sender
|
||||
- (void)toggleNSFWPublicRoomsFiltering:(UISwitch *)sender
|
||||
{
|
||||
if (sender && [sender isKindOfClass:UISwitch.class])
|
||||
{
|
||||
UISwitch *switchButton = (UISwitch*)sender;
|
||||
|
||||
RiotSettings.shared.showNSFWPublicRooms = switchButton.isOn;
|
||||
|
||||
[self.tableView reloadData];
|
||||
}
|
||||
RiotSettings.shared.showNSFWPublicRooms = sender.isOn;
|
||||
}
|
||||
|
||||
#pragma mark - TextField listener
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
Object container storing references weakly. Ideal for implementing simple multiple delegation.
|
||||
*/
|
||||
struct DelegateContainer {
|
||||
|
||||
private let hashTable: NSHashTable<AnyObject>
|
||||
|
||||
var delegates: [AnyObject] {
|
||||
return hashTable.allObjects
|
||||
}
|
||||
|
||||
init() {
|
||||
hashTable = NSHashTable(options: .weakMemory)
|
||||
}
|
||||
|
||||
func registerDelegate(_ delegate: AnyObject) {
|
||||
hashTable.add(delegate)
|
||||
}
|
||||
|
||||
func deregisterDelegate(_ delegate: AnyObject) {
|
||||
hashTable.remove(delegate)
|
||||
}
|
||||
|
||||
func notifyDelegatesWithBlock(_ block: (AnyObject) -> Void) {
|
||||
for delegate in hashTable.allObjects {
|
||||
block(delegate)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/**
|
||||
UIView subclass that ignores touches on itself.
|
||||
*/
|
||||
class PassthroughView: UIView {
|
||||
public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
let hitTarget = super.hitTest(point, with: event)
|
||||
|
||||
guard hitTarget == self else {
|
||||
return hitTarget
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
Used to avoid retain cycles by creating a proxy that holds a weak reference to the original object.
|
||||
One example of that would be using CADisplayLink, which strongly retains its target, when manually invalidating it is unfeasable.
|
||||
*/
|
||||
class WeakTarget: NSObject {
|
||||
private(set) weak var target: AnyObject?
|
||||
let selector: Selector
|
||||
|
||||
static let triggerSelector = #selector(WeakTarget.handleTick(parameter:))
|
||||
|
||||
init(_ target: AnyObject, selector: Selector) {
|
||||
self.target = target
|
||||
self.selector = selector
|
||||
}
|
||||
|
||||
@objc private func handleTick(parameter: Any) {
|
||||
_ = self.target?.perform(self.selector, with: parameter)
|
||||
}
|
||||
}
|
||||