diff --git a/CHANGES.rst b/CHANGES.rst index bc28f2c30..5412eb511 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,57 @@ Changes to be released in next version ================================================= +✨ Features + * + +🙌 Improvements + * Room: Added support for Voice Messages (#4090, #4091, #4092, #4094, #4095, #4096) + +🐛 Bugfix + * Room: Fixed mentioning users from room info member details (#4583) + +⚠️ API Changes + * + +🗣 Translations + * + +🧱 Build + * + +Others + * + +Changes in 1.4.7 (2021-07-22) +================================================= + +✨ Features + * + +🙌 Improvements + * + +🐛 Bugfix + * + +⚠️ API Changes + * + +🗣 Translations + * + +🧱 Build + * + +Others + * + +Improvements: + * Upgrade MatrixKit version ([v0.15.5](https://github.com/matrix-org/matrix-ios-kit/releases/tag/v0.15.5)). + +Changes in 1.4.6 (2021-07-16) +================================================= + ✨ Features * @@ -17,6 +68,8 @@ Changes to be released in next version * Use different title for scan button for self verification (#4525). * it's easy for the back button to trigger a leftpanel reveal (#4438). * Show / hide reset button in secrets recovery screen (#4546). + * Share Extension: Fix layout when searching (#4258). + * Timeline: Fix incorrect crop of media thumbnails (#4552). ⚠️ API Changes * @@ -29,6 +82,33 @@ Changes to be released in next version Others * Silenced some documentation, deprecations and SwiftLint warnings. + +Improvements: + * Upgrade MatrixKit version ([v0.15.4](https://github.com/matrix-org/matrix-ios-kit/releases/tag/v0.15.4)). + +Changes in 1.4.5 (2021-07-07) +================================================= + +✨ Features + * + +🙌 Improvements + * + +🐛 Bugfix + * Notifications: Fix an issue where the app is unresponsive after getting some notifications (#4534). + +⚠️ API Changes + * + +🗣 Translations + * + +🧱 Build + * + +Others + * Changes in 1.4.4 (2021-06-30) ================================================= diff --git a/Config/AppIdentifiers.xcconfig b/Config/AppIdentifiers.xcconfig index 391931749..1628e83d8 100644 --- a/Config/AppIdentifiers.xcconfig +++ b/Config/AppIdentifiers.xcconfig @@ -22,8 +22,8 @@ APPLICATION_GROUP_IDENTIFIER = group.im.vector APPLICATION_SCHEME = element // Version -MARKETING_VERSION = 1.4.5 -CURRENT_PROJECT_VERSION = 1.4.5 +MARKETING_VERSION = 1.4.8 +CURRENT_PROJECT_VERSION = 1.4.8 // Team diff --git a/Config/BuildSettings.swift b/Config/BuildSettings.swift index 19c2ec733..1fbe75e57 100644 --- a/Config/BuildSettings.swift +++ b/Config/BuildSettings.swift @@ -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. diff --git a/DesignKit/Source/Colors.swift b/DesignKit/Source/Colors.swift index b5dc66261..fc96f3608 100644 --- a/DesignKit/Source/Colors.swift +++ b/DesignKit/Source/Colors.swift @@ -45,6 +45,10 @@ import UIKit /// - Icons var quarterlyContent: UIColor { get } + /// - Text + /// - Icons + var quinaryContent: UIColor { get } + /// Separating line var separator: UIColor { get } diff --git a/DesignKit/Variants/Colors/Dark/DarkColors.swift b/DesignKit/Variants/Colors/Dark/DarkColors.swift index a1cf2ac54..39a3b0893 100644 --- a/DesignKit/Variants/Colors/Dark/DarkColors.swift +++ b/DesignKit/Variants/Colors/Dark/DarkColors.swift @@ -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) diff --git a/DesignKit/Variants/Colors/Light/LightColors.swift b/DesignKit/Variants/Colors/Light/LightColors.swift index d8b5e108b..53a9566e2 100644 --- a/DesignKit/Variants/Colors/Light/LightColors.swift +++ b/DesignKit/Variants/Colors/Light/LightColors.swift @@ -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) diff --git a/Podfile b/Podfile index d008c31f2..aa6b4d8f5 100644 --- a/Podfile +++ b/Podfile @@ -11,7 +11,7 @@ use_frameworks! # - `{ {kit spec hash} => {sdk spec hash}` to depend on specific pod options (:git => …, :podspec => …) for each repo. Used by Fastfile during CI # # Warning: our internal tooling depends on the name of this variable name, so be sure not to change it -$matrixKitVersion = '= 0.15.3' +$matrixKitVersion = '= 0.15.5' # $matrixKitVersion = :local # $matrixKitVersion = {'develop' => 'develop'} @@ -69,6 +69,8 @@ abstract_target 'RiotPods' do pod 'SwiftBase32', '~> 0.9.0' pod 'SwiftJWT', '~> 3.6.200' pod 'SideMenu', '~> 6.5' + pod 'DSWaveformImage', '~> 6.1.1' + pod 'ffmpeg-kit-ios-audio', '~> 4.4.LTS' pod 'FLEX', '~> 4.4.1', :configurations => ['Debug'] diff --git a/Podfile.lock b/Podfile.lock index 8c6193dfa..8a8b129b9 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -19,6 +19,7 @@ PODS: - BlueRSA (1.0.34) - DGCollectionViewLeftAlignFlowLayout (1.0.4) - Down (0.11.0) + - DSWaveformImage (6.1.1) - DTCoreText (1.6.26): - DTCoreText/Core (= 1.6.26) - DTFoundation/Core (~> 1.7.5) @@ -36,6 +37,7 @@ PODS: - DTFoundation/Core - DTFoundation/UIKit (1.7.18): - DTFoundation/Core + - ffmpeg-kit-ios-audio (4.4) - FLEX (4.4.1) - FlowCommoniOS (1.10.0) - GBDeviceInfo (6.6.0): @@ -56,29 +58,29 @@ PODS: - MatomoTracker (7.4.1): - MatomoTracker/Core (= 7.4.1) - MatomoTracker/Core (7.4.1) - - MatrixKit (0.15.3): + - MatrixKit (0.15.5): - Down (~> 0.11.0) - DTCoreText (~> 1.6.25) - HPGrowingTextView (~> 1.1) - libPhoneNumber-iOS (~> 0.9.13) - - MatrixKit/Core (= 0.15.3) - - MatrixSDK (= 0.19.3) - - MatrixKit/Core (0.15.3): + - MatrixKit/Core (= 0.15.5) + - MatrixSDK (= 0.19.5) + - MatrixKit/Core (0.15.5): - Down (~> 0.11.0) - DTCoreText (~> 1.6.25) - HPGrowingTextView (~> 1.1) - libPhoneNumber-iOS (~> 0.9.13) - - MatrixSDK (= 0.19.3) - - MatrixSDK (0.19.3): - - MatrixSDK/Core (= 0.19.3) - - MatrixSDK/Core (0.19.3): + - MatrixSDK (= 0.19.5) + - MatrixSDK (0.19.5): + - MatrixSDK/Core (= 0.19.5) + - MatrixSDK/Core (0.19.5): - AFNetworking (~> 4.0.0) - GZIP (~> 1.3.0) - libbase58 (~> 0.1.4) - OLMKit (~> 3.2.4) - Realm (= 10.7.6) - SwiftyBeaver (= 1.9.5) - - MatrixSDK/JingleCallStack (0.19.3): + - MatrixSDK/JingleCallStack (0.19.5): - JitsiMeetSDK (= 3.5.0) - MatrixSDK/Core - OLMKit (3.2.4): @@ -104,7 +106,7 @@ PODS: - BlueRSA (~> 1.0) - KituraContracts (~> 1.2) - LoggerAPI (~> 1.7) - - SwiftLint (0.43.0) + - SwiftLint (0.43.1) - SwiftyBeaver (1.9.5) - zxcvbn-ios (1.0.4) - ZXingObjC (3.6.5): @@ -113,6 +115,8 @@ PODS: DEPENDENCIES: - DGCollectionViewLeftAlignFlowLayout (~> 1.0.4) + - DSWaveformImage (~> 6.1.1) + - ffmpeg-kit-ios-audio (~> 4.4.LTS) - FLEX (~> 4.4.1) - FlowCommoniOS (~> 1.10.0) - GBDeviceInfo (~> 6.6.0) @@ -120,7 +124,7 @@ DEPENDENCIES: - KeychainAccess (~> 4.2.2) - KTCenterFlowLayout (~> 1.3.1) - MatomoTracker (~> 7.4.1) - - MatrixKit (= 0.15.3) + - MatrixKit (= 0.15.5) - MatrixSDK - MatrixSDK/JingleCallStack - OLMKit @@ -142,8 +146,10 @@ SPEC REPOS: - BlueRSA - DGCollectionViewLeftAlignFlowLayout - Down + - DSWaveformImage - DTCoreText - DTFoundation + - ffmpeg-kit-ios-audio - FLEX - FlowCommoniOS - GBDeviceInfo @@ -180,8 +186,10 @@ SPEC CHECKSUMS: BlueRSA: 6f9776d62d9773502415a7db3bcbb2bbb3f71fc3 DGCollectionViewLeftAlignFlowLayout: a0fa58797373ded039cafba8133e79373d048399 Down: b6ba1bc985c9d2f4e15e3b293d2207766fa12612 + DSWaveformImage: 3c718a0cf99291887ee70d1d0c18d80101d3d9ce DTCoreText: ec749e013f2e1f76de5e7c7634642e600a7467ce DTFoundation: a53f8cda2489208cbc71c648be177f902ee17536 + ffmpeg-kit-ios-audio: ddfc3dac6f574e83d53f8ae33586711162685d3e FLEX: 7ca2c8cd3a435ff501ff6d2f2141e9bdc934eaab FlowCommoniOS: bcdf81a5f30717e711af08a8c812eb045411ba94 GBDeviceInfo: ed0db16230d2fa280e1cbb39a5a7f60f6946aaec @@ -196,8 +204,8 @@ SPEC CHECKSUMS: LoggerAPI: ad9c4a6f1e32f518fdb43a1347ac14d765ab5e3d Logging: beeb016c9c80cf77042d62e83495816847ef108b MatomoTracker: 24a846c9d3aa76933183fe9d47fd62c9efa863fb - MatrixKit: 6cbe65db11a5450ec8cc02d51660f43b5e95a141 - MatrixSDK: c15663c67bfd2991d897d973c1551ba4de900e25 + MatrixKit: 7606227237cf58c1a1a2235547222c5d75b464c4 + MatrixSDK: 9fa30f9ca2504c4251b99212dcf4ff569bbf45b1 OLMKit: 2d73cd67d149b5c3e3a8eb8ecae93d0b429d8a02 ReadMoreTextView: 19147adf93abce6d7271e14031a00303fe28720d Realm: ed860452717c8db8f4bf832b6807f7f2ce708839 @@ -206,11 +214,11 @@ SPEC CHECKSUMS: SwiftBase32: 9399c25a80666dc66b51e10076bf591e3bbb8f17 SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108 SwiftJWT: 88c412708f58c169d431d344c87bc79a87c830ae - SwiftLint: 0c645fdc6feed3e390c1701ab3cc669f88b42752 + SwiftLint: 99f82d07b837b942dd563c668de129a03fc3fb52 SwiftyBeaver: 84069991dd5dca07d7069100985badaca7f0ce82 zxcvbn-ios: fef98b7c80f1512ff0eec47ac1fa399fc00f7e3c ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: c39d88adc5ec2df412af32b64ceb99a9a1ee92a8 +PODFILE CHECKSUM: c7386ecfb38fc4302613c915aef79eebdb98a53d COCOAPODS: 1.10.1 diff --git a/Riot.xcodeproj/xcshareddata/xcschemes/Riot.xcscheme b/Riot.xcodeproj/xcshareddata/xcschemes/Riot.xcscheme index 84ecb908a..a9bea1d96 100644 --- a/Riot.xcodeproj/xcshareddata/xcschemes/Riot.xcscheme +++ b/Riot.xcodeproj/xcshareddata/xcschemes/Riot.xcscheme @@ -4,7 +4,8 @@ version = "1.3"> + buildImplicitDependencies = "YES" + runPostActionsOnFailure = "NO">
- + +
  • + DSWaveformImage (https://github.com/dmrschmidt/DSWaveformImage) +

    + The MIT License (MIT) +

    + Copyright (c) 2013 Dennis Schmidt +

    + 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: +

    + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. +

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

    +
  • +
  • + ffmpeg-kit-ios-audio (https://github.com/tanersener/ffmpeg-kit) +

    +
    +                   GNU LESSER GENERAL PUBLIC LICENSE
    +                       Version 3, 29 June 2007
    +
    + Copyright (C) 2007 Free Software Foundation, Inc. 
    + 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.
    +        
    +
  • diff --git a/Riot/Categories/MXKRoomBubbleTableViewCell+Riot.m b/Riot/Categories/MXKRoomBubbleTableViewCell+Riot.m index a85ea69be..11a0fa5a1 100644 --- a/Riot/Categories/MXKRoomBubbleTableViewCell+Riot.m +++ b/Riot/Categories/MXKRoomBubbleTableViewCell+Riot.m @@ -397,7 +397,7 @@ NSString *const kMXKRoomBubbleCellKeyVerificationIncomingRequestDeclinePressed = } // Move this view in front - [self.contentView bringSubviewToFront:self.bubbleOverlayContainer]; + [self.bubbleOverlayContainer.superview bringSubviewToFront:self.bubbleOverlayContainer]; } else { diff --git a/Riot/Generated/Images.swift b/Riot/Generated/Images.swift index 2cec2c63b..709ed216d 100644 --- a/Riot/Generated/Images.swift +++ b/Riot/Generated/Images.swift @@ -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") diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index 3168d5ad4..19e61aed8 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -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") diff --git a/Riot/Managers/Settings/RiotSettings.swift b/Riot/Managers/Settings/RiotSettings.swift index 5a6817aca..7d41bc4df 100644 --- a/Riot/Managers/Settings/RiotSettings.swift +++ b/Riot/Managers/Settings/RiotSettings.swift @@ -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. diff --git a/Riot/Modules/GlobalSearch/Files/Views/FilesSearchTableViewCell.m b/Riot/Modules/GlobalSearch/Files/Views/FilesSearchTableViewCell.m index b853631cd..4dfbe686f 100644 --- a/Riot/Modules/GlobalSearch/Files/Views/FilesSearchTableViewCell.m +++ b/Riot/Modules/GlobalSearch/Files/Views/FilesSearchTableViewCell.m @@ -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; diff --git a/Riot/Modules/Room/CellData/RoomBubbleCellData.m b/Riot/Modules/Room/CellData/RoomBubbleCellData.m index 423b84426..2a216c44f 100644 --- a/Riot/Modules/Room/CellData/RoomBubbleCellData.m +++ b/Riot/Modules/Room/CellData/RoomBubbleCellData.m @@ -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; diff --git a/Riot/Modules/Room/DataSources/RoomDataSource.m b/Riot/Modules/Room/DataSources/RoomDataSource.m index 230d313ce..434aee6ac 100644 --- a/Riot/Modules/Room/DataSources/RoomDataSource.m +++ b/Riot/Modules/Room/DataSources/RoomDataSource.m @@ -1039,12 +1039,10 @@ const CGFloat kTypingCellHeight = 24; - (void)applyMaskToAttachmentViewOfBubbleCell:(MXKRoomBubbleTableViewCell *)cell { - if (cell.attachmentView && !cell.attachmentView.layer.mask) + if (cell.attachmentView) { - UIBezierPath *myClippingPath = [UIBezierPath bezierPathWithRoundedRect:cell.attachmentView.bounds cornerRadius:6]; - CAShapeLayer *mask = [CAShapeLayer layer]; - mask.path = myClippingPath.CGPath; - cell.attachmentView.layer.mask = mask; + cell.attachmentView.layer.cornerRadius = 6; + cell.attachmentView.layer.masksToBounds = YES; } } diff --git a/Riot/Modules/Room/RoomInfo/RoomInfoCoordinator.swift b/Riot/Modules/Room/RoomInfo/RoomInfoCoordinator.swift index 495d3468b..569803fbf 100644 --- a/Riot/Modules/Room/RoomInfo/RoomInfoCoordinator.swift +++ b/Riot/Modules/Room/RoomInfo/RoomInfoCoordinator.swift @@ -194,7 +194,8 @@ extension RoomInfoCoordinator: RoomInfoListCoordinatorDelegate { extension RoomInfoCoordinator: RoomParticipantsViewControllerDelegate { func roomParticipantsViewController(_ roomParticipantsViewController: RoomParticipantsViewController!, mention member: MXRoomMember!) { - + self.navigationRouter.popToRootModule(animated: true) + self.delegate?.roomInfoCoordinator(self, didRequestMentionForMember: member) } } diff --git a/Riot/Modules/Room/RoomInfo/RoomInfoCoordinatorBridgePresenter.swift b/Riot/Modules/Room/RoomInfo/RoomInfoCoordinatorBridgePresenter.swift index 44a46cbd0..48dc70d3e 100644 --- a/Riot/Modules/Room/RoomInfo/RoomInfoCoordinatorBridgePresenter.swift +++ b/Riot/Modules/Room/RoomInfo/RoomInfoCoordinatorBridgePresenter.swift @@ -20,6 +20,7 @@ import Foundation @objc protocol RoomInfoCoordinatorBridgePresenterDelegate { func roomInfoCoordinatorBridgePresenterDelegateDidComplete(_ coordinatorBridgePresenter: RoomInfoCoordinatorBridgePresenter) + func roomInfoCoordinatorBridgePresenter(_ coordinatorBridgePresenter: RoomInfoCoordinatorBridgePresenter, didRequestMentionForMember member: MXRoomMember) } /// RoomInfoCoordinatorBridgePresenter enables to start RoomInfoCoordinator from a view controller. @@ -115,6 +116,10 @@ extension RoomInfoCoordinatorBridgePresenter: RoomInfoCoordinatorDelegate { self.delegate?.roomInfoCoordinatorBridgePresenterDelegateDidComplete(self) } + func roomInfoCoordinator(_ coordinator: RoomInfoCoordinatorType, didRequestMentionForMember member: MXRoomMember) { + self.delegate?.roomInfoCoordinatorBridgePresenter(self, didRequestMentionForMember: member) + } + } // MARK: - UIAdaptivePresentationControllerDelegate diff --git a/Riot/Modules/Room/RoomInfo/RoomInfoCoordinatorType.swift b/Riot/Modules/Room/RoomInfo/RoomInfoCoordinatorType.swift index ff2e04626..092a3b874 100644 --- a/Riot/Modules/Room/RoomInfo/RoomInfoCoordinatorType.swift +++ b/Riot/Modules/Room/RoomInfo/RoomInfoCoordinatorType.swift @@ -20,6 +20,7 @@ import Foundation protocol RoomInfoCoordinatorDelegate: AnyObject { func roomInfoCoordinatorDidComplete(_ coordinator: RoomInfoCoordinatorType) + func roomInfoCoordinator(_ coordinator: RoomInfoCoordinatorType, didRequestMentionForMember member: MXRoomMember) } /// `RoomInfoCoordinatorType` is a protocol describing a Coordinator that handle keybackup setup navigation flow. diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index fbd8a25f0..8d32e11e8 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -135,7 +135,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; @interface RoomViewController () + RoomDataSourceDelegate, RoomCreationModalCoordinatorBridgePresenterDelegate, RoomInfoCoordinatorBridgePresenterDelegate, DialpadViewControllerDelegate, RemoveJitsiWidgetViewDelegate, VoiceMessageControllerDelegate> { // The preview header @@ -240,6 +240,8 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; @property (nonatomic, getter=isActivitiesViewExpanded) BOOL activitiesViewExpanded; @property (nonatomic, getter=isScrollToBottomHidden) BOOL scrollToBottomHidden; +@property (nonatomic, strong) VoiceMessageController *voiceMessageController; + @end @implementation RoomViewController @@ -313,6 +315,9 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; // Show / hide actions button in document preview according BuildSettings self.allowActionsInDocumentPreview = BuildSettings.messageDetailsAllowShare; + + _voiceMessageController = [[VoiceMessageController alloc] initWithThemeService:ThemeService.shared mediaServiceProvider:VoiceMessageMediaServiceProvider.sharedProvider]; + self.voiceMessageController.delegate = self; } - (void)viewDidLoad @@ -386,6 +391,10 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; [self.bubblesTableView registerNib:RoomTypingBubbleCell.nib forCellReuseIdentifier:RoomTypingBubbleCell.defaultReuseIdentifier]; + [self.bubblesTableView registerClass:VoiceMessageBubbleCell.class forCellReuseIdentifier:VoiceMessageBubbleCell.defaultReuseIdentifier]; + [self.bubblesTableView registerClass:VoiceMessageWithoutSenderInfoBubbleCell.class forCellReuseIdentifier:VoiceMessageWithoutSenderInfoBubbleCell.defaultReuseIdentifier]; + [self.bubblesTableView registerClass:VoiceMessageWithPaginationTitleBubbleCell.class forCellReuseIdentifier:VoiceMessageWithPaginationTitleBubbleCell.defaultReuseIdentifier]; + [self vc_removeBackTitle]; [self setupRemoveJitsiWidgetRemoveView]; @@ -607,6 +616,8 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; self.roomDataSource.showReadMarker = YES; self.updateRoomReadMarker = NO; isAppeared = NO; + + [VoiceMessageMediaServiceProvider.sharedProvider stopAllServices]; } - (void)viewDidAppear:(BOOL)animated @@ -1114,6 +1125,9 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; if (!self.inputToolbarView || ![self.inputToolbarView isMemberOfClass:roomInputToolbarViewClass]) { [super setRoomInputToolbarViewClass:roomInputToolbarViewClass]; + + [(RoomInputToolbarView *)self.inputToolbarView setVoiceMessageToolbarView:self.voiceMessageController.voiceMessageToolbarView]; + [self updateInputToolBarViewHeight]; } } @@ -2359,6 +2373,16 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; { cellViewClass = RoomGroupCallStatusBubbleCell.class; } + else if (bubbleData.attachment.type == MXKAttachmentTypeVoiceMessage) + { + if (bubbleData.isPaginationFirstBubble) { + cellViewClass = VoiceMessageWithPaginationTitleBubbleCell.class; + } else if (bubbleData.shouldHideSenderInformation) { + cellViewClass = VoiceMessageWithoutSenderInfoBubbleCell.class; + } else { + cellViewClass = VoiceMessageBubbleCell.class; + } + } else if (bubbleData.isIncoming) { if (bubbleData.isAttachmentWithThumbnail) @@ -2718,12 +2742,11 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; [actionIdentifier isEqualToString:RoomGroupCallStatusBubbleCell.answerAction]) { MXWeakify(self); - NSString *appDisplayName = [[NSBundle mainBundle] infoDictionary][@"CFBundleDisplayName"]; // Check app permissions first [MXKTools checkAccessForCall:YES - manualChangeMessageForAudio:[NSString stringWithFormat:[NSBundle mxk_localizedStringForKey:@"microphone_access_not_granted_for_call"], appDisplayName] - manualChangeMessageForVideo:[NSString stringWithFormat:[NSBundle mxk_localizedStringForKey:@"camera_access_not_granted_for_call"], appDisplayName] + manualChangeMessageForAudio:[NSString stringWithFormat:[NSBundle mxk_localizedStringForKey:@"microphone_access_not_granted_for_call"], AppInfo.current.displayName] + manualChangeMessageForVideo:[NSString stringWithFormat:[NSBundle mxk_localizedStringForKey:@"camera_access_not_granted_for_call"], AppInfo.current.displayName] showPopUpInViewController:self completionHandler:^(BOOL granted) { MXStrongifyAndReturnIfNil(self); @@ -3715,12 +3738,10 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; { __weak __typeof(self) weakSelf = self; - NSString *appDisplayName = [[NSBundle mainBundle] infoDictionary][@"CFBundleDisplayName"]; - // Check app permissions first [MXKTools checkAccessForCall:video - manualChangeMessageForAudio:[NSString stringWithFormat:[NSBundle mxk_localizedStringForKey:@"microphone_access_not_granted_for_call"], appDisplayName] - manualChangeMessageForVideo:[NSString stringWithFormat:[NSBundle mxk_localizedStringForKey:@"camera_access_not_granted_for_call"], appDisplayName] + manualChangeMessageForAudio:[NSString stringWithFormat:[NSBundle mxk_localizedStringForKey:@"microphone_access_not_granted_for_call"], AppInfo.current.displayName] + manualChangeMessageForVideo:[NSString stringWithFormat:[NSBundle mxk_localizedStringForKey:@"camera_access_not_granted_for_call"], AppInfo.current.displayName] showPopUpInViewController:self completionHandler:^(BOOL granted) { if (weakSelf) @@ -3936,13 +3957,6 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; [self cancelEventSelection]; } -#pragma mark - RoomParticipantsViewControllerDelegate - -- (void)roomParticipantsViewController:(RoomParticipantsViewController *)roomParticipantsViewController mention:(MXRoomMember*)member -{ - [self mention:member]; -} - #pragma mark - MXKRoomMemberDetailsViewControllerDelegate - (void)roomMemberDetailsViewController:(MXKRoomMemberDetailsViewController *)roomMemberDetailsViewController startChatWithMemberId:(NSString *)matrixId completion:(void (^)(void))completion @@ -5810,7 +5824,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; MXWeakify(self); RoomContextualMenuItem *replyMenuItem = [[RoomContextualMenuItem alloc] initWithMenuAction:RoomContextualMenuActionReply]; - replyMenuItem.isEnabled = [self.roomDataSource canReplyToEventWithId:event.eventId]; + replyMenuItem.isEnabled = [self.roomDataSource canReplyToEventWithId:event.eventId] && !self.voiceMessageController.isRecordingAudio; replyMenuItem.action = ^{ MXStrongifyAndReturnIfNil(self); @@ -6121,6 +6135,11 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; self.roomInfoCoordinatorBridgePresenter = nil; } +- (void)roomInfoCoordinatorBridgePresenter:(RoomInfoCoordinatorBridgePresenter *)coordinatorBridgePresenter didRequestMentionForMember:(MXRoomMember *)member +{ + [self mention:member]; +} + #pragma mark - RemoveJitsiWidgetViewDelegate - (void)removeJitsiWidgetViewDidCompleteSliding:(RemoveJitsiWidgetView *)view @@ -6154,4 +6173,32 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; }]; } +#pragma mark - VoiceMessageControllerDelegate + +- (void)voiceMessageControllerDidRequestMicrophonePermission:(VoiceMessageController *)voiceMessageController +{ + NSString *message = [NSString stringWithFormat:[NSBundle mxk_localizedStringForKey:@"microphone_access_not_granted_for_voice_message"], AppInfo.current.displayName]; + + [MXKTools checkAccessForMediaType:AVMediaTypeAudio + manualChangeMessage: message + showPopUpInViewController:self completionHandler:^(BOOL granted) { + + }]; +} + +- (void)voiceMessageController:(VoiceMessageController *)voiceMessageController + didRequestSendForFileAtURL:(NSURL *)url + duration:(NSUInteger)duration + samples:(NSArray *)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 diff --git a/Riot/Modules/Room/Views/BubbleCells/VoiceMessage/VoiceMessageBubbleCell.swift b/Riot/Modules/Room/Views/BubbleCells/VoiceMessage/VoiceMessageBubbleCell.swift new file mode 100644 index 000000000..397fc9fe7 --- /dev/null +++ b/Riot/Modules/Room/Views/BubbleCells/VoiceMessage/VoiceMessageBubbleCell.swift @@ -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) + } +} diff --git a/Riot/Modules/Room/Views/BubbleCells/VoiceMessage/VoiceMessageWithPaginationTitleBubbleCell.swift b/Riot/Modules/Room/Views/BubbleCells/VoiceMessage/VoiceMessageWithPaginationTitleBubbleCell.swift new file mode 100644 index 000000000..b7e516675 --- /dev/null +++ b/Riot/Modules/Room/Views/BubbleCells/VoiceMessage/VoiceMessageWithPaginationTitleBubbleCell.swift @@ -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 + } +} diff --git a/Riot/Modules/Room/Views/BubbleCells/VoiceMessage/VoiceMessageWithoutSenderInfoBubbleCell.swift b/Riot/Modules/Room/Views/BubbleCells/VoiceMessage/VoiceMessageWithoutSenderInfoBubbleCell.swift new file mode 100644 index 000000000..cc091b39d --- /dev/null +++ b/Riot/Modules/Room/Views/BubbleCells/VoiceMessage/VoiceMessageWithoutSenderInfoBubbleCell.swift @@ -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 + } +} diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h index b2ad883b8..430daa2bf 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h @@ -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. diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m index 26a2f4c69..2234cbf9a 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m @@ -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 diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.xib b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.xib index aef8b2f81..f0f0c35b3 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.xib +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.xib @@ -16,7 +16,7 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageToolbarView.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessageToolbarView.swift new file mode 100644 index 000000000..4650d6fae --- /dev/null +++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageToolbarView.swift @@ -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) + } +} diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageToolbarView.xib b/Riot/Modules/Room/VoiceMessages/VoiceMessageToolbarView.xib new file mode 100644 index 000000000..f11674470 --- /dev/null +++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageToolbarView.xib @@ -0,0 +1,286 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageWaveformView.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessageWaveformView.swift new file mode 100644 index 000000000..8685109c4 --- /dev/null +++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageWaveformView.swift @@ -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) + } +} diff --git a/Riot/Modules/Settings/SettingsViewController.m b/Riot/Modules/Settings/SettingsViewController.m index a6dad221f..11bbcb3d8 100644 --- a/Riot/Modules/Settings/SettingsViewController.m +++ b/Riot/Modules/Settings/SettingsViewController.m @@ -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 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 diff --git a/Riot/Utils/DelegateContainer.swift b/Riot/Utils/DelegateContainer.swift new file mode 100644 index 000000000..c4323e980 --- /dev/null +++ b/Riot/Utils/DelegateContainer.swift @@ -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 + + 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) + } + } +} diff --git a/Riot/Utils/PassthroughView.swift b/Riot/Utils/PassthroughView.swift new file mode 100644 index 000000000..b7dad91b0 --- /dev/null +++ b/Riot/Utils/PassthroughView.swift @@ -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 + } +} diff --git a/Riot/Utils/WeakTarget.swift b/Riot/Utils/WeakTarget.swift new file mode 100644 index 000000000..695df92e8 --- /dev/null +++ b/Riot/Utils/WeakTarget.swift @@ -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) + } +} diff --git a/RiotShareExtension/Modules/Share/Listing/RoomsListViewController.m b/RiotShareExtension/Modules/Share/Listing/RoomsListViewController.m index 777249a6e..94acaa453 100644 --- a/RiotShareExtension/Modules/Share/Listing/RoomsListViewController.m +++ b/RiotShareExtension/Modules/Share/Listing/RoomsListViewController.m @@ -129,6 +129,13 @@ } } +- (void)setKeyboardHeight:(CGFloat)keyboardHeight +{ + // Bypass inherited keyboard handling to fix layout when searching. + // There are no sticky headers to worry about updating. + return; +} + #pragma mark - Private - (void)showShareAlertForRoomPath:(NSIndexPath *)indexPath @@ -289,6 +296,12 @@ // Refresh display [self refreshRecentsTable]; } + + // Dismiss the keyboard when scrolling to match the behaviour of the main app. + if (self.recentsSearchBar.isFirstResponder) + { + [self.recentsSearchBar resignFirstResponder]; + } } } } diff --git a/RiotShareExtension/Modules/Share/Listing/RoomsListViewController.xib b/RiotShareExtension/Modules/Share/Listing/RoomsListViewController.xib index 630d940a3..bc77d3275 100644 --- a/RiotShareExtension/Modules/Share/Listing/RoomsListViewController.xib +++ b/RiotShareExtension/Modules/Share/Listing/RoomsListViewController.xib @@ -1,11 +1,9 @@ - - - - + + - + @@ -39,7 +37,6 @@ - @@ -49,6 +46,7 @@ +