Merge branch 'develop' into doug/update_addressable_gem

This commit is contained in:
Doug
2021-07-22 14:51:44 +01:00
102 changed files with 3873 additions and 252 deletions
+80
View File
@@ -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)
=================================================
+2 -2
View File
@@ -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
+4
View File
@@ -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.
+4
View File
@@ -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)
+3 -1
View File
@@ -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']
+23 -15
View File
@@ -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"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 823 B

After

Width:  |  Height:  |  Size: 765 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

Binary file not shown.

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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 387 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
}
}
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 171 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 B

Binary file not shown.

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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 412 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 B

Binary file not shown.

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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 694 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

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
}
}
@@ -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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 247 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 B

+85
View File
@@ -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" = "يَتَعَذَّرُ عَلينَا إنشَاء المُحادَثَة المُباشِرَة الخَّاصَةِ بِك. يُرجَى التَّحَقُقُ مِنَ المُستَخدِمِيَنَ اللَّذِينَ تُريدُ دَعوَتَهُم ثُمَّ المُحاوَلَةُ مَرةً أُخرَى.";
+14 -1
View File
@@ -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";
+1 -1
View File
@@ -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.";
+7
View File
@@ -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";
+13
View File
@@ -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";
+13
View File
@@ -1426,3 +1426,16 @@
"settings_ui_theme_picker_message_invert_colours" = "« Auto » utilise le paramètre « Inverser les couleurs » de lappreil";
"room_recents_unknown_room_error_message" = "Aucun résultat dans ce salon. Assurez vous de son existence";
"room_creation_dm_error" = "Nous navons 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";
+13
View File
@@ -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";
+24
View File
@@ -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" = "メッセージ";
+40 -40
View File
@@ -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";
+68 -21
View File
@@ -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 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.";
+60 -47
View File
@@ -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";
+13
View File
@@ -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";
+3
View File
@@ -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" = "%@ (සමූහ ඇමතුම)";
+13
View File
@@ -1,3 +1,16 @@
// Titles
"title_home" = "මුල් පිටුව";
"warning" = "අවවාදයයි";
"join" = "එක්වන්න";
"save" = "සුරකින්න";
"cancel" = "අවලංගු කරන්න";
"remove" = "ඉවත් කරන්න";
"leave" = "හැරයන්න";
"start" = "අරඹන්න";
"create" = "සාදන්න";
"continue" = "ඉදිරියට";
"back" = "ආපසු";
"next" = "ඊලඟ";
"title_rooms" = "කාමර";
"title_people" = "මිනිසුන්";
"title_favourites" = "ප්‍රියතමයින්";
+13
View File
@@ -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" = "Sgjendet dot kjo dhomë. Sigurohuni se ekziston";
"room_creation_dm_error" = "Smundëm të krijojmë dot MD-në tuaj. Ju lutemi, kontrolloni përdoruesit të cilëve doni tu 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 smund të kihen në celular për dhoma të fshehtëzuara.";
"room_notifs_settings_account_settings" = "Rregullime llogarie";
"room_notifs_settings_manage_notifications" = "Njoftimet mund ti 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";
+197 -1
View File
@@ -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
{
+9
View File
@@ -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")
+16
View File
@@ -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")
+14
View File
@@ -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.
+63 -16
View File
@@ -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)
}
}
+49 -61
View File
@@ -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
+47
View File
@@ -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)
}
}
}
+32
View File
@@ -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
}
}
+37
View File
@@ -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)
}
}

Some files were not shown because too many files have changed in this diff Show More