Merge pull request #4433 from vector-im/element_4090

Voice messages
This commit is contained in:
manuroe 2021-07-22 15:13:34 +02:00 committed by GitHub
commit b3cca1645f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
81 changed files with 3368 additions and 110 deletions

View file

@ -5,7 +5,7 @@ Changes to be released in next version
*
🙌 Improvements
*
* Room: Added support for Voice Messages (#4090, #4091, #4092, #4094, #4095, #4096)
🐛 Bugfix
* Room: Fixed mentioning users from room info member details (#4583)

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.

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 }

View file

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

View file

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

View file

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

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):
@ -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)
@ -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
@ -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: 6e4ab90565384faccee0cb985abe05663c36f517
PODFILE CHECKSUM: c7386ecfb38fc4302613c915aef79eebdb98a53d
COCOAPODS: 1.10.1

View file

@ -4,7 +4,8 @@
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"

View file

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

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 B

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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>

View file

@ -397,7 +397,7 @@ NSString *const kMXKRoomBubbleCellKeyVerificationIncomingRequestDeclinePressed =
}
// Move this view in front
[self.contentView bringSubviewToFront:self.bubbleOverlayContainer];
[self.bubbleOverlayContainer.superview bringSubviewToFront:self.bubbleOverlayContainer];
}
else
{

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

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

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.

View file

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

View file

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

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)
@ -5803,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);
@ -6152,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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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