diff --git a/CHANGES.rst b/CHANGES.rst index 2f855fa83..36e1d4b85 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,15 @@ Changes to be released in next version * šŸ™Œ Improvements + * Jitsi: Use Jitsi server from homeserver's Well Known, if present, to create conferences (#3158). + * RoomMemberDetailsVC: Enable / disable "Hide all messages from this user" from settings (#4281). + * RoomVC: Show / Hide More and Report Content contextual menu from settings (#4285). + * SettingsVC: Show / hide NSFW and decrypted content options from build settings (#4290). + * RoomVC: Tweaked Scroll to Bottom FAB button (#4272). + * DesignKit: Introduce a new framework to manage design components. + * Add Jitsi widget remove banner for privileged users. + * Update "Jump to unread" banner to a pill style button. + * CallVC: Add transfer button. * Spaces: Hide spaces from room list and home but keep space invites (#4252). šŸ› Bugfix @@ -12,6 +21,7 @@ Changes to be released in next version * RiotSettings: Logging out resets RiotSettings (#4259). * RoomVC: Crash in `setScrollToBottomHidden` method (#4270). * Notifications: Make them work in debug mode (#4274). + * VoIP: Fix call bar layout issue (#4300). āš ļø API Changes * diff --git a/Config/BuildSettings.swift b/Config/BuildSettings.swift index 0df5a4c8b..e7d771d1f 100644 --- a/Config/BuildSettings.swift +++ b/Config/BuildSettings.swift @@ -180,7 +180,7 @@ final class BuildSettings: NSObject { "https://scalar-staging.riot.im/scalar/api", ] // Jitsi server used outside integrations to create conference calls from the call button in the timeline - static let jitsiServerUrl = NSURL(string: "https://jitsi.riot.im") + static let jitsiServerUrl: URL = URL(string: "https://jitsi.riot.im")! // MARK: - Features @@ -242,6 +242,8 @@ final class BuildSettings: NSObject { static let settingsScreenShowChangePassword:Bool = true static let settingsScreenShowInviteFriends:Bool = true static let settingsScreenShowEnableStunServerFallback: Bool = true + static let settingsScreenShowNotificationDecodedContentOption: Bool = true + static let settingsScreenShowNsfwRoomsOption: Bool = true static let settingsSecurityScreenShowSessions:Bool = true static let settingsSecurityScreenShowSetupBackup:Bool = true static let settingsSecurityScreenShowRestoreBackup:Bool = true @@ -268,6 +270,12 @@ final class BuildSettings: NSObject { static let roomScreenAllowMediaLibraryAction: Bool = true static let roomScreenAllowStickerAction: Bool = true static let roomScreenAllowFilesAction: Bool = true + + // MARK: - Room Contextual Menu + + static let roomContextualMenuShowMoreOptionForMessages: Bool = true + static let roomContextualMenuShowMoreOptionForStates: Bool = true + static let roomContextualMenuShowReportContentOption: Bool = true // MARK: - Room Info Screen @@ -284,6 +292,10 @@ final class BuildSettings: NSObject { static let roomSettingsScreenShowAdvancedSettings: Bool = true static let roomSettingsScreenAdvancedShowEncryptToVerifiedOption: Bool = true + // MARK: - Room Member Screen + + static let roomMemberScreenShowIgnore: Bool = true + // MARK: - Message static let messageDetailsAllowShare: Bool = true static let messageDetailsAllowPermalink: Bool = true diff --git a/DesignKit/Common.xcconfig b/DesignKit/Common.xcconfig new file mode 100644 index 000000000..40cf7124b --- /dev/null +++ b/DesignKit/Common.xcconfig @@ -0,0 +1,27 @@ +// +// Copyright 2021 Vector Creations 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. +// + +// Configuration settings file format documentation can be found at: +// https://help.apple.com/xcode/#/dev745c5c974 + +#include "Config/AppIdentifiers.xcconfig" + +PRODUCT_NAME = DesignKit +PRODUCT_BUNDLE_IDENTIFIER = $(BASE_BUNDLE_IDENTIFIER).designkit + +INFOPLIST_FILE = DesignKit/Info.plist + +SKIP_INSTALL = YES diff --git a/DesignKit/Debug.xcconfig b/DesignKit/Debug.xcconfig new file mode 100644 index 000000000..11a7288a4 --- /dev/null +++ b/DesignKit/Debug.xcconfig @@ -0,0 +1,20 @@ +// +// Copyright 2021 Vector Creations 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. +// + +// Configuration settings file format documentation can be found at: +// https://help.apple.com/xcode/#/dev745c5c974 + +#include "Common.xcconfig" diff --git a/DesignKit/DesignKit.h b/DesignKit/DesignKit.h new file mode 100644 index 000000000..4ff68e722 --- /dev/null +++ b/DesignKit/DesignKit.h @@ -0,0 +1,27 @@ +// +// 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 + +//! Project version number for DesignKit. +FOUNDATION_EXPORT double DesignKitVersionNumber; + +//! Project version string for DesignKit. +FOUNDATION_EXPORT const unsigned char DesignKitVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/DesignKit/Info.plist b/DesignKit/Info.plist new file mode 100644 index 000000000..c0701c6d7 --- /dev/null +++ b/DesignKit/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + diff --git a/DesignKit/Release.xcconfig b/DesignKit/Release.xcconfig new file mode 100644 index 000000000..11a7288a4 --- /dev/null +++ b/DesignKit/Release.xcconfig @@ -0,0 +1,20 @@ +// +// Copyright 2021 Vector Creations 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. +// + +// Configuration settings file format documentation can be found at: +// https://help.apple.com/xcode/#/dev745c5c974 + +#include "Common.xcconfig" diff --git a/DesignKit/Source/Colors.swift b/DesignKit/Source/Colors.swift new file mode 100644 index 000000000..b5dc66261 --- /dev/null +++ b/DesignKit/Source/Colors.swift @@ -0,0 +1,64 @@ +// +// 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 UIKit + +/// Colors at https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=1255%3A1104 +@objc public protocol Colors { + + /// - Focused/Active states + /// - CTAs + var accent: UIColor { get } + + /// - Error messages + /// - Content requiring user attention + /// - Notification, alerts + var alert: UIColor { get } + + /// - Text + /// - Icons + var primaryContent: UIColor { get } + + /// - Text + /// - Icons + var secondaryContent: UIColor { get } + + /// - Text + /// - Icons + var tertiaryContent: UIColor { get } + + /// - Text + /// - Icons + var quarterlyContent: UIColor { get } + + /// Separating line + var separator: UIColor { get } + + // Cards, tiles + var tile: UIColor { get } + + /// Top navigation background on iOS + var navigation: UIColor { get } + + /// Background UI color + var background: UIColor { get } + + /// - Names in chat timeline + /// - Avatars default states that include first name letter + var namesAndAvatars: [UIColor] { get } + +} diff --git a/DesignKit/Source/ThemeV2.swift b/DesignKit/Source/ThemeV2.swift new file mode 100644 index 000000000..48f310992 --- /dev/null +++ b/DesignKit/Source/ThemeV2.swift @@ -0,0 +1,28 @@ +// +// 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 UIKit + +/// Theme v2. May be named again as `Theme` when the migration completed. +@objc public protocol ThemeV2 { + + /// Colors object + var colors: Colors { get } + + /// may contain more design components in future, like icons, audio files etc. + +} diff --git a/DesignKit/Variants/Dark/DarkColors.swift b/DesignKit/Variants/Dark/DarkColors.swift new file mode 100644 index 000000000..a1cf2ac54 --- /dev/null +++ b/DesignKit/Variants/Dark/DarkColors.swift @@ -0,0 +1,56 @@ +// +// 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 UIKit + +/// Dark theme colors. Will be a struct when things are more Swifty. +public class DarkColors: Colors { + + public let accent: UIColor = UIColor(rgb: 0x0DBD8B) + + public let alert: UIColor = UIColor(rgb: 0xFF4B55) + + public let primaryContent: UIColor = UIColor(rgb: 0xFFFFFF) + + public let secondaryContent: UIColor = UIColor(rgb: 0xA9B2BC) + + public let tertiaryContent: UIColor = UIColor(rgb: 0x8E99A4) + + public let quarterlyContent: UIColor = UIColor(rgb: 0x6F7882) + + public let separator: UIColor = UIColor(rgb: 0x21262C) + + public let tile: UIColor = UIColor(rgb: 0x394049) + + public let navigation: UIColor = UIColor(rgb: 0x21262C) + + public let background: UIColor = UIColor(rgb: 0x15191E) + + public let namesAndAvatars: [UIColor] = [ + UIColor(rgb: 0x368BD6), + UIColor(rgb: 0xAC3BA8), + UIColor(rgb: 0x03B381), + UIColor(rgb: 0xE64F7A), + UIColor(rgb: 0xFF812D), + UIColor(rgb: 0x2DC2C5), + UIColor(rgb: 0x5C56F5), + UIColor(rgb: 0x74D12C) + ] + + public init() {} + +} diff --git a/DesignKit/Variants/Light/LightColors.swift b/DesignKit/Variants/Light/LightColors.swift new file mode 100644 index 000000000..d8b5e108b --- /dev/null +++ b/DesignKit/Variants/Light/LightColors.swift @@ -0,0 +1,56 @@ +// +// 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 UIKit + +/// Light theme colors. Will be a struct when things are more Swifty. +public class LightColors: Colors { + + public let accent: UIColor = UIColor(rgb: 0x0DBD8B) + + public let alert: UIColor = UIColor(rgb: 0xFF4B55) + + public let primaryContent: UIColor = UIColor(rgb: 0x17191C) + + public let secondaryContent: UIColor = UIColor(rgb: 0x737D8C) + + public let tertiaryContent: UIColor = UIColor(rgb: 0x8D97A5) + + public let quarterlyContent: UIColor = UIColor(rgb: 0xC1C6CD) + + public let separator: UIColor = UIColor(rgb: 0xE3E8F0) + + public let tile: UIColor = UIColor(rgb: 0xF3F8FD) + + public let navigation: UIColor = UIColor(rgb: 0xF4F6FA) + + public let background: UIColor = UIColor(rgb: 0xFFFFFF) + + public let namesAndAvatars: [UIColor] = [ + UIColor(rgb: 0x368BD6), + UIColor(rgb: 0xAC3BA8), + UIColor(rgb: 0x03B381), + UIColor(rgb: 0xE64F7A), + UIColor(rgb: 0xFF812D), + UIColor(rgb: 0x2DC2C5), + UIColor(rgb: 0x5C56F5), + UIColor(rgb: 0x74D12C) + ] + + public init() {} + +} diff --git a/DesignKit/target.yml b/DesignKit/target.yml new file mode 100644 index 000000000..e10f76f12 --- /dev/null +++ b/DesignKit/target.yml @@ -0,0 +1,33 @@ +name: DesignKit + +schemes: + DesignKit: + analyze: + config: Debug + archive: + config: Release + build: + targets: + DesignKit: + - running + - profiling + - analyzing + - archiving + profile: + config: Release + run: + config: Debug + disableMainThreadChecker: true + +targets: + DesignKit: + type: framework + platform: iOS + + configFiles: + Debug: Debug.xcconfig + Release: Release.xcconfig + + sources: + - path: . + - path: ../Riot/Categories/UIColor.swift diff --git a/INSTALL.md b/INSTALL.md index 3e382eb82..5800848d0 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -68,7 +68,7 @@ If you want to modify MatrixKit and/or MatrixSDK locally and see the result in E But before you have to checkout [MatrixKit](https://github.com/matrix-org/matrix-ios-kit) repository in `../matrix-ios-kit` and [MatrixSDK](https://github.com/matrix-org/matrix-ios-sdk) in `../matrix-ios-sdk` locally relatively to your Element iOS project folder. Be sure to use compatible branches for Element iOS, MatrixKit and MatrixSDK. For example, if you want to modify Element iOS from develop branch, use MatrixKit and MatrixSDK develop branches and then make your modifications. -**Important**: By working with local pods (development pods) you will need to use legacy build system in Xcode, to have your local changes taken into account. To enable it go to Xcode menu and select `File > Workspace Settings… > Build System` and then choose `Legacy Build System`. +**Important**: By working with [XcodeGen](https://github.com/yonaskolb/XcodeGen) you will need to use the _New Build System_ in Xcode, to have your some of the xcconfig variables taken into account. It should be enabled by default on the latest Xcode versions, but if you need to enable it go to Xcode menu and select `File > Workspace Settings… > Build System` and then choose `New Build System`. ### `$matrixKitVersion` Modification diff --git a/Riot/Assets/Images.xcassets/Call/call_audio_mute_off_icon.imageset/Microphone.png b/Riot/Assets/Images.xcassets/Call/call_audio_mute_off_icon.imageset/Microphone.png index 699255712..a808cf5bd 100644 Binary files a/Riot/Assets/Images.xcassets/Call/call_audio_mute_off_icon.imageset/Microphone.png and b/Riot/Assets/Images.xcassets/Call/call_audio_mute_off_icon.imageset/Microphone.png differ diff --git a/Riot/Assets/Images.xcassets/Call/call_audio_mute_off_icon.imageset/Microphone@2x.png b/Riot/Assets/Images.xcassets/Call/call_audio_mute_off_icon.imageset/Microphone@2x.png index a73e07451..32681bc51 100644 Binary files a/Riot/Assets/Images.xcassets/Call/call_audio_mute_off_icon.imageset/Microphone@2x.png and b/Riot/Assets/Images.xcassets/Call/call_audio_mute_off_icon.imageset/Microphone@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Call/call_audio_mute_off_icon.imageset/Microphone@3x.png b/Riot/Assets/Images.xcassets/Call/call_audio_mute_off_icon.imageset/Microphone@3x.png index b292d11c5..310c13a63 100644 Binary files a/Riot/Assets/Images.xcassets/Call/call_audio_mute_off_icon.imageset/Microphone@3x.png and b/Riot/Assets/Images.xcassets/Call/call_audio_mute_off_icon.imageset/Microphone@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Call/call_audio_mute_on_icon.imageset/Microphone Off.png b/Riot/Assets/Images.xcassets/Call/call_audio_mute_on_icon.imageset/Microphone Off.png index c194f59b3..fcfe16211 100644 Binary files a/Riot/Assets/Images.xcassets/Call/call_audio_mute_on_icon.imageset/Microphone Off.png and b/Riot/Assets/Images.xcassets/Call/call_audio_mute_on_icon.imageset/Microphone Off.png differ diff --git a/Riot/Assets/Images.xcassets/Call/call_audio_mute_on_icon.imageset/Microphone Off@2x.png b/Riot/Assets/Images.xcassets/Call/call_audio_mute_on_icon.imageset/Microphone Off@2x.png index a4bdab618..6cd4af09b 100644 Binary files a/Riot/Assets/Images.xcassets/Call/call_audio_mute_on_icon.imageset/Microphone Off@2x.png and b/Riot/Assets/Images.xcassets/Call/call_audio_mute_on_icon.imageset/Microphone Off@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Call/call_audio_mute_on_icon.imageset/Microphone Off@3x.png b/Riot/Assets/Images.xcassets/Call/call_audio_mute_on_icon.imageset/Microphone Off@3x.png index cb777a3fa..d3423152d 100644 Binary files a/Riot/Assets/Images.xcassets/Call/call_audio_mute_on_icon.imageset/Microphone Off@3x.png and b/Riot/Assets/Images.xcassets/Call/call_audio_mute_on_icon.imageset/Microphone Off@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Call/call_dialpad_backspace_icon.imageset/Contents.json b/Riot/Assets/Images.xcassets/Call/call_dialpad_backspace_icon.imageset/Contents.json index df240cf11..713667a8c 100644 --- a/Riot/Assets/Images.xcassets/Call/call_dialpad_backspace_icon.imageset/Contents.json +++ b/Riot/Assets/Images.xcassets/Call/call_dialpad_backspace_icon.imageset/Contents.json @@ -19,5 +19,8 @@ "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" } } diff --git a/Riot/Assets/Images.xcassets/Call/call_go_to_chat_icon.imageset/Contents.json b/Riot/Assets/Images.xcassets/Call/call_go_to_chat_icon.imageset/Contents.json new file mode 100644 index 000000000..96e141816 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Call/call_go_to_chat_icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "back.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "back@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "back@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Call/call_go_to_chat_icon.imageset/back.png b/Riot/Assets/Images.xcassets/Call/call_go_to_chat_icon.imageset/back.png new file mode 100644 index 000000000..38a571045 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Call/call_go_to_chat_icon.imageset/back.png differ diff --git a/Riot/Assets/Images.xcassets/Call/call_go_to_chat_icon.imageset/back@2x.png b/Riot/Assets/Images.xcassets/Call/call_go_to_chat_icon.imageset/back@2x.png new file mode 100644 index 000000000..cd85e3085 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Call/call_go_to_chat_icon.imageset/back@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Call/call_go_to_chat_icon.imageset/back@3x.png b/Riot/Assets/Images.xcassets/Call/call_go_to_chat_icon.imageset/back@3x.png new file mode 100644 index 000000000..8c3dc1625 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Call/call_go_to_chat_icon.imageset/back@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Call/call_hangup_large.imageset/End Call.png b/Riot/Assets/Images.xcassets/Call/call_hangup_large.imageset/End Call.png index b1934caaf..d1f21e64e 100644 Binary files a/Riot/Assets/Images.xcassets/Call/call_hangup_large.imageset/End Call.png and b/Riot/Assets/Images.xcassets/Call/call_hangup_large.imageset/End Call.png differ diff --git a/Riot/Assets/Images.xcassets/Call/call_hangup_large.imageset/End Call@2x.png b/Riot/Assets/Images.xcassets/Call/call_hangup_large.imageset/End Call@2x.png index 8a471f94d..7b4433f3f 100644 Binary files a/Riot/Assets/Images.xcassets/Call/call_hangup_large.imageset/End Call@2x.png and b/Riot/Assets/Images.xcassets/Call/call_hangup_large.imageset/End Call@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Call/call_hangup_large.imageset/End Call@3x.png b/Riot/Assets/Images.xcassets/Call/call_hangup_large.imageset/End Call@3x.png index 0a7195c07..46bec1ae1 100644 Binary files a/Riot/Assets/Images.xcassets/Call/call_hangup_large.imageset/End Call@3x.png and b/Riot/Assets/Images.xcassets/Call/call_hangup_large.imageset/End Call@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Call/call_video_icon.imageset/Contents.json b/Riot/Assets/Images.xcassets/Call/call_video_icon.imageset/Contents.json index f69f28085..453722c5e 100644 --- a/Riot/Assets/Images.xcassets/Call/call_video_icon.imageset/Contents.json +++ b/Riot/Assets/Images.xcassets/Call/call_video_icon.imageset/Contents.json @@ -19,5 +19,8 @@ "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" } } diff --git a/Riot/Assets/Images.xcassets/Call/call_video_mute_off_icon.imageset/Video.png b/Riot/Assets/Images.xcassets/Call/call_video_mute_off_icon.imageset/Video.png index e379143ca..afcd4d1f8 100644 Binary files a/Riot/Assets/Images.xcassets/Call/call_video_mute_off_icon.imageset/Video.png and b/Riot/Assets/Images.xcassets/Call/call_video_mute_off_icon.imageset/Video.png differ diff --git a/Riot/Assets/Images.xcassets/Call/call_video_mute_off_icon.imageset/Video@2x.png b/Riot/Assets/Images.xcassets/Call/call_video_mute_off_icon.imageset/Video@2x.png index f458c0c9e..17f48c56d 100644 Binary files a/Riot/Assets/Images.xcassets/Call/call_video_mute_off_icon.imageset/Video@2x.png and b/Riot/Assets/Images.xcassets/Call/call_video_mute_off_icon.imageset/Video@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Call/call_video_mute_off_icon.imageset/Video@3x.png b/Riot/Assets/Images.xcassets/Call/call_video_mute_off_icon.imageset/Video@3x.png index e847ec334..e3bede731 100644 Binary files a/Riot/Assets/Images.xcassets/Call/call_video_mute_off_icon.imageset/Video@3x.png and b/Riot/Assets/Images.xcassets/Call/call_video_mute_off_icon.imageset/Video@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Call/call_video_mute_on_icon.imageset/Video Off.png b/Riot/Assets/Images.xcassets/Call/call_video_mute_on_icon.imageset/Video Off.png index 243928c1a..89052530e 100644 Binary files a/Riot/Assets/Images.xcassets/Call/call_video_mute_on_icon.imageset/Video Off.png and b/Riot/Assets/Images.xcassets/Call/call_video_mute_on_icon.imageset/Video Off.png differ diff --git a/Riot/Assets/Images.xcassets/Call/call_video_mute_on_icon.imageset/Video Off@2x.png b/Riot/Assets/Images.xcassets/Call/call_video_mute_on_icon.imageset/Video Off@2x.png index ef951c8e0..7671bde13 100644 Binary files a/Riot/Assets/Images.xcassets/Call/call_video_mute_on_icon.imageset/Video Off@2x.png and b/Riot/Assets/Images.xcassets/Call/call_video_mute_on_icon.imageset/Video Off@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Call/call_video_mute_on_icon.imageset/Video Off@3x.png b/Riot/Assets/Images.xcassets/Call/call_video_mute_on_icon.imageset/Video Off@3x.png index 975156a50..894447ebc 100644 Binary files a/Riot/Assets/Images.xcassets/Call/call_video_mute_on_icon.imageset/Video Off@3x.png and b/Riot/Assets/Images.xcassets/Call/call_video_mute_on_icon.imageset/Video Off@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Call/camera_switch.imageset/Contents.json b/Riot/Assets/Images.xcassets/Call/camera_switch.imageset/Contents.json index a1d4a004a..67ecee5d6 100644 --- a/Riot/Assets/Images.xcassets/Call/camera_switch.imageset/Contents.json +++ b/Riot/Assets/Images.xcassets/Call/camera_switch.imageset/Contents.json @@ -19,8 +19,5 @@ "info" : { "author" : "xcode", "version" : 1 - }, - "properties" : { - "template-rendering-intent" : "template" } } diff --git a/Riot/Assets/Images.xcassets/Call/camera_switch.imageset/camera_switch.png b/Riot/Assets/Images.xcassets/Call/camera_switch.imageset/camera_switch.png index 27d63753e..1c362a52b 100644 Binary files a/Riot/Assets/Images.xcassets/Call/camera_switch.imageset/camera_switch.png and b/Riot/Assets/Images.xcassets/Call/camera_switch.imageset/camera_switch.png differ diff --git a/Riot/Assets/Images.xcassets/Call/camera_switch.imageset/camera_switch@2x.png b/Riot/Assets/Images.xcassets/Call/camera_switch.imageset/camera_switch@2x.png index 3f526d9a8..a1a67cd00 100644 Binary files a/Riot/Assets/Images.xcassets/Call/camera_switch.imageset/camera_switch@2x.png and b/Riot/Assets/Images.xcassets/Call/camera_switch.imageset/camera_switch@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Call/camera_switch.imageset/camera_switch@3x.png b/Riot/Assets/Images.xcassets/Call/camera_switch.imageset/camera_switch@3x.png index 872210582..ba278efe3 100644 Binary files a/Riot/Assets/Images.xcassets/Call/camera_switch.imageset/camera_switch@3x.png and b/Riot/Assets/Images.xcassets/Call/camera_switch.imageset/camera_switch@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Activities/new_close.imageset/Close.png b/Riot/Assets/Images.xcassets/Room/Activities/new_close.imageset/Close.png new file mode 100644 index 000000000..35a24620d Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Activities/new_close.imageset/Close.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Activities/new_close.imageset/Close@2x.png b/Riot/Assets/Images.xcassets/Room/Activities/new_close.imageset/Close@2x.png new file mode 100644 index 000000000..9ee4ab4f0 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Activities/new_close.imageset/Close@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Activities/new_close.imageset/Close@3x.png b/Riot/Assets/Images.xcassets/Room/Activities/new_close.imageset/Close@3x.png new file mode 100644 index 000000000..5220dd2f9 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Activities/new_close.imageset/Close@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Activities/new_close.imageset/Contents.json b/Riot/Assets/Images.xcassets/Room/Activities/new_close.imageset/Contents.json new file mode 100644 index 000000000..90d19c308 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Room/Activities/new_close.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "Close.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Close@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Close@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Riot/Assets/Images.xcassets/Room/Activities/room_scroll_up.imageset/Contents.json b/Riot/Assets/Images.xcassets/Room/Activities/room_scroll_up.imageset/Contents.json new file mode 100644 index 000000000..92d19c27b --- /dev/null +++ b/Riot/Assets/Images.xcassets/Room/Activities/room_scroll_up.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "Up.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Up@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Up@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "original" + } +} diff --git a/Riot/Assets/Images.xcassets/Room/Activities/room_scroll_up.imageset/Up.png b/Riot/Assets/Images.xcassets/Room/Activities/room_scroll_up.imageset/Up.png new file mode 100644 index 000000000..c9556b53d Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Activities/room_scroll_up.imageset/Up.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Activities/room_scroll_up.imageset/Up@2x.png b/Riot/Assets/Images.xcassets/Room/Activities/room_scroll_up.imageset/Up@2x.png new file mode 100644 index 000000000..f029fd55b Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Activities/room_scroll_up.imageset/Up@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Activities/room_scroll_up.imageset/Up@3x.png b/Riot/Assets/Images.xcassets/Room/Activities/room_scroll_up.imageset/Up@3x.png new file mode 100644 index 000000000..90d3fc8ac Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Activities/room_scroll_up.imageset/Up@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Activities/scrolldown.imageset/scrolldown.png b/Riot/Assets/Images.xcassets/Room/Activities/scrolldown.imageset/scrolldown.png index 4e639fb9b..992d1edaf 100644 Binary files a/Riot/Assets/Images.xcassets/Room/Activities/scrolldown.imageset/scrolldown.png and b/Riot/Assets/Images.xcassets/Room/Activities/scrolldown.imageset/scrolldown.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Activities/scrolldown.imageset/scrolldown@2x.png b/Riot/Assets/Images.xcassets/Room/Activities/scrolldown.imageset/scrolldown@2x.png index aabb2b121..c21846293 100644 Binary files a/Riot/Assets/Images.xcassets/Room/Activities/scrolldown.imageset/scrolldown@2x.png and b/Riot/Assets/Images.xcassets/Room/Activities/scrolldown.imageset/scrolldown@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Activities/scrolldown.imageset/scrolldown@3x.png b/Riot/Assets/Images.xcassets/Room/Activities/scrolldown.imageset/scrolldown@3x.png index 81685ed6b..936e57707 100644 Binary files a/Riot/Assets/Images.xcassets/Room/Activities/scrolldown.imageset/scrolldown@3x.png and b/Riot/Assets/Images.xcassets/Room/Activities/scrolldown.imageset/scrolldown@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Activities/scrolldown_dark.imageset/scrolldown_dark.png b/Riot/Assets/Images.xcassets/Room/Activities/scrolldown_dark.imageset/scrolldown_dark.png index 740a562f4..5d3228686 100644 Binary files a/Riot/Assets/Images.xcassets/Room/Activities/scrolldown_dark.imageset/scrolldown_dark.png and b/Riot/Assets/Images.xcassets/Room/Activities/scrolldown_dark.imageset/scrolldown_dark.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Activities/scrolldown_dark.imageset/scrolldown_dark@2x.png b/Riot/Assets/Images.xcassets/Room/Activities/scrolldown_dark.imageset/scrolldown_dark@2x.png index 1dfe3ab3d..14a8900e0 100644 Binary files a/Riot/Assets/Images.xcassets/Room/Activities/scrolldown_dark.imageset/scrolldown_dark@2x.png and b/Riot/Assets/Images.xcassets/Room/Activities/scrolldown_dark.imageset/scrolldown_dark@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Activities/scrolldown_dark.imageset/scrolldown_dark@3x.png b/Riot/Assets/Images.xcassets/Room/Activities/scrolldown_dark.imageset/scrolldown_dark@3x.png index 14ae3aa1b..729c73bb2 100644 Binary files a/Riot/Assets/Images.xcassets/Room/Activities/scrolldown_dark.imageset/scrolldown_dark@3x.png and b/Riot/Assets/Images.xcassets/Room/Activities/scrolldown_dark.imageset/scrolldown_dark@3x.png differ diff --git a/Riot/Assets/Riot-Defaults.plist b/Riot/Assets/Riot-Defaults.plist index 28d0d04b8..7ef98b242 100644 --- a/Riot/Assets/Riot-Defaults.plist +++ b/Riot/Assets/Riot-Defaults.plist @@ -20,8 +20,6 @@ syncLocalContacts - createConferenceCallsWithJitsi - enableRageShake maxAllowedMediaCacheSize @@ -34,5 +32,7 @@ 15020851 enableBotCreation + enableRingingForGroupCalls + diff --git a/Riot/Assets/SharedImages.xcassets/Common/cancel.imageset/Contents.json b/Riot/Assets/SharedImages.xcassets/Common/cancel.imageset/Contents.json index 2f43e0b91..92357aede 100644 --- a/Riot/Assets/SharedImages.xcassets/Common/cancel.imageset/Contents.json +++ b/Riot/Assets/SharedImages.xcassets/Common/cancel.imageset/Contents.json @@ -1,23 +1,26 @@ { "images" : [ { - "idiom" : "universal", "filename" : "cancel.png", + "idiom" : "universal", "scale" : "1x" }, { - "idiom" : "universal", "filename" : "cancel@2x.png", + "idiom" : "universal", "scale" : "2x" }, { - "idiom" : "universal", "filename" : "cancel@3x.png", + "idiom" : "universal", "scale" : "3x" } ], "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" } -} \ No newline at end of file +} diff --git a/Riot/Assets/en.lproj/Localizable.strings b/Riot/Assets/en.lproj/Localizable.strings index 9d7213925..6541338bd 100644 --- a/Riot/Assets/en.lproj/Localizable.strings +++ b/Riot/Assets/en.lproj/Localizable.strings @@ -123,6 +123,12 @@ /* Incoming named video conference invite from a specific person */ "VIDEO_CONF_NAMED_FROM_USER" = "Video group call from %@: '%@'"; +/* A user added a Jitsi call to a room */ +"GROUP_CALL_STARTED" = "Group call started"; + +/* Group call from user, CallKit caller name */ +"GROUP_CALL_FROM_USER" = "%@ (Group call)"; + /** Key verification **/ "KEY_VERIFICATION_REQUEST_FROM_USER" = "%@ wants to verify"; diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 6b58da5c1..277d820ce 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -65,12 +65,13 @@ "less" = "Less"; // Call Bar -"callbar_only_single_active" = "Active call (%@)"; +"callbar_only_single_active" = "Tap to return to the call (%@)"; "callbar_active_and_single_paused" = "1 active call (%@) Ā· 1 paused call"; "callbar_active_and_multiple_paused" = "1 active call (%@) Ā· %@ paused calls"; "callbar_only_single_paused" = "Paused call"; "callbar_only_multiple_paused" = "%@ paused calls"; "callbar_return" = "Return"; +"callbar_only_single_active_group" = "Tap to Join the group call (%@)"; // Accessibility "accessibility_checkbox_label" = "checkbox"; @@ -312,7 +313,8 @@ Tap the + to start adding people."; "room_member_power_level_short_custom" = "Custom"; // Chat -"room_jump_to_first_unread" = "Jump to first unread message"; +"room_slide_to_end_group_call" = "Slide to end the call for everyone"; +"room_jump_to_first_unread" = "Jump to unread"; "room_accessiblity_scroll_to_bottom" = "Scroll to bottom"; "room_new_message_notification" = "%d new message"; "room_new_messages_notification" = "%d new messages"; @@ -395,6 +397,8 @@ Tap the + to start adding people."; "room_accessibility_hangup" = "Hang up"; "room_place_voice_call" = "Voice call"; "room_open_dialpad" = "Dial pad"; +"room_join_group_call" = "Join"; +"room_no_privileges_to_create_group_call" = "You need to be an admin or a moderator to start a call."; "media_type_accessibility_image" = "Image"; "media_type_accessibility_audio" = "Audio"; @@ -526,6 +530,7 @@ Tap the + to start adding people."; "settings_labs_e2e_encryption_prompt_message" = "To finish setting up encryption you must log in again."; "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_version" = "Version %@"; "settings_olm_version" = "Olm Version %@"; @@ -823,10 +828,22 @@ Tap the + to start adding people."; "event_formatter_message_edited_mention" = "(edited)"; "event_formatter_call_voice" = "Voice call"; "event_formatter_call_video" = "Video call"; -"event_formatter_call_has_ended" = "This call has ended"; -"event_formatter_call_you_currently_in" = "You're currently in this call"; +"event_formatter_call_connecting" = "Connecting…"; +"event_formatter_call_ringing" = "Ringing…"; +"event_formatter_call_has_ended" = "Ended %@"; +"event_formatter_call_you_currently_in" = "Active call"; "event_formatter_call_you_declined" = "You declined this call"; +"event_formatter_call_you_missed" = "You missed this call"; +"event_formatter_call_connection_failed" = "Connection failed"; "event_formatter_call_back" = "Call back"; +"event_formatter_call_decline" = "Decline"; +"event_formatter_call_answer" = "Answer"; +"event_formatter_call_retry" = "Retry"; +"event_formatter_call_end_call" = "End call"; +"event_formatter_group_call" = "Group call"; +"event_formatter_group_call_join" = "Join"; +"event_formatter_group_call_leave" = "Leave"; +"event_formatter_group_call_incoming" = "%@ in %@"; // Events formatter with you "event_formatter_widget_added_by_you" = "You added the widget: %@"; diff --git a/Riot/Categories/MXKRoomBubbleTableViewCell+Riot.h b/Riot/Categories/MXKRoomBubbleTableViewCell+Riot.h index 6bc374644..91b69a123 100644 --- a/Riot/Categories/MXKRoomBubbleTableViewCell+Riot.h +++ b/Riot/Categories/MXKRoomBubbleTableViewCell+Riot.h @@ -58,13 +58,6 @@ extern NSString *const kMXKRoomBubbleCellKeyVerificationIncomingRequestAcceptPre */ extern NSString *const kMXKRoomBubbleCellKeyVerificationIncomingRequestDeclinePressed; -/** - Action identifier used when the user pressed "Call back" button for a declined call. - - The `userInfo` dictionary contains an `MXEvent` object under the `kMXKRoomBubbleCellEventKey` key, representing the invite event of the declined call. - */ -extern NSString *const kMXKRoomBubbleCellCallBackButtonPressed; - /** Define a `MXKRoomBubbleTableViewCell` category at Riot level to handle bubble customisation. */ diff --git a/Riot/Categories/MXKRoomBubbleTableViewCell+Riot.m b/Riot/Categories/MXKRoomBubbleTableViewCell+Riot.m index 34bafb3de..ade4a3c1c 100644 --- a/Riot/Categories/MXKRoomBubbleTableViewCell+Riot.m +++ b/Riot/Categories/MXKRoomBubbleTableViewCell+Riot.m @@ -33,7 +33,6 @@ NSString *const kMXKRoomBubbleCellLongPressOnReactionView = @"kMXKRoomBubbleCell NSString *const kMXKRoomBubbleCellEventIdKey = @"kMXKRoomBubbleCellEventIdKey"; NSString *const kMXKRoomBubbleCellKeyVerificationIncomingRequestAcceptPressed = @"kMXKRoomBubbleCellKeyVerificationAcceptPressed"; NSString *const kMXKRoomBubbleCellKeyVerificationIncomingRequestDeclinePressed = @"kMXKRoomBubbleCellKeyVerificationDeclinePressed"; -NSString *const kMXKRoomBubbleCellCallBackButtonPressed = @"kMXKRoomBubbleCellCallBackButtonPressed"; @implementation MXKRoomBubbleTableViewCell (Riot) diff --git a/Riot/Categories/MXSession+Riot.h b/Riot/Categories/MXSession+Riot.h index f756a2ede..d5fe928de 100644 --- a/Riot/Categories/MXSession+Riot.h +++ b/Riot/Categories/MXSession+Riot.h @@ -18,6 +18,8 @@ #import +@class HomeserverConfiguration; + @interface MXSession (Riot) /** @@ -26,15 +28,9 @@ - (NSUInteger)vc_missedDiscussionsCount; /** - Check if E2E by default is welcomed on the user's HS. - The default value is YES. - - HS admins can disable it in /.well-known/matrix/client by returning: - "im.vector.riot.e2ee": { - "default": false - } - */ -- (BOOL)vc_isE2EByDefaultEnabledByHSAdmin; +Return the homeserver configuration based on HS Well-Known or BuildSettings properties according to existing values. +*/ +- (HomeserverConfiguration*)vc_homeserverConfiguration; /** Riot version of [MXSession canEnableE2EByDefaultInNewRoomWithUsers:] diff --git a/Riot/Categories/MXSession+Riot.m b/Riot/Categories/MXSession+Riot.m index fd0a53c9d..1cc498024 100644 --- a/Riot/Categories/MXSession+Riot.m +++ b/Riot/Categories/MXSession+Riot.m @@ -49,25 +49,17 @@ return missedDiscussionsCount; } -- (BOOL)vc_isE2EByDefaultEnabledByHSAdmin +- (HomeserverConfiguration*)vc_homeserverConfiguration { - BOOL isE2EByDefaultEnabledByHSAdmin = YES; - - MXWellKnown *wellKnown = self.homeserverWellknown; - - if (wellKnown.JSONDictionary[@"im.vector.riot.e2ee"][@"default"]) - { - MXJSONModelSetBoolean(isE2EByDefaultEnabledByHSAdmin, wellKnown.JSONDictionary[@"im.vector.riot.e2ee"][@"default"]); - } - - return isE2EByDefaultEnabledByHSAdmin; + HomeserverConfigurationBuilder *configurationBuilder = [HomeserverConfigurationBuilder new]; + return [configurationBuilder buildFrom:self.homeserverWellknown]; } - (MXHTTPOperation*)vc_canEnableE2EByDefaultInNewRoomWithUsers:(NSArray*)userIds success:(void (^)(BOOL canEnableE2E))success failure:(void (^)(NSError *error))failure; { - if (self.vc_isE2EByDefaultEnabledByHSAdmin) + if ([self vc_homeserverConfiguration].isE2EEByDefaultEnabled) { return [self canEnableE2EByDefaultInNewRoomWithUsers:userIds success:success failure:failure]; } diff --git a/Riot/Categories/UIView.swift b/Riot/Categories/UIView.swift index e7ede840e..9edde2c23 100644 --- a/Riot/Categories/UIView.swift +++ b/Riot/Categories/UIView.swift @@ -64,4 +64,16 @@ extension UIView { self.accessibilityTraits.insert(.notEnabled) } } + + @objc func vc_addShadow(withColor color: UIColor, offset: CGSize, radius: CGFloat, opacity: CGFloat) { + layer.shadowColor = color.cgColor + layer.shadowOpacity = Float(opacity) + layer.shadowRadius = radius + layer.shadowOffset = offset + } + + @objc func vc_removeShadow() { + layer.shadowColor = UIColor.clear.cgColor + } + } diff --git a/Riot/Generated/Images.swift b/Riot/Generated/Images.swift index 7f973231f..4565353fe 100644 --- a/Riot/Generated/Images.swift +++ b/Riot/Generated/Images.swift @@ -31,6 +31,7 @@ internal enum Asset { internal static let callChatIcon = ImageAsset(name: "call_chat_icon") internal static let callDialpadBackspaceIcon = ImageAsset(name: "call_dialpad_backspace_icon") internal static let callDialpadCallIcon = ImageAsset(name: "call_dialpad_call_icon") + internal static let callGoToChatIcon = ImageAsset(name: "call_go_to_chat_icon") internal static let callHangupLarge = ImageAsset(name: "call_hangup_large") internal static let callMoreIcon = ImageAsset(name: "call_more_icon") internal static let callPausedIcon = ImageAsset(name: "call_paused_icon") @@ -104,7 +105,9 @@ internal enum Asset { internal static let actionSticker = ImageAsset(name: "action_sticker") internal static let error = ImageAsset(name: "error") internal static let errorMessageTick = ImageAsset(name: "error_message_tick") + internal static let newClose = ImageAsset(name: "new_close") internal static let roomActivitiesRetry = ImageAsset(name: "room_activities_retry") + internal static let roomScrollUp = ImageAsset(name: "room_scroll_up") internal static let scrolldown = ImageAsset(name: "scrolldown") internal static let scrolldownDark = ImageAsset(name: "scrolldown_dark") internal static let sendingMessageTick = ImageAsset(name: "sending_message_tick") diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index ab9947434..11d0dc52d 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -502,10 +502,14 @@ internal enum VectorL10n { internal static func callbarOnlyMultiplePaused(_ p1: String) -> String { return VectorL10n.tr("Vector", "callbar_only_multiple_paused", p1) } - /// Active call (%@) + /// Tap to return to the call (%@) internal static func callbarOnlySingleActive(_ p1: String) -> String { return VectorL10n.tr("Vector", "callbar_only_single_active", p1) } + /// Tap to Join the group call (%@) + internal static func callbarOnlySingleActiveGroup(_ p1: String) -> String { + return VectorL10n.tr("Vector", "callbar_only_single_active_group", p1) + } /// Paused call internal static var callbarOnlySinglePaused: String { return VectorL10n.tr("Vector", "callbar_only_single_paused") @@ -1238,13 +1242,41 @@ internal enum VectorL10n { internal static var errorUserAlreadyLoggedIn: String { return VectorL10n.tr("Vector", "error_user_already_logged_in") } + /// Answer + internal static var eventFormatterCallAnswer: String { + return VectorL10n.tr("Vector", "event_formatter_call_answer") + } /// Call back internal static var eventFormatterCallBack: String { return VectorL10n.tr("Vector", "event_formatter_call_back") } - /// This call has ended - internal static var eventFormatterCallHasEnded: String { - return VectorL10n.tr("Vector", "event_formatter_call_has_ended") + /// Connecting… + internal static var eventFormatterCallConnecting: String { + return VectorL10n.tr("Vector", "event_formatter_call_connecting") + } + /// Connection failed + internal static var eventFormatterCallConnectionFailed: String { + return VectorL10n.tr("Vector", "event_formatter_call_connection_failed") + } + /// Decline + internal static var eventFormatterCallDecline: String { + return VectorL10n.tr("Vector", "event_formatter_call_decline") + } + /// End call + internal static var eventFormatterCallEndCall: String { + return VectorL10n.tr("Vector", "event_formatter_call_end_call") + } + /// Ended %@ + internal static func eventFormatterCallHasEnded(_ p1: String) -> String { + return VectorL10n.tr("Vector", "event_formatter_call_has_ended", p1) + } + /// Retry + internal static var eventFormatterCallRetry: String { + return VectorL10n.tr("Vector", "event_formatter_call_retry") + } + /// Ringing… + internal static var eventFormatterCallRinging: String { + return VectorL10n.tr("Vector", "event_formatter_call_ringing") } /// Video call internal static var eventFormatterCallVideo: String { @@ -1254,7 +1286,7 @@ internal enum VectorL10n { internal static var eventFormatterCallVoice: String { return VectorL10n.tr("Vector", "event_formatter_call_voice") } - /// You're currently in this call + /// Active call internal static var eventFormatterCallYouCurrentlyIn: String { return VectorL10n.tr("Vector", "event_formatter_call_you_currently_in") } @@ -1262,6 +1294,26 @@ internal enum VectorL10n { internal static var eventFormatterCallYouDeclined: String { return VectorL10n.tr("Vector", "event_formatter_call_you_declined") } + /// You missed this call + internal static var eventFormatterCallYouMissed: String { + return VectorL10n.tr("Vector", "event_formatter_call_you_missed") + } + /// Group call + internal static var eventFormatterGroupCall: String { + return VectorL10n.tr("Vector", "event_formatter_group_call") + } + /// %@ in %@ + internal static func eventFormatterGroupCallIncoming(_ p1: String, _ p2: String) -> String { + return VectorL10n.tr("Vector", "event_formatter_group_call_incoming", p1, p2) + } + /// Join + internal static var eventFormatterGroupCallJoin: String { + return VectorL10n.tr("Vector", "event_formatter_group_call_join") + } + /// Leave + internal static var eventFormatterGroupCallLeave: String { + return VectorL10n.tr("Vector", "event_formatter_group_call_leave") + } /// VoIP conference added by %@ internal static func eventFormatterJitsiWidgetAdded(_ p1: String) -> String { return VectorL10n.tr("Vector", "event_formatter_jitsi_widget_added", p1) @@ -2874,7 +2926,11 @@ internal enum VectorL10n { internal static var roomIntroCellInformationRoomWithoutTopicSentence2Part2: String { return VectorL10n.tr("Vector", "room_intro_cell_information_room_without_topic_sentence2_part2") } - /// Jump to first unread message + /// Join + internal static var roomJoinGroupCall: String { + return VectorL10n.tr("Vector", "room_join_group_call") + } + /// Jump to unread internal static var roomJumpToFirstUnread: String { return VectorL10n.tr("Vector", "room_jump_to_first_unread") } @@ -2950,6 +3006,10 @@ internal enum VectorL10n { internal static func roomNewMessagesNotification(_ p1: Int) -> String { return VectorL10n.tr("Vector", "room_new_messages_notification", p1) } + /// You need to be an admin or a moderator to start a call. + internal static var roomNoPrivilegesToCreateGroupCall: String { + return VectorL10n.tr("Vector", "room_no_privileges_to_create_group_call") + } /// Connectivity to the server has been lost. internal static var roomOfflineNotification: String { return VectorL10n.tr("Vector", "room_offline_notification") @@ -3330,6 +3390,10 @@ internal enum VectorL10n { internal static var roomResourceUsageLimitReachedMessageContact3: String { return VectorL10n.tr("Vector", "room_resource_usage_limit_reached_message_contact_3") } + /// Slide to end the call for everyone + internal static var roomSlideToEndGroupCall: String { + return VectorL10n.tr("Vector", "room_slide_to_end_group_call") + } /// Invite members internal static var roomTitleInviteMembers: String { return VectorL10n.tr("Vector", "room_title_invite_members") @@ -4230,6 +4294,10 @@ internal enum VectorL10n { internal static var settingsLabsE2eEncryptionPromptMessage: String { return VectorL10n.tr("Vector", "settings_labs_e2e_encryption_prompt_message") } + /// Ring for group calls + internal static var settingsLabsEnableRingingForGroupCalls: String { + return VectorL10n.tr("Vector", "settings_labs_enable_ringing_for_group_calls") + } /// React to messages with emoji internal static var settingsLabsMessageReaction: String { return VectorL10n.tr("Vector", "settings_labs_message_reaction") diff --git a/Riot/Managers/Call/CallPresenter.swift b/Riot/Managers/Call/CallPresenter.swift index 94d03f578..b0e72d878 100644 --- a/Riot/Managers/Call/CallPresenter.swift +++ b/Riot/Managers/Call/CallPresenter.swift @@ -17,39 +17,67 @@ import Foundation import MatrixKit +// swiftlint:disable file_length + +#if canImport(JitsiMeetSDK) +import JitsiMeetSDK +import CallKit +#endif + +/// The number of milliseconds in one second. +private let MSEC_PER_SEC: TimeInterval = 1000 + @objcMembers /// Service to manage call screens and call bar UI management. class CallPresenter: NSObject { private enum Constants { static let pipAnimationDuration: TimeInterval = 0.25 + static let groupCallInviteLifetime: TimeInterval = 30 } + /// Utilized sessions private var sessions: [MXSession] = [] + /// Call view controllers map. Keys are callIds. private var callVCs: [String: CallViewController] = [:] + /// Call background tasks map. Keys are callIds. private var callBackgroundTasks: [String: MXBackgroundTask] = [:] - private weak var presentedCallVC: CallViewController? { + /// Actively presented direct call view controller. + private weak var presentedCallVC: UIViewController? { didSet { updateOnHoldCall() } } - private weak var inBarCallVC: CallViewController? - private weak var pipCallVC: CallViewController? + private weak var inBarCallVC: UIViewController? + private weak var pipCallVC: UIViewController? + /// UI operation queue for various UI operations private var uiOperationQueue: OperationQueue = .main + /// Flag to indicate whether the presenter is active. private var isStarted: Bool = false private var callTimer: Timer? + #if canImport(JitsiMeetSDK) + private var widgetEventsListener: Any? + /// Jitsi calls map. Keys are CallKit call UUIDs, values are corresponding widgets. + private var jitsiCalls: [UUID: Widget] = [:] + /// The current Jitsi view controller being displayed or not. + private(set) var jitsiVC: JitsiViewController? { + didSet { + updateOnHoldCall() + } + } + #endif private var isCallKitEnabled: Bool { MXCallKitAdapter.callKitAvailable() && MXKAppSettings.standard()?.isCallKitEnabled == true } - private var activeCallVC: CallViewController? { + private var activeCallVC: UIViewController? { return callVCs.values.filter { (callVC) -> Bool in guard let call = callVC.mxCall else { return false } return !call.isOnHold - }.first + }.first ?? jitsiVC } private var onHoldCallVCs: [CallViewController] { @@ -101,20 +129,201 @@ class CallPresenter: NSObject { } /// Method to be called when the call status bar is tapped. - /// - Returns: If the user interaction handled or not - func callStatusBarButtonTapped() -> Bool { - if let callVC = inBarCallVC ?? activeCallVC { + func callStatusBarTapped() { + if let callVC = (inBarCallVC ?? activeCallVC) as? CallViewController { dismissCallBar(for: callVC) presentCallVC(callVC) - return true + return + } + if let jitsiVC = jitsiVC { + dismissCallBar(for: jitsiVC) + presentCallVC(jitsiVC) + } + } + + // MARK - Group Calls + + /// Open the Jitsi view controller from a widget. + /// - Parameter widget: the jitsi widget + func displayJitsiCall(withWidget widget: Widget) { + #if canImport(JitsiMeetSDK) + let createJitsiBlock = { [weak self] in + guard let self = self else { return } + self.jitsiVC = JitsiViewController() + self.jitsiVC?.openWidget(widget, withVideo: true, success: { [weak self] in + guard let self = self else { return } + if let jitsiVC = self.jitsiVC { + jitsiVC.delegate = self + self.presentCallVC(jitsiVC) + self.startJitsiCall(withWidget: widget) + } + }, failure: { [weak self] (error) in + guard let self = self else { return } + self.jitsiVC = nil + AppDelegate.theDelegate().showAlert(withTitle: nil, + message: VectorL10n.callJitsiError) + }) + } + + if let jitsiVC = jitsiVC { + if jitsiVC.widget.widgetId == widget.widgetId { + self.presentCallVC(jitsiVC) + } else { + // end previous Jitsi call first + endActiveJitsiCall() + createJitsiBlock() + } + } else { + createJitsiBlock() + } + #else + AppDelegate.theDelegate().showAlert(withTitle: nil, + message: Bundle.mxk_localizedString(forKey: "not_supported_yet")) + #endif + } + + private func startJitsiCall(withWidget widget: Widget) { + if self.jitsiCalls.first(where: { $0.value.widgetId == widget.widgetId })?.key != nil { + // this Jitsi call is already managed by this class, no need to report the call again + return + } + + guard let roomId = widget.roomId else { + return + } + + guard let session = sessions.first else { + return + } + + guard let room = session.room(withRoomId: roomId) else { + return + } + + let newUUID = UUID() + let handle = CXHandle(type: .generic, value: roomId) + let startCallAction = CXStartCallAction(call: newUUID, handle: handle) + let transaction = CXTransaction(action: startCallAction) + JMCallKitProxy.request(transaction) { (error) in + if error == nil { + JMCallKitProxy.reportCallUpdate(with: newUUID, + handle: roomId, + displayName: room.summary.displayname, + hasVideo: true) + JMCallKitProxy.reportOutgoingCall(with: newUUID, connectedAt: nil) + + self.jitsiCalls[newUUID] = widget + } + } + } + + func endActiveJitsiCall() { + guard let jitsiVC = jitsiVC else { + // there is no active Jitsi call + return + } + + if pipCallVC == jitsiVC { + // this call currently in the PiP mode, + // first present it by exiting PiP mode and then dismiss it + exitPipCallVC(jitsiVC) + } + + dismissCallVC(jitsiVC) + jitsiVC.hangup() + + self.jitsiVC = nil + + guard let widget = jitsiVC.widget else { + return + } + guard let uuid = self.jitsiCalls.first(where: { $0.value.widgetId == widget.widgetId })?.key else { + // this Jitsi call is not managed by this class + return + } + + let endCallAction = CXEndCallAction(call: uuid) + let transaction = CXTransaction(action: endCallAction) + JMCallKitProxy.request(transaction) { (error) in + if error == nil { + self.jitsiCalls.removeValue(forKey: uuid) + } + } + } + + func processWidgetEvent(_ event: MXEvent, inSession session: MXSession) { + guard JMCallKitProxy.isProviderConfigured() else { + // CallKit proxy is not configured, no benefit in parsing the event + return + } + + guard let widget = Widget(widgetEvent: event, inMatrixSession: session) else { + return + } + + if self.jitsiCalls.first(where: { $0.value.widgetId == widget.widgetId })?.key != nil { + // this Jitsi call is already managed by this class, no need to report the call again + return + } + + if widget.isActive { + guard widget.type == kWidgetTypeJitsiV1 || widget.type == kWidgetTypeJitsiV2 else { + // not a Jitsi widget, ignore + return + } + + if let jitsiVC = jitsiVC, + jitsiVC.widget.widgetId == widget.widgetId { + // this is already the Jitsi call we have atm + return + } + + if TimeInterval(event.age)/MSEC_PER_SEC > Constants.groupCallInviteLifetime { + // too late to process the event + return + } + + // an active Jitsi widget + let newUUID = UUID() + + // assume this Jitsi call will survive + self.jitsiCalls[newUUID] = widget + + if event.sender == session.myUserId { + // outgoing call + JMCallKitProxy.reportOutgoingCall(with: newUUID, connectedAt: nil) + } else { + // incoming call + guard RiotSettings.shared.enableRingingForGroupCalls else { + // do not ring for Jitsi calls + return + } + let user = session.user(withUserId: event.sender) + let displayName = NSString.localizedUserNotificationString(forKey: "GROUP_CALL_FROM_USER", + arguments: [user?.displayname as Any]) + JMCallKitProxy.reportNewIncomingCall(UUID: newUUID, + handle: widget.roomId, + displayName: displayName, + hasVideo: true) { (error) in + if error != nil { + self.jitsiCalls.removeValue(forKey: newUUID) + } + } + } + } else { + guard let uuid = self.jitsiCalls.first(where: { $0.value.widgetId == widget.widgetId })?.key else { + // this Jitsi call is not managed by this class + return + } + JMCallKitProxy.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded) + self.jitsiCalls.removeValue(forKey: uuid) } - return false } // MARK: - Private private func updateOnHoldCall() { - guard let presentedCallVC = presentedCallVC else { + guard let presentedCallVC = presentedCallVC as? CallViewController else { return } @@ -131,9 +340,6 @@ class CallPresenter: NSObject { } private func shouldHandleCall(_ call: MXCall) -> Bool { - if let delegate = delegate, !delegate.callPresenter(self, shouldHandleNewCall: call) { - return false - } return callVCs.count < maximumNumberOfConcurrentCalls } @@ -181,7 +387,19 @@ class CallPresenter: NSObject { } return } - dismissCallVC(callVC, completion: completion) + if callVC.isDisplayingAlert { + completion() + } else { + dismissCallVC(callVC, completion: completion) + } + } + + private func logCallVC(_ callVC: UIViewController, log: String) { + if let callVC = callVC as? CallViewController { + NSLog("[CallPresenter] \(log): call: \(String(describing: callVC.mxCall?.callId))") + } else if let callVC = callVC as? JitsiViewController { + NSLog("[CallPresenter] \(log): call: \(callVC.widget.widgetId)") + } } // MARK: - Timer @@ -200,17 +418,18 @@ class CallPresenter: NSObject { } @objc private func callTimerFired(_ timer: Timer) { - guard let inBarCallVC = inBarCallVC else { - return + if let inBarCallVC = inBarCallVC as? CallViewController { + guard let call = inBarCallVC.mxCall else { + return + } + guard call.state != .ended else { + return + } + + updateCallBar() + } else if inBarCallVC as? JitsiViewController != nil { + updateCallBar() } - guard let call = inBarCallVC.mxCall else { - return - } - guard call.state != .ended else { - return - } - - presentCallBar(for: inBarCallVC, isUpdateOnly: true) } // MARK: - Observers @@ -232,8 +451,32 @@ class CallPresenter: NSObject { selector: #selector(callTileTapped(_:)), name: .RoomCallTileTapped, object: nil) + NotificationCenter.default.addObserver(self, + selector: #selector(groupCallTileTapped(_:)), + name: .RoomGroupCallTileTapped, + object: nil) isStarted = true + + #if canImport(JitsiMeetSDK) + JMCallKitProxy.addListener(self) + + guard let session = sessions.first else { + return + } + + widgetEventsListener = session.listenToEvents([ + MXEventType(identifier: kWidgetMatrixEventTypeString), + MXEventType(identifier: kWidgetModularEventTypeString) + ]) { (event, direction, _) in + if direction == .backwards { + // ignore backwards events + return + } + + self.processWidgetEvent(event, inSession: session) + } + #endif } private func removeCallObservers() { @@ -250,8 +493,24 @@ class CallPresenter: NSObject { NotificationCenter.default.removeObserver(self, name: .RoomCallTileTapped, object: nil) + NotificationCenter.default.removeObserver(self, + name: .RoomGroupCallTileTapped, + object: nil) isStarted = false + + #if canImport(JitsiMeetSDK) + JMCallKitProxy.removeListener(self) + + guard let session = sessions.first else { + return + } + + if let widgetEventsListener = widgetEventsListener { + session.removeListener(widgetEventsListener) + } + widgetEventsListener = nil + #endif } @objc @@ -269,6 +528,15 @@ class CallPresenter: NSObject { } newCallVC.playRingtone = !isCallKitEnabled newCallVC.delegate = self + + if !call.isIncoming { + // put other native calls on hold + callVCs.values.forEach({ $0.mxCall.hold(true) }) + + // terminate Jitsi calls + endActiveJitsiCall() + } + callVCs[call.callId] = newCallVC if UIApplication.shared.applicationState == .background && call.isIncoming { @@ -276,7 +544,7 @@ class CallPresenter: NSObject { // Without CallKit this will allow us to play vibro until the call was ended // With CallKit we'll inform the system when the call is ended to let the system terminate our app to save resources let handler = MXSDKOptions.sharedInstance().backgroundModeHandler - let callBackgroundTask = handler.startBackgroundTask(withName: "[CallService] addMatrixCallObserver", expirationHandler: nil) + let callBackgroundTask = handler.startBackgroundTask(withName: "[CallPresenter] addMatrixCallObserver", expirationHandler: nil) callBackgroundTasks[call.callId] = callBackgroundTask } @@ -296,23 +564,23 @@ class CallPresenter: NSObject { switch call.state { case .createAnswer: - NSLog("[CallService] callStateChanged: call created answer: \(call.callId)") + NSLog("[CallPresenter] callStateChanged: call created answer: \(call.callId)") if call.isIncoming, isCallKitEnabled, let callVC = callVCs[call.callId] { presentCallVC(callVC) } case .connected: - NSLog("[CallService] callStateChanged: call connected: \(call.callId)") + NSLog("[CallPresenter] callStateChanged: call connected: \(call.callId)") callTimer?.fire() case .onHold: - NSLog("[CallService] callStateChanged: call holded: \(call.callId)") + NSLog("[CallPresenter] callStateChanged: call holded: \(call.callId)") callTimer?.fire() callHolded(withCallId: call.callId) case .remotelyOnHold: - NSLog("[CallService] callStateChanged: call remotely holded: \(call.callId)") + NSLog("[CallPresenter] callStateChanged: call remotely holded: \(call.callId)") callTimer?.fire() callHolded(withCallId: call.callId) case .ended: - NSLog("[CallService] callStateChanged: call ended: \(call.callId)") + NSLog("[CallPresenter] callStateChanged: call ended: \(call.callId)") endCall(withCallId: call.callId) default: break @@ -321,7 +589,8 @@ class CallPresenter: NSObject { @objc private func callTileTapped(_ notification: Notification) { - NSLog("[CallService] callTileTapped") + NSLog("[CallPresenter] callTileTapped") + guard let bubbleData = notification.object as? RoomBubbleCellData else { return } @@ -334,7 +603,7 @@ class CallPresenter: NSObject { return } - NSLog("[CallService] callTileTapped: for call: \(callEventContent.callId)") + NSLog("[CallPresenter] callTileTapped: for call: \(callEventContent.callId)") guard let session = sessions.first else { return } @@ -350,13 +619,55 @@ class CallPresenter: NSObject { return } - presentCallVC(callVC) + if callVC == pipCallVC { + exitPipCallVC(callVC) + } else { + presentCallVC(callVC) + } + } + + @objc + private func groupCallTileTapped(_ notification: Notification) { + NSLog("[CallPresenter] groupCallTileTapped") + + guard let bubbleData = notification.object as? RoomBubbleCellData else { + return + } + + guard let randomEvent = bubbleData.allLinkedEvents().randomElement() else { + return + } + + guard randomEvent.eventType == .custom, + (randomEvent.type == kWidgetMatrixEventTypeString || + randomEvent.type == kWidgetModularEventTypeString) else { + return + } + + guard let session = sessions.first else { return } + + guard let widget = Widget(widgetEvent: randomEvent, inMatrixSession: session) else { + return + } + + NSLog("[CallPresenter] groupCallTileTapped: for call: \(widget.widgetId)") + + guard let jitsiVC = jitsiVC, + jitsiVC.widget.widgetId == widget.widgetId else { + return + } + + if jitsiVC == pipCallVC { + exitPipCallVC(jitsiVC) + } else { + presentCallVC(jitsiVC) + } } // MARK: - Call Screens - private func presentCallVC(_ callVC: CallViewController, completion: (() -> Void)? = nil) { - NSLog("[CallService] presentCallVC: call: \(String(describing: callVC.mxCall?.callId))") + private func presentCallVC(_ callVC: UIViewController, completion: (() -> Void)? = nil) { + logCallVC(callVC, log: "presentCallVC") // do not use PiP transitions here, as we really want to present the screen callVC.transitioningDelegate = nil @@ -379,8 +690,8 @@ class CallPresenter: NSObject { uiOperationQueue.addOperation(operation) } - private func dismissCallVC(_ callVC: CallViewController, completion: (() -> Void)? = nil) { - NSLog("[CallService] dismissCallVC: call: \(String(describing: callVC.mxCall?.callId))") + private func dismissCallVC(_ callVC: UIViewController, completion: (() -> Void)? = nil) { + logCallVC(callVC, log: "dismissCallVC") // do not use PiP transitions here, as we really want to dismiss the screen callVC.transitioningDelegate = nil @@ -394,8 +705,8 @@ class CallPresenter: NSObject { uiOperationQueue.addOperation(operation) } - private func enterPipCallVC(_ callVC: CallViewController, completion: (() -> Void)? = nil) { - NSLog("[CallService] enterPipCallVC: call: \(String(describing: callVC.mxCall?.callId))") + private func enterPipCallVC(_ callVC: UIViewController, completion: (() -> Void)? = nil) { + logCallVC(callVC, log: "enterPipCallVC") // assign self as transitioning delegate callVC.transitioningDelegate = self @@ -410,8 +721,8 @@ class CallPresenter: NSObject { uiOperationQueue.addOperation(operation) } - private func exitPipCallVC(_ callVC: CallViewController, completion: (() -> Void)? = nil) { - NSLog("[CallService] exitPipCallVC: call: \(String(describing: callVC.mxCall?.callId))") + private func exitPipCallVC(_ callVC: UIViewController, completion: (() -> Void)? = nil) { + logCallVC(callVC, log: "exitPipCallVC") // assign self as transitioning delegate callVC.transitioningDelegate = self @@ -428,24 +739,29 @@ class CallPresenter: NSObject { // MARK: - Call Bar - private func presentCallBar(for callVC: CallViewController?, isUpdateOnly: Bool = false, completion: (() -> Void)? = nil) { - NSLog("[CallService] presentCallBar: call: \(String(describing: callVC?.mxCall?.callId))") + private func presentCallBar(for callVC: UIViewController, completion: (() -> Void)? = nil) { + logCallVC(callVC, log: "presentCallBar") let activeCallVC = self.activeCallVC let operation = CallBarPresentOperation(presenter: self, activeCallVC: activeCallVC, numberOfPausedCalls: numberOfPausedCalls) { [weak self] in // active calls are more prior to paused ones. - // So, if user taps the bar when we have one active and one paused calls, we navigate to the active one. - if !isUpdateOnly { - self?.inBarCallVC = activeCallVC ?? callVC - } + // So, if user taps the bar when we have one active and one paused call, we navigate to the active one. + self?.inBarCallVC = activeCallVC ?? callVC completion?() } uiOperationQueue.addOperation(operation) } - private func dismissCallBar(for callVC: CallViewController, completion: (() -> Void)? = nil) { - NSLog("[CallService] dismissCallBar: call: \(String(describing: callVC.mxCall?.callId))") + private func updateCallBar() { + let activeCallVC = self.activeCallVC + + let operation = CallBarUpdateOperation(presenter: self, activeCallVC: activeCallVC, numberOfPausedCalls: numberOfPausedCalls) + uiOperationQueue.addOperation(operation) + } + + private func dismissCallBar(for callVC: UIViewController, completion: (() -> Void)? = nil) { + logCallVC(callVC, log: "dismissCallBar") let operation = CallBarDismissOperation(presenter: self) { [weak self] in if callVC == self?.inBarCallVC { @@ -474,8 +790,13 @@ extension CallPresenter: MXKCallViewControllerDelegate { // wait for the call state changes, will be handled there return } else { - dismissCallVC(callVC) - self.presentCallBar(for: callVC, completion: completion) + if callVC.mxCall.isVideoCall { + // go to pip mode here + enterPipCallVC(callVC, completion: completion) + } else { + dismissCallVC(callVC) + self.presentCallBar(for: callVC, completion: completion) + } } } @@ -497,20 +818,6 @@ extension CallPresenter: MXKCallViewControllerDelegate { presentCallVC(onHoldCallVC) } - func callViewControllerDidTapPiPButton(_ callViewController: MXKCallViewController!) { - guard let callVC = callViewController as? CallViewController else { - // this call screen is not handled by this service - return - } - - // sanity check - // do not enter PiP mode if not a video call - guard callVC.mxCall.isVideoCall else { return } - - // go to pip mode here - enterPipCallVC(callVC) - } - } // MARK: - UIViewControllerTransitioningDelegate @@ -562,3 +869,84 @@ extension OperationQueue { } } + +#if canImport(JitsiMeetSDK) +// MARK: - JMCallKitListener + +extension CallPresenter: JMCallKitListener { + + func providerDidReset() { + + } + + func performAnswerCall(UUID: UUID) { + guard let widget = jitsiCalls[UUID] else { + return + } + + displayJitsiCall(withWidget: widget) + } + + func performEndCall(UUID: UUID) { + guard let widget = jitsiCalls[UUID] else { + return + } + + if let jitsiVC = jitsiVC, jitsiVC.widget.widgetId == widget.widgetId { + // hangup an active call + dismissCallVC(jitsiVC) + endActiveJitsiCall() + } else { + // decline incoming call + JitsiService.shared.declineWidget(withId: widget.widgetId) + } + } + + func performSetMutedCall(UUID: UUID, isMuted: Bool) { + guard let widget = jitsiCalls[UUID] else { + return + } + + if let jitsiVC = jitsiVC, jitsiVC.widget.widgetId == widget.widgetId { + // mute the active Jitsi call + jitsiVC.setAudioMuted(isMuted) + } + } + + func performStartCall(UUID: UUID, isVideo: Bool) { + + } + + func providerDidActivateAudioSession(session: AVAudioSession) { + + } + + func providerDidDeactivateAudioSession(session: AVAudioSession) { + + } + + func providerTimedOutPerformingAction(action: CXAction) { + + } + +} + +// MARK: - JitsiViewControllerDelegate + +extension CallPresenter: JitsiViewControllerDelegate { + + func jitsiViewController(_ jitsiViewController: JitsiViewController!, dismissViewJitsiController completion: (() -> Void)!) { + if jitsiViewController == jitsiVC { + endActiveJitsiCall() + } + } + + func jitsiViewController(_ jitsiViewController: JitsiViewController!, goBackToApp completion: (() -> Void)!) { + if jitsiViewController == jitsiVC { + enterPipCallVC(jitsiViewController, completion: completion) + } + } + +} + +#endif diff --git a/Riot/Managers/Call/CallPresenterDelegate.swift b/Riot/Managers/Call/CallPresenterDelegate.swift index c3a47cb3a..57ee1c941 100644 --- a/Riot/Managers/Call/CallPresenterDelegate.swift +++ b/Riot/Managers/Call/CallPresenterDelegate.swift @@ -18,32 +18,31 @@ import Foundation @objc protocol CallPresenterDelegate: class { - // New call - func callPresenter(_ presenter: CallPresenter, - shouldHandleNewCall call: MXCall) -> Bool - // Call screens func callPresenter(_ presenter: CallPresenter, - presentCallViewController viewController: CallViewController, + presentCallViewController viewController: UIViewController, completion:(() -> Void)?) func callPresenter(_ presenter: CallPresenter, - dismissCallViewController viewController: CallViewController, + dismissCallViewController viewController: UIViewController, completion:(() -> Void)?) // Call Bar func callPresenter(_ presenter: CallPresenter, - presentCallBarFor activeCallViewController: CallViewController?, + presentCallBarFor activeCallViewController: UIViewController?, numberOfPausedCalls: UInt, completion:(() -> Void)?) + func callPresenter(_ presenter: CallPresenter, + updateCallBarFor activeCallViewController: UIViewController?, + numberOfPausedCalls: UInt) func callPresenter(_ presenter: CallPresenter, dismissCallBar completion:(() -> Void)?) // PiP func callPresenter(_ presenter: CallPresenter, - enterPipForCallViewController viewController: CallViewController, + enterPipForCallViewController viewController: UIViewController, completion:(() -> Void)?) func callPresenter(_ presenter: CallPresenter, - exitPipForCallViewController viewController: CallViewController, + exitPipForCallViewController viewController: UIViewController, completion:(() -> Void)?) } diff --git a/Riot/Managers/Call/Operations/CallBarPresentOperation.swift b/Riot/Managers/Call/Operations/CallBarPresentOperation.swift index 8e2eb610a..31ec542f1 100644 --- a/Riot/Managers/Call/Operations/CallBarPresentOperation.swift +++ b/Riot/Managers/Call/Operations/CallBarPresentOperation.swift @@ -19,12 +19,12 @@ import Foundation class CallBarPresentOperation: AsyncOperation { private var presenter: CallPresenter - private var activeCallVC: CallViewController? + private var activeCallVC: UIViewController? private var numberOfPausedCalls: UInt private var completion: (() -> Void)? init(presenter: CallPresenter, - activeCallVC: CallViewController?, + activeCallVC: UIViewController?, numberOfPausedCalls: UInt, completion: (() -> Void)? = nil) { self.presenter = presenter @@ -36,7 +36,10 @@ class CallBarPresentOperation: AsyncOperation { override func main() { presenter.delegate?.callPresenter(presenter, presentCallBarFor: activeCallVC, numberOfPausedCalls: numberOfPausedCalls, completion: { self.finish() - self.completion?() + // wait for the next life cycle to detect status bar layout updates + DispatchQueue.main.async { + self.completion?() + } }) } diff --git a/Riot/Managers/Call/Operations/CallBarUpdateOperation.swift b/Riot/Managers/Call/Operations/CallBarUpdateOperation.swift new file mode 100644 index 000000000..23c7a5f5d --- /dev/null +++ b/Riot/Managers/Call/Operations/CallBarUpdateOperation.swift @@ -0,0 +1,38 @@ +// +// Copyright 2020 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 CallBarUpdateOperation: AsyncOperation { + + private var presenter: CallPresenter + private var activeCallVC: UIViewController? + private var numberOfPausedCalls: UInt + + init(presenter: CallPresenter, + activeCallVC: UIViewController?, + numberOfPausedCalls: UInt) { + self.presenter = presenter + self.activeCallVC = activeCallVC + self.numberOfPausedCalls = numberOfPausedCalls + } + + override func main() { + presenter.delegate?.callPresenter(presenter, updateCallBarFor: activeCallVC, numberOfPausedCalls: numberOfPausedCalls) + self.finish() + } + +} diff --git a/Riot/Managers/Call/Operations/CallVCDismissOperation.swift b/Riot/Managers/Call/Operations/CallVCDismissOperation.swift index 7a7073d39..facc8428c 100644 --- a/Riot/Managers/Call/Operations/CallVCDismissOperation.swift +++ b/Riot/Managers/Call/Operations/CallVCDismissOperation.swift @@ -19,11 +19,11 @@ import Foundation class CallVCDismissOperation: AsyncOperation { private var presenter: CallPresenter - private var callVC: CallViewController + private var callVC: UIViewController private var completion: (() -> Void)? init(presenter: CallPresenter, - callVC: CallViewController, + callVC: UIViewController, completion: (() -> Void)? = nil) { self.presenter = presenter self.callVC = callVC diff --git a/Riot/Managers/Call/Operations/CallVCEnterPipOperation.swift b/Riot/Managers/Call/Operations/CallVCEnterPipOperation.swift index be140e7ae..74bde2785 100644 --- a/Riot/Managers/Call/Operations/CallVCEnterPipOperation.swift +++ b/Riot/Managers/Call/Operations/CallVCEnterPipOperation.swift @@ -19,11 +19,11 @@ import Foundation class CallVCEnterPipOperation: AsyncOperation { private var presenter: CallPresenter - private var callVC: CallViewController + private var callVC: UIViewController private var completion: (() -> Void)? init(presenter: CallPresenter, - callVC: CallViewController, + callVC: UIViewController, completion: (() -> Void)? = nil) { self.presenter = presenter self.callVC = callVC diff --git a/Riot/Managers/Call/Operations/CallVCExitPipOperation.swift b/Riot/Managers/Call/Operations/CallVCExitPipOperation.swift index 0a4d17147..1e74c7ded 100644 --- a/Riot/Managers/Call/Operations/CallVCExitPipOperation.swift +++ b/Riot/Managers/Call/Operations/CallVCExitPipOperation.swift @@ -19,11 +19,11 @@ import Foundation class CallVCExitPipOperation: AsyncOperation { private var presenter: CallPresenter - private var callVC: CallViewController + private var callVC: UIViewController private var completion: (() -> Void)? init(presenter: CallPresenter, - callVC: CallViewController, + callVC: UIViewController, completion: (() -> Void)? = nil) { self.presenter = presenter self.callVC = callVC diff --git a/Riot/Managers/Call/Operations/CallVCPresentOperation.swift b/Riot/Managers/Call/Operations/CallVCPresentOperation.swift index 1a58f86cd..fae80e51b 100644 --- a/Riot/Managers/Call/Operations/CallVCPresentOperation.swift +++ b/Riot/Managers/Call/Operations/CallVCPresentOperation.swift @@ -19,11 +19,11 @@ import Foundation class CallVCPresentOperation: AsyncOperation { private var presenter: CallPresenter - private var callVC: CallViewController + private var callVC: UIViewController private var completion: (() -> Void)? init(presenter: CallPresenter, - callVC: CallViewController, + callVC: UIViewController, completion: (() -> Void)? = nil) { self.presenter = presenter self.callVC = callVC diff --git a/Riot/Managers/Call/PiPAnimator.swift b/Riot/Managers/Call/PiPAnimator.swift index 17faf256e..46171be6d 100644 --- a/Riot/Managers/Call/PiPAnimator.swift +++ b/Riot/Managers/Call/PiPAnimator.swift @@ -25,7 +25,7 @@ import Foundation class PiPAnimator: NSObject { private enum Constants { - static let pipViewScale: CGFloat = 0.3 + static let pipViewSize: CGSize = CGSize(width: 90, height: 130) } let animationDuration: TimeInterval @@ -62,15 +62,15 @@ class PiPAnimator: NSObject { pipView.delegate = pipViewDelegate keyWindow.addSubview(pipView) - let transform = CGAffineTransform(scaleX: Constants.pipViewScale, y: Constants.pipViewScale) - let targetRect = fromVC.view.bounds.applying(transform) + let scale = Constants.pipViewSize.width/pipView.frame.width + let transform = CGAffineTransform(scaleX: scale, y: scale) + let targetSize = Constants.pipViewSize let animator = UIViewPropertyAnimator(duration: animationDuration, dampingRatio: 1) { pipView.transform = transform pipView.move(in: keyWindow, - to: .bottomLeft, - targetSize: targetRect.size) + targetSize: targetSize) } animator.addCompletion { (position) in @@ -117,6 +117,7 @@ class PiPAnimator: NSObject { animator.addCompletion { (position) in toVC.additionalSafeAreaInsets = .zero + toVC.view.frame = context.finalFrame(for: toVC) toVC.view.isHidden = false snapshot.removeFromSuperview() diff --git a/Riot/Managers/Call/PiPView.swift b/Riot/Managers/Call/PiPView.swift index 2eb15b56c..8f5a0587a 100644 --- a/Riot/Managers/Call/PiPView.swift +++ b/Riot/Managers/Call/PiPView.swift @@ -17,8 +17,8 @@ import UIKit @objc enum PiPViewPosition: Int { - case bottomLeft // default value - case bottomRight + case bottomLeft + case bottomRight // default value case topRight case topLeft } @@ -32,12 +32,12 @@ import UIKit class PiPView: UIView { private enum Defaults { - static let margins: UIOffset = UIOffset(horizontal: 20, vertical: 20) + static let margins: UIEdgeInsets = UIEdgeInsets(top: 64, left: 20, bottom: 64, right: 20) static let cornerRadius: CGFloat = 8 static let animationDuration: TimeInterval = 0.25 } - var margins: UIOffset = Defaults.margins { + var margins: UIEdgeInsets = Defaults.margins { didSet { guard self.superview != nil else { return } self.move(to: self.position, animated: true) @@ -48,7 +48,7 @@ class PiPView: UIView { layer.cornerRadius = cornerRadius } } - var position: PiPViewPosition = .bottomLeft + var position: PiPViewPosition = .bottomRight weak var delegate: PiPViewDelegate? private var originalCenter: CGPoint = .zero @@ -97,7 +97,7 @@ NSLayoutConstraint.activate([ } func move(in view: UIView? = nil, - to position: PiPViewPosition = .bottomLeft, + to position: PiPViewPosition = .bottomRight, targetSize: CGSize? = nil, animated: Bool = false, completion: ((Bool) -> Void)? = nil) { @@ -148,25 +148,36 @@ NSLayoutConstraint.activate([ } let targetSize = targetSize ?? frame.size + var superviewWidth: CGFloat = 0 + var superviewHeight: CGFloat = 0 + + if UIDevice.current.orientation.isPortrait { + superviewWidth = min(view.bounds.width, view.bounds.height) + superviewHeight = max(view.bounds.width, view.bounds.height) + } else { + superviewWidth = max(view.bounds.width, view.bounds.height) + superviewHeight = min(view.bounds.width, view.bounds.height) + } + switch position { case .bottomLeft: - let origin = CGPoint(x: margins.horizontal + view.safeAreaInsets.left, - y: view.bounds.height - view.safeAreaInsets.bottom - targetSize.height - margins.vertical) + let origin = CGPoint(x: margins.left + view.safeAreaInsets.left, + y: superviewHeight - view.safeAreaInsets.bottom - targetSize.height - margins.bottom) return CGRect(origin: origin, size: targetSize) case .bottomRight: - let origin = CGPoint(x: view.bounds.width - view.safeAreaInsets.right - margins.horizontal - targetSize.width, - y: view.bounds.height - view.safeAreaInsets.bottom - targetSize.height - margins.vertical) + let origin = CGPoint(x: superviewWidth - view.safeAreaInsets.right - margins.right - targetSize.width, + y: superviewHeight - view.safeAreaInsets.bottom - targetSize.height - margins.bottom) return CGRect(origin: origin, size: targetSize) case .topRight: - let origin = CGPoint(x: view.bounds.width - view.safeAreaInsets.right - margins.horizontal - targetSize.width, - y: margins.vertical + view.safeAreaInsets.top) + let origin = CGPoint(x: superviewWidth - view.safeAreaInsets.right - margins.right - targetSize.width, + y: margins.top + view.safeAreaInsets.top) return CGRect(origin: origin, size: targetSize) case .topLeft: - let origin = CGPoint(x: margins.horizontal + view.safeAreaInsets.left, - y: margins.vertical + view.safeAreaInsets.top) + let origin = CGPoint(x: margins.left + view.safeAreaInsets.left, + y: margins.top + view.safeAreaInsets.top) return CGRect(origin: origin, size: targetSize) } diff --git a/Riot/Managers/PushNotification/PushNotificationService.m b/Riot/Managers/PushNotification/PushNotificationService.m index 44a639c9e..be6a61c79 100644 --- a/Riot/Managers/PushNotification/PushNotificationService.m +++ b/Riot/Managers/PushNotification/PushNotificationService.m @@ -323,6 +323,30 @@ Matrix session observer used to detect new opened sessions. #pragma mark - UNUserNotificationCenterDelegate +- (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler +{ + NSDictionary *userInfo = notification.request.content.userInfo; + if (userInfo[Constants.userInfoKeyPresentNotificationOnForeground]) + { + if (!userInfo[Constants.userInfoKeyPresentNotificationInRoom] + && [[AppDelegate theDelegate].visibleRoomId isEqualToString:userInfo[@"room_id"]]) + { + // do not show the notification when we're in the notified room + completionHandler(UNNotificationPresentationOptionNone); + } + else + { + completionHandler(UNNotificationPresentationOptionBadge + | UNNotificationPresentationOptionSound + | UNNotificationPresentationOptionAlert); + } + } + else + { + completionHandler(UNNotificationPresentationOptionNone); + } +} + // iOS 10+, see application:handleActionWithIdentifier:forLocalNotification:withResponseInfo:completionHandler: - (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)(void))completionHandler { @@ -568,23 +592,37 @@ Matrix session observer used to detect new opened sessions. return; } - // process the call invite synchronously - [session.callManager handleCallEvent:lastCallInvite]; - MXCall *call = [session.callManager callWithCallId:lastCallInvite.content[@"call_id"]]; - if (call) + if (lastCallInvite.eventType == MXEventTypeCallInvite) { - [session.callManager.callKitAdapter reportIncomingCall:call]; - NSLog(@"[PushNotificationService] didReceiveIncomingPushWithPayload: Reporting new call in room %@ for the event: %@", roomId, eventId); - - // Wait for the sync response in cache to be processed for data integrity. - dispatch_group_notify(dispatchGroup, dispatch_get_main_queue(), ^{ - // After reporting the call, we can continue async. Launch a background sync to handle call answers/declines on other devices of the user. - [self launchBackgroundSync]; - }); + // process the call invite synchronously + [session.callManager handleCallEvent:lastCallInvite]; + MXCall *call = [session.callManager callWithCallId:lastCallInvite.content[@"call_id"]]; + if (call) + { + [session.callManager.callKitAdapter reportIncomingCall:call]; + NSLog(@"[PushNotificationService] didReceiveIncomingPushWithPayload: Reporting new call in room %@ for the event: %@", roomId, eventId); + + // Wait for the sync response in cache to be processed for data integrity. + dispatch_group_notify(dispatchGroup, dispatch_get_main_queue(), ^{ + // After reporting the call, we can continue async. Launch a background sync to handle call answers/declines on other devices of the user. + [self launchBackgroundSync]; + }); + } + else + { + NSLog(@"[PushNotificationService] didReceiveIncomingPushWithPayload: Error on call object on room %@ for the event: %@", roomId, eventId); + } + } + else if ([lastCallInvite.type isEqualToString:kWidgetMatrixEventTypeString] || + [lastCallInvite.type isEqualToString:kWidgetModularEventTypeString]) + { + [[AppDelegate theDelegate].callPresenter processWidgetEvent:lastCallInvite + inSession:session]; } else { - NSLog(@"[PushNotificationService] didReceiveIncomingPushWithPayload: Error on call object on room %@ for the event: %@", roomId, eventId); + // It's a serious error. There is nothing to avoid iOS to kill us here. + NSLog(@"[PushNotificationService] didReceiveIncomingPushWithPayload: We have an unknown type of event for %@. There is something wrong.", eventId); } } else diff --git a/Riot/Managers/Settings/RiotSettings.swift b/Riot/Managers/Settings/RiotSettings.swift index 1886975aa..954741ccb 100644 --- a/Riot/Managers/Settings/RiotSettings.swift +++ b/Riot/Managers/Settings/RiotSettings.swift @@ -27,7 +27,6 @@ final class RiotSettings: NSObject { static let identityServerUrlString = "identityserverurl" static let enableCrashReport = "enableCrashReport" static let enableRageShake = "enableRageShake" - static let createConferenceCallsWithJitsi = "createConferenceCallsWithJitsi" static let userInterfaceTheme = "userInterfaceTheme" static let notificationsShowDecryptedContent = "showDecryptedContent" static let pinRoomsWithMissedNotifications = "pinRoomsWithMissedNotif" @@ -52,6 +51,7 @@ final class RiotSettings: NSObject { static let roomCreationScreenAllowRoomTypeConfiguration = "roomCreationScreenAllowRoomTypeConfiguration" static let roomCreationScreenRoomIsPublic = "roomCreationScreenRoomIsPublic" static let allowInviteExernalUsers = "allowInviteExernalUsers" + static let enableRingingForGroupCalls = "enableRingingForGroupCalls" static let roomSettingsScreenShowLowPriorityOption = "roomSettingsScreenShowLowPriorityOption" static let roomSettingsScreenShowDirectChatOption = "roomSettingsScreenShowDirectChatOption" static let roomSettingsScreenAllowChangingAccessSettings = "roomSettingsScreenAllowChangingAccessSettings" @@ -60,6 +60,8 @@ final class RiotSettings: NSObject { static let roomSettingsScreenShowFlairSettings = "roomSettingsScreenShowFlairSettings" static let roomSettingsScreenShowAdvancedSettings = "roomSettingsScreenShowAdvancedSettings" static let roomSettingsScreenAdvancedShowEncryptToVerifiedOption = "roomSettingsScreenAdvancedShowEncryptToVerifiedOption" + static let settingsScreenShowNotificationDecodedContentOption = "settingsScreenShowNotificationDecodedContentOption" + static let settingsScreenShowNsfwRoomsOption = "settingsScreenShowNsfwRoomsOption" static let roomsAllowToJoinPublicRooms = "roomsAllowToJoinPublicRooms" static let homeScreenShowFavouritesTab = "homeScreenShowFavouritesTab" static let homeScreenShowPeopleTab = "homeScreenShowPeopleTab" @@ -71,7 +73,11 @@ final class RiotSettings: NSObject { static let roomScreenAllowMediaLibraryAction = "roomScreenAllowMediaLibraryAction" static let roomScreenAllowStickerAction = "roomScreenAllowStickerAction" static let roomScreenAllowFilesAction = "roomScreenAllowFilesAction" + static let roomContextualMenuShowMoreOptionForMessages = "roomContextualMenuShowMoreOptionForMessages" + static let roomContextualMenuShowMoreOptionForStates = "roomContextualMenuShowMoreOptionForStates" + static let roomContextualMenuShowReportContentOption = "roomContextualMenuShowReportContentOption" static let roomInfoScreenShowIntegrations = "roomInfoScreenShowIntegrations" + static let roomMemberScreenShowIgnore = "roomMemberScreenShowIgnore" static let unifiedSearchScreenShowPublicDirectory = "unifiedSearchScreenShowPublicDirectory" } @@ -198,14 +204,15 @@ final class RiotSettings: NSObject { // MARK: Labs - var createConferenceCallsWithJitsi: Bool { + /// Indicates if CallKit ringing is enabled for group calls. This setting does not disable the CallKit integration for group calls, only relates to ringing. + var enableRingingForGroupCalls: Bool { get { - return defaults.bool(forKey: UserDefaultsKeys.createConferenceCallsWithJitsi) + return defaults.bool(forKey: UserDefaultsKeys.enableRingingForGroupCalls) } set { - defaults.set(newValue, forKey: UserDefaultsKeys.createConferenceCallsWithJitsi) + defaults.set(newValue, forKey: UserDefaultsKeys.enableRingingForGroupCalls) } } - + // MARK: Calls /// Indicate if `allowStunServerFallback` settings has been set once. @@ -322,6 +329,39 @@ final class RiotSettings: NSObject { defaults.set(newValue, forKey: UserDefaultsKeys.roomScreenAllowFilesAction) } } + + // MARK: - Room Contextual Menu + + var roomContextualMenuShowMoreOptionForMessages: Bool { + get { + guard defaults.object(forKey: UserDefaultsKeys.roomContextualMenuShowMoreOptionForMessages) != nil else { + return BuildSettings.roomContextualMenuShowMoreOptionForMessages + } + return defaults.bool(forKey: UserDefaultsKeys.roomContextualMenuShowMoreOptionForMessages) + } set { + defaults.set(newValue, forKey: UserDefaultsKeys.roomContextualMenuShowMoreOptionForMessages) + } + } + var roomContextualMenuShowMoreOptionForStates: Bool { + get { + guard defaults.object(forKey: UserDefaultsKeys.roomContextualMenuShowMoreOptionForStates) != nil else { + return BuildSettings.roomContextualMenuShowMoreOptionForStates + } + return defaults.bool(forKey: UserDefaultsKeys.roomContextualMenuShowMoreOptionForStates) + } set { + defaults.set(newValue, forKey: UserDefaultsKeys.roomContextualMenuShowMoreOptionForStates) + } + } + var roomContextualMenuShowReportContentOption: Bool { + get { + guard defaults.object(forKey: UserDefaultsKeys.roomContextualMenuShowReportContentOption) != nil else { + return BuildSettings.roomContextualMenuShowReportContentOption + } + return defaults.bool(forKey: UserDefaultsKeys.roomContextualMenuShowReportContentOption) + } set { + defaults.set(newValue, forKey: UserDefaultsKeys.roomContextualMenuShowReportContentOption) + } + } // MARK: - Room Info Screen @@ -336,6 +376,19 @@ final class RiotSettings: NSObject { } } + // MARK: - Room Member Screen + + var roomMemberScreenShowIgnore: Bool { + get { + guard defaults.object(forKey: UserDefaultsKeys.roomMemberScreenShowIgnore) != nil else { + return BuildSettings.roomMemberScreenShowIgnore + } + return defaults.bool(forKey: UserDefaultsKeys.roomMemberScreenShowIgnore) + } set { + defaults.set(newValue, forKey: UserDefaultsKeys.roomMemberScreenShowIgnore) + } + } + // MARK: - Room Creation Screen var roomCreationScreenAllowEncryptionConfiguration: Bool { @@ -467,6 +520,26 @@ final class RiotSettings: NSObject { defaults.set(newValue, forKey: UserDefaultsKeys.settingsScreenShowEnableStunServerFallback) } } + var settingsScreenShowNotificationDecodedContentOption: Bool { + get { + guard defaults.object(forKey: UserDefaultsKeys.settingsScreenShowNotificationDecodedContentOption) != nil else { + return BuildSettings.settingsScreenShowNotificationDecodedContentOption + } + return defaults.bool(forKey: UserDefaultsKeys.settingsScreenShowNotificationDecodedContentOption) + } set { + defaults.set(newValue, forKey: UserDefaultsKeys.settingsScreenShowNotificationDecodedContentOption) + } + } + var settingsScreenShowNsfwRoomsOption: Bool { + get { + guard defaults.object(forKey: UserDefaultsKeys.settingsScreenShowNsfwRoomsOption) != nil else { + return BuildSettings.settingsScreenShowNsfwRoomsOption + } + return defaults.bool(forKey: UserDefaultsKeys.settingsScreenShowNsfwRoomsOption) + } set { + defaults.set(newValue, forKey: UserDefaultsKeys.settingsScreenShowNsfwRoomsOption) + } + } var settingsSecurityScreenShowSessions: Bool { get { guard defaults.object(forKey: UserDefaultsKeys.settingsSecurityScreenShowSessions) != nil else { diff --git a/Riot/Managers/Theme/Theme.swift b/Riot/Managers/Theme/Theme.swift index 54815e497..72d2d1ab8 100644 --- a/Riot/Managers/Theme/Theme.swift +++ b/Riot/Managers/Theme/Theme.swift @@ -15,10 +15,11 @@ */ import UIKit +import DesignKit /// Provide color constant values defined by the designer /// https://app.zeplin.io/project/5c122fa790c5b4241ffa6be7/screen/5c619592daff2f1241d82e75 -@objc protocol Theme { +@objc protocol Theme: ThemeV2 { var identifier: String { get } @@ -93,6 +94,9 @@ import UIKit /// Color to tint the search background image var matrixSearchBackgroundImageTintColor: UIColor { get } + /// Color to use in shadows. Should be contrast to `backgroundColor`. + var shadowColor: UIColor { get } + // MARK: - Customisation methods diff --git a/Riot/Managers/Theme/ThemeService.m b/Riot/Managers/Theme/ThemeService.m index f29dbcaab..175ab5f64 100644 --- a/Riot/Managers/Theme/ThemeService.m +++ b/Riot/Managers/Theme/ThemeService.m @@ -155,6 +155,9 @@ NSString *const kThemeServiceDidChangeThemeNotification = @"kThemeServiceDidChan // Define the UISearchBar cancel button color [[UIBarButtonItem appearanceWhenContainedInInstancesOfClasses:@[[UISearchBar class]]] setTitleTextAttributes:@{ NSForegroundColorAttributeName : self.theme.tintColor } forState: UIControlStateNormal]; + + [[UIStackView appearanceWhenContainedInInstancesOfClasses:@[[UINavigationBar class]]] setSpacing:-7]; + [[UIStackView appearanceWhenContainedInInstancesOfClasses:@[[UINavigationBar class]]] setDistribution:UIStackViewDistributionEqualCentering]; } @end diff --git a/Riot/Managers/Theme/Themes/DarkTheme.swift b/Riot/Managers/Theme/Themes/DarkTheme.swift index d3752abf4..a3bc39557 100644 --- a/Riot/Managers/Theme/Themes/DarkTheme.swift +++ b/Riot/Managers/Theme/Themes/DarkTheme.swift @@ -16,6 +16,7 @@ import Foundation import UIKit +import DesignKit /// Color constants for the dark theme @objcMembers @@ -87,6 +88,8 @@ class DarkTheme: NSObject, Theme { var matrixSearchBackgroundImageTintColor: UIColor = UIColor(rgb: 0x7E7E7E) var secondaryCircleButtonBackgroundColor: UIColor = UIColor(rgb: 0xE3E8F0) + var shadowColor: UIColor = UIColor(rgb: 0xFFFFFF) + var messageTickColor: UIColor = .white func applyStyle(onTabBar tabBar: UITabBar) { @@ -137,4 +140,10 @@ class DarkTheme: NSObject, Theme { button.tintColor = self.tintColor button.setTitleColor(self.tintColor, for: .normal) } + + /// MARK: - Theme v2 + + lazy var colors: Colors = { + return DarkColors() + }() } diff --git a/Riot/Managers/Theme/Themes/DefaultTheme.swift b/Riot/Managers/Theme/Themes/DefaultTheme.swift index eb0ed522e..7f6ae77b1 100644 --- a/Riot/Managers/Theme/Themes/DefaultTheme.swift +++ b/Riot/Managers/Theme/Themes/DefaultTheme.swift @@ -16,6 +16,7 @@ import Foundation import UIKit +import DesignKit /// Color constants for the default theme @objcMembers @@ -97,6 +98,8 @@ class DefaultTheme: NSObject, Theme { var secondaryCircleButtonBackgroundColor: UIColor = UIColor(rgb: 0xE3E8F0) + var shadowColor: UIColor = UIColor(rgb: 0x000000) + func applyStyle(onTabBar tabBar: UITabBar) { tabBar.unselectedItemTintColor = self.tabBarUnselectedItemTintColor tabBar.tintColor = self.tintColor @@ -144,4 +147,10 @@ class DefaultTheme: NSObject, Theme { button.tintColor = self.tintColor button.setTitleColor(self.tintColor, for: .normal) } + + /// MARK: - Theme v2 + + lazy var colors: Colors = { + return LightColors() + }() } diff --git a/Riot/Managers/Widgets/WidgetConstants.h b/Riot/Managers/Widgets/WidgetConstants.h new file mode 100644 index 000000000..6c6e4bfbf --- /dev/null +++ b/Riot/Managers/Widgets/WidgetConstants.h @@ -0,0 +1,42 @@ +// +// 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 + +/** + The type of matrix event used for matrix widgets. + */ +FOUNDATION_EXPORT NSString *const kWidgetMatrixEventTypeString; + +/** + The type of matrix event used for modular widgets. + TODO: It should be replaced by kWidgetMatrixEventTypeString. + */ +FOUNDATION_EXPORT NSString *const kWidgetModularEventTypeString; + +/** + Known types widgets. + */ +FOUNDATION_EXPORT NSString *const kWidgetTypeJitsiV1; +FOUNDATION_EXPORT NSString *const kWidgetTypeJitsiV2; +FOUNDATION_EXPORT NSString *const kWidgetTypeStickerPicker; + +@interface WidgetConstants : NSObject + +- (instancetype)init NS_UNAVAILABLE; ++ (instancetype)new NS_UNAVAILABLE; + +@end diff --git a/Riot/Managers/Widgets/WidgetConstants.m b/Riot/Managers/Widgets/WidgetConstants.m new file mode 100644 index 000000000..cdff6531d --- /dev/null +++ b/Riot/Managers/Widgets/WidgetConstants.m @@ -0,0 +1,27 @@ +// +// 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 "WidgetConstants.h" + +NSString *const kWidgetMatrixEventTypeString = @"m.widget"; +NSString *const kWidgetModularEventTypeString = @"im.vector.modular.widgets"; +NSString *const kWidgetTypeJitsiV1 = @"jitsi"; +NSString *const kWidgetTypeJitsiV2 = @"m.jitsi"; +NSString *const kWidgetTypeStickerPicker = @"m.stickerpicker"; + +@implementation WidgetConstants + +@end diff --git a/Riot/Managers/Widgets/WidgetManager.h b/Riot/Managers/Widgets/WidgetManager.h index 99d3737ef..09c2b2588 100644 --- a/Riot/Managers/Widgets/WidgetManager.h +++ b/Riot/Managers/Widgets/WidgetManager.h @@ -20,27 +20,10 @@ #import #import "Widget.h" +#import "WidgetConstants.h" @class WidgetManagerConfig; -/** - The type of matrix event used for matrix widgets. - */ -FOUNDATION_EXPORT NSString *const kWidgetMatrixEventTypeString; - -/** - The type of matrix event used for modular widgets. - TODO: It should be replaced by kWidgetMatrixEventTypeString. - */ -FOUNDATION_EXPORT NSString *const kWidgetModularEventTypeString; - -/** - Known types widgets. - */ -FOUNDATION_EXPORT NSString *const kWidgetTypeJitsiV1; -FOUNDATION_EXPORT NSString *const kWidgetTypeJitsiV2; -FOUNDATION_EXPORT NSString *const kWidgetTypeStickerPicker; - /** Posted when a widget has been created, updated or disabled. The notification object is the `Widget` instance. diff --git a/Riot/Managers/Widgets/WidgetManager.m b/Riot/Managers/Widgets/WidgetManager.m index b9b7ba1c3..ac212da1a 100644 --- a/Riot/Managers/Widgets/WidgetManager.m +++ b/Riot/Managers/Widgets/WidgetManager.m @@ -19,17 +19,12 @@ #import "Riot-Swift.h" #import "JitsiWidgetData.h" +#import "MXSession+Riot.h" #import #pragma mark - Contants -NSString *const kWidgetMatrixEventTypeString = @"m.widget"; -NSString *const kWidgetModularEventTypeString = @"im.vector.modular.widgets"; -NSString *const kWidgetTypeJitsiV1 = @"jitsi"; -NSString *const kWidgetTypeJitsiV2 = @"m.jitsi"; -NSString *const kWidgetTypeStickerPicker = @"m.stickerpicker"; - NSString *const kWidgetManagerDidUpdateWidgetNotification = @"kWidgetManagerDidUpdateWidgetNotification"; NSString *const WidgetManagerErrorDomain = @"WidgetManagerErrorDomain"; @@ -284,7 +279,7 @@ NSString *const WidgetManagerErrorDomain = @"WidgetManagerErrorDomain"; // Riot-Web still uses V1 type NSString *widgetId = [NSString stringWithFormat:@"%@_%@_%@", kWidgetTypeJitsiV1, room.mxSession.myUser.userId, @((uint64_t)([[NSDate date] timeIntervalSince1970] * 1000))]; - NSURL *preferredJitsiServerUrl = BuildSettings.jitsiServerUrl; + NSURL *preferredJitsiServerUrl = [room.mxSession vc_homeserverConfiguration].jitsi.serverURL; JitsiService *jitsiService = JitsiService.shared; diff --git a/Riot/Model/HomeserverConfiguration/HomeserverConfiguration.swift b/Riot/Model/HomeserverConfiguration/HomeserverConfiguration.swift new file mode 100644 index 000000000..f09d8608c --- /dev/null +++ b/Riot/Model/HomeserverConfiguration/HomeserverConfiguration.swift @@ -0,0 +1,34 @@ +// +// Copyright 2020 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 + +/// Represents the homeserver configuration (usually based on HS Well-Known or hardoced values in the project) +@objcMembers +final class HomeserverConfiguration: NSObject { + + // Note: Use an object per configuration subject when there is multiple properties related + let jitsi: HomeserverJitsiConfiguration + let isE2EEByDefaultEnabled: Bool + + init(jitsi: HomeserverJitsiConfiguration, + isE2EEByDefaultEnabled: Bool) { + self.jitsi = jitsi + self.isE2EEByDefaultEnabled = isE2EEByDefaultEnabled + + super.init() + } +} diff --git a/Riot/Model/HomeserverConfiguration/HomeserverConfigurationBuilder.swift b/Riot/Model/HomeserverConfiguration/HomeserverConfigurationBuilder.swift new file mode 100644 index 000000000..c77ff6bab --- /dev/null +++ b/Riot/Model/HomeserverConfiguration/HomeserverConfigurationBuilder.swift @@ -0,0 +1,125 @@ +// +// Copyright 2020 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 + +/// `HomeserverConfigurationBuilder` build `HomeserverConfiguration` objects according to injected inputs +@objcMembers +final class HomeserverConfigurationBuilder: NSObject { + + // MARK: - Properties + + private let vectorWellKnownParser = VectorWellKnownParser() + + // MARK: - Public + + /// Create an `HomeserverConfiguration` from an HS Well-Known when possible otherwise it takes hardcoded values from BuildSettings by default. + func build(from wellKnown: MXWellKnown?) -> HomeserverConfiguration { + + let isE2EEByDefaultEnabled: Bool + let jitsiPreferredDomain: String + + var vectorWellKnownEncryptionConfiguration: VectorWellKnownEncryptionConfiguration? + var vectorWellKnownJitsiConfiguration: VectorWellKnownJitsiConfiguration? + + if let wellKnown = wellKnown, let vectorWellKnown = self.vectorWellKnownParser.parse(jsonDictionary: wellKnown.jsonDictionary()) { + vectorWellKnownEncryptionConfiguration = self.getEncryptionConfiguration(from: vectorWellKnown) + vectorWellKnownJitsiConfiguration = self.getJitsiConfiguration(from: vectorWellKnown) + } + + // Encryption configuration + if let vectorWellKnownEncryptionConfig = vectorWellKnownEncryptionConfiguration { + isE2EEByDefaultEnabled = vectorWellKnownEncryptionConfig.isE2EEByDefaultEnabled + } else { + // Enable E2EE by default when there is no value + isE2EEByDefaultEnabled = true + } + + // Jitsi configuration + let jitsiServerURL: URL + let hardcodedJitsiServerURL: URL = BuildSettings.jitsiServerUrl + + if let vectorWellKnownJitsiConfig = vectorWellKnownJitsiConfiguration { + jitsiPreferredDomain = vectorWellKnownJitsiConfig.preferredDomain + jitsiServerURL = self.jitsiServerURL(from: jitsiPreferredDomain) ?? hardcodedJitsiServerURL + } else { + guard let hardcodedJitsiDomain = hardcodedJitsiServerURL.host else { + fatalError("[HomeserverConfigurationBuilder] Fail to get Jitsi domain from hardcoded Jitsi URL") + } + jitsiPreferredDomain = hardcodedJitsiDomain + jitsiServerURL = hardcodedJitsiServerURL + } + + // Create HomeserverConfiguration + + let jitsiConfiguration = HomeserverJitsiConfiguration(serverDomain: jitsiPreferredDomain, + serverURL: jitsiServerURL) + + return HomeserverConfiguration(jitsi: jitsiConfiguration, isE2EEByDefaultEnabled: isE2EEByDefaultEnabled) + } + + // MARK: - Private + + private func getJitsiConfiguration(from vectorWellKnown: VectorWellKnown) -> VectorWellKnownJitsiConfiguration? { + + let jitsiConfiguration: VectorWellKnownJitsiConfiguration? + + if let lastJitsiConfiguration = vectorWellKnown.jitsi { + jitsiConfiguration = lastJitsiConfiguration + } else if let deprecatedJitsiConfiguration = vectorWellKnown.deprecatedJitsi { + NSLog("[HomeserverConfigurationBuilder] getJitsiConfiguration - Use deprecated configuration") + jitsiConfiguration = deprecatedJitsiConfiguration + } else { + NSLog("[HomeserverConfigurationBuilder] getJitsiConfiguration - No configuration found") + jitsiConfiguration = nil + } + + return jitsiConfiguration + } + + private func getEncryptionConfiguration(from vectorWellKnown: VectorWellKnown) -> VectorWellKnownEncryptionConfiguration? { + + let encryptionConfiguration: VectorWellKnownEncryptionConfiguration? + + if let lastEncryptionConfiguration = vectorWellKnown.encryption { + encryptionConfiguration = lastEncryptionConfiguration + } else if let deprecatedEncryptionConfiguration = vectorWellKnown.deprecatedEncryption { + NSLog("[HomeserverConfigurationBuilder] getEncryptionConfiguration - Use deprecated configuration") + encryptionConfiguration = deprecatedEncryptionConfiguration + } else { + NSLog("[HomeserverConfigurationBuilder] getEncryptionConfiguration - No configuration found") + encryptionConfiguration = nil + } + + return encryptionConfiguration + } + + private func jitsiServerURL(from jitsiServerDomain: String) -> URL? { + let jitsiStringURL: String + if jitsiServerDomain.starts(with: "http") { + jitsiStringURL = jitsiServerDomain + } else { + jitsiStringURL = "https://\(jitsiServerDomain)" + } + + guard let jitsiServerURL = URL(string: jitsiStringURL) else { + NSLog("[HomeserverConfigurationBuilder] Jitsi server URL is not valid") + return nil + } + + return jitsiServerURL + } +} diff --git a/Riot/Model/HomeserverConfiguration/HomeserverJitsiConfiguration.swift b/Riot/Model/HomeserverConfiguration/HomeserverJitsiConfiguration.swift new file mode 100644 index 000000000..e5e93bed0 --- /dev/null +++ b/Riot/Model/HomeserverConfiguration/HomeserverJitsiConfiguration.swift @@ -0,0 +1,31 @@ +// +// Copyright 2020 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 + +/// `HomeserverJitsiConfiguration` gives Jitsi widget configuration used by homeserver +@objcMembers +final class HomeserverJitsiConfiguration: NSObject { + let serverDomain: String + let serverURL: URL + + init(serverDomain: String, serverURL: URL) { + self.serverDomain = serverDomain + self.serverURL = serverURL + + super.init() + } +} diff --git a/Riot/Model/WellKnown/VectorWellKnown.swift b/Riot/Model/WellKnown/VectorWellKnown.swift new file mode 100644 index 000000000..2a44c0c19 --- /dev/null +++ b/Riot/Model/WellKnown/VectorWellKnown.swift @@ -0,0 +1,60 @@ +// +// Copyright 2020 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 + +// MARK: - Well Known + +/// `VectorWellKnown` represents additional Well Known configuration specific to Element client +struct VectorWellKnown { + let encryption: VectorWellKnownEncryptionConfiguration? + let jitsi: VectorWellKnownJitsiConfiguration? + + // Deprecated properties + let deprecatedEncryption: VectorWellKnownEncryptionConfiguration? + let deprecatedJitsi: VectorWellKnownJitsiConfiguration? +} + +// MARK: Decodable +extension VectorWellKnown: Decodable { + /// JSON keys associated to VectorWellKnown properties + enum CodingKeys: String, CodingKey { + case encryption = "io.element.e2ee" + case jitsi = "io.element.jitsi" + // Deprecated keys + case deprecatedEncryption = "im.vector.riot.e2ee" + case deprecatedJitsi = "im.vector.riot.jitsi" + } +} + +// MARK: - Encryption + +struct VectorWellKnownEncryptionConfiguration: Decodable { + + /// Indicate if E2EE is enabled by default + let isE2EEByDefaultEnabled: Bool + + enum CodingKeys: String, CodingKey { + case isE2EEByDefaultEnabled = "default" + } +} + +// MARK: - Jitsi +struct VectorWellKnownJitsiConfiguration: Decodable { + + /// Default Jitsi server + let preferredDomain: String +} diff --git a/Riot/Model/WellKnown/VectorWellKnownParser.swift b/Riot/Model/WellKnown/VectorWellKnownParser.swift new file mode 100644 index 000000000..3fc7325c6 --- /dev/null +++ b/Riot/Model/WellKnown/VectorWellKnownParser.swift @@ -0,0 +1,34 @@ +// +// Copyright 2020 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 + +final class VectorWellKnownParser { + + func parse(jsonDictionary: [AnyHashable: Any]) -> VectorWellKnown? { + let serializationService = SerializationService() + let vectorWellKnown: VectorWellKnown? + + do { + vectorWellKnown = try serializationService.deserialize(jsonDictionary) + } catch { + vectorWellKnown = nil + NSLog("[VectorWellKnownParser] Fail to parse application Well Known keys with error: \(error)") + } + + return vectorWellKnown + } +} diff --git a/Riot/Modules/Application/LegacyAppDelegate.h b/Riot/Modules/Application/LegacyAppDelegate.h index ac28af8f8..1cea08e0d 100644 --- a/Riot/Modules/Application/LegacyAppDelegate.h +++ b/Riot/Modules/Application/LegacyAppDelegate.h @@ -30,6 +30,7 @@ @protocol Configurable; @protocol LegacyAppDelegateDelegate; @class CallBar; +@class CallPresenter; #pragma mark - Notifications /** @@ -54,8 +55,8 @@ extern NSString *const AppDelegateUniversalLinkDidChangeNotification; @interface LegacyAppDelegate : UIResponder < UIApplicationDelegate, UISplitViewControllerDelegate, -UINavigationControllerDelegate, -JitsiViewControllerDelegate> +UINavigationControllerDelegate +> { // background sync management void (^_completionHandler)(UIBackgroundFetchResult); @@ -117,6 +118,11 @@ JitsiViewControllerDelegate> // Build Settings @property (nonatomic, readonly) id configuration; +/** + Call presenter instance. May be nil unless at least one session initialized. + */ +@property (nonatomic, strong, readonly) CallPresenter *callPresenter; + + (instancetype)theDelegate; #pragma mark - Push Notifications @@ -218,21 +224,6 @@ JitsiViewControllerDelegate> */ - (BOOL)handleUniversalLinkFragment:(NSString*)fragment; -#pragma mark - Jitsi call - -/** - Open the Jitsi view controller from a widget. - - @param jitsiWidget the jitsi widget. - @param video to indicate voice or video call. - */ -- (void)displayJitsiViewControllerWithWidget:(Widget*)jitsiWidget andVideo:(BOOL)video; - -/** - The current Jitsi view controller being displayed. - */ -@property (nonatomic, readonly) JitsiViewController *jitsiViewController; - #pragma mark - Call status handling /** diff --git a/Riot/Modules/Application/LegacyAppDelegate.m b/Riot/Modules/Application/LegacyAppDelegate.m index 24c19db8d..ece6aad63 100644 --- a/Riot/Modules/Application/LegacyAppDelegate.m +++ b/Riot/Modules/Application/LegacyAppDelegate.m @@ -224,7 +224,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni @property (nonatomic, strong) PushNotificationService *pushNotificationService; @property (nonatomic, strong) PushNotificationStore *pushNotificationStore; @property (nonatomic, strong) LocalAuthenticationService *localAuthenticationService; -@property (nonatomic, strong) CallPresenter *callPresenter; +@property (nonatomic, strong, readwrite) CallPresenter *callPresenter; @property (nonatomic, strong) MajorUpdateManager *majorUpdateManager; @@ -3012,90 +3012,6 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni } } -#pragma mark - Jitsi call - -- (void)displayJitsiViewControllerWithWidget:(Widget*)jitsiWidget andVideo:(BOOL)video -{ -#ifdef CALL_STACK_JINGLE - if (!_jitsiViewController) - { - MXWeakify(self); - [self checkPermissionForNativeWidget:jitsiWidget fromUrl:JitsiService.shared.serverURL completion:^(BOOL granted) { - MXStrongifyAndReturnIfNil(self); - if (!granted) - { - return; - } - - self->_jitsiViewController = [JitsiViewController jitsiViewController]; - - [self->_jitsiViewController openWidget:jitsiWidget withVideo:video success:^{ - - self->_jitsiViewController.delegate = self; - [self presentJitsiViewController:nil]; - - } failure:^(NSError *error) { - - self->_jitsiViewController = nil; - - [self showAlertWithTitle:nil message:NSLocalizedStringFromTable(@"call_jitsi_error", @"Vector", nil)]; - }]; - }]; - } - else - { - [self showAlertWithTitle:nil message:NSLocalizedStringFromTable(@"call_already_displayed", @"Vector", nil)]; - } -#else - [self showAlertWithTitle:nil message:[NSBundle mxk_localizedStringForKey:@"not_supported_yet"]]; -#endif -} - -- (void)presentJitsiViewController:(void (^)(void))completion -{ - [self removeCallStatusBar]; - - if (_jitsiViewController) - { - if (@available(iOS 13.0, *)) - { - _jitsiViewController.modalPresentationStyle = UIModalPresentationFullScreen; - } - - [self presentViewController:_jitsiViewController animated:YES completion:completion]; - } -} - -- (void)jitsiViewController:(JitsiViewController *)jitsiViewController dismissViewJitsiController:(void (^)(void))completion -{ - if (jitsiViewController == _jitsiViewController) - { - [_jitsiViewController dismissViewControllerAnimated:YES completion:completion]; - _jitsiViewController = nil; - - [self removeCallStatusBar]; - } -} - -- (void)jitsiViewController:(JitsiViewController *)jitsiViewController goBackToApp:(void (^)(void))completion -{ - if (jitsiViewController == _jitsiViewController) - { - [_jitsiViewController dismissViewControllerAnimated:YES completion:^{ - - MXRoom *room = [_jitsiViewController.widget.mxSession roomWithRoomId:_jitsiViewController.widget.roomId]; - NSString *btnTitle = [NSString stringWithFormat:NSLocalizedStringFromTable(@"active_call_details", @"Vector", nil), room.summary.displayname]; - [self updateCallStatusBar:btnTitle]; - - if (completion) - { - completion(); - } - }]; - } -} - - #pragma mark - Native Widget Permission - (void)checkPermissionForNativeWidget:(Widget*)widget fromUrl:(NSURL*)url completion:(void (^)(BOOL granted))completion @@ -3228,13 +3144,8 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni return result; } -- (void)updateCallStatusBar:(NSString*)title +- (void)displayCallStatusBarWithTitle:(NSString*)title { - if (_callBar) - { - _callBar.title = title; - return; - } // Add a call status bar CGSize topBarSize = CGSizeMake([[UIScreen mainScreen] bounds].size.width, [self calculateCallStatusBarHeight]); @@ -3257,22 +3168,27 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni [_callBar.heightAnchor constraintEqualToAnchor:_callStatusBarWindow.heightAnchor].active = YES; _callStatusBarWindow.hidden = NO; - [self statusBarDidChangeFrame]; + [self deviceOrientationDidChange]; - // We need to listen to the system status bar size change events to refresh the root controller frame. + // We need to listen to the device orientation change events to refresh the root controller frame. // Else the navigation bar position will be wrong. [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(statusBarDidChangeFrame) - name:UIApplicationDidChangeStatusBarFrameNotification + selector:@selector(deviceOrientationDidChange) + name:UIDeviceOrientationDidChangeNotification object:nil]; } +- (void)updateCallStatusBarWithTitle:(NSString*)title +{ + _callBar.title = title; +} + - (void)removeCallStatusBar { if (_callStatusBarWindow) { - // No more need to listen to system status bar changes - [[NSNotificationCenter defaultCenter] removeObserver:self name:UIApplicationDidChangeStatusBarFrameNotification object:nil]; + // No more need to listen to device orientation changes + [[NSNotificationCenter defaultCenter] removeObserver:self name:UIDeviceOrientationDidChangeNotification object:nil]; // Hide & destroy it _callStatusBarWindow.hidden = YES; @@ -3280,11 +3196,11 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni _callBar = nil; _callStatusBarWindow = nil; - [self statusBarDidChangeFrame]; + [self deviceOrientationDidChange]; } } -- (void)statusBarDidChangeFrame +- (void)deviceOrientationDidChange { UIApplication *app = [UIApplication sharedApplication]; UIViewController *rootController = app.keyWindow.rootViewController; @@ -3296,30 +3212,20 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni { CGFloat callStatusBarHeight = [self calculateCallStatusBarHeight]; - UIInterfaceOrientation statusBarOrientation = [UIApplication sharedApplication].statusBarOrientation; + UIDeviceOrientation deviceOrientation = [UIDevice currentDevice].orientation; + CGFloat width; - switch (statusBarOrientation) + if (UIDeviceOrientationIsPortrait(deviceOrientation)) { - case UIInterfaceOrientationLandscapeLeft: - { - _callStatusBarWindow.frame = CGRectMake(-rootControllerFrame.size.width / 2, -callStatusBarHeight / 2, rootControllerFrame.size.width, callStatusBarHeight); - _callStatusBarWindow.transform = CGAffineTransformMake(0, -1, 1, 0, callStatusBarHeight / 2, rootControllerFrame.size.width / 2); - break; - } - case UIInterfaceOrientationLandscapeRight: - { - _callStatusBarWindow.frame = CGRectMake(-rootControllerFrame.size.width / 2, -callStatusBarHeight / 2, rootControllerFrame.size.width, callStatusBarHeight); - _callStatusBarWindow.transform = CGAffineTransformMake(0, 1, -1, 0, rootControllerFrame.size.height - callStatusBarHeight / 2, rootControllerFrame.size.width / 2); - break; - } - default: - { - _callStatusBarWindow.transform = CGAffineTransformIdentity; - _callStatusBarWindow.frame = CGRectMake(0, 0, rootControllerFrame.size.width, callStatusBarHeight); - break; - } + width = MIN(rootControllerFrame.size.width, rootControllerFrame.size.height); } - + else + { + width = MAX(rootControllerFrame.size.width, rootControllerFrame.size.height); + } + + _callStatusBarWindow.frame = CGRectMake(0, 0, width, callStatusBarHeight); + // Apply the vertical offset due to call status bar rootControllerFrame.origin.y = callStatusBarHeight; rootControllerFrame.size.height -= callStatusBarHeight; @@ -4481,12 +4387,6 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni #pragma mark - CallPresenterDelegate -- (BOOL)callPresenter:(CallPresenter *)presenter shouldHandleNewCall:(MXCall *)call -{ - // Ignore the call if a call is already in progress - return _jitsiViewController == nil; -} - - (void)callPresenter:(CallPresenter *)presenter presentCallViewController:(CallViewController *)viewController completion:(void (^)(void))completion { if (@available(iOS 13.0, *)) @@ -4494,19 +4394,23 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni viewController.modalPresentationStyle = UIModalPresentationFullScreen; } - [self presentViewController:viewController animated:YES completion:completion]; + [self presentViewController:viewController animated:NO completion:completion]; } -- (void)callPresenter:(CallPresenter *)presenter dismissCallViewController:(CallViewController *)viewController completion:(void (^)(void))completion +- (void)callPresenter:(CallPresenter *)presenter dismissCallViewController:(UIViewController *)viewController completion:(void (^)(void))completion { // Check whether the call view controller is actually presented if (viewController.presentingViewController) { - [viewController dismissViewControllerAnimated:YES completion:^{ + [viewController.presentingViewController dismissViewControllerAnimated:NO completion:^{ - if (viewController.shouldPromptForStunServerFallback) + if ([viewController isKindOfClass:CallViewController.class]) { - [self promptForStunServerFallback]; + CallViewController *callVC = (CallViewController *)viewController; + if (callVC.shouldPromptForStunServerFallback) + { + [self promptForStunServerFallback]; + } } if (completion) @@ -4525,26 +4429,69 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni } } -- (void)callPresenter:(CallPresenter *)presenter presentCallBarFor:(CallViewController *)activeCallViewController numberOfPausedCalls:(NSUInteger)numberOfPausedCalls completion:(void (^)(void))completion +- (void)callPresenter:(CallPresenter *)presenter presentCallBarFor:(UIViewController *)activeCallViewController numberOfPausedCalls:(NSUInteger)numberOfPausedCalls completion:(void (^)(void))completion +{ + [self displayCallStatusBarWithTitle:nil]; + [self callPresenter:presenter updateCallBarFor:activeCallViewController numberOfPausedCalls:numberOfPausedCalls]; + + if (completion) + { + completion(); + } +} + +- (void)callPresenter:(CallPresenter *)presenter updateCallBarFor:(UIViewController *)activeCallViewController numberOfPausedCalls:(NSUInteger)numberOfPausedCalls { NSString *btnTitle; if (activeCallViewController) { + NSString *callStatus = @""; + BOOL isGroupCall = NO; + if ([activeCallViewController isKindOfClass:[CallViewController class]]) + { + CallViewController *activeCallVC = (CallViewController *)activeCallViewController; + callStatus = activeCallVC.callStatusLabel.text; + } + else if ([activeCallViewController isKindOfClass:[JitsiViewController class]]) + { + JitsiViewController *jitsiVC = (JitsiViewController *)activeCallViewController; + NSUInteger duration = jitsiVC.callDuration / 1000; + NSUInteger secs = duration % 60; + NSUInteger mins = (duration / 60) % 60; + NSUInteger hours = duration / 3600; + if (hours > 0) + { + callStatus = [NSString stringWithFormat:@"%02tu:%02tu:%02tu", hours, mins, secs]; + } + else + { + callStatus = [NSString stringWithFormat:@"%02tu:%02tu", mins, secs]; + } + isGroupCall = YES; + } + if (numberOfPausedCalls == 0) { // only one active - btnTitle = [NSString stringWithFormat:NSLocalizedStringFromTable(@"callbar_only_single_active", @"Vector", nil), activeCallViewController.callStatusLabel.text]; + if (isGroupCall) + { + btnTitle = [NSString stringWithFormat:NSLocalizedStringFromTable(@"callbar_only_single_active_group", @"Vector", nil), callStatus]; + } + else + { + btnTitle = [NSString stringWithFormat:NSLocalizedStringFromTable(@"callbar_only_single_active", @"Vector", nil), callStatus]; + } } else if (numberOfPausedCalls == 1) { // one active and one paused - btnTitle = [NSString stringWithFormat:NSLocalizedStringFromTable(@"callbar_active_and_single_paused", @"Vector", nil), activeCallViewController.callStatusLabel.text]; + btnTitle = [NSString stringWithFormat:NSLocalizedStringFromTable(@"callbar_active_and_single_paused", @"Vector", nil), callStatus]; } else { // one active and multiple paused - btnTitle = [NSString stringWithFormat:NSLocalizedStringFromTable(@"callbar_active_and_multiple_paused", @"Vector", nil), activeCallViewController.callStatusLabel.text, @(numberOfPausedCalls)]; + btnTitle = [NSString stringWithFormat:NSLocalizedStringFromTable(@"callbar_active_and_multiple_paused", @"Vector", nil), callStatus, @(numberOfPausedCalls)]; } } else @@ -4560,12 +4507,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni } } - [self updateCallStatusBar:btnTitle]; - - if (completion) - { - completion(); - } + [self updateCallStatusBarWithTitle:btnTitle]; } - (void)callPresenter:(CallPresenter *)presenter dismissCallBar:(void (^)(void))completion @@ -4578,7 +4520,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni } } -- (void)callPresenter:(CallPresenter *)presenter enterPipForCallViewController:(CallViewController *)viewController completion:(void (^)(void))completion +- (void)callPresenter:(CallPresenter *)presenter enterPipForCallViewController:(UIViewController *)viewController completion:(void (^)(void))completion { // Check whether the call view controller is actually presented if (viewController.presentingViewController) @@ -4594,7 +4536,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni } } -- (void)callPresenter:(CallPresenter *)presenter exitPipForCallViewController:(CallViewController *)viewController completion:(void (^)(void))completion +- (void)callPresenter:(CallPresenter *)presenter exitPipForCallViewController:(UIViewController *)viewController completion:(void (^)(void))completion { if (@available(iOS 13.0, *)) { @@ -4606,18 +4548,11 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni #pragma mark - CallBarDelegate -- (void)callBarDidTapReturnButton:(CallBar *)callBar +- (void)callBarDidTap:(CallBar *)callBar { - if ([_callPresenter callStatusBarButtonTapped]) - { - return; - } - else if (_jitsiViewController) - { - [self presentJitsiViewController:nil]; - } + [_callPresenter callStatusBarTapped]; } - + #pragma mark - Authentication - (BOOL)continueSSOLoginWithToken:(NSString*)loginToken txnId:(NSString*)txnId diff --git a/Riot/Modules/Authentication/AuthenticationViewController.m b/Riot/Modules/Authentication/AuthenticationViewController.m index 0a738a370..48e73eda0 100644 --- a/Riot/Modules/Authentication/AuthenticationViewController.m +++ b/Riot/Modules/Authentication/AuthenticationViewController.m @@ -1430,7 +1430,7 @@ static const CGFloat kAuthInputContainerViewMinHeightConstraintConstant = 150.0; // TODO: This is still not sure we want to disable the automatic cross-signing bootstrap // if the admin disabled e2e by default. // Do like riot-web for the moment - if (session.vc_isE2EByDefaultEnabledByHSAdmin) + if ([session vc_homeserverConfiguration].isE2EEByDefaultEnabled) { // Bootstrap cross-signing on user's account // We do it for both registration and new login as long as cross-signing does not exist yet diff --git a/Riot/Modules/Call/CallViewController.h b/Riot/Modules/Call/CallViewController.h index 54ef33aa6..98bedce60 100644 --- a/Riot/Modules/Call/CallViewController.h +++ b/Riot/Modules/Call/CallViewController.h @@ -21,13 +21,11 @@ */ @interface CallViewController : MXKCallViewController -@property (weak, nonatomic) IBOutlet UIView *gradientMaskContainerView; @property (weak, nonatomic) IBOutlet UIButton *chatButton; @property (weak, nonatomic) IBOutlet UIView *callControlsBackgroundView; @property (unsafe_unretained, nonatomic) IBOutlet NSLayoutConstraint *callerImageViewWidthConstraint; -@property (weak, nonatomic) IBOutlet NSLayoutConstraint *moreButtonLeadingConstraint; // Effect views @property (weak, nonatomic) IBOutlet MXKImageView *blurredCallerImageView; diff --git a/Riot/Modules/Call/CallViewController.m b/Riot/Modules/Call/CallViewController.m index 92dc8438d..bfcf9d46c 100644 --- a/Riot/Modules/Call/CallViewController.m +++ b/Riot/Modules/Call/CallViewController.m @@ -107,6 +107,12 @@ [self.endCallButton setImage:hangUpButtonImage forState:UIControlStateNormal]; [self.endCallButton setImage:hangUpButtonImage forState:UIControlStateHighlighted]; + // force orientation to portrait if phone + if ([UIDevice currentDevice].isPhone) + { + [[UIDevice currentDevice] setValue:[NSNumber numberWithInteger: UIInterfaceOrientationPortrait] forKey:@"orientation"]; + } + [self updateLocalPreviewLayout]; [self configureUserInterface]; @@ -134,6 +140,8 @@ self.callStatusLabel.textColor = self.overriddenTheme.baseTextPrimaryColor; [self.resumeButton setTitleColor:self.overriddenTheme.tintColor forState:UIControlStateNormal]; + [self.transferButton setTitleColor:self.overriddenTheme.tintColor + forState:UIControlStateNormal]; self.localPreviewContainerView.layer.borderColor = self.overriddenTheme.tintColor.CGColor; self.localPreviewContainerView.layer.borderWidth = 2; @@ -152,6 +160,30 @@ [super viewWillDisappear:animated]; } +- (UIInterfaceOrientationMask)supportedInterfaceOrientations +{ + // limit orientation to portrait only for phone + if ([UIDevice currentDevice].isPhone) + { + return UIInterfaceOrientationMaskPortrait; + } + return [super supportedInterfaceOrientations]; +} + +- (UIInterfaceOrientation)preferredInterfaceOrientationForPresentation +{ + if ([UIDevice currentDevice].isPhone) + { + return UIInterfaceOrientationPortrait; + } + return [super preferredInterfaceOrientationForPresentation]; +} + +- (BOOL)shouldAutorotate +{ + return NO; +} + #pragma mark - override MXKViewController - (UIView *)createIncomingCallView @@ -347,18 +379,6 @@ return _overriddenTheme; } -- (void)setMxCall:(MXCall *)mxCall -{ - [super setMxCall:mxCall]; - - if (self.videoMuteButton.isHidden) - { - // shift more button to left - self.moreButtonLeadingConstraint.constant = 8.0; - [self.view layoutIfNeeded]; - } -} - - (UIImage*)picturePlaceholder { CGFloat fontSize = floor(self.callerImageViewWidthConstraint.constant * 0.7); @@ -385,26 +405,19 @@ - (void)updatePeerInfoDisplay { - NSString *peerDisplayName; - NSString *peerAvatarURL; + [super updatePeerInfoDisplay]; + NSString *peerAvatarURL; + if (self.peer) { - peerDisplayName = [self.peer displayname]; - if (!peerDisplayName.length) - { - peerDisplayName = self.peer.userId; - } peerAvatarURL = self.peer.avatarUrl; } else if (self.mxCall.isConferenceCall) { - peerDisplayName = self.mxCall.room.summary.displayname; peerAvatarURL = self.mxCall.room.summary.avatar; } - - self.callerNameLabel.text = peerDisplayName; - + self.blurredCallerImageView.contentMode = UIViewContentModeScaleAspectFill; self.callerImageView.contentMode = UIViewContentModeScaleAspectFill; if (peerAvatarURL) @@ -415,7 +428,7 @@ andImageOrientation:UIImageOrientationUp previewImage:self.picturePlaceholder mediaManager:self.mainSession.mediaManager]; - + // Retrieve the avatar in full resolution [self.callerImageView setImageURI:peerAvatarURL withType:nil @@ -521,7 +534,8 @@ showsBackspaceButton:NO showsCallButton:NO formattingEnabled:NO - editingEnabled:NO]; + editingEnabled:NO + playTones:YES]; DialpadViewController *controller = [DialpadViewController instantiateWithConfiguration:config]; controller.delegate = self; self.customSizedPresentationController = [[CustomSizedPresentationController alloc] initWithPresentedViewController:controller presentingViewController:self]; diff --git a/Riot/Modules/Call/CallViewController.xib b/Riot/Modules/Call/CallViewController.xib index 821dfd614..d5cf47f3c 100644 --- a/Riot/Modules/Call/CallViewController.xib +++ b/Riot/Modules/Call/CallViewController.xib @@ -1,9 +1,9 @@ - - + + - + @@ -20,24 +20,25 @@ - + + - + + - - + @@ -77,21 +78,54 @@ - - - - - - - - - + + + + + + @@ -321,7 +357,7 @@ - + @@ -425,7 +461,7 @@ - + @@ -433,7 +469,7 @@ - + @@ -445,14 +481,14 @@ - + + - + - - - + + diff --git a/Riot/Modules/Call/Dialpad/DialpadConfiguration.swift b/Riot/Modules/Call/Dialpad/DialpadConfiguration.swift index a1d91f3d2..04d617ea9 100644 --- a/Riot/Modules/Call/Dialpad/DialpadConfiguration.swift +++ b/Riot/Modules/Call/Dialpad/DialpadConfiguration.swift @@ -38,6 +38,9 @@ class DialpadConfiguration: NSObject { /// Option for a dial pad to enable editing on typed text or not. var editingEnabled: Bool + /// Option for a dial pad to play tones when digits tapped or not. + var playTones: Bool + /// Default configuration object. All options are enabled by default. static let `default`: DialpadConfiguration = DialpadConfiguration() @@ -46,13 +49,15 @@ class DialpadConfiguration: NSObject { showsBackspaceButton: Bool = true, showsCallButton: Bool = true, formattingEnabled: Bool = true, - editingEnabled: Bool = true) { + editingEnabled: Bool = true, + playTones: Bool = true) { self.showsTitle = showsTitle self.showsCloseButton = showsCloseButton self.showsBackspaceButton = showsBackspaceButton self.showsCallButton = showsCallButton self.formattingEnabled = formattingEnabled self.editingEnabled = editingEnabled + self.playTones = playTones super.init() } diff --git a/Riot/Modules/Call/Dialpad/DialpadViewController.storyboard b/Riot/Modules/Call/Dialpad/DialpadViewController.storyboard index 8f81e6b7f..33d53a158 100644 --- a/Riot/Modules/Call/Dialpad/DialpadViewController.storyboard +++ b/Riot/Modules/Call/Dialpad/DialpadViewController.storyboard @@ -1,9 +1,9 @@ - + - + @@ -171,7 +171,7 @@ - - - @@ -285,15 +286,27 @@ - + - + + + + + + + + + + + + + diff --git a/Riot/Modules/Call/Dialpad/DialpadViewController.swift b/Riot/Modules/Call/Dialpad/DialpadViewController.swift index 743b39471..23e2c21ce 100644 --- a/Riot/Modules/Call/Dialpad/DialpadViewController.swift +++ b/Riot/Modules/Call/Dialpad/DialpadViewController.swift @@ -60,6 +60,7 @@ class DialpadViewController: UIViewController { } @IBOutlet private weak var lineView: UIView! @IBOutlet private weak var digitsStackView: UIStackView! + @IBOutlet private var digitButtons: [DialpadButton]! @IBOutlet private weak var backspaceButton: DialpadActionButton! { didSet { backspaceButton.type = .backspace @@ -83,6 +84,20 @@ class DialpadViewController: UIViewController { private enum Constants { static let sizeOniPad: CGSize = CGSize(width: 375, height: 667) static let additionalTopInset: CGFloat = 20 + static let digitButtonViewDatas: [Int: DialpadButton.ViewData] = [ + -2: .init(title: "#", tone: 1211), + -1: .init(title: "*", tone: 1210), + 0: .init(title: "0", tone: 1200, subtitle: "+"), + 1: .init(title: "1", tone: 1201, showsSubtitleSpace: true), + 2: .init(title: "2", tone: 1202, subtitle: "ABC"), + 3: .init(title: "3", tone: 1203, subtitle: "DEF"), + 4: .init(title: "4", tone: 1204, subtitle: "GHI"), + 5: .init(title: "5", tone: 1205, subtitle: "JKL"), + 6: .init(title: "6", tone: 1206, subtitle: "MNO"), + 7: .init(title: "7", tone: 1207, subtitle: "PQRS"), + 8: .init(title: "8", tone: 1208, subtitle: "TUV"), + 9: .init(title: "9", tone: 1209, subtitle: "WXYZ") + ] } private var wasCursorAtTheEnd: Bool = true @@ -135,6 +150,12 @@ class DialpadViewController: UIViewController { if UIDevice.current.isPhone { UIDevice.current.setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation") } + + for button in digitButtons { + if let viewData = Constants.digitButtonViewDatas[button.tag] { + button.render(withViewData: viewData) + } + } } override var preferredStatusBarStyle: UIStatusBarStyle { @@ -212,10 +233,15 @@ class DialpadViewController: UIViewController { theme.applyStyle(onNavigationBar: navigationBar) } - titleLabel.textColor = theme.noticeSecondaryColor + if theme.identifier == ThemeIdentifier.light.rawValue { + titleLabel.textColor = theme.noticeSecondaryColor + closeButton.setBackgroundImage(Asset.Images.closeButton.image.vc_tintedImage(usingColor: theme.tabBarUnselectedItemTintColor), for: .normal) + } else { + titleLabel.textColor = theme.baseTextSecondaryColor + closeButton.setBackgroundImage(Asset.Images.closeButton.image.vc_tintedImage(usingColor: theme.baseTextSecondaryColor), for: .normal) + } phoneNumberTextField.textColor = theme.textPrimaryColor lineView.backgroundColor = theme.lineBreakColor - closeButton.setBackgroundImage(Asset.Images.closeButton.image.vc_tintedImage(usingColor: theme.tabBarUnselectedItemTintColor), for: .normal) updateThemesOfAllButtons(in: digitsStackView, with: theme) } @@ -253,12 +279,19 @@ class DialpadViewController: UIViewController { } @IBAction private func digitButtonAction(_ sender: DialpadButton) { - let digit = sender.title(for: .normal) ?? "" + guard let digitViewData = Constants.digitButtonViewDatas[sender.tag] else { + return + } + let digit = digitViewData.title defer { delegate?.dialpadViewControllerDidTapDigit?(self, digit: digit) } + if configuration.playTones { + AudioServicesPlaySystemSound(digitViewData.tone) + } + if !configuration.editingEnabled { phoneNumber += digit return diff --git a/Riot/Modules/Call/Dialpad/Views/DialpadActionButton.swift b/Riot/Modules/Call/Dialpad/Views/DialpadActionButton.swift index 3426269d5..86db7ba40 100644 --- a/Riot/Modules/Call/Dialpad/Views/DialpadActionButton.swift +++ b/Riot/Modules/Call/Dialpad/Views/DialpadActionButton.swift @@ -30,9 +30,11 @@ class DialpadActionButton: DialpadButton { override func update(theme: Theme) { switch type { case .backspace: - backgroundColor = theme.warningColor + backgroundColor = .clear + tintColor = theme.colors.tertiaryContent case .call: - backgroundColor = theme.tintColor + backgroundColor = theme.colors.accent + tintColor = .white } } diff --git a/Riot/Modules/Call/Dialpad/Views/DialpadButton.swift b/Riot/Modules/Call/Dialpad/Views/DialpadButton.swift index c8e55b5ea..f39394e19 100644 --- a/Riot/Modules/Call/Dialpad/Views/DialpadButton.swift +++ b/Riot/Modules/Call/Dialpad/Views/DialpadButton.swift @@ -19,8 +19,27 @@ import UIKit /// Digit button class for Dialpad screen class DialpadButton: UIButton { + struct ViewData { + let title: String + let tone: SystemSoundID + let subtitle: String? + let showsSubtitleSpace: Bool + + init(title: String, tone: SystemSoundID, subtitle: String? = nil, showsSubtitleSpace: Bool = false) { + self.title = title + self.tone = tone + self.subtitle = subtitle + self.showsSubtitleSpace = showsSubtitleSpace + } + } + + private var viewData: ViewData? + private var theme: Theme = ThemeService.shared().theme + private enum Constants { static let size: CGSize = CGSize(width: 68, height: 68) + static let titleFont: UIFont = .boldSystemFont(ofSize: 32) + static let subtitleFont: UIFont = .boldSystemFont(ofSize: 12) } init() { @@ -40,6 +59,31 @@ class DialpadButton: UIButton { private func setup() { clipsToBounds = true layer.cornerRadius = Constants.size.width/2 + vc_enableMultiLinesTitle() + } + + func render(withViewData viewData: ViewData) { + self.viewData = viewData + + let totalAttributedString = NSMutableAttributedString(string: viewData.title, + attributes: [ + .font: Constants.titleFont, + .foregroundColor: theme.textPrimaryColor + ]) + + if let subtitle = viewData.subtitle { + totalAttributedString.append(NSAttributedString(string: "\n" + subtitle, attributes: [ + .font: Constants.subtitleFont, + .foregroundColor: theme.textPrimaryColor + ])) + } else if viewData.showsSubtitleSpace { + totalAttributedString.append(NSAttributedString(string: "\n ", attributes: [ + .font: Constants.subtitleFont, + .foregroundColor: theme.textPrimaryColor + ])) + } + + setAttributedTitle(totalAttributedString, for: .normal) } } @@ -49,8 +93,14 @@ class DialpadButton: UIButton { extension DialpadButton: Themable { func update(theme: Theme) { - setTitleColor(theme.textPrimaryColor, for: .normal) + self.theme = theme + backgroundColor = theme.headerBackgroundColor + + // re-render view data if set + if let viewData = self.viewData { + render(withViewData: viewData) + } } } diff --git a/Riot/Modules/Call/Views/CallBar/CallBar.swift b/Riot/Modules/Call/Views/CallBar/CallBar.swift index d79531e32..941e22b0a 100644 --- a/Riot/Modules/Call/Views/CallBar/CallBar.swift +++ b/Riot/Modules/Call/Views/CallBar/CallBar.swift @@ -18,24 +18,13 @@ import Foundation import Reusable @objc protocol CallBarDelegate: class { - func callBarDidTapReturnButton(_ callBar: CallBar) + func callBarDidTap(_ callBar: CallBar) } @objcMembers class CallBar: UIView, NibLoadable { - - @IBOutlet private weak var callIcon: UIImageView! { - didSet { - callIcon.image = Asset.Images.voiceCallHangonIcon.image.vc_tintedImage(usingColor: .white) - } - } + @IBOutlet private weak var titleLabel: UILabel! - @IBOutlet private weak var returnLabel: UILabel! { - didSet { - returnLabel.text = VectorL10n.callbarReturn - } - } - @IBOutlet private weak var returnButton: UIButton! weak var delegate: CallBarDelegate? @@ -43,6 +32,13 @@ class CallBar: UIView, NibLoadable { return CallBar.loadFromNib() } + override func awakeFromNib() { + super.awakeFromNib() + + let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(tapGestureRecognized(_:))) + addGestureRecognizer(tapRecognizer) + } + var title: String? { get { return titleLabel.text @@ -51,7 +47,8 @@ class CallBar: UIView, NibLoadable { } } - @IBAction private func returnButtonTapped(_ sender: UIButton) { - delegate?.callBarDidTapReturnButton(self) + @objc + private func tapGestureRecognized(_ sender: UITapGestureRecognizer) { + delegate?.callBarDidTap(self) } } diff --git a/Riot/Modules/Call/Views/CallBar/CallBar.xib b/Riot/Modules/Call/Views/CallBar/CallBar.xib index 93de09976..79873a89c 100644 --- a/Riot/Modules/Call/Views/CallBar/CallBar.xib +++ b/Riot/Modules/Call/Views/CallBar/CallBar.xib @@ -1,9 +1,9 @@ - + - + @@ -17,53 +17,20 @@ - - - - - - - - - - - - - - - - + - - + - - - @@ -76,15 +43,9 @@ - - - - - - diff --git a/Riot/Modules/CreateRoom/EnterNewRoomDetails/EnterNewRoomDetailsViewModel.swift b/Riot/Modules/CreateRoom/EnterNewRoomDetails/EnterNewRoomDetailsViewModel.swift index a9a48bdde..4967b5161 100644 --- a/Riot/Modules/CreateRoom/EnterNewRoomDetails/EnterNewRoomDetailsViewModel.swift +++ b/Riot/Modules/CreateRoom/EnterNewRoomDetails/EnterNewRoomDetailsViewModel.swift @@ -39,7 +39,7 @@ final class EnterNewRoomDetailsViewModel: EnterNewRoomDetailsViewModelType { init(session: MXSession) { self.session = session - roomCreationParameters.isEncrypted = session.vc_isE2EByDefaultEnabledByHSAdmin() && RiotSettings.shared.roomCreationScreenRoomIsEncrypted + roomCreationParameters.isEncrypted = session.vc_homeserverConfiguration().isE2EEByDefaultEnabled && RiotSettings.shared.roomCreationScreenRoomIsEncrypted roomCreationParameters.isPublic = RiotSettings.shared.roomCreationScreenRoomIsPublic } diff --git a/Riot/Modules/Integrations/Widgets/Jitsi/JitsiService.swift b/Riot/Modules/Integrations/Widgets/Jitsi/JitsiService.swift index aeade5370..2d5a3a7b3 100644 --- a/Riot/Modules/Integrations/Widgets/Jitsi/JitsiService.swift +++ b/Riot/Modules/Integrations/Widgets/Jitsi/JitsiService.swift @@ -22,9 +22,14 @@ import JitsiMeetSDK enum JitsiServiceError: Error { case widgetContentCreationFailed case emptyResponse + case noWellKnown case unknown } +private enum HTTPStatusCodes { + static let notFound: Int = 404 +} + /// JitsiService enables to abstract and configure Jitsi Meet SDK @objcMembers final class JitsiService: NSObject { @@ -61,6 +66,10 @@ final class JitsiService: NSObject { private var httpClients: [String: MXHTTPClient] = [:] + /// Holds widgetIds for declined group calls. Made a map to speed up lookups. + /// Values are useless, not used with false values. + private var declinedJitsiWidgets: [String: Bool] = [:] + // MARK: - Setup private override init() { @@ -69,6 +78,18 @@ final class JitsiService: NSObject { // MARK: - Public + func declineWidget(withId widgetId: String) { + declinedJitsiWidgets[widgetId] = true + } + + func resetDeclineForWidget(withId widgetId: String) { + declinedJitsiWidgets.removeValue(forKey: widgetId) + } + + func isWidgetDeclined(withId widgetId: String) -> Bool { + return declinedJitsiWidgets[widgetId] == true + } + // MARK: Configuration func configureDefaultConferenceOptions(with serverURL: URL) { @@ -104,6 +125,11 @@ final class JitsiService: NSObject { completion(.failure(error)) } }, failure: { (error) in + if let urlResponse = MXHTTPOperation.urlResponse(fromError: error), + urlResponse.statusCode == HTTPStatusCodes.notFound { + completion(.failure(JitsiServiceError.noWellKnown)) + return + } completion(.failure(error ?? JitsiServiceError.unknown)) }) } @@ -111,20 +137,37 @@ final class JitsiService: NSObject { /// Create Jitsi widget content @discardableResult func createJitsiWidgetContent(jitsiServerURL: URL, roomID: String, isAudioOnly: Bool, success: @escaping ([String: Any]) -> Void, failure: @escaping ((Error) -> Void)) -> MXHTTPOperation? { + guard let serverDomain = jitsiServerURL.host else { + failure(JitsiServiceError.widgetContentCreationFailed) + return nil + } + return self.getWellKnown(for: jitsiServerURL) { (result) in + var continueOperation: Bool = false + var authType: JitsiAuthenticationType? + switch result { case .success(let jitsiWellKnown): - if let serverDomain = jitsiServerURL.host, let widgetContent = self.createJitsiWidgetContent(serverDomain: serverDomain, - authenticationType: jitsiWellKnown.authenticationType, - roomID: roomID, - isAudioOnly: isAudioOnly) { - success(widgetContent) - } else { - failure(JitsiServiceError.widgetContentCreationFailed) - } + authType = jitsiWellKnown.authenticationType + continueOperation = true case .failure(let error): NSLog("[JitsiService] Fail to get Jitsi Well Known with error: \(error)") - failure(error) + if let error = error as? JitsiServiceError, error == .noWellKnown { + // no well-known, continue with no auth + continueOperation = true + } else { + failure(error) + } + } + + if continueOperation, + let widgetContent = self.createJitsiWidgetContent(serverDomain: serverDomain, + authenticationType: authType, + roomID: roomID, + isAudioOnly: isAudioOnly) { + success(widgetContent) + } else { + failure(JitsiServiceError.widgetContentCreationFailed) } } } diff --git a/Riot/Modules/Integrations/Widgets/Jitsi/JitsiViewController.h b/Riot/Modules/Integrations/Widgets/Jitsi/JitsiViewController.h index 93419ebb3..653608c47 100644 --- a/Riot/Modules/Integrations/Widgets/Jitsi/JitsiViewController.h +++ b/Riot/Modules/Integrations/Widgets/Jitsi/JitsiViewController.h @@ -58,6 +58,11 @@ success:(void (^)(void))success failure:(void (^)(NSError *error))failure; +/** + Set audio muted for the Jitsi call. + */ +- (void)setAudioMuted:(BOOL)muted; + /** Hang up the jitsi conference call in progress. */ @@ -68,6 +73,11 @@ */ @property (nonatomic, readonly) Widget *widget; +/** + Total duration of the call. In milliseconds. + */ +@property (nonatomic, readonly) NSUInteger callDuration; + /** The delegate for the view controller. */ diff --git a/Riot/Modules/Integrations/Widgets/Jitsi/JitsiViewController.m b/Riot/Modules/Integrations/Widgets/Jitsi/JitsiViewController.m index f93bcea75..29d528350 100644 --- a/Riot/Modules/Integrations/Widgets/Jitsi/JitsiViewController.m +++ b/Riot/Modules/Integrations/Widgets/Jitsi/JitsiViewController.m @@ -22,8 +22,17 @@ @import JitsiMeetSDK; static const NSString *kJitsiDataErrorKey = @"error"; +/** + Class name for RCTSafeAreaView. It's in the React Native SDK, so we cannot import its header. + */ +static NSString * _Nonnull kRCTSafeAreaViewClassName = @"RCTSafeAreaView"; -@interface JitsiViewController () +/* + Some feature flags defined in https://github.com/jitsi/jitsi-meet/blob/master/react/features/base/flags/constants.js + */ +static NSString * _Nonnull kJitsiFeatureFlagChatEnabled = @"chat.enabled"; + +@interface JitsiViewController () // The jitsi-meet SDK view @property (nonatomic, weak) IBOutlet JitsiMeetView *jitsiMeetView; @@ -33,6 +42,11 @@ static const NSString *kJitsiDataErrorKey = @"error"; @property (nonatomic, strong) NSString *jwtToken; @property (nonatomic) BOOL startWithVideo; +/** + Overlay views in self.jitsiMeetView. Only provided if the screen is in the PiP mode. + */ +@property (nonatomic, strong) NSArray *overlayViews; + @end @implementation JitsiViewController @@ -157,11 +171,33 @@ static const NSString *kJitsiDataErrorKey = @"error"; }]; } +- (void)setAudioMuted:(BOOL)muted +{ + [self.jitsiMeetView setAudioMuted:muted]; +} + - (void)hangup { [self.jitsiMeetView leave]; } +- (NSUInteger)callDuration +{ + MXEvent *widgetEvent = self.widget.widgetEvent; + if (widgetEvent) + { + if (widgetEvent.originServerTs == kMXUndefinedTimestamp) + { + return 0; + } + else + { + return (uint64_t)[NSDate date].timeIntervalSince1970*1000 - widgetEvent.originServerTs; + } + } + return 0; +} + #pragma mark - Private // Fill Jitsi data based on Matrix Widget V2 widget data @@ -237,12 +273,33 @@ static const NSString *kJitsiDataErrorKey = @"error"; andEmail:nil andAvatar:avatarUrl]; builder.token = self.jwtToken; + [builder setFeatureFlag:kJitsiFeatureFlagChatEnabled withBoolean:NO]; }]; [self.jitsiMeetView join:jitsiMeetConferenceOptions]; } } +/** + Finds all the views in self.jitsiMeetView recursively those kind of class with the name `kRCTSafeAreaViewClassName`. + */ +- (NSArray*)overlayViewsIn:(UIView *)view +{ + Class class = NSClassFromString(kRCTSafeAreaViewClassName); + if ([view isKindOfClass:class]) + { + return @[view]; + } + + NSMutableArray *result = [NSMutableArray arrayWithCapacity:2]; + + [view.subviews enumerateObjectsUsingBlock:^(__kindof UIView * _Nonnull subview, NSUInteger idx, BOOL * _Nonnull stop) { + [result addObjectsFromArray:[self overlayViewsIn:subview]]; + }]; + + return result; +} + #pragma mark - JitsiMeetViewDelegate - (void)conferenceWillJoin:(NSDictionary *)data @@ -287,6 +344,26 @@ static const NSString *kJitsiDataErrorKey = @"error"; } } +#pragma mark - PictureInPicturable + +- (void)enterPiP +{ + self.overlayViews = [self overlayViewsIn:self.view]; + for (UIView *view in self.overlayViews) + { + view.alpha = 0; + } +} + +- (void)exitPiP +{ + for (UIView *view in self.overlayViews) + { + view.alpha = 1.0; + } + self.overlayViews = nil; +} + @end #endif diff --git a/Riot/Modules/Room/CellData/RoomBubbleCellData.h b/Riot/Modules/Room/CellData/RoomBubbleCellData.h index 7637153f0..af8c19090 100644 --- a/Riot/Modules/Room/CellData/RoomBubbleCellData.h +++ b/Riot/Modules/Room/CellData/RoomBubbleCellData.h @@ -28,6 +28,7 @@ typedef NS_ENUM(NSInteger, RoomBubbleCellDataTag) RoomBubbleCellDataTagKeyVerificationRequest, RoomBubbleCellDataTagKeyVerificationConclusion, RoomBubbleCellDataTagCall, + RoomBubbleCellDataTagGroupCall, RoomBubbleCellDataTagRoomCreationIntro }; diff --git a/Riot/Modules/Room/CellData/RoomBubbleCellData.m b/Riot/Modules/Room/CellData/RoomBubbleCellData.m index 2f00e796b..28987ffba 100644 --- a/Riot/Modules/Room/CellData/RoomBubbleCellData.m +++ b/Riot/Modules/Room/CellData/RoomBubbleCellData.m @@ -64,6 +64,8 @@ static NSAttributedString *timestampVerticalWhitespace = nil; if (self) { + self.displayTimestampForSelectedComponentOnLeftWhenPossible = YES; + switch (event.eventType) { case MXEventTypeRoomMember: @@ -131,6 +133,8 @@ static NSAttributedString *timestampVerticalWhitespace = nil; } break; case MXEventTypeCallInvite: + case MXEventTypeCallAnswer: + case MXEventTypeCallHangup: case MXEventTypeCallReject: { self.tag = RoomBubbleCellDataTagCall; @@ -140,6 +144,25 @@ static NSAttributedString *timestampVerticalWhitespace = nil; // Collapse them by default self.collapsed = YES; + + // Show timestamps always on right + self.displayTimestampForSelectedComponentOnLeftWhenPossible = NO; + } + case MXEventTypeCustom: + { + if ([event.type isEqualToString:kWidgetMatrixEventTypeString] + || [event.type isEqualToString:kWidgetModularEventTypeString]) + { + Widget *widget = [[Widget alloc] initWithWidgetEvent:event inMatrixSession:roomDataSource.mxSession]; + if ([widget.type isEqualToString:kWidgetTypeJitsiV1] || + [widget.type isEqualToString:kWidgetTypeJitsiV2]) + { + self.tag = RoomBubbleCellDataTagGroupCall; + + // Show timestamps always on right + self.displayTimestampForSelectedComponentOnLeftWhenPossible = NO; + } + } } break; default: @@ -153,8 +176,6 @@ static NSAttributedString *timestampVerticalWhitespace = nil; // Reset attributedTextMessage to force reset MXKRoomCellData parameters self.attributedTextMessage = nil; - - self.displayTimestampForSelectedComponentOnLeftWhenPossible = YES; } return self; @@ -743,9 +764,15 @@ static NSAttributedString *timestampVerticalWhitespace = nil; case RoomBubbleCellDataTagCall: shouldAddEvent = NO; break; + case RoomBubbleCellDataTagGroupCall: + shouldAddEvent = NO; + break; case RoomBubbleCellDataTagRoomCreateConfiguration: shouldAddEvent = NO; break; + case RoomBubbleCellDataTagRoomCreationIntro: + shouldAddEvent = NO; + break; default: break; } @@ -788,9 +815,25 @@ static NSAttributedString *timestampVerticalWhitespace = nil; shouldAddEvent = NO; break; case MXEventTypeCallInvite: + case MXEventTypeCallAnswer: + case MXEventTypeCallHangup: case MXEventTypeCallReject: shouldAddEvent = NO; break; + case MXEventTypeCustom: + { + if ([event.type isEqualToString:kWidgetMatrixEventTypeString] + || [event.type isEqualToString:kWidgetModularEventTypeString]) + { + Widget *widget = [[Widget alloc] initWithWidgetEvent:event inMatrixSession:roomDataSource.mxSession]; + if ([widget.type isEqualToString:kWidgetTypeJitsiV1] || + [widget.type isEqualToString:kWidgetTypeJitsiV2]) + { + shouldAddEvent = NO; + } + } + break; + } default: break; } diff --git a/Riot/Modules/Room/DataSources/RoomDataSource.m b/Riot/Modules/Room/DataSources/RoomDataSource.m index 5edf5ba24..0a639ebc2 100644 --- a/Riot/Modules/Room/DataSources/RoomDataSource.m +++ b/Riot/Modules/Room/DataSources/RoomDataSource.m @@ -856,7 +856,9 @@ const CGFloat kTypingCellHeight = 24; cellData.showTimestampForSelectedComponent = self.showBubbleDateTimeOnSelection; - if (cellData.collapsed && cellData.nextCollapsableCellData) + if (cellData.collapsed + && cellData.nextCollapsableCellData + && cellData.tag != RoomBubbleCellDataTagCall) { // Select nothing for a collased cell but open it [self collapseRoomBubble:cellData collapsed:NO]; diff --git a/Riot/Modules/Room/Members/Detail/RoomMemberDetailsViewController.m b/Riot/Modules/Room/Members/Detail/RoomMemberDetailsViewController.m index 368d5993d..82a621795 100644 --- a/Riot/Modules/Room/Members/Detail/RoomMemberDetailsViewController.m +++ b/Riot/Modules/Room/Members/Detail/RoomMemberDetailsViewController.m @@ -628,7 +628,7 @@ } // Check whether the option Ignore may be presented - if (self.mxRoomMember.membership == MXMembershipJoin) + if (RiotSettings.shared.roomMemberScreenShowIgnore && self.mxRoomMember.membership == MXMembershipJoin) { // is he already ignored ? if (![self.mainSession isUserIgnored:self.mxRoomMember.userId]) diff --git a/Riot/Modules/Room/RoomViewController.h b/Riot/Modules/Room/RoomViewController.h index 50e86e70b..dac5cbb05 100644 --- a/Riot/Modules/Room/RoomViewController.h +++ b/Riot/Modules/Room/RoomViewController.h @@ -34,6 +34,11 @@ */ extern NSNotificationName const RoomCallTileTappedNotification; +/** + Notification string used to indicate group call tile tapped in a room. Notification object will be the `RoomBubbleCellData` object. + */ +extern NSNotificationName const RoomGroupCallTileTappedNotification; + @interface RoomViewController : MXKRoomViewController // The preview header @@ -42,16 +47,18 @@ extern NSNotificationName const RoomCallTileTappedNotification; // The jump to last unread banner @property (weak, nonatomic) IBOutlet UIView *jumpToLastUnreadBannerContainer; -@property (weak, nonatomic) IBOutlet NSLayoutConstraint *jumpToLastUnreadBannerContainerTopConstraint; +@property (weak, nonatomic) IBOutlet UIView *jumpToLastUnreadBanner; @property (weak, nonatomic) IBOutlet UIImageView *jumpToLastUnreadImageView; @property (weak, nonatomic) IBOutlet UIButton *jumpToLastUnreadButton; @property (weak, nonatomic) IBOutlet UILabel *jumpToLastUnreadLabel; @property (weak, nonatomic) IBOutlet UIButton *resetReadMarkerButton; -@property (weak, nonatomic) IBOutlet UIView *jumpToLastUnreadBannerSeparatorView; @property (weak, nonatomic) IBOutlet UIView *inputBackgroundView; @property (weak, nonatomic) IBOutlet UIButton *scrollToBottomButton; @property (weak, nonatomic) IBOutlet BadgeLabel *scrollToBottomBadgeLabel; +// Remove Jitsi widget container +@property (weak, nonatomic) IBOutlet UIView *removeJitsiWidgetContainer; + /** Preview data for a room invitation received by email, or a link to a room. */ diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index fa4be8496..d08af5d10 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -129,12 +129,13 @@ #import "Riot-Swift.h" NSNotificationName const RoomCallTileTappedNotification = @"RoomCallTileTappedNotification"; +NSNotificationName const RoomGroupCallTileTappedNotification = @"RoomGroupCallTileTappedNotification"; const NSTimeInterval kResizeComposerAnimationDuration = .05; @interface RoomViewController () + RoomDataSourceDelegate, RoomCreationModalCoordinatorBridgePresenterDelegate, RoomInfoCoordinatorBridgePresenterDelegate, DialpadViewControllerDelegate, RemoveJitsiWidgetViewDelegate> { // The preview header @@ -195,6 +196,12 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; // Tell whether the view controller is appeared or not. BOOL isAppeared; + + // Tell whether the room has a Jitsi call or not. + BOOL hasJitsiCall; + + // The right bar button items back up. + NSArray *rightBarButtonItems; // Observe kThemeServiceDidChangeThemeNotification to handle user interface theme change. id kThemeServiceDidChangeThemeNotificationObserver; @@ -213,6 +220,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; } @property (nonatomic, weak) IBOutlet UIView *overlayContainerView; +@property (nonatomic, strong) RemoveJitsiWidgetView *removeJitsiWidgetView; @property (nonatomic, strong) RoomContextualMenuViewController *roomContextualMenuViewController; @@ -372,6 +380,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; // call cells [self.bubblesTableView registerClass:RoomDirectCallStatusBubbleCell.class forCellReuseIdentifier:RoomDirectCallStatusBubbleCell.defaultReuseIdentifier]; + [self.bubblesTableView registerClass:RoomGroupCallStatusBubbleCell.class forCellReuseIdentifier:RoomGroupCallStatusBubbleCell.defaultReuseIdentifier]; [self.bubblesTableView registerClass:RoomCreationIntroCell.class forCellReuseIdentifier:RoomCreationIntroCell.defaultReuseIdentifier]; @@ -379,6 +388,8 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; [self vc_removeBackTitle]; + [self setupRemoveJitsiWidgetRemoveView]; + // Replace the default input toolbar view. // Note: this operation will force the layout of subviews. That is why cell view classes must be registered before. [self updateRoomInputToolbarViewClassIfNeeded]; @@ -447,11 +458,11 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; self.activityIndicator.backgroundColor = ThemeService.shared.theme.overlayBackgroundColor; + [self.removeJitsiWidgetView updateWithTheme:ThemeService.shared.theme]; + // Prepare jump to last unread banner - self.jumpToLastUnreadBannerContainer.backgroundColor = ThemeService.shared.theme.backgroundColor; - self.jumpToLastUnreadImageView.tintColor = ThemeService.shared.theme.textPrimaryColor; + self.jumpToLastUnreadImageView.tintColor = ThemeService.shared.theme.tintColor; self.jumpToLastUnreadLabel.textColor = ThemeService.shared.theme.textPrimaryColor; - self.jumpToLastUnreadBannerSeparatorView.backgroundColor = ThemeService.shared.theme.lineBreakColor; self.previewHeaderContainer.backgroundColor = ThemeService.shared.theme.headerBackgroundColor; @@ -465,23 +476,31 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; [self.bubblesTableView reloadData]; } - self.scrollToBottomButton.layer.shadowColor = [UIColor blackColor].CGColor; - self.scrollToBottomButton.layer.shadowOpacity = 0.2; - self.scrollToBottomButton.layer.shadowRadius = 6; - self.scrollToBottomButton.layer.shadowOffset = CGSizeMake(0, 4); + [self.scrollToBottomButton vc_addShadowWithColor:ThemeService.shared.theme.shadowColor + offset:CGSizeMake(0, 4) + radius:6 + opacity:0.2]; self.inputBackgroundView.backgroundColor = [ThemeService.shared.theme.backgroundColor colorWithAlphaComponent:0.98]; - if ([ThemeService.shared.themeId isEqualToString:@"light"]) + if (ThemeService.shared.isCurrentThemeDark) + { + [self.scrollToBottomButton setImage:[UIImage imageNamed:@"scrolldown_dark"] forState:UIControlStateNormal]; + + self.jumpToLastUnreadBanner.backgroundColor = ThemeService.shared.theme.colors.navigation; + [self.jumpToLastUnreadBanner vc_removeShadow]; + self.resetReadMarkerButton.tintColor = ThemeService.shared.theme.colors.quarterlyContent; + } + else { [self.scrollToBottomButton setImage:[UIImage imageNamed:@"scrolldown"] forState:UIControlStateNormal]; - } - else if ([ThemeService.shared.themeId isEqualToString:@"dark"] || [ThemeService.shared.themeId isEqualToString:@"black"]) - { - [self.scrollToBottomButton setImage:[UIImage imageNamed:@"scrolldown_dark"] forState:UIControlStateNormal]; - } - else if (@available(iOS 12.0, *) && ThemeService.shared.theme.userInterfaceStyle == UIUserInterfaceStyleDark) { - [self.scrollToBottomButton setImage:[UIImage imageNamed:@"scrolldown_dark"] forState:UIControlStateNormal]; + + self.jumpToLastUnreadBanner.backgroundColor = ThemeService.shared.theme.colors.background; + [self.jumpToLastUnreadBanner vc_addShadowWithColor:ThemeService.shared.theme.shadowColor + offset:CGSizeMake(0, 4) + radius:8 + opacity:0.1]; + self.resetReadMarkerButton.tintColor = ThemeService.shared.theme.colors.tertiaryContent; } self.scrollToBottomBadgeLabel.badgeColor = ThemeService.shared.theme.tintColor; @@ -510,6 +529,9 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; // Refresh the room title view [self refreshRoomTitle]; + // refresh remove Jitsi widget view + [self refreshRemoveJitsiWidgetView]; + // Refresh tool bar if the room data source is set. if (self.roomDataSource) { @@ -621,6 +643,14 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; }]; [self refreshMissedDiscussionsCount:YES]; self.keyboardHeight = MAX(self.keyboardHeight, 0); + + if (hasJitsiCall && + ![[AppDelegate theDelegate].callPresenter.jitsiVC.widget.roomId isEqualToString:self.roomDataSource.roomId]) + { + // the room had a Jitsi call before, but not now + hasJitsiCall = NO; + [self reloadBubblesTable:YES]; + } } - (void)viewDidDisappear:(BOOL)animated @@ -650,6 +680,13 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; [[NSNotificationCenter defaultCenter] removeObserver:mxEventDidDecryptNotificationObserver]; mxEventDidDecryptNotificationObserver = nil; } + + JitsiViewController *jitsiVC = [AppDelegate theDelegate].callPresenter.jitsiVC; + if ([jitsiVC.widget.roomId isEqualToString:self.roomDataSource.roomId]) + { + hasJitsiCall = YES; + [self reloadBubblesTable:YES]; + } } - (void)viewDidLayoutSubviews @@ -657,7 +694,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; [super viewDidLayoutSubviews]; UIEdgeInsets contentInset = self.bubblesTableView.contentInset; - contentInset.bottom = self.bottomLayoutGuide.length; + contentInset.bottom = self.view.safeAreaInsets.bottom; self.bubblesTableView.contentInset = contentInset; // Check here whether a subview has been added or removed @@ -709,15 +746,18 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; self.previewHeaderContainerHeightConstraint.constant = frame.origin.y + frame.size.height; self.bubblesTableViewTopConstraint.constant = self.previewHeaderContainerHeightConstraint.constant - self.bubblesTableView.mxk_adjustedContentInset.top; - self.jumpToLastUnreadBannerContainerTopConstraint.constant = self.previewHeaderContainerHeightConstraint.constant; } else { // In non expanded header mode, the navigation bar is opaque // The table view must not display behind it self.edgesForExtendedLayout = UIRectEdgeLeft | UIRectEdgeBottom | UIRectEdgeRight; - - self.jumpToLastUnreadBannerContainerTopConstraint.constant = self.bubblesTableView.mxk_adjustedContentInset.top; // no expanded + } + + // stay at the bottom if already was + if (self.isBubblesTableScrollViewAtTheBottom) + { + [self scrollBubblesTableViewToBottomAnimated:NO]; } [self refreshMissedDiscussionsCount:YES]; @@ -932,14 +972,14 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; self.eventsAcknowledgementEnabled = YES; - // Set room title view - [self refreshRoomTitle]; - // Store ref on customized room data source if ([dataSource isKindOfClass:RoomDataSource.class]) { customizedRoomDataSource = (RoomDataSource*)dataSource; } + + // Set room title view + [self refreshRoomTitle]; } else { @@ -1367,6 +1407,29 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; #pragma mark - Internals +- (UIBarButtonItem *)videoCallBarButtonItem +{ + UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithImage:[UIImage imageNamed:@"video_call"] + style:UIBarButtonItemStylePlain + target:self + action:@selector(onVideoCallPressed:)]; + item.accessibilityLabel = NSLocalizedStringFromTable(@"room_accessibility_video_call", @"Vector", nil); + + return item; +} + +- (void)setupRemoveJitsiWidgetRemoveView +{ + self.removeJitsiWidgetView = [RemoveJitsiWidgetView instantiate]; + self.removeJitsiWidgetView.delegate = self; + + [self.removeJitsiWidgetContainer vc_addSubViewMatchingParent:self.removeJitsiWidgetView]; + + self.removeJitsiWidgetContainer.hidden = YES; + + [self refreshRemoveJitsiWidgetView]; +} + - (void)forceLayoutRefresh { // Sanity check: check whether the table view data source is set. @@ -1408,7 +1471,18 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; MXCall *callInRoom = [self.roomDataSource.mxSession.callManager callInRoom:self.roomDataSource.roomId]; return (callInRoom && callInRoom.state != MXCallStateEnded) - || [[AppDelegate theDelegate].jitsiViewController.widget.roomId isEqualToString:self.roomDataSource.roomId]; + || customizedRoomDataSource.jitsiWidget; +} + +/** + Returns a flag for the current user whether it's privileged to add/remove Jitsi widgets to this room. + */ +- (BOOL)canEditJitsiWidget +{ + MXRoomPowerLevels *powerLevels = [self.roomDataSource.roomState powerLevels]; + NSInteger requiredPower = [powerLevels minimumPowerLevelForSendingEventAsStateEvent:kWidgetModularEventTypeString]; + NSInteger myPower = [powerLevels powerLevelOfUserWithUserID:self.roomDataSource.mxSession.myUserId]; + return myPower >= requiredPower; } - (void)refreshRoomTitle @@ -1427,27 +1501,84 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; if (self.roomDataSource.isLive) { rightBarButtonItems = [NSMutableArray new]; + BOOL hasCustomJoinButton = NO; - UIEdgeInsets itemInsets = UIEdgeInsetsMake(0, -5, 0, 5); if (self.supportCallOption) { - UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithImage:[UIImage imageNamed:@"voice_call_hangon_icon"] style:UIBarButtonItemStylePlain target:self action:@selector(onVoiceCallPressed:)]; - item.accessibilityLabel = NSLocalizedStringFromTable(@"room_accessibility_call", @"Vector", nil); - item.imageInsets = UIEdgeInsetsMake(0, -5, 0, 5); - [rightBarButtonItems addObject:item]; - - item = [[UIBarButtonItem alloc] initWithImage:[UIImage imageNamed:@"video_call"] style:UIBarButtonItemStylePlain target:self action:@selector(onVideoCallPressed:)]; - item.imageInsets = UIEdgeInsetsMake(0, 10, 0, -10); - item.accessibilityLabel = NSLocalizedStringFromTable(@"room_accessibility_video_call", @"Vector", nil); - [rightBarButtonItems addObject:item]; - itemInsets = UIEdgeInsetsMake(0, 20, 0, -20); + if (self.roomDataSource.room.summary.membersCount.joined == 2 && self.roomDataSource.room.isDirect) + { + // voice call button for Matrix call + UIBarButtonItem *itemVoice = [[UIBarButtonItem alloc] initWithImage:[UIImage imageNamed:@"voice_call_hangon_icon"] + style:UIBarButtonItemStylePlain + target:self + action:@selector(onVoiceCallPressed:)]; + itemVoice.accessibilityLabel = NSLocalizedStringFromTable(@"room_accessibility_call", @"Vector", nil); + itemVoice.enabled = !self.isCallActive; + [rightBarButtonItems addObject:itemVoice]; + + // video call button for Matrix call + UIBarButtonItem *itemVideo = [self videoCallBarButtonItem]; + itemVideo.enabled = !self.isCallActive; + [rightBarButtonItems addObject:itemVideo]; + } + else + { + // video call button for Jitsi call + if (self.isCallActive) + { + JitsiViewController *jitsiVC = [AppDelegate theDelegate].callPresenter.jitsiVC; + if ([jitsiVC.widget.roomId isEqualToString:self.roomDataSource.roomId]) + { + // show a disabled call button + UIBarButtonItem *item = [self videoCallBarButtonItem]; + item.enabled = NO; + [rightBarButtonItems addObject:item]; + } + else + { + // show Join button + CallTileActionButton *button = [CallTileActionButton new]; + [button setImage:[UIImage imageNamed:@"call_video_icon"] + forState:UIControlStateNormal]; + [button setTitle:NSLocalizedStringFromTable(@"room_join_group_call", @"Vector", nil) + forState:UIControlStateNormal]; + [button addTarget:self + action:@selector(onVideoCallPressed:) + forControlEvents:UIControlEventTouchUpInside]; + button.contentEdgeInsets = UIEdgeInsetsMake(4, 12, 4, 12); + UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithCustomView:button]; + item.accessibilityLabel = NSLocalizedStringFromTable(@"room_accessibility_video_call", @"Vector", nil); + [rightBarButtonItems addObject:item]; + + hasCustomJoinButton = YES; + } + } + else + { + // show a video call button + // item will still be enabled, and when tapped an alert will be displayed to the user + UIBarButtonItem *item = [self videoCallBarButtonItem]; + if (!self.canEditJitsiWidget) + { + item.image = [[UIImage imageNamed:@"video_call"] vc_withAlpha:0.3]; + } + [rightBarButtonItems addObject:item]; + } + } } if ([self widgetsCount:NO]) { - UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithImage:[UIImage imageNamed:@"integrations_icon"] style:UIBarButtonItemStylePlain target:self action:@selector(onIntegrationsPressed:)]; - item.imageInsets = itemInsets; + UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithImage:[UIImage imageNamed:@"integrations_icon"] + style:UIBarButtonItemStylePlain + target:self + action:@selector(onIntegrationsPressed:)]; item.accessibilityLabel = NSLocalizedStringFromTable(@"room_accessibility_integrations", @"Vector", nil); + if (hasCustomJoinButton) + { + item.imageInsets = UIEdgeInsetsMake(0, -5, 0, -5); + item.landscapeImagePhoneInsets = UIEdgeInsetsMake(0, -5, 0, -5); + } [rightBarButtonItems addObject:item]; } @@ -1992,7 +2123,6 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; animations:^{ self.bubblesTableViewTopConstraint.constant = 0; - self.jumpToLastUnreadBannerContainerTopConstraint.constant = self.bubblesTableView.mxk_adjustedContentInset.top; // Force to render the view [self forceLayoutRefresh]; @@ -2111,7 +2241,6 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; animations:^{ self.bubblesTableViewTopConstraint.constant = self.previewHeaderContainerHeightConstraint.constant - self.bubblesTableView.mxk_adjustedContentInset.top; - self.jumpToLastUnreadBannerContainerTopConstraint.constant = self.previewHeaderContainerHeightConstraint.constant; previewHeader.roomAvatar.alpha = 1; @@ -2226,6 +2355,10 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; { cellViewClass = RoomDirectCallStatusBubbleCell.class; } + else if (bubbleData.tag == RoomBubbleCellDataTagGroupCall) + { + cellViewClass = RoomGroupCallStatusBubbleCell.class; + } else if (bubbleData.isIncoming) { if (bubbleData.isAttachmentWithThumbnail) @@ -2402,6 +2535,20 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; { // post notification `RoomCallTileTapped` [[NSNotificationCenter defaultCenter] postNotificationName:RoomCallTileTappedNotification object:bubbleData]; + + preventBubblesTableViewScroll = YES; + [self selectEventWithId:tappedEvent.eventId]; + } + } + else if (bubbleData.tag == RoomBubbleCellDataTagGroupCall) + { + if ([bubbleData isKindOfClass:[RoomBubbleCellData class]]) + { + // post notification `RoomGroupCallTileTapped` + [[NSNotificationCenter defaultCenter] postNotificationName:RoomGroupCallTileTappedNotification object:bubbleData]; + + preventBubblesTableViewScroll = YES; + [self selectEventWithId:tappedEvent.eventId]; } } else @@ -2536,13 +2683,83 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; [self showReactionHistoryForEventId:tappedEventId animated:YES]; } } - else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellCallBackButtonPressed]) + else if ([actionIdentifier isEqualToString:RoomDirectCallStatusBubbleCell.callBackAction]) { MXEvent *callInviteEvent = userInfo[kMXKRoomBubbleCellEventKey]; MXCallInviteEventContent *eventContent = [MXCallInviteEventContent modelFromJSON:callInviteEvent.content]; [self placeCallWithVideo2:eventContent.isVideoCall]; } + else if ([actionIdentifier isEqualToString:RoomDirectCallStatusBubbleCell.declineAction]) + { + MXEvent *callInviteEvent = userInfo[kMXKRoomBubbleCellEventKey]; + MXCallInviteEventContent *eventContent = [MXCallInviteEventContent modelFromJSON:callInviteEvent.content]; + + MXCall *call = [self.mainSession.callManager callWithCallId:eventContent.callId]; + [call hangup]; + } + else if ([actionIdentifier isEqualToString:RoomDirectCallStatusBubbleCell.answerAction]) + { + MXEvent *callInviteEvent = userInfo[kMXKRoomBubbleCellEventKey]; + MXCallInviteEventContent *eventContent = [MXCallInviteEventContent modelFromJSON:callInviteEvent.content]; + + MXCall *call = [self.mainSession.callManager callWithCallId:eventContent.callId]; + [call answer]; + } + else if ([actionIdentifier isEqualToString:RoomDirectCallStatusBubbleCell.endCallAction]) + { + MXEvent *callInviteEvent = userInfo[kMXKRoomBubbleCellEventKey]; + MXCallInviteEventContent *eventContent = [MXCallInviteEventContent modelFromJSON:callInviteEvent.content]; + + MXCall *call = [self.mainSession.callManager callWithCallId:eventContent.callId]; + [call hangup]; + } + else if ([actionIdentifier isEqualToString:RoomGroupCallStatusBubbleCell.joinAction] || + [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] + showPopUpInViewController:self completionHandler:^(BOOL granted) { + + MXStrongifyAndReturnIfNil(self); + if (granted) + { + // Present the Jitsi view controller + Widget *jitsiWidget = [self->customizedRoomDataSource jitsiWidget]; + if (jitsiWidget) + { + [[AppDelegate theDelegate].callPresenter displayJitsiCallWithWidget:jitsiWidget]; + } + } + else + { + NSLog(@"[RoomVC] didRecognizeAction:inCell:userInfo Warning: The application does not have the permission to join/answer the group call"); + } + }]; + + MXEvent *widgetEvent = userInfo[kMXKRoomBubbleCellEventKey]; + Widget *widget = [[Widget alloc] initWithWidgetEvent:widgetEvent + inMatrixSession:customizedRoomDataSource.mxSession]; + [[JitsiService shared] resetDeclineForWidgetWithId:widget.widgetId]; + } + else if ([actionIdentifier isEqualToString:RoomGroupCallStatusBubbleCell.leaveAction]) + { + [[AppDelegate theDelegate].callPresenter endActiveJitsiCall]; + [self reloadBubblesTable:YES]; + } + else if ([actionIdentifier isEqualToString:RoomGroupCallStatusBubbleCell.declineAction]) + { + MXEvent *widgetEvent = userInfo[kMXKRoomBubbleCellEventKey]; + Widget *widget = [[Widget alloc] initWithWidgetEvent:widgetEvent + inMatrixSession:customizedRoomDataSource.mxSession]; + [[JitsiService shared] declineWidgetWithId:widget.widgetId]; + [self reloadBubblesTable:YES]; + } else if ([actionIdentifier isEqualToString:RoomCreationIntroCell.tapOnAvatarView]) { [self showRoomAvatarChange]; @@ -2574,6 +2791,23 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell; MXKAttachment *attachment = roomBubbleTableViewCell.bubbleData.attachment; + BOOL isJitsiCallEvent = NO; + switch (selectedEvent.eventType) { + case MXEventTypeCustom: + if ([selectedEvent.type isEqualToString:kWidgetMatrixEventTypeString] + || [selectedEvent.type isEqualToString:kWidgetModularEventTypeString]) + { + Widget *widget = [[Widget alloc] initWithWidgetEvent:selectedEvent inMatrixSession:self.roomDataSource.mxSession]; + if ([widget.type isEqualToString:kWidgetTypeJitsiV1] || + [widget.type isEqualToString:kWidgetTypeJitsiV2]) + { + isJitsiCallEvent = YES; + } + } + default: + break; + } + if (currentAlert) { [currentAlert dismissViewControllerAnimated:NO completion:nil]; @@ -2659,26 +2893,29 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; }]]; } - [currentAlert addAction:[UIAlertAction actionWithTitle:NSLocalizedStringFromTable(@"room_event_action_quote", @"Vector", nil) - style:UIAlertActionStyleDefault - handler:^(UIAlertAction * action) { - - if (weakSelf) - { - typeof(self) self = weakSelf; + if (!isJitsiCallEvent) + { + [currentAlert addAction:[UIAlertAction actionWithTitle:NSLocalizedStringFromTable(@"room_event_action_quote", @"Vector", nil) + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { - [self cancelEventSelection]; + if (weakSelf) + { + typeof(self) self = weakSelf; + + [self cancelEventSelection]; + + // Quote the message a la Markdown into the input toolbar composer + self.inputToolbarView.textMessage = [NSString stringWithFormat:@"%@\n>%@\n\n", self.inputToolbarView.textMessage, selectedComponent.textMessage]; + + // And display the keyboard + [self.inputToolbarView becomeFirstResponder]; + } - // Quote the message a la Markdown into the input toolbar composer - self.inputToolbarView.textMessage = [NSString stringWithFormat:@"%@\n>%@\n\n", self.inputToolbarView.textMessage, selectedComponent.textMessage]; - - // And display the keyboard - [self.inputToolbarView becomeFirstResponder]; - } - - }]]; + }]]; + } - if (BuildSettings.messageDetailsAllowShare) + if (!isJitsiCallEvent && BuildSettings.messageDetailsAllowShare) { [currentAlert addAction:[UIAlertAction actionWithTitle:NSLocalizedStringFromTable(@"room_event_action_share", @"Vector", nil) style:UIAlertActionStyleDefault @@ -2987,7 +3224,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; } } - if (![selectedEvent.sender isEqualToString:self.mainSession.myUser.userId]) + if (![selectedEvent.sender isEqualToString:self.mainSession.myUser.userId] && RiotSettings.shared.roomContextualMenuShowReportContentOption) { [currentAlert addAction:[UIAlertAction actionWithTitle:NSLocalizedStringFromTable(@"room_event_action_report", @"Vector", nil) style:UIAlertActionStyleDefault @@ -3097,7 +3334,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; }]]; } - if (self.roomDataSource.room.summary.isEncrypted) + if (!isJitsiCallEvent && self.roomDataSource.room.summary.isEncrypted) { [currentAlert addAction:[UIAlertAction actionWithTitle:NSLocalizedStringFromTable(@"room_event_action_view_encryption", @"Vector", nil) style:UIAlertActionStyleDefault @@ -3571,94 +3808,69 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; - (void)placeCallWithVideo2:(BOOL)video { - __weak __typeof(self) weakSelf = self; - - // If there is already a jitsi widget, join it Widget *jitsiWidget = [customizedRoomDataSource jitsiWidget]; if (jitsiWidget) { - [[AppDelegate theDelegate] displayJitsiViewControllerWithWidget:jitsiWidget andVideo:video]; + // If there is already a Jitsi call, join it + [[AppDelegate theDelegate].callPresenter displayJitsiCallWithWidget:jitsiWidget]; } - - // If enabled, create the conf using jitsi widget and open it directly - else if (RiotSettings.shared.createConferenceCallsWithJitsi - && self.roomDataSource.room.summary.membersCount.joined > 2) - { - [self startActivityIndicator]; - - [[WidgetManager sharedManager] createJitsiWidgetInRoom:self.roomDataSource.room - withVideo:video - success:^(Widget *jitsiWidget) - { - if (weakSelf) - { - typeof(self) self = weakSelf; - [self stopActivityIndicator]; - - [[AppDelegate theDelegate] displayJitsiViewControllerWithWidget:jitsiWidget andVideo:video]; - } - } - failure:^(NSError *error) - { - if (weakSelf) - { - typeof(self) self = weakSelf; - [self stopActivityIndicator]; - - [self showJitsiErrorAsAlert:error]; - } - }]; - } - // Classic conference call is not supported in encrypted rooms - else if (self.roomDataSource.room.summary.isEncrypted && self.roomDataSource.room.summary.membersCount.joined > 2) - { - [currentAlert dismissViewControllerAnimated:NO completion:nil]; - - currentAlert = [UIAlertController alertControllerWithTitle:[NSBundle mxk_localizedStringForKey:@"room_no_conference_call_in_encrypted_rooms"] message:nil preferredStyle:UIAlertControllerStyleAlert]; - - [currentAlert addAction:[UIAlertAction actionWithTitle:[NSBundle mxk_localizedStringForKey:@"ok"] - style:UIAlertActionStyleDefault - handler:^(UIAlertAction * action) - { - if (weakSelf) - { - typeof(self) self = weakSelf; - self->currentAlert = nil; - } - - }]]; - - [currentAlert mxk_setAccessibilityIdentifier:@"RoomVCCallAlert"]; - [self presentViewController:currentAlert animated:YES completion:nil]; - } - - // In case of conference call, check that the user has enough power level - else if (self.roomDataSource.room.summary.membersCount.joined > 2 && - ![MXCallManager canPlaceConferenceCallInRoom:self.roomDataSource.room roomState:self.roomDataSource.roomState]) - { - [currentAlert dismissViewControllerAnimated:NO completion:nil]; - - currentAlert = [UIAlertController alertControllerWithTitle:[NSBundle mxk_localizedStringForKey:@"room_no_power_to_create_conference_call"] message:nil preferredStyle:UIAlertControllerStyleAlert]; - - [currentAlert addAction:[UIAlertAction actionWithTitle:[NSBundle mxk_localizedStringForKey:@"ok"] - style:UIAlertActionStyleDefault - handler:^(UIAlertAction * action) - { - if (weakSelf) - { - typeof(self) self = weakSelf; - self->currentAlert = nil; - } - }]]; - - [currentAlert mxk_setAccessibilityIdentifier:@"RoomVCCallAlert"]; - [self presentViewController:currentAlert animated:YES completion:nil]; - } - - // Classic 1:1 or group call can be done else { - [self.roomDataSource.room placeCallWithVideo:video success:nil failure:nil]; + if (self.roomDataSource.room.summary.membersCount.joined == 2 && self.roomDataSource.room.isDirect) + { + // Matrix call + [self.roomDataSource.room placeCallWithVideo:video success:nil failure:nil]; + } + else + { + // Jitsi call + if (self.canEditJitsiWidget) + { + // User has right to add a Jitsi widget + // Create the Jitsi widget and open it directly + [self startActivityIndicator]; + + MXWeakify(self); + + [[WidgetManager sharedManager] createJitsiWidgetInRoom:self.roomDataSource.room + withVideo:video + success:^(Widget *jitsiWidget) + { + MXStrongifyAndReturnIfNil(self); + [self stopActivityIndicator]; + + [[AppDelegate theDelegate].callPresenter displayJitsiCallWithWidget:jitsiWidget]; + } + failure:^(NSError *error) + { + MXStrongifyAndReturnIfNil(self); + [self stopActivityIndicator]; + + [self showJitsiErrorAsAlert:error]; + }]; + } + else + { + // Insufficient privileges to add a Jitsi widget + MXWeakify(self); + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + + currentAlert = [UIAlertController alertControllerWithTitle:[NSBundle mxk_localizedStringForKey:@"room_no_privileges_to_create_group_call"] + message:nil + preferredStyle:UIAlertControllerStyleAlert]; + + [currentAlert addAction:[UIAlertAction actionWithTitle:[NSBundle mxk_localizedStringForKey:@"ok"] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) + { + MXStrongifyAndReturnIfNil(self); + self->currentAlert = nil; + }]]; + + [currentAlert mxk_setAccessibilityIdentifier:@"RoomVCCallAlert"]; + [self presentViewController:currentAlert animated:YES completion:nil]; + } + } } } @@ -3669,9 +3881,10 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; { [callInRoom hangup]; } - else if ([[AppDelegate theDelegate].jitsiViewController.widget.roomId isEqualToString:self.roomDataSource.roomId]) + else if ([[AppDelegate theDelegate].callPresenter.jitsiVC.widget.roomId isEqualToString:self.roomDataSource.roomId]) { - [[AppDelegate theDelegate].jitsiViewController hangup]; + [[AppDelegate theDelegate].callPresenter endActiveJitsiCall]; + [self reloadBubblesTable:YES]; } [self refreshActivitiesViewDisplay]; @@ -4264,17 +4477,20 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; - (void)listenWidgetNotifications { + MXWeakify(self); + kMXKWidgetManagerDidUpdateWidgetObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kWidgetManagerDidUpdateWidgetNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + MXStrongifyAndReturnIfNil(self); + Widget *widget = notif.object; if (widget.mxSession == self.roomDataSource.mxSession - && [widget.roomId isEqualToString:customizedRoomDataSource.roomId]) + && [widget.roomId isEqualToString:self->customizedRoomDataSource.roomId]) { - // Jitsi conference widget existence is shown in the bottom bar - // Update the bar - [self refreshActivitiesViewDisplay]; - [self refreshRoomInputToolbar]; + // Call button update [self refreshRoomTitle]; + // Remove Jitsi widget view update + [self refreshRemoveJitsiWidgetView]; } }]; } @@ -4322,8 +4538,6 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; [roomActivitiesView removeGestureRecognizer:roomActivitiesView.gestureRecognizers[0]]; } - Widget *jitsiWidget = [customizedRoomDataSource jitsiWidget]; - if ([self.roomDataSource.mxSession.syncError.errcode isEqualToString:kMXErrCodeStringResourceLimitExceeded]) { self.activitiesViewExpanded = YES; @@ -4383,102 +4597,6 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; } }]; } - else if (customizedRoomDataSource.roomState.isOngoingConferenceCall) - { - // Show the "Ongoing conference call" banner only if the user is not in the conference - MXCall *callInRoom = [self.roomDataSource.mxSession.callManager callInRoom:self.roomDataSource.roomId]; - if (callInRoom && callInRoom.state != MXCallStateEnded) - { - if ([self checkUnsentMessages] == NO) - { - [self refreshTypingNotification]; - } - } - else - { - self.activitiesViewExpanded = YES; - [roomActivitiesView displayOngoingConferenceCall:^(BOOL video) { - - NSLog(@"[RoomVC] onOngoingConferenceCallPressed"); - - // Make sure there is not yet a call - if (![customizedRoomDataSource.mxSession.callManager callInRoom:customizedRoomDataSource.roomId]) - { - [customizedRoomDataSource.room placeCallWithVideo:video success:nil failure:nil]; - } - } onClosePressed:nil]; - } - } - else if (jitsiWidget) - { - // The room has an active jitsi widget - // Show it in the banner if the user is not already in - LegacyAppDelegate *appDelegate = [AppDelegate theDelegate]; - if ([appDelegate.jitsiViewController.widget.widgetId isEqualToString:jitsiWidget.widgetId]) - { - if ([self checkUnsentMessages] == NO) - { - [self refreshTypingNotification]; - } - } - else - { - self.activitiesViewExpanded = YES; - [roomActivitiesView displayOngoingConferenceCall:^(BOOL video) { - - NSLog(@"[RoomVC] onOngoingConferenceCallPressed (jitsi)"); - - __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] - showPopUpInViewController:self completionHandler:^(BOOL granted) { - - if (weakSelf) - { - if (granted) - { - // Present the Jitsi view controller - [appDelegate displayJitsiViewControllerWithWidget:jitsiWidget andVideo:video]; - } - else - { - NSLog(@"[RoomVC] onOngoingConferenceCallPressed: Warning: The application does not have the perssion to join the call"); - } - } - }]; - - } onClosePressed:^{ - - [self startActivityIndicator]; - - // Close the widget - __weak __typeof(self) weakSelf = self; - [[WidgetManager sharedManager] closeWidget:jitsiWidget.widgetId inRoom:self.roomDataSource.room success:^{ - - if (weakSelf) - { - typeof(self) self = weakSelf; - [self stopActivityIndicator]; - - // The banner will automatically leave thanks to kWidgetManagerDidUpdateWidgetNotification - } - - } failure:^(NSError *error) { - if (weakSelf) - { - typeof(self) self = weakSelf; - - [self showJitsiErrorAsAlert:error]; - [self stopActivityIndicator]; - } - }]; - }]; - } - } else if ([self checkUnsentMessages] == NO) { // Show "scroll to bottom" icon when the most recent message is not visible, @@ -5021,6 +5139,32 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; } } +- (void)refreshRemoveJitsiWidgetView +{ + if (self.roomDataSource.isLive && !self.roomDataSource.isPeeking) + { + Widget *jitsiWidget = [customizedRoomDataSource jitsiWidget]; + + if (jitsiWidget && self.canEditJitsiWidget) + { + [self.removeJitsiWidgetView reset]; + self.removeJitsiWidgetContainer.hidden = NO; + self.removeJitsiWidgetView.delegate = self; + } + else + { + self.removeJitsiWidgetContainer.hidden = YES; + self.removeJitsiWidgetView.delegate = nil; + } + } + else + { + [self.removeJitsiWidgetView reset]; + self.removeJitsiWidgetContainer.hidden = YES; + self.removeJitsiWidgetView.delegate = self; + } +} + - (void)refreshJumpToLastUnreadBannerDisplay { // This banner is only displayed when the room timeline is in live (and no peeking). @@ -5368,12 +5512,25 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; ]; } - return @[ - [self copyMenuItemWithEvent:event andCell:cell], - [self replyMenuItemWithEvent:event], - [self editMenuItemWithEvent:event], - [self moreMenuItemWithEvent:event andCell:cell] - ]; + BOOL showMoreOption = (event.isState && RiotSettings.shared.roomContextualMenuShowMoreOptionForStates) || (!event.isState && RiotSettings.shared.roomContextualMenuShowMoreOptionForMessages); + + if (showMoreOption) + { + return @[ + [self copyMenuItemWithEvent:event andCell:cell], + [self replyMenuItemWithEvent:event], + [self editMenuItemWithEvent:event], + [self moreMenuItemWithEvent:event andCell:cell] + ]; + } + else + { + return @[ + [self copyMenuItemWithEvent:event andCell:cell], + [self replyMenuItemWithEvent:event], + [self editMenuItemWithEvent:event] + ]; + } } - (void)showContextualMenuForEvent:(MXEvent*)event fromSingleTapGesture:(BOOL)usedSingleTapGesture cell:(id)cell animated:(BOOL)animated @@ -5579,6 +5736,17 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; case MXEventTypeKeyVerificationCancel: isCopyActionEnabled = NO; break; + case MXEventTypeCustom: + if ([event.type isEqualToString:kWidgetMatrixEventTypeString] + || [event.type isEqualToString:kWidgetModularEventTypeString]) + { + Widget *widget = [[Widget alloc] initWithWidgetEvent:event inMatrixSession:self.roomDataSource.mxSession]; + if ([widget.type isEqualToString:kWidgetTypeJitsiV1] || + [widget.type isEqualToString:kWidgetTypeJitsiV2]) + { + isCopyActionEnabled = NO; + } + } default: break; } @@ -5955,4 +6123,37 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; self.roomInfoCoordinatorBridgePresenter = nil; } +#pragma mark - RemoveJitsiWidgetViewDelegate + +- (void)removeJitsiWidgetViewDidCompleteSliding:(RemoveJitsiWidgetView *)view +{ + view.delegate = nil; + Widget *jitsiWidget = [customizedRoomDataSource jitsiWidget]; + + [self startActivityIndicator]; + + // close the widget + MXWeakify(self); + + [[WidgetManager sharedManager] closeWidget:jitsiWidget.widgetId + inRoom:self.roomDataSource.room + success:^{ + MXStrongifyAndReturnIfNil(self); + [self stopActivityIndicator]; + // we can wait for kWidgetManagerDidUpdateWidgetNotification, but we want to be faster + self.removeJitsiWidgetContainer.hidden = YES; + self.removeJitsiWidgetView.delegate = nil; + + // end active call if exists + if ([[AppDelegate theDelegate].callPresenter.jitsiVC.widget.widgetId isEqualToString:jitsiWidget.widgetId]) + { + [[AppDelegate theDelegate].callPresenter endActiveJitsiCall]; + } + } failure:^(NSError *error) { + MXStrongifyAndReturnIfNil(self); + [self showJitsiErrorAsAlert:error]; + [self stopActivityIndicator]; + }]; +} + @end diff --git a/Riot/Modules/Room/RoomViewController.xib b/Riot/Modules/Room/RoomViewController.xib index 2371d6a0e..5b1a0a909 100644 --- a/Riot/Modules/Room/RoomViewController.xib +++ b/Riot/Modules/Room/RoomViewController.xib @@ -15,15 +15,15 @@ - - - - - + + + + + @@ -55,76 +55,86 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/Room/Views/BubbleCells/Call/Direct/RoomDirectCallStatusBubbleCell.swift b/Riot/Modules/Room/Views/BubbleCells/Call/Direct/RoomDirectCallStatusBubbleCell.swift index 8a5391c8b..a3dbb63dc 100644 --- a/Riot/Modules/Room/Views/BubbleCells/Call/Direct/RoomDirectCallStatusBubbleCell.swift +++ b/Riot/Modules/Room/Views/BubbleCells/Call/Direct/RoomDirectCallStatusBubbleCell.swift @@ -16,68 +16,187 @@ import UIKit +/// The number of milliseconds in one second. +private let MSEC_PER_SEC: TimeInterval = 1000 + +@objcMembers class RoomDirectCallStatusBubbleCell: RoomBaseCallBubbleCell { - private enum Constants { - static let statusTextFontSize: CGFloat = 14 - static let statusTextInsets: UIEdgeInsets = UIEdgeInsets(top: 8, left: 8, bottom: 12, right: 8) - // swiftlint:disable force_unwrapping - static let statusCallBackURL: URL = URL(string: "element://call")! - // swiftlint:enable force_unwrapping + private static var className: String { + return String(describing: self) } - private lazy var statusTextView: UITextView = { - let textView = UITextView() - textView.font = .systemFont(ofSize: Constants.statusTextFontSize) - textView.backgroundColor = .clear - textView.textColor = ThemeService.shared().theme.noticeSecondaryColor - textView.linkTextAttributes = [ - .font: UIFont.systemFont(ofSize: Constants.statusTextFontSize), - .foregroundColor: ThemeService.shared().theme.tintColor - ] - textView.textAlignment = .center - textView.contentInset = .zero - textView.isEditable = false - textView.isSelectable = false - textView.isScrollEnabled = false - textView.scrollsToTop = false - textView.textContainerInset = Constants.statusTextInsets - textView.textContainer.lineFragmentPadding = 0 - textView.delegate = self - return textView - }() - - override var bottomContentView: UIView? { - return statusTextView + /// Action identifier used when the user pressed "Call back" button for a declined call. + /// The `userInfo` dictionary contains an `MXEvent` object under the `kMXKRoomBubbleCellEventKey` key, representing the invite event of the declined call. + static var callBackAction: String { + return self.className + ".callBack" } - override func update(theme: Theme) { - super.update(theme: theme) - statusTextView.textColor = theme.noticeSecondaryColor - statusTextView.linkTextAttributes = [ - .font: UIFont.systemFont(ofSize: Constants.statusTextFontSize), - .foregroundColor: theme.tintColor - ] + /// Action identifier used when the user pressed "Answer" button for an incoming call. + /// The `userInfo` dictionary contains an `MXEvent` object under the `kMXKRoomBubbleCellEventKey` key, representing the invite event of the call. + static var answerAction: String { + return self.className + ".answer" + } + + /// Action identifier used when the user pressed "Decline" button for an incoming call. + /// The `userInfo` dictionary contains an `MXEvent` object under the `kMXKRoomBubbleCellEventKey` key, representing the invite event of the call. + static var declineAction: String { + return self.className + ".decline" + } + + /// Action identifier used when the user pressed "End call" button for an incoming call. + /// The `userInfo` dictionary contains an `MXEvent` object under the `kMXKRoomBubbleCellEventKey` key, representing the invite event of the call. + static var endCallAction: String { + return self.className + ".endCall" + } + + private var callDurationString: String = "" + private var isVideoCall: Bool = false + private var isIncoming: Bool = false + private var callInviteEvent: MXEvent? + private var viewState: ViewState = .unknown { + didSet { + updateBottomContentView() + } + } + + private enum ViewState { + case unknown + case ringing + case active + case declined + case missed + case ended + case failed + } + + private static var callDurationFormatter: DateComponentsFormatter { + let formatter = DateComponentsFormatter() + formatter.zeroFormattingBehavior = .dropAll + formatter.allowedUnits = [.hour, .minute, .second] + formatter.unitsStyle = .abbreviated + return formatter + } + + private func updateBottomContentView() { + bottomContentView = bottomView(for: viewState) + } + + private var callTypeIcon: UIImage { + if isVideoCall { + return Asset.Images.callVideoIcon.image + } else { + return Asset.Images.voiceCallHangonIcon.image + } + } + + private var actionUserInfo: [AnyHashable: Any]? { + if let event = callInviteEvent { + return [kMXKRoomBubbleCellEventKey: event] + } + return nil + } + + private func bottomView(for state: ViewState) -> UIView? { + switch state { + case .unknown: + return nil + case .ringing: + let view = HorizontalButtonsContainerView.loadFromNib() + + view.firstButton.style = .negative + view.firstButton.setTitle(VectorL10n.eventFormatterCallDecline, for: .normal) + view.firstButton.setImage(Asset.Images.voiceCallHangupIcon.image, for: .normal) + view.firstButton.removeTarget(nil, action: nil, for: .touchUpInside) + view.firstButton.addTarget(self, action: #selector(declineCallAction(_:)), for: .touchUpInside) + + view.secondButton.style = .positive + view.secondButton.setTitle(VectorL10n.eventFormatterCallAnswer, for: .normal) + view.secondButton.setImage(callTypeIcon, for: .normal) + view.secondButton.removeTarget(nil, action: nil, for: .touchUpInside) + view.secondButton.addTarget(self, action: #selector(answerCallAction(_:)), for: .touchUpInside) + + return view + case .active: + let view = HorizontalButtonsContainerView.loadFromNib() + view.secondButton.isHidden = true + + view.firstButton.style = .negative + view.firstButton.setTitle(VectorL10n.eventFormatterCallEndCall, for: .normal) + view.firstButton.setImage(Asset.Images.voiceCallHangupIcon.image, for: .normal) + view.firstButton.removeTarget(nil, action: nil, for: .touchUpInside) + view.firstButton.addTarget(self, action: #selector(endCallAction(_:)), for: .touchUpInside) + + return view + case .declined: + let view = HorizontalButtonsContainerView.loadFromNib() + view.secondButton.isHidden = true + + view.firstButton.style = .positive + view.firstButton.setTitle(VectorL10n.eventFormatterCallBack, for: .normal) + view.firstButton.setImage(callTypeIcon, for: .normal) + view.firstButton.removeTarget(nil, action: nil, for: .touchUpInside) + view.firstButton.addTarget(self, action: #selector(callBackAction(_:)), for: .touchUpInside) + + return view + case .missed: + let view = HorizontalButtonsContainerView.loadFromNib() + view.secondButton.isHidden = true + + view.firstButton.style = .positive + view.firstButton.setTitle(VectorL10n.eventFormatterCallBack, for: .normal) + view.firstButton.setImage(callTypeIcon, for: .normal) + view.firstButton.removeTarget(nil, action: nil, for: .touchUpInside) + view.firstButton.addTarget(self, action: #selector(callBackAction(_:)), for: .touchUpInside) + + return view + case .ended: + return nil + case .failed: + let view = HorizontalButtonsContainerView.loadFromNib() + view.secondButton.isHidden = true + + view.firstButton.style = .positive + view.firstButton.setTitle(VectorL10n.eventFormatterCallRetry, for: .normal) + view.firstButton.setImage(callTypeIcon, for: .normal) + view.firstButton.removeTarget(nil, action: nil, for: .touchUpInside) + view.firstButton.addTarget(self, action: #selector(callBackAction(_:)), for: .touchUpInside) + + return view + } } private func configure(withCall call: MXCall) { switch call.state { - case .connected, - .fledgling, - .waitLocalMedia, - .createOffer, - .inviteSent, - .createAnswer, - .connecting, - .onHold, - .remotelyOnHold: - statusTextView.text = VectorL10n.eventFormatterCallYouCurrentlyIn + case .fledgling, + .waitLocalMedia, + .createOffer, + .connecting: + viewState = .active + if call.isIncoming { + statusText = VectorL10n.eventFormatterCallYouCurrentlyIn + } else { + statusText = VectorL10n.eventFormatterCallConnecting + } + case .inviteSent: + if call.isIncoming { + statusText = VectorL10n.eventFormatterCallYouCurrentlyIn + } else { + statusText = VectorL10n.eventFormatterCallRinging + } + case .createAnswer, + .connected, + .onHold, + .remotelyOnHold: + viewState = .active + statusText = VectorL10n.eventFormatterCallYouCurrentlyIn case .ringing: if call.isIncoming { - // should not be here - statusTextView.text = nil + viewState = .ringing + statusText = nil } else { - statusTextView.text = VectorL10n.eventFormatterCallYouCurrentlyIn + viewState = .active + statusText = VectorL10n.eventFormatterCallYouCurrentlyIn } case .ended: switch call.endReason { @@ -85,19 +204,29 @@ class RoomDirectCallStatusBubbleCell: RoomBaseCallBubbleCell { .hangup, .hangupElsewhere, .remoteHangup, - .missed, .answeredElseWhere: - statusTextView.text = VectorL10n.eventFormatterCallHasEnded + viewState = .ended + statusText = VectorL10n.eventFormatterCallHasEnded(callDurationString) + case .missed: + if call.isIncoming { + viewState = .missed + statusText = VectorL10n.eventFormatterCallYouMissed + } else { + statusText = VectorL10n.eventFormatterCallHasEnded(callDurationString) + } case .busy: configureForRejectedCall(call: call) @unknown default: - statusTextView.text = VectorL10n.eventFormatterCallHasEnded + viewState = .ended + statusText = VectorL10n.eventFormatterCallHasEnded(callDurationString) } case .inviteExpired, .answeredElseWhere: - statusTextView.text = VectorL10n.eventFormatterCallHasEnded + viewState = .ended + statusText = VectorL10n.eventFormatterCallHasEnded(callDurationString) @unknown default: - statusTextView.text = VectorL10n.eventFormatterCallHasEnded + viewState = .ended + statusText = VectorL10n.eventFormatterCallHasEnded(callDurationString) } } @@ -114,46 +243,135 @@ class RoomDirectCallStatusBubbleCell: RoomBaseCallBubbleCell { } if isMyReject { - - let centerParagraphStyle = NSMutableParagraphStyle() - centerParagraphStyle.alignment = .center - - let mutableAttrString = NSMutableAttributedString(string: VectorL10n.eventFormatterCallYouDeclined + " " + VectorL10n.eventFormatterCallBack, attributes: [ - .font: UIFont.systemFont(ofSize: Constants.statusTextFontSize), - .foregroundColor: ThemeService.shared().theme.noticeSecondaryColor, - .paragraphStyle: centerParagraphStyle - ]) - - let range = mutableAttrString.mutableString.range(of: VectorL10n.eventFormatterCallBack) - if range.location != NSNotFound { - mutableAttrString.addAttribute(.link, value: Constants.statusCallBackURL, range: range) - } - - statusTextView.attributedText = mutableAttrString - statusTextView.isSelectable = true + viewState = .declined + statusText = VectorL10n.eventFormatterCallYouDeclined } else { - statusTextView.text = VectorL10n.eventFormatterCallHasEnded + viewState = .ended + statusText = VectorL10n.eventFormatterCallHasEnded(callDurationString) } } + private func configureForHangupCall(withEvent event: MXEvent) { + guard let hangupEventContent = MXCallHangupEventContent(fromJSON: event.content) else { + viewState = .ended + statusText = VectorL10n.eventFormatterCallHasEnded(callDurationString) + return + } + + switch hangupEventContent.reasonType { + case .userHangup: + viewState = .ended + statusText = VectorL10n.eventFormatterCallHasEnded(callDurationString) + default: + viewState = .failed + statusText = VectorL10n.eventFormatterCallConnectionFailed + } + } + + private func configureForUnansweredCall() { + if isIncoming { + // missed call + viewState = .missed + statusText = VectorL10n.eventFormatterCallYouMissed + } else { + // outgoing unanswered call + viewState = .ended + statusText = VectorL10n.eventFormatterCallHasEnded(callDurationString) + } + } + + // MARK: - Actions + + @objc + private func callBackAction(_ sender: CallTileActionButton) { + self.delegate?.cell(self, + didRecognizeAction: Self.callBackAction, + userInfo: actionUserInfo) + } + + @objc + private func declineCallAction(_ sender: CallTileActionButton) { + self.delegate?.cell(self, + didRecognizeAction: Self.declineAction, + userInfo: actionUserInfo) + } + + @objc + private func answerCallAction(_ sender: CallTileActionButton) { + self.delegate?.cell(self, + didRecognizeAction: Self.answerAction, + userInfo: actionUserInfo) + } + + @objc + private func endCallAction(_ sender: CallTileActionButton) { + self.delegate?.cell(self, + didRecognizeAction: Self.endCallAction, + userInfo: actionUserInfo) + } + // MARK: - MXKCellRendering override func render(_ cellData: MXKCellData!) { super.render(cellData) + viewState = .unknown + guard let bubbleCellData = cellData as? RoomBubbleCellData else { return } let events = bubbleCellData.allLinkedEvents() - // getting a random event for call id is enough - guard let randomEvent = bubbleCellData.events.randomElement() else { + guard let inviteEvent = events.first(where: { $0.eventType == .callInvite }) else { return } - guard let callEventContent = MXCallEventContent(fromJSON: randomEvent.content) else { return } - let callId = callEventContent.callId + if bubbleCellData.senderId == bubbleCellData.mxSession.myUserId { + // event sent by my user, no means in displaying our own avatar and display name + if let directUserId = bubbleCellData.mxSession.directUserId(inRoom: bubbleCellData.roomId) { + let user = bubbleCellData.mxSession.user(withUserId: directUserId) + + let placeholder = AvatarGenerator.generateAvatar(forMatrixItem: directUserId, + withDisplayName: user?.displayname) + + innerContentView.avatarImageView.setImageURI(user?.avatarUrl, + withType: nil, + andImageOrientation: .up, + toFitViewSize: innerContentView.avatarImageView.frame.size, + with: MXThumbnailingMethodCrop, + previewImage: placeholder, + mediaManager: bubbleCellData.mxSession.mediaManager) + innerContentView.avatarImageView.defaultBackgroundColor = .clear + + innerContentView.callerNameLabel.text = user?.displayname + } + } else { + innerContentView.avatarImageView.setImageURI(bubbleCellData.senderAvatarUrl, + withType: nil, + andImageOrientation: .up, + toFitViewSize: innerContentView.avatarImageView.frame.size, + with: MXThumbnailingMethodCrop, + previewImage: bubbleCellData.senderAvatarPlaceholder, + mediaManager: bubbleCellData.mxSession.mediaManager) + innerContentView.avatarImageView.defaultBackgroundColor = .clear + + innerContentView.callerNameLabel.text = bubbleCellData.senderDisplayName + } + + guard let callInviteEventContent = MXCallInviteEventContent(fromJSON: inviteEvent.content) else { + return + } + isVideoCall = callInviteEventContent.isVideoCall() + callDurationString = readableCallDuration(from: events) + isIncoming = inviteEvent.sender != bubbleCellData.mxSession.myUserId + callInviteEvent = inviteEvent + innerContentView.callIconView.image = self.callTypeIcon + innerContentView.callTypeLabel.text = isVideoCall ? + VectorL10n.eventFormatterCallVideo : + VectorL10n.eventFormatterCallVoice + + let callId = callInviteEventContent.callId guard let call = bubbleCellData.mxSession.callManager.call(withCallId: callId) else { // check events include a reject event @@ -162,46 +380,50 @@ class RoomDirectCallStatusBubbleCell: RoomBaseCallBubbleCell { return } - // there is no reject event, we can just say this call has ended - statusTextView.text = VectorL10n.eventFormatterCallHasEnded + // check events include an answer event + if !events.contains(where: { $0.eventType == .callAnswer }) { + configureForUnansweredCall() + return + } + + // check events include a hangup event + if let hangupEvent = events.first(where: { $0.eventType == .callHangup }) { + configureForHangupCall(withEvent: hangupEvent) + return + } + + // there is no reject or hangup event, we can just say this call has ended + viewState = .ended + statusText = VectorL10n.eventFormatterCallHasEnded(callDurationString) return } configure(withCall: call) } - override func prepareForReuse() { - statusTextView.isSelectable = false - statusTextView.text = nil - statusTextView.attributedText = nil - - super.prepareForReuse() - } - -} - -// MARK: - UITextViewDelegate - -extension RoomDirectCallStatusBubbleCell { - - override func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { - if URL == Constants.statusCallBackURL && interaction == .invokeDefaultAction { - let userInfo: [AnyHashable: Any]? - - guard let bubbleCellData = bubbleData as? RoomBubbleCellData else { - return false - } - let events = bubbleCellData.allLinkedEvents() - if let callInviteEvent = events.first(where: { $0.eventType == .callInvite }) { - userInfo = [kMXKRoomBubbleCellEventKey: callInviteEvent] - } else { - userInfo = nil - } - - self.delegate?.cell(self, didRecognizeAction: kMXKRoomBubbleCellCallBackButtonPressed, userInfo: userInfo) - return true + private func callDuration(from events: [MXEvent]) -> TimeInterval { + guard let startDate = events.first(where: { $0.eventType == .callAnswer })?.originServerTs else { + // never started + return 0 } - return false + guard let endDate = events.first(where: { $0.eventType == .callHangup })?.originServerTs + ?? events.first(where: { $0.eventType == .callReject })?.originServerTs else { + // not ended yet, compute the diff from now + return (NSTimeIntervalSince1970 - TimeInterval(startDate))/MSEC_PER_SEC + } + + // ended, compute the diff between two dates + return TimeInterval(endDate - startDate)/MSEC_PER_SEC + } + + private func readableCallDuration(from events: [MXEvent]) -> String { + let duration = callDuration(from: events) + + if duration <= 0 { + return "" + } + + return RoomDirectCallStatusBubbleCell.callDurationFormatter.string(from: duration) ?? "" } } diff --git a/Riot/Modules/Room/Views/BubbleCells/Call/Group/RoomGroupCallStatusBubbleCell.swift b/Riot/Modules/Room/Views/BubbleCells/Call/Group/RoomGroupCallStatusBubbleCell.swift new file mode 100644 index 000000000..15d7a9cd6 --- /dev/null +++ b/Riot/Modules/Room/Views/BubbleCells/Call/Group/RoomGroupCallStatusBubbleCell.swift @@ -0,0 +1,334 @@ +// +// 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 + +/// The number of milliseconds in one second. +private let MSEC_PER_SEC: TimeInterval = 1000 + +@objcMembers +class RoomGroupCallStatusBubbleCell: RoomBaseCallBubbleCell { + + private static var className: String { + return String(describing: self) + } + + /// Action identifier used when the user pressed "Join" button for an active call. + /// The `userInfo` dictionary contains an `MXEvent` object under the `kMXKRoomBubbleCellEventKey` key, representing the widget event of the call. + static var joinAction: String { + return self.className + ".join" + } + + /// Action identifier used when the user pressed "Leave" button for an active call. + /// The `userInfo` dictionary contains an `MXEvent` object under the `kMXKRoomBubbleCellEventKey` key, representing the widget event of the call. + static var leaveAction: String { + return self.className + ".leave" + } + + /// Action identifier used when the user pressed "Answer" button for an incoming call. + /// The `userInfo` dictionary contains an `MXEvent` object under the `kMXKRoomBubbleCellEventKey` key, representing the widget event of the call. + static var answerAction: String { + return self.className + ".answer" + } + + /// Action identifier used when the user pressed "Decline" button for an incoming call. + /// The `userInfo` dictionary contains an `MXEvent` object under the `kMXKRoomBubbleCellEventKey` key, representing the widget event of the call. + static var declineAction: String { + return self.className + ".decline" + } + + private var callDurationString: String = "" + private var isIncoming: Bool = false + private var widgetEvent: MXEvent! + private var widgetId: String! + private var viewState: ViewState = .unknown { + didSet { + updateBottomContentView() + } + } + + private enum Constants { + static let secondsToDisplayAnswerDeclineOptions: TimeInterval = 30 + } + + private enum ViewState { + case unknown + case ringing + case active + case declined + case ended + } + + private static var callDurationFormatter: DateComponentsFormatter { + let formatter = DateComponentsFormatter() + formatter.zeroFormattingBehavior = .dropAll + formatter.allowedUnits = [.hour, .minute, .second] + formatter.unitsStyle = .abbreviated + return formatter + } + + private func updateBottomContentView() { + bottomContentView = bottomView(for: viewState) + } + + private var callTypeIcon: UIImage { + // always return a video call icon + return Asset.Images.callVideoIcon.image + } + + private var isJoined: Bool { + return widgetId != nil && + AppDelegate.theDelegate().callPresenter.jitsiVC?.widget.widgetId == widgetId + } + + private var actionUserInfo: [AnyHashable: Any]? { + if let event = widgetEvent { + return [kMXKRoomBubbleCellEventKey: event] + } + return nil + } + + private func bottomView(for state: ViewState) -> UIView? { + switch state { + case .unknown: + return nil + case .ringing: + let view = HorizontalButtonsContainerView.loadFromNib() + + view.firstButton.style = .negative + view.firstButton.setTitle(VectorL10n.eventFormatterCallDecline, for: .normal) + view.firstButton.setImage(Asset.Images.voiceCallHangupIcon.image, for: .normal) + view.firstButton.removeTarget(nil, action: nil, for: .touchUpInside) + view.firstButton.addTarget(self, action: #selector(declineCallAction(_:)), for: .touchUpInside) + + view.secondButton.style = .positive + view.secondButton.setTitle(VectorL10n.eventFormatterCallAnswer, for: .normal) + view.secondButton.setImage(callTypeIcon, for: .normal) + view.secondButton.removeTarget(nil, action: nil, for: .touchUpInside) + view.secondButton.addTarget(self, action: #selector(answerCallAction(_:)), for: .touchUpInside) + + return view + case .active: + let view = HorizontalButtonsContainerView.loadFromNib() + view.secondButton.isHidden = true + + if isJoined { + // show a "Leave" button + view.firstButton.style = .negative + view.firstButton.setTitle(VectorL10n.eventFormatterGroupCallLeave, for: .normal) + view.firstButton.setImage(nil, for: .normal) + view.firstButton.removeTarget(nil, action: nil, for: .touchUpInside) + view.firstButton.addTarget(self, action: #selector(leaveAction(_:)), for: .touchUpInside) + } else { + // show a "Join" button + view.firstButton.style = .positive + view.firstButton.setTitle(VectorL10n.eventFormatterGroupCallJoin, for: .normal) + view.firstButton.setImage(callTypeIcon, for: .normal) + view.firstButton.removeTarget(nil, action: nil, for: .touchUpInside) + view.firstButton.addTarget(self, action: #selector(joinAction(_:)), for: .touchUpInside) + } + + return view + case .declined: + let view = HorizontalButtonsContainerView.loadFromNib() + view.secondButton.isHidden = true + + view.firstButton.style = .positive + view.firstButton.setTitle(VectorL10n.eventFormatterGroupCallJoin, for: .normal) + view.firstButton.setImage(callTypeIcon, for: .normal) + view.firstButton.removeTarget(nil, action: nil, for: .touchUpInside) + view.firstButton.addTarget(self, action: #selector(joinAction(_:)), for: .touchUpInside) + + return view + case .ended: + return nil + } + } + + // MARK: - Actions + + @objc + private func joinAction(_ sender: CallTileActionButton) { + self.delegate?.cell(self, + didRecognizeAction: Self.joinAction, + userInfo: actionUserInfo) + } + + @objc + private func leaveAction(_ sender: CallTileActionButton) { + self.delegate?.cell(self, + didRecognizeAction: Self.leaveAction, + userInfo: actionUserInfo) + } + + @objc + private func declineCallAction(_ sender: CallTileActionButton) { + self.delegate?.cell(self, + didRecognizeAction: Self.declineAction, + userInfo: actionUserInfo) + } + + @objc + private func answerCallAction(_ sender: CallTileActionButton) { + self.delegate?.cell(self, + didRecognizeAction: Self.answerAction, + userInfo: actionUserInfo) + } + + // MARK: - MXKCellRendering + + override func render(_ cellData: MXKCellData!) { + super.render(cellData) + + viewState = .unknown + + guard let bubbleCellData = cellData as? RoomBubbleCellData else { + return + } + + let events = bubbleCellData.allLinkedEvents() + + NSLog("[RoomGroupCallStatusBubbleCell] render: \(events.count) events: \(events)") + + guard let widgetEvent = events + .first(where: { + $0.eventType == .custom && + ($0.type == kWidgetMatrixEventTypeString || $0.type == kWidgetModularEventTypeString) + }) else { + return + } + + guard let widgetId = widgetEvent.stateKey else { + return + } + + guard let room = bubbleCellData.mxSession.room(withRoomId: widgetEvent.roomId) else { + return + } + + callDurationString = readableCallDuration(from: widgetEvent, endEvent: nil) + isIncoming = widgetEvent.sender != bubbleCellData.mxSession.myUserId + self.widgetEvent = widgetEvent + self.widgetId = widgetId + innerContentView.callIconView.image = Asset.Images.callVideoIcon.image + innerContentView.callTypeLabel.text = VectorL10n.eventFormatterCallVideo + + if isIncoming && !isJoined && + TimeInterval(widgetEvent.age)/MSEC_PER_SEC < Constants.secondsToDisplayAnswerDeclineOptions { + + if JitsiService.shared.isWidgetDeclined(withId: widgetId) { + innerContentView.callerNameLabel.text = room.summary.displayname + room.summary.setRoomAvatarImageIn(innerContentView.avatarImageView) + + viewState = .declined + statusText = VectorL10n.eventFormatterCallYouDeclined + } else { + innerContentView.callerNameLabel.text = VectorL10n.eventFormatterGroupCallIncoming(bubbleCellData.senderDisplayName, room.summary.displayname) + + innerContentView.avatarImageView.setImageURI(bubbleCellData.senderAvatarUrl, + withType: nil, + andImageOrientation: .up, + toFitViewSize: innerContentView.avatarImageView.frame.size, + with: MXThumbnailingMethodCrop, + previewImage: bubbleCellData.senderAvatarPlaceholder, + mediaManager: bubbleCellData.mxSession.mediaManager) + + viewState = .ringing + statusText = nil + } + } else { + innerContentView.callerNameLabel.text = room.summary.displayname + + room.summary.setRoomAvatarImageIn(innerContentView.avatarImageView) + } + + innerContentView.avatarImageView.defaultBackgroundColor = .clear + + room.state { [weak self] (roomState) in + guard let self = self else { return } + guard let widgets = WidgetManager.shared()?.widgets(ofTypes: [ + kWidgetTypeJitsiV1, + kWidgetTypeJitsiV2 + ], + in: room, + with: roomState) else { + self.viewState = .ended + self.statusText = VectorL10n.eventFormatterCallHasEnded(self.callDurationString) + return + } + + let removeWidgetEvent = roomState?.stateEvents + .filter({ $0.stateKey == widgetId }) + .first(where: { $0.content.isEmpty }) + self.callDurationString = self.readableCallDuration(from: widgetEvent, + endEvent: removeWidgetEvent) + + guard let widget = widgets.first(where: { $0.widgetId == widgetId }) else { + self.viewState = .ended + self.statusText = VectorL10n.eventFormatterCallHasEnded(self.callDurationString) + return + } + + if widget.isActive { + if !self.isIncoming { + self.viewState = .active + self.statusText = VectorL10n.eventFormatterCallYouCurrentlyIn + } else if !self.isJoined && + TimeInterval(widgetEvent.age)/MSEC_PER_SEC < Constants.secondsToDisplayAnswerDeclineOptions { + + if JitsiService.shared.isWidgetDeclined(withId: widgetId) { + self.viewState = .declined + self.statusText = VectorL10n.eventFormatterCallYouDeclined + } else { + self.viewState = .ringing + self.statusText = nil + } + } else { + self.viewState = .active + self.statusText = VectorL10n.eventFormatterCallYouCurrentlyIn + } + } else { + self.viewState = .ended + self.statusText = VectorL10n.eventFormatterCallHasEnded(self.callDurationString) + } + } + } + + private func callDuration(from startEvent: MXEvent?, endEvent: MXEvent?) -> TimeInterval { + guard let startDate = startEvent?.originServerTs else { + // never started + return 0 + } + guard let endDate = endEvent?.originServerTs else { + // not ended yet, compute the diff from now + return (NSTimeIntervalSince1970 - TimeInterval(startDate))/MSEC_PER_SEC + } + + // ended, compute the diff between two dates + return TimeInterval(max(0, Double(endDate) - Double(startDate)))/MSEC_PER_SEC + } + + private func readableCallDuration(from startEvent: MXEvent?, endEvent: MXEvent?) -> String { + let duration = callDuration(from: startEvent, endEvent: endEvent) + + if duration <= 0 { + return "" + } + + return RoomGroupCallStatusBubbleCell.callDurationFormatter.string(from: duration) ?? "" + } + +} diff --git a/Riot/Modules/Room/Views/BubbleCells/Call/RoomBaseCallBubbleCell.swift b/Riot/Modules/Room/Views/BubbleCells/Call/RoomBaseCallBubbleCell.swift index fed709feb..d760d83bd 100644 --- a/Riot/Modules/Room/Views/BubbleCells/Call/RoomBaseCallBubbleCell.swift +++ b/Riot/Modules/Room/Views/BubbleCells/Call/RoomBaseCallBubbleCell.swift @@ -19,7 +19,7 @@ import Reusable class RoomBaseCallBubbleCell: MXKRoomBubbleTableViewCell { - fileprivate lazy var innerContentView: CallBubbleCellBaseContentView = { + lazy var innerContentView: CallBubbleCellBaseContentView = { return CallBubbleCellBaseContentView.loadFromNib() }() @@ -36,16 +36,29 @@ class RoomBaseCallBubbleCell: MXKRoomBubbleTableViewCell { override func setupViews() { super.setupViews() - self.contentView.vc_removeAllSubviews() self.contentView.vc_addSubViewMatchingParent(innerContentView) - - updateBottomContentView() } - // Properties to override - private(set) var bottomContentView: UIView? + // Bottom content view. Will be spanned in bottomContainerView + var bottomContentView: UIView? { + didSet { + updateBottomContentView() + } + } - func updateBottomContentView() { + var statusText: String? { + get { + return innerContentView.statusText + } set { + innerContentView.statusText = newValue + } + } + + private func updateBottomContentView() { + defer { + innerContentView.relayoutCallSummary() + } + innerContentView.bottomContainerView.vc_removeAllSubviews() guard let bottomContentView = bottomContentView else { return } @@ -58,15 +71,37 @@ class RoomBaseCallBubbleCell: MXKRoomBubbleTableViewCell { // MARK: - Overrides + override var bubbleInfoContainer: UIView! { + get { + guard let infoContainer = innerContentView.bubbleInfoContainer else { + fatalError("[RoomBaseCallBubbleCell] bubbleInfoContainer should not be used before set") + } + return infoContainer + } set { + super.bubbleInfoContainer = newValue + } + } + override var bubbleOverlayContainer: UIView! { get { guard let overlayContainer = innerContentView.bubbleOverlayContainer else { fatalError("[RoomBaseCallBubbleCell] bubbleOverlayContainer should not be used before set") } return overlayContainer + } set { + super.bubbleOverlayContainer = newValue + } + } + + override var bubbleInfoContainerTopConstraint: NSLayoutConstraint! { + get { + guard let infoContainerTopConstraint = innerContentView.bubbleInfoContainerTopConstraint else { + fatalError("[RoomBaseCallBubbleCell] bubbleInfoContainerTopConstraint should not be used before set") + } + return infoContainerTopConstraint } set { - super.bubbleOverlayContainer = newValue + super.bubbleInfoContainerTopConstraint = newValue } } @@ -95,7 +130,28 @@ class RoomBaseCallBubbleCell: MXKRoomBubbleTableViewCell { } cell.render(cellData) - return cell.contentView.systemLayoutSizeFitting(fittingSize).height + // we need to add suitable height manually for read receipts view, as adding of them is not handled in the render method + var readReceiptsHeight: CGFloat = 0 + if let bubbleCellData = cellData as? RoomBubbleCellData, + bubbleCellData.showBubbleReceipts, + bubbleCellData.readReceipts.count > 0 { + readReceiptsHeight = cell.innerContentView.readReceiptsContainerView.systemLayoutSizeFitting(fittingSize).height + + cell.innerContentView.interItemSpacing + } + + return cell.contentView.systemLayoutSizeFitting(fittingSize).height + readReceiptsHeight + } + +} + +extension RoomBaseCallBubbleCell: BubbleCellReadReceiptsDisplayable { + + func addReadReceiptsView(_ readReceiptsView: UIView) { + innerContentView.addReadReceiptsView(readReceiptsView) + } + + func removeReadReceiptsView() { + innerContentView.removeReadReceiptsView() } } @@ -104,10 +160,13 @@ extension RoomBaseCallBubbleCell: Themable { func update(theme: Theme) { innerContentView.update(theme: theme) + if let themable = bottomContentView as? Themable { + themable.update(theme: theme) + } } } -extension RoomBaseCallBubbleCell: NibLoadable, Reusable { +extension RoomBaseCallBubbleCell: NibReusable { } diff --git a/Riot/Modules/Room/Views/BubbleCells/Call/RoomBaseCallBubbleCell.xib b/Riot/Modules/Room/Views/BubbleCells/Call/RoomBaseCallBubbleCell.xib index b7b585fca..052188b08 100644 --- a/Riot/Modules/Room/Views/BubbleCells/Call/RoomBaseCallBubbleCell.xib +++ b/Riot/Modules/Room/Views/BubbleCells/Call/RoomBaseCallBubbleCell.xib @@ -1,19 +1,18 @@ - + - + - + - - + diff --git a/Riot/Modules/Room/Views/RemoveJitsiWidget/ArrowsAnimationView.swift b/Riot/Modules/Room/Views/RemoveJitsiWidget/ArrowsAnimationView.swift new file mode 100644 index 000000000..1c4c0c9d2 --- /dev/null +++ b/Riot/Modules/Room/Views/RemoveJitsiWidget/ArrowsAnimationView.swift @@ -0,0 +1,114 @@ +// +// 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 ArrowsAnimationView: UIView { + + private enum Constants { + static let numberOfArrows: Int = 3 + static let arrowSize: CGSize = CGSize(width: 14, height: 14) + static let gradientAnimationKey: String = "gradient" + static let gradientRatios: [CGFloat] = [1.0, 0.3, 0.2] + } + + private var gradientLayer: CAGradientLayer! + private lazy var gradientAnimation: CABasicAnimation = { + let animation = CABasicAnimation(keyPath: "locations") + animation.fromValue = [0.0, 0.0, 0.25] + animation.toValue = [0.75, 1.0, 1.0] + animation.repeatCount = .infinity + animation.duration = 1 + return animation + }() + private var theme: Theme = ThemeService.shared().theme + private var arrowImageViews: [UIImageView] = [] + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + private func setup() { + let arrowImage = Asset.Images.disclosureIcon.image + for i in 0.. Bool { + switch (lhs, rhs) { + case (.notStarted, .notStarted): + return true + case (let .sliding(percentage1), let .sliding(percentage2)): + return percentage1 == percentage2 + case (.completed, .completed): + return true + default: + return false + } + } + } + + @IBOutlet private weak var slidingViewLeadingConstraint: NSLayoutConstraint! + @IBOutlet private weak var slidingView: UIView! + @IBOutlet private weak var slidingViewLabel: UILabel! { + didSet { + slidingViewLabel.text = VectorL10n.roomSlideToEndGroupCall + } + } + @IBOutlet private weak var arrowsView: ArrowsAnimationView! + @IBOutlet private weak var hangupView: UIView! + @IBOutlet private weak var hangupImage: UIImageView! + @IBOutlet private weak var topSeparatorView: UIView! + @IBOutlet private weak var bottomSeparatorView: UIView! + + private var state: State = .notStarted + private var theme: Theme = ThemeService.shared().theme + + // MARK - Private + + private func configure(withState state: State) { + switch state { + case .notStarted: + arrowsView.isAnimating = false + hangupView.backgroundColor = .clear + hangupImage.tintColor = theme.noticeColor + slidingViewLeadingConstraint.constant = 0 + case .sliding(let percentage): + arrowsView.isAnimating = true + if percentage < Constants.activationThreshold { + hangupView.backgroundColor = .clear + hangupImage.tintColor = theme.noticeColor + } else { + hangupView.backgroundColor = theme.noticeColor + hangupImage.tintColor = theme.callScreenButtonTintColor + } + slidingViewLeadingConstraint.constant = percentage * slidingView.frame.width + case .completed: + arrowsView.isAnimating = false + hangupView.backgroundColor = theme.noticeColor + hangupImage.tintColor = theme.callScreenButtonTintColor + } + } + + private func updateState(to newState: State) { + guard newState != state else { + return + } + configure(withState: newState) + state = newState + + if state == .completed { + delegate?.removeJitsiWidgetViewDidCompleteSliding(self) + } + } + + // MARK: - Touch Handling + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + super.touchesBegan(touches, with: event) + + if state == .notStarted { + updateState(to: .sliding(percentage: 0)) + } + } + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + super.touchesMoved(touches, with: event) + + switch state { + case .sliding: + if let touch = touches.first { + let touchLocation = touch.location(in: self) + if frame.contains(touchLocation) { + let percentage = touchLocation.x / frame.width + updateState(to: .sliding(percentage: percentage)) + } + } + default: + break + } + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + super.touchesEnded(touches, with: event) + + switch state { + case .sliding(let percentage): + if percentage < Constants.activationThreshold { + updateState(to: .notStarted) + } else { + updateState(to: .completed) + } + default: + break + } + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + super.touchesCancelled(touches, with: event) + + switch state { + case .sliding(let percentage): + if percentage < Constants.activationThreshold { + updateState(to: .notStarted) + } else { + updateState(to: .completed) + } + default: + break + } + } + + // MARK: - API + + weak var delegate: RemoveJitsiWidgetViewDelegate? + + static func instantiate() -> RemoveJitsiWidgetView { + let view = RemoveJitsiWidgetView.loadFromNib() + view.update(theme: ThemeService.shared().theme) + return view + } + + func reset() { + updateState(to: .notStarted) + } + +} + +// MARK: - NibLoadable + +extension RemoveJitsiWidgetView: NibLoadable { } + +// MARK: - Themable + +extension RemoveJitsiWidgetView: Themable { + + func update(theme: Theme) { + self.theme = theme + + self.backgroundColor = theme.headerBackgroundColor + + slidingViewLabel.textColor = theme.textPrimaryColor + arrowsView.update(theme: theme) + topSeparatorView.backgroundColor = theme.lineBreakColor + bottomSeparatorView.backgroundColor = theme.lineBreakColor + + configure(withState: state) + } + +} diff --git a/Riot/Modules/Room/Views/RemoveJitsiWidget/RemoveJitsiWidgetView.xib b/Riot/Modules/Room/Views/RemoveJitsiWidget/RemoveJitsiWidgetView.xib new file mode 100644 index 000000000..6d2d62b0b --- /dev/null +++ b/Riot/Modules/Room/Views/RemoveJitsiWidget/RemoveJitsiWidgetView.xib @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/Room/Views/RemoveJitsiWidget/RemoveJitsiWidgetViewDelegate.swift b/Riot/Modules/Room/Views/RemoveJitsiWidget/RemoveJitsiWidgetViewDelegate.swift new file mode 100644 index 000000000..f48af79a1 --- /dev/null +++ b/Riot/Modules/Room/Views/RemoveJitsiWidget/RemoveJitsiWidgetViewDelegate.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 + +@objc +protocol RemoveJitsiWidgetViewDelegate: class { + + /// Tells the delegate that the user complete sliding on the view + /// - Parameter view: The view instance + func removeJitsiWidgetViewDidCompleteSliding(_ view: RemoveJitsiWidgetView) +} diff --git a/Riot/Modules/Settings/SettingsViewController.m b/Riot/Modules/Settings/SettingsViewController.m index 12adb0364..0ae7fb7c2 100644 --- a/Riot/Modules/Settings/SettingsViewController.m +++ b/Riot/Modules/Settings/SettingsViewController.m @@ -142,7 +142,7 @@ enum enum { - LABS_USE_JITSI_WIDGET_INDEX = 0, + LABS_ENABLE_RINGING_FOR_GROUP_CALLS_INDEX = 0 }; enum @@ -350,7 +350,10 @@ TableViewSectionsDelegate> Section *sectionNotificationSettings = [Section sectionWithTag:SECTION_TAG_NOTIFICATIONS]; [sectionNotificationSettings addRowWithTag:NOTIFICATION_SETTINGS_ENABLE_PUSH_INDEX]; - [sectionNotificationSettings addRowWithTag:NOTIFICATION_SETTINGS_SHOW_DECODED_CONTENT]; + if (RiotSettings.shared.settingsScreenShowNotificationDecodedContentOption) + { + [sectionNotificationSettings addRowWithTag:NOTIFICATION_SETTINGS_SHOW_DECODED_CONTENT]; + } [sectionNotificationSettings addRowWithTag:NOTIFICATION_SETTINGS_GLOBAL_SETTINGS_INDEX]; [sectionNotificationSettings addRowWithTag:NOTIFICATION_SETTINGS_PIN_MISSED_NOTIFICATIONS_INDEX]; [sectionNotificationSettings addRowWithTag:NOTIFICATION_SETTINGS_PIN_UNREAD_INDEX]; @@ -458,7 +461,10 @@ TableViewSectionsDelegate> [sectionOther addRowWithTag:OTHER_PRIVACY_INDEX]; } [sectionOther addRowWithTag:OTHER_THIRD_PARTY_INDEX]; - [sectionOther addRowWithTag:OTHER_SHOW_NSFW_ROOMS_INDEX]; + if (RiotSettings.shared.settingsScreenShowNsfwRoomsOption) + { + [sectionOther addRowWithTag:OTHER_SHOW_NSFW_ROOMS_INDEX]; + } if (BuildSettings.settingsScreenAllowChangingCrashUsageDataSettings) { @@ -480,9 +486,12 @@ TableViewSectionsDelegate> if (BuildSettings.settingsScreenShowLabSettings) { Section *sectionLabs = [Section sectionWithTag:SECTION_TAG_LABS]; - [sectionLabs addRowWithTag:LABS_USE_JITSI_WIDGET_INDEX]; + [sectionLabs addRowWithTag:LABS_ENABLE_RINGING_FOR_GROUP_CALLS_INDEX]; sectionLabs.headerTitle = NSLocalizedStringFromTable(@"settings_labs", @"Vector", nil); - [tmpSections addObject:sectionLabs]; + if (sectionLabs.hasAnyRows) + { + [tmpSections addObject:sectionLabs]; + } } if ([groupsDataSource numberOfSectionsInTableView:self.tableView] && groupsDataSource.joinedGroupsSection != -1) @@ -2244,16 +2253,16 @@ TableViewSectionsDelegate> } else if (section == SECTION_TAG_LABS) { - if (row == LABS_USE_JITSI_WIDGET_INDEX) + if (row == LABS_ENABLE_RINGING_FOR_GROUP_CALLS_INDEX) { - MXKTableViewCellWithLabelAndSwitch* labelAndSwitchCell = [self getLabelAndSwitchCell:tableView forIndexPath:indexPath]; - - labelAndSwitchCell.mxkLabel.text = NSLocalizedStringFromTable(@"settings_labs_create_conference_with_jitsi", @"Vector", nil); - labelAndSwitchCell.mxkSwitch.on = RiotSettings.shared.createConferenceCallsWithJitsi; - labelAndSwitchCell.mxkSwitch.onTintColor = ThemeService.shared.theme.tintColor; - - [labelAndSwitchCell.mxkSwitch addTarget:self action:@selector(toggleJitsiForConference:) forControlEvents:UIControlEventTouchUpInside]; - + MXKTableViewCellWithLabelAndSwitch *labelAndSwitchCell = [self getLabelAndSwitchCell:tableView forIndexPath:indexPath]; + + labelAndSwitchCell.mxkLabel.text = NSLocalizedStringFromTable(@"settings_labs_enable_ringing_for_group_calls", @"Vector", nil); + labelAndSwitchCell.mxkSwitch.on = RiotSettings.shared.enableRingingForGroupCalls; + labelAndSwitchCell.mxkSwitch.tintColor = ThemeService.shared.theme.tintColor; + + [labelAndSwitchCell.mxkSwitch addTarget:self action:@selector(toggleEnableRingingForGroupCalls:) forControlEvents:UIControlEventValueChanged]; + cell = labelAndSwitchCell; } } @@ -2944,14 +2953,12 @@ TableViewSectionsDelegate> } } -- (void)toggleJitsiForConference:(id)sender +- (void)toggleEnableRingingForGroupCalls:(UISwitch *)sender { - if (sender && [sender isKindOfClass:UISwitch.class]) + if (sender) { - UISwitch *switchButton = (UISwitch*)sender; + RiotSettings.shared.enableRingingForGroupCalls = sender.isOn; - RiotSettings.shared.createConferenceCallsWithJitsi = switchButton.isOn; - [self.tableView reloadData]; } } diff --git a/Riot/Modules/SplitView/SplitViewCoordinator.swift b/Riot/Modules/SplitView/SplitViewCoordinator.swift index 3f78605f7..59e8b6a2a 100644 --- a/Riot/Modules/SplitView/SplitViewCoordinator.swift +++ b/Riot/Modules/SplitView/SplitViewCoordinator.swift @@ -46,7 +46,7 @@ final class SplitViewCoordinator: NSObject, SplitViewCoordinatorType { self.rootRouter = router self.session = session - let splitViewController = UISplitViewController() + let splitViewController = RiotSplitViewController() splitViewController.preferredDisplayMode = .allVisible self.splitViewController = splitViewController } diff --git a/Riot/SupportingFiles/Riot-Bridging-Header.h b/Riot/SupportingFiles/Riot-Bridging-Header.h index 7daa40759..2a6d6a754 100644 --- a/Riot/SupportingFiles/Riot-Bridging-Header.h +++ b/Riot/SupportingFiles/Riot-Bridging-Header.h @@ -6,6 +6,7 @@ @import MatrixKit; #import "WebViewViewController.h" +#import "RiotSplitViewController.h" #import "RiotNavigationController.h" #import "ThemeService.h" #import "TableViewCellWithCheckBoxAndLabel.h" diff --git a/Riot/Utils/Constants.swift b/Riot/Utils/Constants.swift index 886615072..fcf886256 100644 --- a/Riot/Utils/Constants.swift +++ b/Riot/Utils/Constants.swift @@ -16,9 +16,16 @@ import Foundation -enum Constants { +@objcMembers +class Constants: NSObject { - static let toBeRemovedNotificationCategoryIdentifier = "TO_BE_REMOVED" - static let callInviteNotificationCategoryIdentifier = "CALL_INVITE" + static let toBeRemovedNotificationCategoryIdentifier: String = "TO_BE_REMOVED" + static let callInviteNotificationCategoryIdentifier: String = "CALL_INVITE" + + /// Notification userInfo key to present a notification when the app is on foreground. Value should be set as a Bool for this key. + static let userInfoKeyPresentNotificationOnForeground: String = "ALWAYS_PRESENT_NOTIFICATION" + + /// Notification userInfo key to present a notification even if the app is on foreground and in the notification's room screen. Value should be set as a Bool for this key. + static let userInfoKeyPresentNotificationInRoom: String = "IN_ROOM_PRESENT_NOTIFICATION" } diff --git a/Riot/Utils/EventFormatter.m b/Riot/Utils/EventFormatter.m index 532d26162..f0038508c 100644 --- a/Riot/Utils/EventFormatter.m +++ b/Riot/Utils/EventFormatter.m @@ -127,14 +127,7 @@ static NSString *const kEventFormatterTimeFormat = @"HH:mm"; || [activeWidget.type isEqualToString:kWidgetTypeJitsiV2]) { // This was a jitsi widget - if (isEventSenderMyUser) - { - displayText = NSLocalizedStringFromTable(@"event_formatter_jitsi_widget_removed_by_you", @"Vector", nil); - } - else - { - displayText = [NSString stringWithFormat:NSLocalizedStringFromTable(@"event_formatter_jitsi_widget_removed", @"Vector", nil), senderDisplayName]; - } + return nil; } else { @@ -191,9 +184,7 @@ static NSString *const kEventFormatterTimeFormat = @"HH:mm"; } break; case MXEventTypeCallCandidates: - case MXEventTypeCallAnswer: case MXEventTypeCallSelectAnswer: - case MXEventTypeCallHangup: case MXEventTypeCallNegotiate: case MXEventTypeCallReplaces: case MXEventTypeCallRejectReplacement: diff --git a/Riot/target.yml b/Riot/target.yml index ccd717311..7a0d46b75 100644 --- a/Riot/target.yml +++ b/Riot/target.yml @@ -34,6 +34,7 @@ targets: - target: RiotShareExtension - target: SiriIntents - target: RiotNSE + - target: DesignKit configFiles: Debug: Debug.xcconfig diff --git a/RiotNSE/Common.xcconfig b/RiotNSE/Common.xcconfig index ee4f8b558..7479a1ec8 100644 --- a/RiotNSE/Common.xcconfig +++ b/RiotNSE/Common.xcconfig @@ -27,3 +27,4 @@ INFOPLIST_FILE = RiotNSE/Info.plist CODE_SIGN_ENTITLEMENTS = RiotNSE/RiotNSE.entitlements SKIP_INSTALL = YES +SWIFT_OBJC_BRIDGING_HEADER = $(SRCROOT)/$(PRODUCT_NAME)/SupportingFiles/RiotNSE-Bridging-Header.h diff --git a/RiotNSE/NotificationService.swift b/RiotNSE/NotificationService.swift index a7def37e1..ec2230cb9 100644 --- a/RiotNSE/NotificationService.swift +++ b/RiotNSE/NotificationService.swift @@ -269,6 +269,7 @@ class NotificationService: UNNotificationServiceExtension { case .success(let (roomState, eventSenderName)): var notificationTitle: String? var notificationBody: String? + var additionalUserInfo: [AnyHashable: Any]? var threadIdentifier: String? = roomId let currentUserId = account.mxCredentials.userId @@ -372,6 +373,22 @@ class NotificationService: UNNotificationServiceExtension { } notificationBody = NSString.localizedUserNotificationString(forKey: "STICKER_FROM_USER", arguments: [eventSenderName as Any]) + case .custom: + if (event.type == kWidgetMatrixEventTypeString || event.type == kWidgetModularEventTypeString), + let type = event.content?["type"] as? String, + (type == kWidgetTypeJitsiV1 || type == kWidgetTypeJitsiV2) { + notificationBody = NSString.localizedUserNotificationString(forKey: "GROUP_CALL_STARTED", arguments: nil) + notificationTitle = roomDisplayName + + // call notifications should stand out from normal messages, so we don't stack them + threadIdentifier = nil + // only send VoIP pushes if ringing is enabled for group calls + if RiotSettings.shared.enableRingingForGroupCalls { + self.sendVoipPush(forEvent: event) + } else { + additionalUserInfo = [Constants.userInfoKeyPresentNotificationOnForeground: true] + } + } default: break } @@ -393,7 +410,8 @@ class NotificationService: UNNotificationServiceExtension { threadIdentifier: threadIdentifier, userId: currentUserId, event: event, - pushRule: pushRule) + pushRule: pushRule, + additionalInfo: additionalUserInfo) NSLog("[NotificationService] notificationContentForEvent: Calling onComplete.") onComplete(notificationContent) @@ -451,7 +469,8 @@ class NotificationService: UNNotificationServiceExtension { threadIdentifier: String?, userId: String?, event: MXEvent, - pushRule: MXPushRule?) -> UNNotificationContent { + pushRule: MXPushRule?, + additionalInfo: [AnyHashable: Any]? = nil) -> UNNotificationContent { let notificationContent = UNMutableNotificationContent() if let title = title { @@ -469,12 +488,16 @@ class NotificationService: UNNotificationServiceExtension { if let soundName = notificationSoundName(fromPushRule: pushRule) { notificationContent.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: soundName)) } - notificationContent.userInfo = notificationUserInfo(forEvent: event, andUserId: userId) + notificationContent.userInfo = notificationUserInfo(forEvent: event, + andUserId: userId, + additionalInfo: additionalInfo) return notificationContent } - private func notificationUserInfo(forEvent event: MXEvent, andUserId userId: String?) -> [AnyHashable: Any] { + private func notificationUserInfo(forEvent event: MXEvent, + andUserId userId: String?, + additionalInfo: [AnyHashable: Any]? = nil) -> [AnyHashable: Any] { var notificationUserInfo: [AnyHashable: Any] = [ "type": "full", "room_id": event.roomId as Any, @@ -483,6 +506,11 @@ class NotificationService: UNNotificationServiceExtension { if let userId = userId { notificationUserInfo["user_id"] = userId } + if let additionalInfo = additionalInfo { + for (key, value) in additionalInfo { + notificationUserInfo[key] = value + } + } return notificationUserInfo } diff --git a/RiotNSE/SupportingFiles/RiotNSE-Bridging-Header.h b/RiotNSE/SupportingFiles/RiotNSE-Bridging-Header.h new file mode 100644 index 000000000..aa38f8734 --- /dev/null +++ b/RiotNSE/SupportingFiles/RiotNSE-Bridging-Header.h @@ -0,0 +1,22 @@ +// +// 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. +// + +#ifndef RiotNSE_Bridging_Header_h +#define RiotNSE_Bridging_Header_h + +#import "WidgetConstants.h" + +#endif /* RiotNSE_Bridging_Header_h */ diff --git a/RiotNSE/target.yml b/RiotNSE/target.yml index de68fdcd7..edfbb540a 100644 --- a/RiotNSE/target.yml +++ b/RiotNSE/target.yml @@ -58,3 +58,4 @@ targets: - path: ../Riot/Utils/Constants.swift - path: ../Riot/Categories/String.swift - path: ../Riot/Categories/Character.swift + - path: ../Riot/Managers/Widgets/WidgetConstants.m diff --git a/RiotTests/HomeserverConfigurationTests.swift b/RiotTests/HomeserverConfigurationTests.swift new file mode 100644 index 000000000..d13ef9211 --- /dev/null +++ b/RiotTests/HomeserverConfigurationTests.swift @@ -0,0 +1,71 @@ +// +// Copyright 2020 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 XCTest + +@testable import Riot + +class HomeserverConfigurationTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + // MARK: - Tests + + func testHomeserverConfigurationBuilder() { + + let expectedJitsiServer = "your.jitsi.example.org" + let expectedJitsiServerStringURL = "https://\(expectedJitsiServer)" + let expectedDeprecatedJitsiServer = "your.deprecated.jitsi.example.org" + let expectedE2EEEByDefaultEnabled = true + let expectedDeprecatedE2EEEByDefaultEnabled = false + + let wellKnownDictionary: [String: Any] = [ + "m.homeserver": [ + "base_url": "https://your.homeserver.org" + ], + "m.identity_server": [ + "base_url": "https://your.identity-server.org" + ], + "im.vector.riot.e2ee" : [ + "default" : expectedDeprecatedE2EEEByDefaultEnabled + ], + "im.vector.riot.jitsi" : [ + "preferredDomain" : expectedDeprecatedJitsiServer + ], + "io.element.e2ee" : [ + "default" : expectedE2EEEByDefaultEnabled + ], + "io.element.jitsi" : [ + "preferredDomain" : expectedJitsiServer + ] + ] + + let wellKnown = MXWellKnown(fromJSON: wellKnownDictionary) + + let homeserverConfigurationBuilder = HomeserverConfigurationBuilder() + let homeserverConfiguration = homeserverConfigurationBuilder.build(from: wellKnown) + + XCTAssertEqual(homeserverConfiguration.jitsi.serverDomain, expectedJitsiServer) + XCTAssertEqual(homeserverConfiguration.jitsi.serverURL.absoluteString, expectedJitsiServerStringURL) + XCTAssertEqual(homeserverConfiguration.isE2EEByDefaultEnabled, expectedE2EEEByDefaultEnabled) + } +} diff --git a/RiotTests/VectorWellKnownTests.swift b/RiotTests/VectorWellKnownTests.swift new file mode 100644 index 000000000..d27ebf020 --- /dev/null +++ b/RiotTests/VectorWellKnownTests.swift @@ -0,0 +1,104 @@ +// +// Copyright 2020 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 XCTest + +@testable import Riot + +class VectorWellKnownTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + // MARK: - Tests + + func testVectorWellKnownParsing() { + + let expectedJitsiServer = "your.jitsi.example.org" + let expectedE2EEEByDefaultEnabled = false + + let wellKnownDictionary: [String: Any] = [ + "im.vector.riot.e2ee" : [ + "default" : expectedE2EEEByDefaultEnabled + ], + "im.vector.riot.jitsi" : [ + "preferredDomain" : expectedJitsiServer + ], + "io.element.e2ee" : [ + "default" : expectedE2EEEByDefaultEnabled + ], + "io.element.jitsi" : [ + "preferredDomain" : expectedJitsiServer + ] + ] + + let serializationService = SerializationService() + + do { + let vectorWellKnown: VectorWellKnown = try serializationService.deserialize(wellKnownDictionary) + + let jistiConfiguration = vectorWellKnown.jitsi + let encryptionConfiguration = vectorWellKnown.encryption + + XCTAssertNotNil(jistiConfiguration) + XCTAssertNotNil(encryptionConfiguration) + + XCTAssertEqual(jistiConfiguration?.preferredDomain, expectedJitsiServer) + XCTAssertEqual(encryptionConfiguration?.isE2EEByDefaultEnabled, expectedE2EEEByDefaultEnabled) + + let deprecatedJistiConfiguration = vectorWellKnown.deprecatedJitsi + let deprecatedEncryptionConfiguration = vectorWellKnown.deprecatedEncryption + + XCTAssertNotNil(deprecatedJistiConfiguration) + XCTAssertNotNil(deprecatedEncryptionConfiguration) + + XCTAssertEqual(deprecatedJistiConfiguration?.preferredDomain, expectedJitsiServer) + XCTAssertEqual(deprecatedEncryptionConfiguration?.isE2EEByDefaultEnabled, expectedE2EEEByDefaultEnabled) + + } catch { + XCTFail("Fail with error: \(error)") + } + } + + func testVectorWellKnownParsingMissingKey() { + + let expectedE2EEEByDefaultEnabled = false + + let wellKnownDictionary: [String: Any] = [ + "io.element.e2ee" : [ + "default" : expectedE2EEEByDefaultEnabled + ] + ] + + let serializationService = SerializationService() + + do { + let vectorWellKnown: VectorWellKnown = try serializationService.deserialize(wellKnownDictionary) + + XCTAssertNil(vectorWellKnown.jitsi) + XCTAssertNotNil(vectorWellKnown.encryption) + + XCTAssertEqual(vectorWellKnown.encryption?.isE2EEByDefaultEnabled, expectedE2EEEByDefaultEnabled) + } catch { + XCTFail("Fail with error: \(error)") + } + } +} diff --git a/project.yml b/project.yml index 729329e43..b2384fd09 100644 --- a/project.yml +++ b/project.yml @@ -30,4 +30,5 @@ include: - path: RiotTests/target.yml - path: RiotShareExtension/target.yml - path: SiriIntents/target.yml - - path: RiotNSE/target.yml \ No newline at end of file + - path: RiotNSE/target.yml + - path: DesignKit/target.yml