diff --git a/CHANGES.rst b/CHANGES.rst index 7c971f5c2..76bb579d0 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,15 +2,27 @@ Changes to be released in next version ================================================= ✨ Features - * + * Composer Update - Typing and sending a message (#4085) + * Switching composer between text mode & action mode (#4087) + * Explore typing notifications inspired by web (#4134) 🙌 Improvements * Make the application settings more configurable (#4171) * Possibility to lock some room creation parameters from settings (#4181) * Enable / disable external friends invite (#4173) + * Composer update - UI enhancements (#4133) + * Increase grow/shrink animation speed in new composer (#4187) + * Limit typing notifications timeline jumps (#4176) + * Consider displaying names in typing notifications (#4175) 🐛 Bugfix - * + * If you start typing while the new attachment sending mode is on, the send button appears (#4155) + * The final frames of the appearance animation of the new composer buttons are missing (#4160) + * Crash in [RoomViewController setupActions] (#4162) + * Too much vertical whitespace when replying (#4164) + * Black theme uses dark background for composer (#4192) + * Vertical layout of typing notifs can go wonky (#4159) + * Crash in [RoomViewController refreshTypingNotification] (#4161) ⚠️ API Changes * diff --git a/Riot/Assets/Images.xcassets/Room/Actions/Contents.json b/Riot/Assets/Images.xcassets/Room/Actions/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Riot/Assets/Images.xcassets/Room/Actions/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Room/Actions/action_camera.imageset/Contents.json b/Riot/Assets/Images.xcassets/Room/Actions/action_camera.imageset/Contents.json new file mode 100644 index 000000000..aec39f6e4 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Room/Actions/action_camera.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "action_camera.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "action_camera@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "action_camera@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Riot/Assets/Images.xcassets/Room/Actions/action_camera.imageset/action_camera.png b/Riot/Assets/Images.xcassets/Room/Actions/action_camera.imageset/action_camera.png new file mode 100644 index 000000000..799e2c02a Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Actions/action_camera.imageset/action_camera.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Actions/action_camera.imageset/action_camera@2x.png b/Riot/Assets/Images.xcassets/Room/Actions/action_camera.imageset/action_camera@2x.png new file mode 100644 index 000000000..191b388f9 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Actions/action_camera.imageset/action_camera@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Actions/action_camera.imageset/action_camera@3x.png b/Riot/Assets/Images.xcassets/Room/Actions/action_camera.imageset/action_camera@3x.png new file mode 100644 index 000000000..7af3968c9 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Actions/action_camera.imageset/action_camera@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Actions/action_file.imageset/Contents.json b/Riot/Assets/Images.xcassets/Room/Actions/action_file.imageset/Contents.json new file mode 100644 index 000000000..63ec74393 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Room/Actions/action_file.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "action_file.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "action_file@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "action_file@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Riot/Assets/Images.xcassets/Room/Actions/action_file.imageset/action_file.png b/Riot/Assets/Images.xcassets/Room/Actions/action_file.imageset/action_file.png new file mode 100644 index 000000000..0d944b128 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Actions/action_file.imageset/action_file.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Actions/action_file.imageset/action_file@2x.png b/Riot/Assets/Images.xcassets/Room/Actions/action_file.imageset/action_file@2x.png new file mode 100644 index 000000000..4f4522ab7 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Actions/action_file.imageset/action_file@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Actions/action_file.imageset/action_file@3x.png b/Riot/Assets/Images.xcassets/Room/Actions/action_file.imageset/action_file@3x.png new file mode 100644 index 000000000..13ed744fc Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Actions/action_file.imageset/action_file@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Actions/action_media_library.imageset/Contents.json b/Riot/Assets/Images.xcassets/Room/Actions/action_media_library.imageset/Contents.json new file mode 100644 index 000000000..7854a9423 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Room/Actions/action_media_library.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "action_media_library.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "action_media_library@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "action_media_library@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Riot/Assets/Images.xcassets/Room/Actions/action_media_library.imageset/action_media_library.png b/Riot/Assets/Images.xcassets/Room/Actions/action_media_library.imageset/action_media_library.png new file mode 100644 index 000000000..0adba836c Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Actions/action_media_library.imageset/action_media_library.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Actions/action_media_library.imageset/action_media_library@2x.png b/Riot/Assets/Images.xcassets/Room/Actions/action_media_library.imageset/action_media_library@2x.png new file mode 100644 index 000000000..aa6ddc4f9 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Actions/action_media_library.imageset/action_media_library@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Actions/action_media_library.imageset/action_media_library@3x.png b/Riot/Assets/Images.xcassets/Room/Actions/action_media_library.imageset/action_media_library@3x.png new file mode 100644 index 000000000..2dfd074ab Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Actions/action_media_library.imageset/action_media_library@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Actions/action_sticker.imageset/Contents.json b/Riot/Assets/Images.xcassets/Room/Actions/action_sticker.imageset/Contents.json new file mode 100644 index 000000000..adf12f0d6 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Room/Actions/action_sticker.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "action_sticker.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "action_sticker@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "action_sticker@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Riot/Assets/Images.xcassets/Room/Actions/action_sticker.imageset/action_sticker.png b/Riot/Assets/Images.xcassets/Room/Actions/action_sticker.imageset/action_sticker.png new file mode 100644 index 000000000..cb7a04d3f Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Actions/action_sticker.imageset/action_sticker.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Actions/action_sticker.imageset/action_sticker@2x.png b/Riot/Assets/Images.xcassets/Room/Actions/action_sticker.imageset/action_sticker@2x.png new file mode 100644 index 000000000..13a2b7511 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Actions/action_sticker.imageset/action_sticker@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Actions/action_sticker.imageset/action_sticker@3x.png b/Riot/Assets/Images.xcassets/Room/Actions/action_sticker.imageset/action_sticker@3x.png new file mode 100644 index 000000000..d000e8f4c Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Actions/action_sticker.imageset/action_sticker@3x.png differ diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 3bb638c4b..b24183508 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -407,6 +407,8 @@ Tap the + to start adding people."; "external_link_confirmation_title" = "Double-check this link"; "external_link_confirmation_message" = "The link %@ is taking you to another site: %@\n\nAre you sure you want to continue?"; +"room_multiple_typing_notification" = "%@ and others"; + // Unknown devices "unknown_devices_alert_title" = "Room contains unknown sessions"; "unknown_devices_alert" = "This room contains unknown sessions which have not been verified.\nThis means there is no guarantee that the sessions belong to the users they claim to.\nWe recommend you go through the verification process for each session before continuing, but you can resend the message without verifying if you prefer."; diff --git a/Riot/Generated/Images.swift b/Riot/Generated/Images.swift index c1110dc85..8c6ffccdf 100644 --- a/Riot/Generated/Images.swift +++ b/Riot/Generated/Images.swift @@ -99,6 +99,10 @@ internal enum Asset { internal static let peopleEmptyScreenArtwork = ImageAsset(name: "people_empty_screen_artwork") internal static let peopleEmptyScreenArtworkDark = ImageAsset(name: "people_empty_screen_artwork_dark") internal static let peopleFloatingAction = ImageAsset(name: "people_floating_action") + internal static let actionCamera = ImageAsset(name: "action_camera") + internal static let actionFile = ImageAsset(name: "action_file") + internal static let actionMediaLibrary = ImageAsset(name: "action_media_library") + 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 roomActivitiesRetry = ImageAsset(name: "room_activities_retry") diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index 7ce62a5db..3d1130a7a 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -2978,6 +2978,10 @@ internal enum VectorL10n { internal static var roomMessageUnableOpenLinkErrorMessage: String { return VectorL10n.tr("Vector", "room_message_unable_open_link_error_message") } + /// %@ and others + internal static func roomMultipleTypingNotification(_ p1: String) -> String { + return VectorL10n.tr("Vector", "room_multiple_typing_notification", p1) + } /// %d new message internal static func roomNewMessageNotification(_ p1: Int) -> String { return VectorL10n.tr("Vector", "room_new_message_notification", p1) diff --git a/Riot/Modules/DotsView/DotsView.swift b/Riot/Modules/DotsView/DotsView.swift new file mode 100644 index 000000000..adcf3ef49 --- /dev/null +++ b/Riot/Modules/DotsView/DotsView.swift @@ -0,0 +1,146 @@ +// +// 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 + +@IBDesignable +@objcMembers +class DotsView: UIView { + // MARK: - Public properties + + @IBInspectable var highlightedDotColor: UIColor = .darkGray + + @IBInspectable var dotColor: UIColor = .lightGray + + @IBInspectable var dotMaxWidth: CGFloat = 10 { + didSet { + self.sizeToFit() + } + } + + @IBInspectable var dotMinWidth: CGFloat = 8 { + didSet { + self.sizeToFit() + } + } + + @IBInspectable var numberOfDots: UInt = 3 { + didSet { + createDotViews() + } + } + + @IBInspectable var interSpaceMargin: CGFloat = 7 { + didSet { + self.sizeToFit() + } + } + + // MARK: - Private members + + private var dotLayers: Array = Array() + private var highlightedDotIndex: UInt = 0 { + didSet { + updateDotViews() + } + } + private let updateInterval: TimeInterval = 0.4 + private var lastUpdateDate: Date = Date() + private var animating: Bool = false { + didSet { + let displayLink = CADisplayLink(target: self, selector: #selector(fireTimer)) + displayLink.add(to: .current, forMode: .default) + } + } + + // MARK: - Lifecycle + + required init?(coder: NSCoder) { + super.init(coder: coder) + createDotViews() + } + + override init(frame: CGRect) { + super.init(frame: frame) + createDotViews() + } + + override func layoutSubviews() { + super.layoutSubviews() + + updateDotViews() + } + + override var intrinsicContentSize: CGSize { + return CGSize(width: dotMaxWidth + (CGFloat(numberOfDots) - 1) * (dotMinWidth + interSpaceMargin), height: dotMaxWidth) + } + + override func didMoveToSuperview() { + animating = superview != nil + } + + // MARK: - Interface Builder + + override func prepareForInterfaceBuilder() { + super.prepareForInterfaceBuilder() + createDotViews() + } + + // MARK: - Private methods + + private func createDotViews() { + while dotLayers.count > numberOfDots { + dotLayers.popLast()?.removeFromSuperlayer() + } + + while dotLayers.count < numberOfDots { + let dotLayer = CALayer() + dotLayer.masksToBounds = true + layer.addSublayer(dotLayer) + dotLayers.append(dotLayer) + } + + if highlightedDotIndex >= dotLayers.count { + highlightedDotIndex = 0 + updateDotViews() + } + } + + private func updateDotViews() { + CATransaction.begin() + CATransaction.setAnimationDuration(1) + var x: CGFloat = 0 + for (index, dotLayer) in dotLayers.enumerated() { + if index == highlightedDotIndex { + dotLayer.frame = CGRect(x: x, y: (bounds.height - dotMaxWidth) / 2, width: dotMaxWidth, height: dotMaxWidth) + dotLayer.backgroundColor = dotColor.cgColor + } else { + dotLayer.frame = CGRect(x: x, y: (bounds.height - dotMinWidth) / 2, width: dotMinWidth, height: dotMinWidth) + dotLayer.backgroundColor = index == ((highlightedDotIndex + 1) % numberOfDots) ? highlightedDotColor.cgColor : dotColor.cgColor + } + dotLayer.cornerRadius = dotLayer.bounds.height / 2 + x = dotLayer.frame.maxX + interSpaceMargin + } + lastUpdateDate = Date() + CATransaction.commit() + } + + @objc private func fireTimer() { + if Date().timeIntervalSince(lastUpdateDate) >= updateInterval { + self.highlightedDotIndex = (self.highlightedDotIndex + 1) % self.numberOfDots + } + } +} diff --git a/Riot/Modules/Room/DataSources/RoomDataSource.h b/Riot/Modules/Room/DataSources/RoomDataSource.h index f28f91fac..725171572 100644 --- a/Riot/Modules/Room/DataSources/RoomDataSource.h +++ b/Riot/Modules/Room/DataSources/RoomDataSource.h @@ -21,6 +21,8 @@ #import "MXRoomSummary+Riot.h" +#import "TypingUserInfo.h" + @protocol RoomDataSourceDelegate; /** @@ -48,6 +50,11 @@ */ @property(nonatomic, readonly) RoomEncryptionTrustLevel encryptionTrustLevel; +/** + List of members who are typing in the room. + */ +@property(nonatomic, nullable) NSArray *currentTypingUsers; + /** Check if there is an active jitsi widget in the room and return it. @@ -93,6 +100,8 @@ success:(void(^)(void))success failure:(void(^)(NSError*))failure; +- (void)resetTypingNotification; + @end @protocol RoomDataSourceDelegate diff --git a/Riot/Modules/Room/DataSources/RoomDataSource.m b/Riot/Modules/Room/DataSources/RoomDataSource.m index 9ce91bf9a..0a639ebc2 100644 --- a/Riot/Modules/Room/DataSources/RoomDataSource.m +++ b/Riot/Modules/Room/DataSources/RoomDataSource.m @@ -27,6 +27,7 @@ #import "MXRoom+Riot.h" +const CGFloat kTypingCellHeight = 24; @interface RoomDataSource() { @@ -53,6 +54,8 @@ @property (nonatomic) BOOL showRoomCreationCell; +@property (nonatomic) NSInteger typingCellIndex; + @end @implementation RoomDataSource @@ -185,6 +188,16 @@ [self setNeedsUpdateAdditionalContentHeightForCellData:cellData]; } +- (CGFloat)cellHeightAtIndex:(NSInteger)index withMaximumWidth:(CGFloat)maxWidth +{ + if (index == self.typingCellIndex) + { + return kTypingCellHeight; + } + + return [super cellHeightAtIndex:index withMaximumWidth:maxWidth]; +} + - (void)setNeedsUpdateAdditionalContentHeightForCellData:(id)cellData { RoomBubbleCellData *roomBubbleCellData; @@ -261,16 +274,40 @@ [self updateStatusInfo]; } - // we may have changed the number of bubbles in this block, consider that change - return bubbles.count; + if (!self.currentTypingUsers) + { + self.typingCellIndex = -1; + + // we may have changed the number of bubbles in this block, consider that change + return bubbles.count; + } + + self.typingCellIndex = bubbles.count; + return bubbles.count + 1; } - // leave it as is, if coming as 0 from super - return count; + if (!self.currentTypingUsers) + { + self.typingCellIndex = -1; + + // leave it as is, if coming as 0 from super + return count; + } + + self.typingCellIndex = count; + return count + 1; } - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { + if (indexPath.row == self.typingCellIndex) + { + RoomTypingBubbleCell *cell = [tableView dequeueReusableCellWithIdentifier:RoomTypingBubbleCell.defaultReuseIdentifier forIndexPath:indexPath]; + [cell updateWithTheme:ThemeService.shared.theme]; + [cell updateTypingUsers:_currentTypingUsers mediaManager:self.mxSession.mediaManager]; + return cell; + } + // Do cell data customization that needs to be done before [MXKRoomBubbleTableViewCell render] RoomBubbleCellData *roomBubbleCellData = [self cellDataAtIndex:indexPath.row]; @@ -917,6 +954,10 @@ }]; } +- (void)resetTypingNotification { + self.currentTypingUsers = nil; +} + #pragma - Accessibility - (void)setupAccessibilityForCell:(MXKRoomBubbleTableViewCell *)cell withCellData:(RoomBubbleCellData*)cellData diff --git a/Riot/Modules/Room/DataSources/TypingUserInfo.h b/Riot/Modules/Room/DataSources/TypingUserInfo.h new file mode 100644 index 000000000..54914dc6e --- /dev/null +++ b/Riot/Modules/Room/DataSources/TypingUserInfo.h @@ -0,0 +1,34 @@ +// +// 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 +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface TypingUserInfo : NSObject + +@property (nonatomic, strong) NSString *userId; +@property (nonatomic, strong, nullable) NSString *displayName; +@property (nonatomic, strong, nullable) NSString *avatarUrl; + +- (instancetype) initWithMember:(MXRoomMember*)member; + +- (instancetype) initWithUserId:(NSString*)userId; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Riot/Modules/Room/DataSources/TypingUserInfo.m b/Riot/Modules/Room/DataSources/TypingUserInfo.m new file mode 100644 index 000000000..fa59cde01 --- /dev/null +++ b/Riot/Modules/Room/DataSources/TypingUserInfo.m @@ -0,0 +1,46 @@ +// +// 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 "TypingUserInfo.h" + +@implementation TypingUserInfo + +- (instancetype) initWithMember:(MXRoomMember*)member +{ + self = [self initWithUserId:member.userId]; + + if (self) + { + self.displayName = member.displayname; + self.avatarUrl = member.avatarUrl; + } + + return self; +} + +- (instancetype) initWithUserId:(NSString*)userId +{ + self = [super init]; + + if (self) + { + self.userId = userId; + } + + return self; +} + +@end diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index d03a13a13..870efe697 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -124,12 +124,15 @@ #import "SettingsViewController.h" #import "SecurityViewController.h" +#import "TypingUserInfo.h" + #import "Riot-Swift.h" NSNotificationName const RoomCallTileTappedNotification = @"RoomCallTileTappedNotification"; NSNotificationName const RoomGroupCallTileTappedNotification = @"RoomGroupCallTileTappedNotification"; NSNotificationName const RoomViewControllerViewDidAppearNotification = @"RoomViewControllerViewDidAppearNotification"; NSNotificationName const RoomViewControllerViewDidDisappearNotification = @"RoomViewControllerViewDidDisappearNotification"; +const NSTimeInterval kResizeComposerAnimationDuration = .05; @interface RoomViewController () currentAlert = nil; + + }]]; + + [currentAlert addAction:[UIAlertAction actionWithTitle:[NSBundle mxk_localizedStringForKey:@"yes"] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) + { + MXStrongifyAndReturnIfNil(self); + self->currentAlert = nil; + + // Show the sticker picker settings screen + IntegrationManagerViewController *modularVC = [[IntegrationManagerViewController alloc] + initForMXSession:self.roomDataSource.mxSession + inRoom:self.roomDataSource.roomId + screen:[IntegrationManagerViewController screenForWidget:kWidgetTypeStickerPicker] + widgetId:nil]; + + [self presentViewController:modularVC animated:NO completion:nil]; + }]]; + + [currentAlert mxk_setAccessibilityIdentifier:@"RoomVCStickerPickerAlert"]; + [self presentViewController:currentAlert animated:YES completion:nil]; + } +} + +- (void)roomInputToolbarViewDidTapFileUpload +{ + MXKDocumentPickerPresenter *documentPickerPresenter = [MXKDocumentPickerPresenter new]; + documentPickerPresenter.delegate = self; + + NSArray *allowedUTIs = @[MXKUTI.data]; + [documentPickerPresenter presentDocumentPickerWith:allowedUTIs from:self animated:YES completion:nil]; + + self.documentPickerPresenter = documentPickerPresenter; +} + #pragma mark - Dialpad - (void)openDialpad @@ -3437,80 +3579,6 @@ NSNotificationName const RoomViewControllerViewDidDisappearNotification = @"Room self.navigationItem.backBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"" style:UIBarButtonItemStylePlain target:nil action:nil]; } -#pragma mark - RoomInputToolbarViewDelegate - -- (void)roomInputToolbarViewPresentStickerPicker:(MXKRoomInputToolbarView*)toolbarView -{ - // Search for the sticker picker widget in the user account - Widget *widget = [[WidgetManager sharedManager] userWidgets:self.roomDataSource.mxSession ofTypes:@[kWidgetTypeStickerPicker]].firstObject; - - if (widget) - { - // Display the widget - [widget widgetUrl:^(NSString * _Nonnull widgetUrl) { - - StickerPickerViewController *stickerPickerVC = [[StickerPickerViewController alloc] initWithUrl:widgetUrl forWidget:widget]; - - stickerPickerVC.roomDataSource = self.roomDataSource; - - [self.navigationController pushViewController:stickerPickerVC animated:YES]; - } failure:^(NSError * _Nonnull error) { - - NSLog(@"[RoomVC] Cannot display widget %@", widget); - [[AppDelegate theDelegate] showErrorAsAlert:error]; - }]; - } - else - { - // The Sticker picker widget is not installed yet. Propose the user to install it - __weak typeof(self) weakSelf = self; - - [currentAlert dismissViewControllerAnimated:NO completion:nil]; - - NSString *alertMessage = [NSString stringWithFormat:@"%@\n%@", - NSLocalizedStringFromTable(@"widget_sticker_picker_no_stickerpacks_alert", @"Vector", nil), - NSLocalizedStringFromTable(@"widget_sticker_picker_no_stickerpacks_alert_add_now", @"Vector", nil) - ]; - - currentAlert = [UIAlertController alertControllerWithTitle:nil message:alertMessage preferredStyle:UIAlertControllerStyleAlert]; - - [currentAlert addAction:[UIAlertAction actionWithTitle:[NSBundle mxk_localizedStringForKey:@"no"] - style:UIAlertActionStyleCancel - handler:^(UIAlertAction * action) - { - if (weakSelf) - { - typeof(self) self = weakSelf; - self->currentAlert = nil; - } - - }]]; - - [currentAlert addAction:[UIAlertAction actionWithTitle:[NSBundle mxk_localizedStringForKey:@"yes"] - style:UIAlertActionStyleDefault - handler:^(UIAlertAction * action) - { - if (weakSelf) - { - typeof(self) self = weakSelf; - self->currentAlert = nil; - - // Show the sticker picker settings screen - IntegrationManagerViewController *modularVC = [[IntegrationManagerViewController alloc] - initForMXSession:self.roomDataSource.mxSession - inRoom:self.roomDataSource.roomId - screen:[IntegrationManagerViewController screenForWidget:kWidgetTypeStickerPicker] - widgetId:nil]; - - [self presentViewController:modularVC animated:NO completion:nil]; - } - }]]; - - [currentAlert mxk_setAccessibilityIdentifier:@"RoomVCStickerPickerAlert"]; - [self presentViewController:currentAlert animated:YES completion:nil]; - } -} - #pragma mark - VoIP - (void)placeCallWithVideo:(BOOL)video @@ -3757,27 +3825,6 @@ NSNotificationName const RoomViewControllerViewDidDisappearNotification = @"Room } } -- (void)roomInputToolbarViewDidTapFileUpload:(MXKRoomInputToolbarView *)toolbarView -{ - MXKDocumentPickerPresenter *documentPickerPresenter = [MXKDocumentPickerPresenter new]; - documentPickerPresenter.delegate = self; - - NSArray *allowedUTIs = @[MXKUTI.data]; - [documentPickerPresenter presentDocumentPickerWith:allowedUTIs from:self animated:YES completion:nil]; - - self.documentPickerPresenter = documentPickerPresenter; -} - -- (void)roomInputToolbarViewDidTapCamera:(MXKRoomInputToolbarView*)toolbarView -{ - [self showCameraControllerAnimated:YES]; -} - -- (void)roomInputToolbarViewDidTapMediaLibrary:(MXKRoomInputToolbarView*)toolbarView -{ - [self showMediaPickerAnimated:YES]; -} - - (void)roomInputToolbarViewDidTapCancel:(MXKRoomInputToolbarView*)toolbarView { [self cancelEventSelection]; @@ -4181,54 +4228,51 @@ NSNotificationName const RoomViewControllerViewDidDisappearNotification = @"Room - (void)refreshTypingNotification { - if ([self.titleView isKindOfClass:RoomTitleView.class]) - { - RoomTitleView *titleView = (RoomTitleView *)self.titleView; - - // Prepare here typing notification - NSString* text = nil; - NSUInteger count = currentTypingUsers.count; - - // get the room member names - NSMutableArray *names = [[NSMutableArray alloc] init]; - - // keeps the only the first two users - for(int i = 0; i < MIN(count, 2); i++) + RoomDataSource *roomDataSource = (RoomDataSource *) self.roomDataSource; + BOOL needsUpdate = currentTypingUsers.count != roomDataSource.currentTypingUsers.count; + + NSMutableArray *typingUsers = [NSMutableArray new]; + for (NSUInteger i = 0 ; i < currentTypingUsers.count ; i++) { + NSString *userId = currentTypingUsers[i]; + MXRoomMember* member = [self.roomDataSource.roomState.members memberWithUserId:userId]; + TypingUserInfo *userInfo; + if (member) { - NSString* name = currentTypingUsers[i]; - - MXRoomMember* member = [self.roomDataSource.roomState.members memberWithUserId:name]; - - if (member && member.displayname.length) - { - name = member.displayname; - } - - // sanity check - if (name) - { - [names addObject:name]; - } - } - - if (0 == names.count) - { - // something to do ? - } - else if (1 == names.count) - { - text = [NSString stringWithFormat:NSLocalizedStringFromTable(@"room_one_user_is_typing", @"Vector", nil), names[0]]; - } - else if (2 == names.count) - { - text = [NSString stringWithFormat:NSLocalizedStringFromTable(@"room_two_users_are_typing", @"Vector", nil), names[0], names[1]]; + userInfo = [[TypingUserInfo alloc] initWithMember: member]; } else { - text = [NSString stringWithFormat:NSLocalizedStringFromTable(@"room_many_users_are_typing", @"Vector", nil), names[0], names[1]]; + userInfo = [[TypingUserInfo alloc] initWithUserId: userId]; + } + [typingUsers addObject:userInfo]; + needsUpdate = needsUpdate || userInfo.userId != ((MXRoomMember *) roomDataSource.currentTypingUsers[i]).userId; + } + + if (needsUpdate) + { + BOOL needsReload = roomDataSource.currentTypingUsers == nil; + roomDataSource.currentTypingUsers = typingUsers; + if (needsReload) + { + [self.bubblesTableView reloadData]; + } + else + { + NSInteger count = [self.bubblesTableView numberOfRowsInSection:0]; + NSIndexPath *lastIndexPath = [NSIndexPath indexPathForRow:count - 1 inSection:0]; + [self.bubblesTableView reloadRowsAtIndexPaths:@[lastIndexPath] withRowAnimation:UITableViewRowAnimationFade]; } - titleView.typingNotificationString = text; + if (self.isScrollToBottomHidden + && !self.bubblesTableView.isDragging + && !self.bubblesTableView.isDecelerating) + { + NSInteger count = [self.bubblesTableView numberOfRowsInSection:0]; + if (count) + { + [self scrollBubblesTableViewToBottomAnimated:YES]; + } + } } } diff --git a/Riot/Modules/Room/Views/BubbleCells/RoomTypingBubbleCell.swift b/Riot/Modules/Room/Views/BubbleCells/RoomTypingBubbleCell.swift new file mode 100644 index 000000000..f0eba07b2 --- /dev/null +++ b/Riot/Modules/Room/Views/BubbleCells/RoomTypingBubbleCell.swift @@ -0,0 +1,143 @@ +// +// 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 + +@objcMembers +class RoomTypingBubbleCell: MXKTableViewCell, Themable { + // MARK: - Constants + + private enum Constants { + static let maxPictureCount = 4 + static let pictureSize: CGFloat = 24 + static let pictureMaxMargin: CGFloat = 16 + static let pictureMinMargin: CGFloat = 8 + } + + // MARK: - Outlets + + @IBOutlet private weak var additionalUsersLabel: UILabel! + @IBOutlet private weak var additionalUsersLabelLeadingConstraint: NSLayoutConstraint! + @IBOutlet private weak var dotsView: DotsView! + @IBOutlet private weak var dotsViewLeadingConstraint: NSLayoutConstraint! + + // MARK: - members + + private var userPictureViews: [MXKImageView] = [] + + // MARK: - Lifecycle + + override func awakeFromNib() { + super.awakeFromNib() + + update(theme: ThemeService.shared().theme) + } + + override func prepareForReuse() { + super.prepareForReuse() + + for pictureView in userPictureViews { + pictureView.removeFromSuperview() + } + } + + override func layoutSubviews() { + super.layoutSubviews() + + dotsView.isHidden = userPictureViews.count == 0 + + guard userPictureViews.count > 0 else { + return + } + + additionalUsersLabel?.sizeToFit() + + var pictureViewsMaxX: CGFloat = 0 + var xOffset: CGFloat = 0 + for pictureView in userPictureViews { + pictureView.center = CGPoint(x: Constants.pictureMaxMargin + xOffset + pictureView.bounds.midX, y: self.bounds.midY) + xOffset += round(pictureView.bounds.maxX * 2 / 3) + pictureViewsMaxX = pictureView.frame.maxX + } + + let leftMagin: CGFloat = pictureViewsMaxX + (userPictureViews.count == 1 ? Constants.pictureMaxMargin : Constants.pictureMinMargin) + additionalUsersLabelLeadingConstraint.constant = leftMagin + + dotsViewLeadingConstraint?.constant = additionalUsersLabel.text.isEmptyOrNil == true ? leftMagin : leftMagin + 8 + additionalUsersLabel.frame.width + } + + // MARK: - Overrides + + override class func defaultReuseIdentifier() -> String { + return String(describing: self) + } + + override class func nib() -> UINib { + return UINib(nibName: String(describing: self), bundle: nil) + } + + // MARK: - Themable + + func update(theme: Theme) { + additionalUsersLabel.textColor = theme.textSecondaryColor + dotsView.highlightedDotColor = theme.textTertiaryColor + dotsView.dotColor = theme.textSecondaryColor + } + + + // MARK: - Business methods + + func updateTypingUsers(_ typingUsers: [TypingUserInfo], mediaManager: MXMediaManager) { + for pictureView in userPictureViews { + pictureView.removeFromSuperview() + } + userPictureViews = [] + + for user in typingUsers { + if userPictureViews.count >= Constants.maxPictureCount { + break + } + + let pictureView = MXKImageView(frame: CGRect(x: 0, y: 0, width: Constants.pictureSize, height: Constants.pictureSize)) + pictureView.layer.masksToBounds = true + pictureView.layer.cornerRadius = pictureView.bounds.midX + + let defaultavatarImage = AvatarGenerator.generateAvatar(forMatrixItem: user.userId, withDisplayName: user.displayName) + pictureView.setImageURI(user.avatarUrl, withType: nil, andImageOrientation: .up, toFitViewSize: pictureView.bounds.size, with: MXThumbnailingMethodCrop, previewImage: defaultavatarImage, mediaManager: mediaManager) + + userPictureViews.append(pictureView) + self.contentView.addSubview(pictureView) + } + + switch typingUsers.count { + case 0: + additionalUsersLabel.text = nil + case 1: + additionalUsersLabel.text = firstUserNameFor(typingUsers) + default: + additionalUsersLabel.text = VectorL10n.roomMultipleTypingNotification(firstUserNameFor(typingUsers) ?? "") + } + self.setNeedsLayout() + } + + private func firstUserNameFor(_ typingUsers: Array) -> String? { + guard let firstUser = typingUsers.first else { + return nil + } + + return firstUser.displayName.isEmptyOrNil ? firstUser.userId : firstUser.displayName + } +} diff --git a/Riot/Modules/Room/Views/BubbleCells/RoomTypingBubbleCell.xib b/Riot/Modules/Room/Views/BubbleCells/RoomTypingBubbleCell.xib new file mode 100644 index 000000000..5cd15a92d --- /dev/null +++ b/Riot/Modules/Room/Views/BubbleCells/RoomTypingBubbleCell.xib @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomActionItem.swift b/Riot/Modules/Room/Views/InputToolbar/RoomActionItem.swift new file mode 100644 index 000000000..773e4be3d --- /dev/null +++ b/Riot/Modules/Room/Views/InputToolbar/RoomActionItem.swift @@ -0,0 +1,30 @@ +// +// 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 + +@objcMembers +class RoomActionItem: NSObject { + let image: UIImage + let action: (() -> Void) + + init(image: UIImage, andAction action: @escaping () -> Void) { + self.image = image + self.action = action + + super.init() + } +} diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomActionsBar.swift b/Riot/Modules/Room/Views/InputToolbar/RoomActionsBar.swift new file mode 100644 index 000000000..5482ab833 --- /dev/null +++ b/Riot/Modules/Room/Views/InputToolbar/RoomActionsBar.swift @@ -0,0 +1,132 @@ +// +// 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 + +@objcMembers +class RoomActionsBar: UIScrollView, Themable { + // MARK: - Properties + + var itemSpacing: CGFloat = 20 { + didSet { + self.setNeedsLayout() + } + } + + var actionItems: [RoomActionItem] = [] { + didSet { + var actionButtons: [UIButton] = [] + for (index, item) in actionItems.enumerated() { + let button = UIButton(type: .custom) + button.setImage(item.image, for: .normal) + button.addTarget(self, action: #selector(buttonAction(_:)), for: .touchUpInside) + button.tintColor = ThemeService.shared().theme.tintColor + button.tag = index + actionButtons.append(button) + addSubview(button) + } + self.actionButtons = actionButtons + self.lastBounds = .zero + self.setNeedsLayout() + } + } + + private var actionButtons: [UIButton] = [] { + willSet { + for button in actionButtons { + button.removeFromSuperview() + } + } + } + + private var lastBounds = CGRect.zero + + // MARK: - Lifecycle + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupView() + } + + override func layoutSubviews() { + super.layoutSubviews() + + guard lastBounds != self.bounds else { + return + } + + lastBounds = self.bounds + + var currentX: CGFloat = 0 + for button in actionButtons { + button.transform = CGAffineTransform.identity + button.frame = CGRect(x: currentX, y: 0, width: self.bounds.height, height: self.bounds.height) + currentX = button.frame.maxX + itemSpacing + } + + self.contentSize = CGSize(width: currentX - itemSpacing, height: self.bounds.height) + } + + // MARK: - Themable + + func update(theme: Theme) { + for button in actionButtons { + button.tintColor = theme.tintColor + } + } + + // MARK: - Business methods + + func animate(showIn: Bool, completion: ((Bool) -> Void)? = nil) { + if showIn { + for button in actionButtons { + button.transform = CGAffineTransform(translationX: 0, y: self.bounds.height) + } + for (index, button) in actionButtons.enumerated() { + UIView.animate(withDuration: 0.3, delay: 0.05 * Double(index), usingSpringWithDamping: 0.45, initialSpringVelocity: 11, options: .curveEaseInOut) { + button.transform = CGAffineTransform.identity + } completion: { (finished) in + completion?(finished) + } + } + } else { + for (index, button) in actionButtons.enumerated() { + UIView.animate(withDuration: 0.25, delay: 0.05 * Double(index), options: .curveEaseInOut) { + button.transform = CGAffineTransform(translationX: 0, y: self.bounds.height) + } completion: { (finished) in + if index == self.actionButtons.count - 1 { + completion?(finished) + } + } + } + } + } + + // MARK: - Private methods + + @objc private func buttonAction(_ sender: UIButton) { + actionItems[sender.tag].action() + } + + private func setupView() { + self.showsHorizontalScrollIndicator = false + } +} diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h index e5559e9fe..b2ad883b8 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h @@ -18,6 +18,8 @@ #import "MediaPickerViewController.h" +@class RoomActionsBar; + /** Destination of the message in the composer */ @@ -31,34 +33,6 @@ typedef enum : NSUInteger @protocol RoomInputToolbarViewDelegate -/** - Tells the delegate that the user wants to display the sticker picker. - - @param toolbarView the room input toolbar view. - */ -- (void)roomInputToolbarViewPresentStickerPicker:(MXKRoomInputToolbarView*)toolbarView; - -/** - Tells the delegate that the user wants to send external files. - - @param toolbarView the room input toolbar view - */ -- (void)roomInputToolbarViewDidTapFileUpload:(MXKRoomInputToolbarView*)toolbarView; - -/** - Tells the delegate that the user wants to take photo or video with camera. - - @param toolbarView the room input toolbar view - */ -- (void)roomInputToolbarViewDidTapCamera:(MXKRoomInputToolbarView*)toolbarView; - -/** - Tells the delegate that the user wants to show media library. - - @param toolbarView the room input toolbar view - */ -- (void)roomInputToolbarViewDidTapMediaLibrary:(MXKRoomInputToolbarView*)toolbarView; - /** Tells the delegate that the user wants to cancel the current edition / reply. @@ -95,6 +69,7 @@ typedef enum : NSUInteger @property (weak, nonatomic) IBOutlet UIImageView *inputContextImageView; @property (weak, nonatomic) IBOutlet UILabel *inputContextLabel; @property (weak, nonatomic) IBOutlet UIButton *inputContextButton; +@property (weak, nonatomic) IBOutlet RoomActionsBar *actionsBar; /** Tell whether the filled data will be sent encrypted. NO by default. @@ -111,4 +86,9 @@ typedef enum : NSUInteger */ @property (nonatomic) RoomInputToolbarViewSendMode sendMode; +/** + YES if action menu is opened. NO otherwise + */ +@property (nonatomic, getter=isActionMenuOpened) BOOL actionMenuOpened; + @end diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m index dfceba978..26a2f4c69 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m @@ -27,7 +27,13 @@ #import "WidgetManager.h" #import "IntegrationManagerViewController.h" -const double RoomInputToolbarViewContextBarHeight = 30; +const double kContextBarHeight = 24; +const NSTimeInterval kSendModeAnimationDuration = .15; +const NSTimeInterval kActionMenuAttachButtonAnimationDuration = .4; +const CGFloat kActionMenuAttachButtonSpringVelocity = 7; +const CGFloat kActionMenuAttachButtonSpringDamping = .45; +const NSTimeInterval kActionMenuContentAlphaAnimationDuration = .2; +const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3; @interface RoomInputToolbarView() { @@ -120,6 +126,7 @@ const double RoomInputToolbarViewContextBarHeight = 30; self.inputContextImageView.tintColor = ThemeService.shared.theme.textSecondaryColor; self.inputContextLabel.textColor = ThemeService.shared.theme.textSecondaryColor; self.inputContextButton.tintColor = ThemeService.shared.theme.textSecondaryColor; + [self.actionsBar updateWithTheme:ThemeService.shared.theme]; } #pragma mark - @@ -142,6 +149,7 @@ const double RoomInputToolbarViewContextBarHeight = 30; RoomInputToolbarViewSendMode previousMode = _sendMode; _sendMode = sendMode; + self.actionMenuOpened = NO; [self updatePlaceholder]; [self updateToolbarButtonLabelWithPreviousMode: previousMode]; } @@ -159,26 +167,26 @@ const double RoomInputToolbarViewContextBarHeight = 30; self.inputContextImageView.image = [UIImage imageNamed:@"input_reply_icon"]; self.inputContextLabel.text = [NSString stringWithFormat:NSLocalizedStringFromTable(@"room_message_replying_to", @"Vector", nil), self.eventSenderDisplayName]; - self.inputContextViewHeightConstraint.constant = RoomInputToolbarViewContextBarHeight; - updatedHeight += RoomInputToolbarViewContextBarHeight; - self->growingTextView.maxHeight -= RoomInputToolbarViewContextBarHeight; + self.inputContextViewHeightConstraint.constant = kContextBarHeight; + updatedHeight += kContextBarHeight; + self->growingTextView.maxHeight -= kContextBarHeight; break; case RoomInputToolbarViewSendModeEdit: buttonImage = [UIImage imageNamed:@"save_icon"]; self.inputContextImageView.image = [UIImage imageNamed:@"input_edit_icon"]; self.inputContextLabel.text = NSLocalizedStringFromTable(@"room_message_editing", @"Vector", nil); - self.inputContextViewHeightConstraint.constant = RoomInputToolbarViewContextBarHeight; - updatedHeight += RoomInputToolbarViewContextBarHeight; - self->growingTextView.maxHeight -= RoomInputToolbarViewContextBarHeight; + self.inputContextViewHeightConstraint.constant = kContextBarHeight; + updatedHeight += kContextBarHeight; + self->growingTextView.maxHeight -= kContextBarHeight; break; default: buttonImage = [UIImage imageNamed:@"send_icon"]; if (previousMode != _sendMode) { - updatedHeight -= RoomInputToolbarViewContextBarHeight; - self->growingTextView.maxHeight += RoomInputToolbarViewContextBarHeight; + updatedHeight -= kContextBarHeight; + self->growingTextView.maxHeight += kContextBarHeight; } self.inputContextViewHeightConstraint.constant = 0; break; @@ -199,7 +207,7 @@ const double RoomInputToolbarViewContextBarHeight = 30; if (self.mainToolbarHeightConstraint.constant != updatedHeight) { - [UIView animateWithDuration:.3 animations:^{ + [UIView animateWithDuration:kSendModeAnimationDuration animations:^{ self.mainToolbarHeightConstraint.constant = updatedHeight; [self layoutIfNeeded]; @@ -329,92 +337,7 @@ const double RoomInputToolbarViewContextBarHeight = 30; { if (button == self.attachMediaButton) { - // Check whether media attachment is supported - if ([self.delegate respondsToSelector:@selector(roomInputToolbarView:presentViewController:)]) - { - // Ask the user the kind of the call: voice or video? - actionSheet = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet]; - - __weak typeof(self) weakSelf = self; - - [actionSheet addAction:[UIAlertAction actionWithTitle:NSLocalizedStringFromTable(@"room_action_camera", @"Vector", nil) - style:UIAlertActionStyleDefault - handler:^(UIAlertAction * action) { - - if (weakSelf) - { - typeof(self) self = weakSelf; - self->actionSheet = nil; - - [self.delegate roomInputToolbarViewDidTapCamera:self]; - } - }]]; - - - [actionSheet addAction:[UIAlertAction actionWithTitle:NSLocalizedStringFromTable(@"room_action_send_photo_or_video", @"Vector", nil) - style:UIAlertActionStyleDefault - handler:^(UIAlertAction * action) { - - if (weakSelf) - { - typeof(self) self = weakSelf; - self->actionSheet = nil; - - [self.delegate roomInputToolbarViewDidTapMediaLibrary:self]; - } - - }]]; - - if (BuildSettings.allowSendingStickers) - { - [actionSheet addAction:[UIAlertAction actionWithTitle:NSLocalizedStringFromTable(@"room_action_send_sticker", @"Vector", nil) - style:UIAlertActionStyleDefault - handler:^(UIAlertAction * action) { - - if (weakSelf) - { - typeof(self) self = weakSelf; - self->actionSheet = nil; - - [self.delegate roomInputToolbarViewPresentStickerPicker:self]; - } - - }]]; - } - - [actionSheet addAction:[UIAlertAction actionWithTitle:NSLocalizedStringFromTable(@"room_action_send_file", @"Vector", nil) - style:UIAlertActionStyleDefault - handler:^(UIAlertAction * action) { - - if (weakSelf) - { - typeof(self) self = weakSelf; - self->actionSheet = nil; - - [self.delegate roomInputToolbarViewDidTapFileUpload:self]; - } - }]]; - - [actionSheet addAction:[UIAlertAction actionWithTitle:[NSBundle mxk_localizedStringForKey:@"cancel"] - style:UIAlertActionStyleCancel - handler:^(UIAlertAction * action) { - - if (weakSelf) - { - typeof(self) self = weakSelf; - self->actionSheet = nil; - } - - }]]; - - [actionSheet popoverPresentationController].sourceView = self.attachMediaButton; - [actionSheet popoverPresentationController].sourceRect = self.attachMediaButton.bounds; - [self.window.rootViewController presentViewController:actionSheet animated:YES completion:nil]; - } - else - { - NSLog(@"[RoomInputToolbarView] Attach media is not supported"); - } + self.actionMenuOpened = !self.isActionMenuOpened; } [super onTouchUpInside:button]; @@ -433,6 +356,8 @@ const double RoomInputToolbarViewContextBarHeight = 30; - (void)updateSendButtonWithMessage:(NSString *)textMessage { + self.actionMenuOpened = NO; + if (textMessage.length) { self.rightInputToolbarButton.alpha = 1; @@ -443,9 +368,61 @@ const double RoomInputToolbarViewContextBarHeight = 30; self.rightInputToolbarButton.alpha = 0; self.messageComposerContainerTrailingConstraint.constant = 12; } + [self layoutIfNeeded]; } +#pragma mark - properties + +- (void)setActionMenuOpened:(BOOL)actionMenuOpened +{ + if (_actionMenuOpened != actionMenuOpened) + { + _actionMenuOpened = actionMenuOpened; + + if (self->growingTextView.internalTextView.selectedRange.length > 0) + { + NSRange range = self->growingTextView.internalTextView.selectedRange; + range.location = range.location + range.length; + range.length = 0; + self->growingTextView.internalTextView.selectedRange = range; + } + + if (_actionMenuOpened) { + self.actionsBar.hidden = NO; + [self.actionsBar animateWithShowIn:_actionMenuOpened completion:nil]; + } + else + { + [self.actionsBar animateWithShowIn:_actionMenuOpened completion:^(BOOL finished) { + self.actionsBar.hidden = YES; + }]; + } + + [UIView animateWithDuration:kActionMenuAttachButtonAnimationDuration delay:0 usingSpringWithDamping:kActionMenuAttachButtonSpringDamping initialSpringVelocity:kActionMenuAttachButtonSpringVelocity options:UIViewAnimationOptionCurveEaseIn animations:^{ + self.attachMediaButton.transform = actionMenuOpened ? CGAffineTransformMakeRotation(M_PI * 3 / 4) : CGAffineTransformIdentity; + } completion:nil]; + + [UIView animateWithDuration:kActionMenuContentAlphaAnimationDuration delay:_actionMenuOpened ? 0 : .1 options:UIViewAnimationOptionCurveEaseIn animations:^{ + self->messageComposerContainer.alpha = actionMenuOpened ? 0 : 1; + self.rightInputToolbarButton.alpha = self->growingTextView.text.length == 0 || actionMenuOpened ? 0 : 1; + } completion:nil]; + + [UIView animateWithDuration:kActionMenuComposerHeightAnimationDuration animations:^{ + if (actionMenuOpened) + { + self.mainToolbarHeightConstraint.constant = self.mainToolbarMinHeightConstraint.constant; + } + else + { + [self->growingTextView refreshHeight]; + } + [self layoutIfNeeded]; + [self.delegate roomInputToolbarView:self heightDidChanged:self.mainToolbarHeightConstraint.constant completion:nil]; + }]; + } +} + #pragma mark - Clipboard - Handle image/data paste from general pasteboard - (void)paste:(id)sender diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.xib b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.xib index fbc52e9f1..aef8b2f81 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.xib +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.xib @@ -27,6 +27,14 @@ + @@ -37,16 +45,16 @@ - +