WIP Merge tag '2.19.4.4'

- restore video playback in fullscreen

This was a large merge, so I'm opting to make some changes in separate
commits.
This commit is contained in:
Michael Kirk 2018-01-16 15:25:58 -05:00
commit a423fe8a0e
24 changed files with 1520 additions and 820 deletions

View File

@ -42,7 +42,6 @@
344F248720069ECB00CFB4F4 /* ModalActivityIndicatorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 344F248620069ECB00CFB4F4 /* ModalActivityIndicatorViewController.swift */; };
344F248A20069F0600CFB4F4 /* ViewControllerUtils.h in Headers */ = {isa = PBXBuildFile; fileRef = 344F248820069F0600CFB4F4 /* ViewControllerUtils.h */; };
344F248B20069F0600CFB4F4 /* ViewControllerUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 344F248920069F0600CFB4F4 /* ViewControllerUtils.m */; };
3456D2C41FFFCC70001EA55D /* OWSBackupViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 3456D2C21FFFCC6F001EA55D /* OWSBackupViewController.m */; };
3461284B1FD0B94000532771 /* SAELoadViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3461284A1FD0B93F00532771 /* SAELoadViewController.swift */; };
346129341FD1A88700532771 /* OWSSwiftUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 346129331FD1A88700532771 /* OWSSwiftUtils.swift */; };
346129391FD1B47300532771 /* OWSPreferences.h in Headers */ = {isa = PBXBuildFile; fileRef = 346129371FD1B47200532771 /* OWSPreferences.h */; settings = {ATTRIBUTES = (Public, ); }; };
@ -194,8 +193,6 @@
34FD93701E3BD43A00109093 /* OWSAnyTouchGestureRecognizer.m in Sources */ = {isa = PBXBuildFile; fileRef = 34FD936F1E3BD43A00109093 /* OWSAnyTouchGestureRecognizer.m */; };
4505C2BF1E648EA300CEBF41 /* ExperienceUpgrade.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4505C2BE1E648EA300CEBF41 /* ExperienceUpgrade.swift */; };
450998651FD8A34D00D89EB3 /* DeviceSleepManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 348F2EAD1F0D21BC00D4ECE0 /* DeviceSleepManager.swift */; };
450998661FD8BD9C00D89EB3 /* FullImageViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F8481E8DF1700035BE1A /* FullImageViewController.m */; };
450998671FD8BDA600D89EB3 /* FullImageViewController.h in Headers */ = {isa = PBXBuildFile; fileRef = 34B3F8471E8DF1700035BE1A /* FullImageViewController.h */; settings = {ATTRIBUTES = (Public, ); }; };
450998681FD8C0FF00D89EB3 /* AttachmentSharing.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F83A1E8DF1700035BE1A /* AttachmentSharing.m */; };
450998691FD8C10200D89EB3 /* AttachmentSharing.h in Headers */ = {isa = PBXBuildFile; fileRef = 34B3F8391E8DF1700035BE1A /* AttachmentSharing.h */; settings = {ATTRIBUTES = (Public, ); }; };
4509E79A1DD653700025A59F /* WebRTC.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4509E7991DD653700025A59F /* WebRTC.framework */; };
@ -250,6 +247,7 @@
452D1EE81DCA90D100A57EC4 /* MesssagesBubblesSizeCalculatorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452D1EE71DCA90D100A57EC4 /* MesssagesBubblesSizeCalculatorTest.swift */; };
452EA09E1EA7ABE00078744B /* AttachmentPointerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452EA09D1EA7ABE00078744B /* AttachmentPointerView.swift */; };
452ECA4D1E087E7200E2F016 /* MessageFetcherJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452ECA4C1E087E7200E2F016 /* MessageFetcherJob.swift */; };
453034AB200289F50018945D /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 453034AA200289F50018945D /* VideoPlayerView.swift */; };
4535186B1FC635DD00210559 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4535186A1FC635DD00210559 /* ShareViewController.swift */; };
4535186E1FC635DD00210559 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 4535186C1FC635DD00210559 /* MainInterface.storyboard */; };
453518721FC635DD00210559 /* SignalShareExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 453518681FC635DD00210559 /* SignalShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
@ -290,6 +288,7 @@
45A663C51F92EC760027B59E /* GroupTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45A663C41F92EC760027B59E /* GroupTableViewCell.swift */; };
45A6DAD61EBBF85500893231 /* ReminderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45A6DAD51EBBF85500893231 /* ReminderView.swift */; };
45AE48511E0732D6004D96C2 /* TurnServerInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45AE48501E0732D6004D96C2 /* TurnServerInfo.swift */; };
45B9EE9C200E91FB005D2F2D /* MediaDetailViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 45B9EE9B200E91FB005D2F2D /* MediaDetailViewController.m */; };
45BB93381E688E14001E3939 /* UIDevice+featureSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45BB93371E688E14001E3939 /* UIDevice+featureSupport.swift */; };
45BC829D1FD9C4B400011CF3 /* ShareViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45BC829C1FD9C4B400011CF3 /* ShareViewDelegate.swift */; };
45BD60821DE9547E00A8F436 /* Contacts.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 45BD60811DE9547E00A8F436 /* Contacts.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
@ -630,8 +629,6 @@
34B3F8441E8DF1700035BE1A /* ExperienceUpgradesPageViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExperienceUpgradesPageViewController.swift; sourceTree = "<group>"; };
34B3F8451E8DF1700035BE1A /* FingerprintViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FingerprintViewController.h; sourceTree = "<group>"; };
34B3F8461E8DF1700035BE1A /* FingerprintViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FingerprintViewController.m; sourceTree = "<group>"; };
34B3F8471E8DF1700035BE1A /* FullImageViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FullImageViewController.h; sourceTree = "<group>"; };
34B3F8481E8DF1700035BE1A /* FullImageViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FullImageViewController.m; sourceTree = "<group>"; };
34B3F8491E8DF1700035BE1A /* InboxTableViewCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = InboxTableViewCell.h; sourceTree = "<group>"; };
34B3F84A1E8DF1700035BE1A /* InboxTableViewCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = InboxTableViewCell.m; sourceTree = "<group>"; };
34B3F84C1E8DF1700035BE1A /* InviteFlow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InviteFlow.swift; sourceTree = "<group>"; };
@ -776,6 +773,7 @@
452D1EE71DCA90D100A57EC4 /* MesssagesBubblesSizeCalculatorTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = MesssagesBubblesSizeCalculatorTest.swift; path = Models/MesssagesBubblesSizeCalculatorTest.swift; sourceTree = "<group>"; };
452EA09D1EA7ABE00078744B /* AttachmentPointerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentPointerView.swift; sourceTree = "<group>"; };
452ECA4C1E087E7200E2F016 /* MessageFetcherJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageFetcherJob.swift; sourceTree = "<group>"; };
453034AA200289F50018945D /* VideoPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = "<group>"; };
453518681FC635DD00210559 /* SignalShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SignalShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
4535186A1FC635DD00210559 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
4535186D1FC635DD00210559 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = "<group>"; };
@ -828,6 +826,8 @@
45A6DAD51EBBF85500893231 /* ReminderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReminderView.swift; sourceTree = "<group>"; };
45AE48501E0732D6004D96C2 /* TurnServerInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TurnServerInfo.swift; sourceTree = "<group>"; };
45B201741DAECBFD00C461E0 /* Signal-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Signal-Bridging-Header.h"; sourceTree = "<group>"; };
45B9EE9A200E91FB005D2F2D /* MediaDetailViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MediaDetailViewController.h; sourceTree = "<group>"; };
45B9EE9B200E91FB005D2F2D /* MediaDetailViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MediaDetailViewController.m; sourceTree = "<group>"; };
45BB93371E688E14001E3939 /* UIDevice+featureSupport.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIDevice+featureSupport.swift"; sourceTree = "<group>"; };
45BC829C1FD9C4B400011CF3 /* ShareViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewDelegate.swift; sourceTree = "<group>"; };
45BD60811DE9547E00A8F436 /* Contacts.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Contacts.framework; path = System/Library/Frameworks/Contacts.framework; sourceTree = SDKROOT; };
@ -1319,6 +1319,8 @@
34B3F8491E8DF1700035BE1A /* InboxTableViewCell.h */,
34B3F84A1E8DF1700035BE1A /* InboxTableViewCell.m */,
34B3F84C1E8DF1700035BE1A /* InviteFlow.swift */,
45B9EE9A200E91FB005D2F2D /* MediaDetailViewController.h */,
45B9EE9B200E91FB005D2F2D /* MediaDetailViewController.m */,
34CA1C261F7156F300E51C51 /* MessageDetailViewController.swift */,
34B3F84F1E8DF1700035BE1A /* NewContactThreadViewController.h */,
34B3F8501E8DF1700035BE1A /* NewContactThreadViewController.m */,
@ -1561,8 +1563,6 @@
children = (
34B3F8391E8DF1700035BE1A /* AttachmentSharing.h */,
34B3F83A1E8DF1700035BE1A /* AttachmentSharing.m */,
34B3F8471E8DF1700035BE1A /* FullImageViewController.h */,
34B3F8481E8DF1700035BE1A /* FullImageViewController.m */,
45E5471F1FD755E700DFC09E /* AttachmentApprovalViewController.swift */,
3400C7901EAF89CD008A8584 /* SharingThreadPickerViewController.h */,
3400C7911EAF89CD008A8584 /* SharingThreadPickerViewController.m */,
@ -1748,6 +1748,7 @@
76EB052B18170B33006006FC /* Views */ = {
isa = PBXGroup;
children = (
453034AA200289F50018945D /* VideoPlayerView.swift */,
45E5A6981F61E6DD001E4A8A /* MarqueeLabel.swift */,
452EA09D1EA7ABE00078744B /* AttachmentPointerView.swift */,
34E3E5671EC4B19400495BAC /* AudioProgressView.swift */,
@ -2071,7 +2072,6 @@
346129391FD1B47300532771 /* OWSPreferences.h in Headers */,
344D6CED20069E070042AF96 /* NewNonContactConversationViewController.h in Headers */,
346129DE1FD5C02A00532771 /* LockInteractionController.h in Headers */,
450998671FD8BDA600D89EB3 /* FullImageViewController.h in Headers */,
451F8A451FD71570005CB9DA /* BlockListUIUtils.h in Headers */,
451F8A4A1FD715D9005CB9DA /* OWSContactAvatarBuilder.h in Headers */,
34480B5B1FD0A7E300BC14EF /* SignalMessaging-Prefix.pch in Headers */,
@ -2775,7 +2775,6 @@
344F248720069ECB00CFB4F4 /* ModalActivityIndicatorViewController.swift in Sources */,
346129961FD1E30000532771 /* OWSDatabaseMigration.m in Sources */,
346129CD1FD2072E00532771 /* UIImage+OWS.m in Sources */,
450998661FD8BD9C00D89EB3 /* FullImageViewController.m in Sources */,
344D6CEC20069E070042AF96 /* NewNonContactConversationViewController.m in Sources */,
346129FB1FD5F31400532771 /* OWS101ExistingUsersBlockOnIdentityChange.m in Sources */,
450998651FD8A34D00D89EB3 /* DeviceSleepManager.swift in Sources */,
@ -2926,6 +2925,7 @@
34B3F8771E8DF1700035BE1A /* ContactsPicker.swift in Sources */,
45C0DC1B1E68FE9000E04C47 /* UIApplication+OWS.swift in Sources */,
45638BDF1F3DDB2200128435 /* MessageSender+Promise.swift in Sources */,
453034AB200289F50018945D /* VideoPlayerView.swift in Sources */,
45FBC5C81DF8575700E9B410 /* CallKitCallManager.swift in Sources */,
34B3F8911E8DF1710035BE1A /* ShowGroupMembersViewController.m in Sources */,
4539B5861F79348F007141FF /* PushRegistrationManager.swift in Sources */,
@ -2940,6 +2940,7 @@
34D1F0C01F8EC1760066283D /* MessageRecipientStatusUtils.swift in Sources */,
45F659731E1BD99C00444429 /* CallKitCallUIAdaptee.swift in Sources */,
45BB93381E688E14001E3939 /* UIDevice+featureSupport.swift in Sources */,
45B9EE9C200E91FB005D2F2D /* MediaDetailViewController.m in Sources */,
458DE9D61DEE3FD00071BB03 /* PeerConnectionClient.swift in Sources */,
34D1F0841F8678AA0066283D /* ConversationInputToolbar.m in Sources */,
340CB2271EAC25820001CAA1 /* UpdateGroupViewController.m in Sources */,

View File

@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "VideoPlayer_Slider_Thumb_15x15_@1x.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "VideoPlayer_Slider_Thumb_15x15_@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "VideoPlayer_Slider_Thumb_15x15_@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -11,7 +11,7 @@
#import "DateUtil.h"
#import "DebugUIPage.h"
#import "FingerprintViewController.h"
#import "FullImageViewController.h"
#import "MediaDetailViewController.h"
#import "HomeViewController.h"
#import "NotificationsManager.h"
#import "OWSAnyTouchGestureRecognizer.h"

View File

@ -1,5 +1,5 @@
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
NS_ASSUME_NONNULL_BEGIN
@ -18,7 +18,9 @@ NS_ASSUME_NONNULL_BEGIN
- (void)didTapImageViewItem:(ConversationViewItem *)viewItem
attachmentStream:(TSAttachmentStream *)attachmentStream
imageView:(UIView *)imageView;
- (void)didTapVideoViewItem:(ConversationViewItem *)viewItem attachmentStream:(TSAttachmentStream *)attachmentStream;
- (void)didTapVideoViewItem:(ConversationViewItem *)viewItem
attachmentStream:(TSAttachmentStream *)attachmentStream
imageView:(UIView *)imageView;
- (void)didTapAudioViewItem:(ConversationViewItem *)viewItem attachmentStream:(TSAttachmentStream *)attachmentStream;
- (void)didTapTruncatedTextMessage:(ConversationViewItem *)conversationItem;
- (void)didTapFailedIncomingAttachment:(ConversationViewItem *)viewItem

View File

@ -1,11 +1,13 @@
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "ConversationViewCell.h"
NS_ASSUME_NONNULL_BEGIN
extern const CGFloat OWSMessageCellCornerRadius;
@interface OWSMessageCell : ConversationViewCell
+ (NSString *)cellReuseIdentifier;

View File

@ -19,6 +19,9 @@
NS_ASSUME_NONNULL_BEGIN
// This approximates the curve of our message bubbles, which makes the animation feel a little smoother.
const CGFloat OWSMessageCellCornerRadius = 17;
@interface BubbleMaskingView : UIView
@property (nonatomic) BOOL isOutgoing;
@ -1313,7 +1316,9 @@ NS_ASSUME_NONNULL_BEGIN
[self.delegate didTapAudioViewItem:self.viewItem attachmentStream:self.attachmentStream];
return;
case OWSMessageCellType_Video:
[self.delegate didTapVideoViewItem:self.viewItem attachmentStream:self.attachmentStream];
[self.delegate didTapVideoViewItem:self.viewItem
attachmentStream:self.attachmentStream
imageView:self.stillImageView];
return;
case OWSMessageCellType_GenericAttachment:
#ifdef DEBUG

View File

@ -1,5 +1,5 @@
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
NS_ASSUME_NONNULL_BEGIN
@ -46,8 +46,7 @@ NS_ASSUME_NONNULL_BEGIN
- (NSString *)messageText;
- (void)setMessageText:(NSString *_Nullable)value;
- (void)clearTextMessage;
- (nullable NSString *)textInputPrimaryLanguage;
- (void)toggleDefaultKeyboard;
- (void)updateFontSizes;

View File

@ -1,5 +1,5 @@
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "ConversationInputToolbar.h"
@ -16,7 +16,7 @@
NS_ASSUME_NONNULL_BEGIN
static void *kConversationInputTextViewObservingContext = &kConversationInputTextViewObservingContext;
static const CGFloat ConversationInputToolbarBorderViewHeight = 0.5;
@interface ConversationInputToolbar () <UIGestureRecognizerDelegate, ConversationTextViewToolbarDelegate>
@property (nonatomic, readonly) UIView *contentView;
@ -31,6 +31,8 @@ static void *kConversationInputTextViewObservingContext = &kConversationInputTex
@property (nonatomic) NSArray<NSLayoutConstraint *> *contentContraints;
@property (nonatomic) NSValue *lastTextContentSize;
@property (nonatomic) CGFloat toolbarHeight;
@property (nonatomic) CGFloat textViewHeight;
#pragma mark - Voice Memo Recording UI
@ -69,18 +71,25 @@ static void *kConversationInputTextViewObservingContext = &kConversationInputTex
[self removeKVOObservers];
}
- (CGSize)intrinsicContentSize
{
CGSize newSize = CGSizeMake(self.bounds.size.width, self.toolbarHeight + ConversationInputToolbarBorderViewHeight);
return newSize;
}
- (void)createContents
{
self.layoutMargins = UIEdgeInsetsZero;
self.backgroundColor = [UIColor ows_inputToolbarBackgroundColor];
self.autoresizingMask = UIViewAutoresizingFlexibleHeight;
UIView *borderView = [UIView new];
borderView.backgroundColor = [UIColor colorWithWhite:238 / 255.f alpha:1.f];
[self addSubview:borderView];
[borderView autoPinWidthToSuperview];
[borderView autoPinEdgeToSuperviewEdge:ALEdgeTop];
[borderView autoSetDimension:ALDimensionHeight toSize:0.5f];
[borderView autoSetDimension:ALDimensionHeight toSize:ConversationInputToolbarBorderViewHeight];
_contentView = [UIView containerView];
[self addSubview:self.contentView];
@ -190,6 +199,26 @@ static void *kConversationInputTextViewObservingContext = &kConversationInputTex
[self.inputTextView.undoManager removeAllActions];
}
- (void)toggleDefaultKeyboard
{
// Primary language is nil for the emoji keyboard.
if (!self.inputTextView.textInputMode.primaryLanguage) {
// Stay on emoji keyboard after sending
return;
}
// Otherwise, we want to toggle back to default keyboard if the user had the numeric keyboard present.
// Momentarily switch to a non-default keyboard, else reloadInputViews
// will not affect the displayed keyboard. In practice this isn't perceptable to the user.
// The alternative would be to dismiss-and-pop the keyboard, but that can cause a more pronounced animation.
self.inputTextView.keyboardType = UIKeyboardTypeNumbersAndPunctuation;
[self.inputTextView reloadInputViews];
self.inputTextView.keyboardType = UIKeyboardTypeDefault;
[self.inputTextView reloadInputViews];
}
- (void)setShouldShowVoiceMemoButton:(BOOL)shouldShowVoiceMemoButton
{
if (_shouldShowVoiceMemoButton == shouldShowVoiceMemoButton) {
@ -223,12 +252,16 @@ static void *kConversationInputTextViewObservingContext = &kConversationInputTex
const CGFloat kMinTextViewHeight = ceil(self.inputTextView.font.lineHeight
+ self.inputTextView.textContainerInset.top + self.inputTextView.textContainerInset.bottom
+ self.inputTextView.contentInset.top + self.inputTextView.contentInset.bottom);
const CGFloat kMaxTextViewHeight = 100.f;
// Exactly 4 lines of text with default sizing.
const CGFloat kMaxTextViewHeight = 98.f;
const CGFloat textViewDesiredHeight = (self.inputTextView.contentSize.height + self.inputTextView.contentInset.top
+ self.inputTextView.contentInset.bottom);
const CGFloat textViewHeight = ceil(Clamp(textViewDesiredHeight, kMinTextViewHeight, kMaxTextViewHeight));
const CGFloat kMinContentHeight = kMinTextViewHeight + textViewVInset * 2;
self.textViewHeight = textViewHeight;
self.toolbarHeight = textViewHeight + textViewVInset * 2;
if (self.attachmentToApprove) {
OWSAssert(self.attachmentView);
@ -247,14 +280,14 @@ static void *kConversationInputTextViewObservingContext = &kConversationInputTex
self.contentContraints = @[
[self.attachmentView autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:textViewVInset],
[self.attachmentView autoPinEdgeToSuperviewEdge:ALEdgeBottom withInset:textViewVInset],
[self.attachmentView autoPinBottomToSuperviewWithMargin:textViewVInset],
[self.attachmentView autoPinEdgeToSuperviewEdge:ALEdgeLeft withInset:contentHInset],
[self.attachmentView autoSetDimension:ALDimensionHeight toSize:150.f],
[self.rightButtonWrapper autoPinEdge:ALEdgeLeft toEdge:ALEdgeRight ofView:self.attachmentView],
[self.rightButtonWrapper autoPinEdgeToSuperviewEdge:ALEdgeRight],
[self.rightButtonWrapper autoPinEdgeToSuperviewEdge:ALEdgeTop],
[self.rightButtonWrapper autoPinEdgeToSuperviewEdge:ALEdgeBottom],
[self.rightButtonWrapper autoPinBottomToSuperviewWithMargin:0],
[rightButton autoSetDimension:ALDimensionHeight toSize:kMinContentHeight],
[rightButton autoPinLeadingToSuperviewWithMargin:contentHSpacing],
@ -316,7 +349,7 @@ static void *kConversationInputTextViewObservingContext = &kConversationInputTex
self.contentContraints = @[
[self.leftButtonWrapper autoPinEdgeToSuperviewEdge:ALEdgeLeft],
[self.leftButtonWrapper autoPinEdgeToSuperviewEdge:ALEdgeTop],
[self.leftButtonWrapper autoPinEdgeToSuperviewEdge:ALEdgeBottom],
[self.leftButtonWrapper autoPinBottomToSuperviewWithMargin:0],
[leftButton autoSetDimension:ALDimensionHeight toSize:kMinContentHeight],
[leftButton autoPinLeadingToSuperviewWithMargin:contentHInset],
@ -325,18 +358,18 @@ static void *kConversationInputTextViewObservingContext = &kConversationInputTex
[self.inputTextView autoPinEdge:ALEdgeLeft toEdge:ALEdgeRight ofView:self.leftButtonWrapper],
[self.inputTextView autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:textViewVInset],
[self.inputTextView autoPinEdgeToSuperviewEdge:ALEdgeBottom withInset:textViewVInset],
[self.inputTextView autoPinBottomToSuperviewWithMargin:textViewVInset],
[self.inputTextView autoSetDimension:ALDimensionHeight toSize:textViewHeight],
[self.rightButtonWrapper autoPinEdge:ALEdgeLeft toEdge:ALEdgeRight ofView:self.inputTextView],
[self.rightButtonWrapper autoPinEdgeToSuperviewEdge:ALEdgeRight],
[self.rightButtonWrapper autoPinEdgeToSuperviewEdge:ALEdgeTop],
[self.rightButtonWrapper autoPinEdgeToSuperviewEdge:ALEdgeBottom],
[self.rightButtonWrapper autoPinBottomToSuperviewWithMargin:0],
[rightButton autoSetDimension:ALDimensionHeight toSize:kMinContentHeight],
[rightButton autoPinLeadingToSuperviewWithMargin:contentHSpacing],
[rightButton autoPinTrailingToSuperviewWithMargin:contentHInset],
[rightButton autoPinEdgeToSuperviewEdge:ALEdgeBottom],
[rightButton autoPinEdgeToSuperviewEdge:ALEdgeBottom]
];
// Layout immediately, unless the input toolbar hasn't even been laid out yet.
@ -709,6 +742,7 @@ static void *kConversationInputTextViewObservingContext = &kConversationInputTex
if (!lastTextContentSize || fabs(lastTextContentSize.CGSizeValue.width - textContentSize.width) > 0.1f
|| fabs(lastTextContentSize.CGSizeValue.height - textContentSize.height) > 0.1f) {
[self ensureContentConstraints];
[self invalidateIntrinsicContentSize];
}
}
}
@ -811,13 +845,6 @@ static void *kConversationInputTextViewObservingContext = &kConversationInputTex
- (void)viewWillDisappear:(BOOL)animated
{
[self.attachmentView viewWillDisappear:animated];
[self endEditingTextMessage];
}
- (nullable NSString *)textInputPrimaryLanguage
{
return self.inputTextView.textInputMode.primaryLanguage;
}
@end

View File

@ -18,7 +18,7 @@
#import "DateUtil.h"
#import "DebugUITableViewController.h"
#import "FingerprintViewController.h"
#import "FullImageViewController.h"
#import "MediaDetailViewController.h"
#import "NSAttributedString+OWS.h"
#import "NewGroupViewController.h"
#import "OWSAudioAttachmentPlayer.h"
@ -57,7 +57,6 @@
#import <JSQMessagesViewController/JSQSystemSoundPlayer+JSQMessages.h>
#import <JSQMessagesViewController/UIColor+JSQMessages.h>
#import <JSQSystemSoundPlayer/JSQSystemSoundPlayer.h>
#import <MediaPlayer/MediaPlayer.h>
#import <MobileCoreServices/UTCoreTypes.h>
#import <PromiseKit/AnyPromise.h>
#import <SignalMessaging/Environment.h>
@ -175,7 +174,6 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
@property (nonatomic) NSArray<ConversationViewItem *> *viewItems;
@property (nonatomic) NSMutableDictionary<NSString *, ConversationViewItem *> *viewItemCache;
@property (nonatomic, nullable) MPMoviePlayerController *videoPlayer;
@property (nonatomic, nullable) AVAudioRecorder *audioRecorder;
@property (nonatomic, nullable) OWSAudioAttachmentPlayer *audioAttachmentPlayer;
@property (nonatomic, nullable) NSUUID *voiceMessageUUID;
@ -220,17 +218,18 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
@property (nonatomic, readonly) BOOL isGroupConversation;
@property (nonatomic) BOOL isUserScrolling;
@property (nonatomic) NSLayoutConstraint *scrollDownButtonButtomConstraint;
@property (nonatomic) ConversationScrollButton *scrollDownButton;
#ifdef DEBUG
@property (nonatomic) ConversationScrollButton *scrollUpButton;
#endif
@property (nonatomic) BOOL isViewCompletelyAppeared;
@property (nonatomic) BOOL isViewVisible;
@property (nonatomic) BOOL isAppInBackground;
@property (nonatomic) BOOL shouldObserveDBModifications;
@property (nonatomic) BOOL viewHasEverAppeared;
@property (nonatomic) BOOL wasScrolledToBottomBeforeKeyboardShow;
@property (nonatomic) BOOL wasScrolledToBottomBeforeLayoutChange;
@property (nonatomic) BOOL hasUnreadMessages;
@end
@ -328,6 +327,10 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
selector:@selector(signalAccountsDidChange:)
name:OWSContactsManagerSignalAccountsDidChangeNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(keyboardWillChangeFrame:)
name:UIKeyboardWillChangeFrameNotification
object:nil];
}
- (void)signalAccountsDidChange:(NSNotification *)notification
@ -461,13 +464,13 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
{
if (_peek) {
self.inputToolbar.hidden = YES;
[self.inputToolbar endEditing:TRUE];
[self dismissKeyBoard];
return;
}
if (self.userLeftGroup) {
self.inputToolbar.hidden = YES; // user has requested they leave the group. further sends disallowed
[self.inputToolbar endEditing:TRUE];
[self dismissKeyBoard];
} else {
self.inputToolbar.hidden = NO;
}
@ -510,6 +513,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
self.collectionView.dataSource = self;
self.collectionView.showsVerticalScrollIndicator = YES;
self.collectionView.showsHorizontalScrollIndicator = NO;
self.collectionView.keyboardDismissMode = UIScrollViewKeyboardDismissModeInteractive;
self.collectionView.backgroundColor = [UIColor whiteColor];
[self.view addSubview:self.collectionView];
[self.collectionView autoPinWidthToSuperview];
@ -526,10 +530,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
_inputToolbar = [ConversationInputToolbar new];
self.inputToolbar.inputToolbarDelegate = self;
self.inputToolbar.inputTextViewDelegate = self;
[self.view addSubview:self.inputToolbar];
[self.inputToolbar autoPinWidthToSuperview];
[self.inputToolbar autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:self.collectionView];
[self autoPinViewToBottomGuideOrKeyboard:self.inputToolbar];
[self.collectionView autoPinToBottomLayoutGuideOfViewController:self withInset:0];
self.loadMoreHeader = [UILabel new];
self.loadMoreHeader.text = NSLocalizedString(@"CONVERSATION_VIEW_LOADING_MORE_MESSAGES",
@ -543,6 +544,16 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
[self.loadMoreHeader autoSetDimension:ALDimensionHeight toSize:kLoadMoreHeaderHeight];
}
- (BOOL)canBecomeFirstResponder
{
return YES;
}
- (nullable UIView *)inputAccessoryView
{
return self.inputToolbar;
}
- (void)registerCellClasses
{
[self.collectionView registerClass:[OWSSystemMessageCell class]
@ -892,6 +903,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
}];
[actionSheetController addAction:dismissAction];
[self dismissKeyBoard];
[self presentViewController:actionSheetController animated:YES completion:nil];
}
}
@ -1008,6 +1020,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
_callOnOpen = NO;
}
self.isViewCompletelyAppeared = YES;
self.viewHasEverAppeared = YES;
}
@ -1021,6 +1034,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
[super viewWillDisappear:animated];
self.isViewCompletelyAppeared = NO;
[self.inputToolbar viewWillDisappear:animated];
}
@ -1038,7 +1052,6 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
[self markVisibleMessagesAsRead];
[self cancelVoiceMemo];
[self.cellMediaCache removeAllObjects];
[self.inputToolbar endEditingTextMessage];
self.isUserScrolling = NO;
}
@ -1390,20 +1403,6 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
#pragma mark - JSQMessagesViewController method overrides
- (void)toggleDefaultKeyboard
{
// Primary language is nil for the emoji keyboard & we want to stay on it after sending
if (!self.inputToolbar.textInputPrimaryLanguage) {
return;
}
// The JSQ event listeners cause a bounce animation, so we temporarily disable them.
[self setShouldIgnoreKeyboardChanges:YES];
[self dismissKeyBoard];
[self popKeyBoard];
[self setShouldIgnoreKeyboardChanges:NO];
}
#pragma mark - Dynamic Text
/**
@ -1644,6 +1643,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
[actionSheetController addAction:resendMessageAction];
[self dismissKeyBoard];
[self presentViewController:actionSheetController animated:YES completion:nil];
}
@ -1678,6 +1678,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
[actionSheetController addAction:resendMessageAction];
[self dismissKeyBoard];
[self presentViewController:actionSheetController animated:YES completion:nil];
}
@ -1805,6 +1806,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
}];
[alertController addAction:resetSessionAction];
[self dismissKeyBoard];
[self presentViewController:alertController animated:YES completion:nil];
}
@ -1845,6 +1847,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
}];
[actionSheetController addAction:acceptSafetyNumberAction];
[self dismissKeyBoard];
[self presentViewController:actionSheetController animated:YES completion:nil];
}
@ -1874,9 +1877,8 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
[alertController addAction:callAction];
[alertController addAction:[OWSAlerts cancelAction]];
[[UIApplication sharedApplication].frontmostViewController presentViewController:alertController
animated:YES
completion:nil];
[self dismissKeyBoard];
[self presentViewController:alertController animated:YES completion:nil];
}
#pragma mark - ConversationViewCellDelegate
@ -1925,6 +1927,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
}];
[actionSheetController addAction:blockAction];
[self dismissKeyBoard];
[self presentViewController:actionSheetController animated:YES completion:nil];
}
@ -1988,46 +1991,32 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
OWSAssert(attachmentStream);
OWSAssert(imageView);
[self dismissKeyBoard];
UIWindow *window = [UIApplication sharedApplication].keyWindow;
CGRect convertedRect = [imageView convertRect:imageView.bounds toView:window];
FullImageViewController *vc = [[FullImageViewController alloc] initWithAttachmentStream:attachmentStream
MediaDetailViewController *vc = [[MediaDetailViewController alloc] initWithAttachmentStream:attachmentStream
fromRect:convertedRect
viewItem:viewItem];
[vc presentFromViewController:self];
[vc presentFromViewController:self replacingView:imageView];
}
- (void)didTapVideoViewItem:(ConversationViewItem *)viewItem attachmentStream:(TSAttachmentStream *)attachmentStream
- (void)didTapVideoViewItem:(ConversationViewItem *)viewItem
attachmentStream:(TSAttachmentStream *)attachmentStream
imageView:(UIImageView *)imageView
{
OWSAssertIsOnMainThread();
OWSAssert(viewItem);
OWSAssert(attachmentStream);
NSFileManager *fileManager = [NSFileManager defaultManager];
if (![fileManager fileExistsAtPath:[attachmentStream.mediaURL path]]) {
OWSFail(@"%@ Missing video file: %@", self.logTag, attachmentStream.mediaURL);
}
[self dismissKeyBoard];
self.videoPlayer = [[MPMoviePlayerController alloc] initWithContentURL:attachmentStream.mediaURL];
[_videoPlayer prepareToPlay];
UIWindow *window = [UIApplication sharedApplication].keyWindow;
CGRect convertedRect = [imageView convertRect:imageView.bounds toView:window];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(moviePlayerWillExitFullscreen:)
name:MPMoviePlayerWillExitFullscreenNotification
object:_videoPlayer];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(moviePlayerDidExitFullscreen:)
name:MPMoviePlayerDidExitFullscreenNotification
object:_videoPlayer];
_videoPlayer.controlStyle = MPMovieControlStyleDefault;
_videoPlayer.shouldAutoplay = YES;
[self.view addSubview:_videoPlayer.view];
// We can't animate from the cell media frame;
// MPMoviePlayerController will animate a crop of its
// contents rather than scaling them.
_videoPlayer.view.frame = self.view.bounds;
[_videoPlayer setFullscreen:YES animated:NO];
MediaDetailViewController *vc = [[MediaDetailViewController alloc] initWithAttachmentStream:attachmentStream
fromRect:convertedRect
viewItem:viewItem];
[vc presentFromViewController:self replacingView:imageView];
}
- (void)didTapAudioViewItem:(ConversationViewItem *)viewItem attachmentStream:(TSAttachmentStream *)attachmentStream
@ -2108,42 +2097,6 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
[self.navigationController pushViewController:view animated:YES];
}
#pragma mark - Video Playback
// There's more than one way to exit the fullscreen video playback.
// There's a done button, a "toggle fullscreen" button and I think
// there's some gestures too. These fire slightly different notifications.
// We want to hide & clean up the video player immediately in all of
// these cases.
- (void)moviePlayerWillExitFullscreen:(id)sender
{
DDLogDebug(@"%@ %s", self.logTag, __PRETTY_FUNCTION__);
[self clearVideoPlayer];
}
// See comment on moviePlayerWillExitFullscreen:
- (void)moviePlayerDidExitFullscreen:(id)sender
{
DDLogDebug(@"%@ %s", self.logTag, __PRETTY_FUNCTION__);
[self clearVideoPlayer];
}
- (void)clearVideoPlayer
{
[_videoPlayer stop];
[_videoPlayer.view removeFromSuperview];
self.videoPlayer = nil;
}
- (void)setVideoPlayer:(MPMoviePlayerController *_Nullable)videoPlayer
{
_videoPlayer = videoPlayer;
[ViewControllerUtils setAudioIgnoresHardwareMuteSwitch:videoPlayer != nil];
}
#pragma mark - System Messages
- (void)didTapSystemMessageWithInteraction:(TSInteraction *)interaction
@ -2244,7 +2197,9 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
[self.view addSubview:self.scrollDownButton];
[self.scrollDownButton autoSetDimension:ALDimensionWidth toSize:ConversationScrollButton.buttonSize];
[self.scrollDownButton autoSetDimension:ALDimensionHeight toSize:ConversationScrollButton.buttonSize];
[self.scrollDownButton autoPinEdge:ALEdgeBottom toEdge:ALEdgeTop ofView:self.inputToolbar];
self.scrollDownButtonButtomConstraint =
[self.scrollDownButton autoPinEdge:ALEdgeBottom toEdge:ALEdgeBottom ofView:self.collectionView];
[self.scrollDownButton autoPinEdgeToSuperviewEdge:ALEdgeTrailing];
#ifdef DEBUG
@ -2360,6 +2315,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
[[UIDocumentMenuViewController alloc] initWithDocumentTypes:documentTypes inMode:pickerMode];
menuController.delegate = self;
[self dismissKeyBoard];
[self presentViewController:menuController animated:YES completion:nil];
}
@ -2371,6 +2327,8 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
[[GifPickerViewController alloc] initWithThread:self.thread messageSender:self.messageSender];
view.delegate = self;
UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:view];
[self dismissKeyBoard];
[self presentViewController:navigationController animated:YES completion:nil];
}
@ -2410,6 +2368,8 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
// post iOS11, document picker has no blue header.
[UIUtil applyDefaultSystemAppearence];
}
[self dismissKeyBoard];
[self presentViewController:documentPicker animated:YES completion:nil];
}
@ -2515,8 +2475,9 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
picker.mediaTypes = @[ (__bridge NSString *)kUTTypeImage, (__bridge NSString *)kUTTypeMovie ];
picker.allowsEditing = NO;
picker.delegate = self;
dispatch_async(dispatch_get_main_queue(), ^{
[self dismissKeyBoard];
[self presentViewController:picker animated:YES completion:[UIUtil modalCompletionBlock]];
});
}];
@ -2536,6 +2497,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
picker.delegate = self;
picker.mediaTypes = @[ (__bridge NSString *)kUTTypeImage, (__bridge NSString *)kUTTypeMovie ];
[self dismissKeyBoard];
[self presentViewController:picker animated:YES completion:[UIUtil modalCompletionBlock]];
}
@ -3098,8 +3060,11 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
const CGFloat kIsAtBottomTolerancePts = 5;
// Note the usage of MAX() to handle the case where there isn't enough
// content to fill the collection view at its current size.
CGFloat contentOffsetYBottom = MAX(0.f, contentHeight - self.collectionView.bounds.size.height);
BOOL isScrolledToBottom = (self.collectionView.contentOffset.y > contentOffsetYBottom - kIsAtBottomTolerancePts);
CGFloat contentOffsetYBottom
= MAX(0.f, contentHeight + self.collectionView.contentInset.bottom - self.collectionView.bounds.size.height);
CGFloat distanceFromBottom = contentOffsetYBottom - self.collectionView.contentOffset.y;
BOOL isScrolledToBottom = distanceFromBottom <= kIsAtBottomTolerancePts;
return isScrolledToBottom;
}
@ -3291,6 +3256,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
- (void)attachmentButtonPressed
{
[self dismissKeyBoard];
__weak ConversationViewController *weakSelf = self;
if ([self isBlockedContactConversation]) {
@ -3366,6 +3332,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
[gifAction setValue:gifImage forKey:@"image"];
[actionSheetController addAction:gifAction];
[self dismissKeyBoard];
[self presentViewController:actionSheetController animated:true completion:nil];
}
@ -3675,6 +3642,95 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
});
}
- (void)keyboardWillChangeFrame:(NSNotification *)notification
{
// `willChange` is the correct keyboard notifiation to observe when adjusting contentInset
// in lockstep with the keyboard presentation animation. `didChange` results in the contentInset
// not adjusting until after the keyboard is fully up.
DDLogVerbose(@"%@ %s", self.logTag, __PRETTY_FUNCTION__);
[self handleKeyboardNotification:notification];
}
- (void)handleKeyboardNotification:(NSNotification *)notification
{
AssertIsOnMainThread();
NSDictionary *userInfo = [notification userInfo];
NSValue *_Nullable keyboardBeginFrameValue = userInfo[UIKeyboardFrameBeginUserInfoKey];
if (!keyboardBeginFrameValue) {
OWSFail(@"%@ Missing keyboard begin frame", self.logTag);
return;
}
NSValue *_Nullable keyboardEndFrameValue = userInfo[UIKeyboardFrameEndUserInfoKey];
if (!keyboardEndFrameValue) {
OWSFail(@"%@ Missing keyboard end frame", self.logTag);
return;
}
CGRect keyboardEndFrame = [keyboardEndFrameValue CGRectValue];
UIEdgeInsets oldInsets = self.collectionView.contentInset;
UIEdgeInsets newInsets = oldInsets;
// bottomLayoutGuide accounts for extra offset needed on iPhoneX
newInsets.bottom = keyboardEndFrame.size.height - self.bottomLayoutGuide.length;
BOOL wasScrolledToBottom = [self isScrolledToBottom];
void (^adjustInsets)(void) = ^(void) {
self.collectionView.contentInset = newInsets;
self.collectionView.scrollIndicatorInsets = newInsets;
// Note there is a bug in iOS11.2 which where switching to the emoji keyboard
// does not fire a UIKeyboardFrameWillChange notification. In that case, the scroll
// down button gets mostly obscured by the keyboard.
// RADAR: #36297652
self.scrollDownButtonButtomConstraint.constant = -1 * newInsets.bottom;
[self.scrollDownButton setNeedsLayout];
[self.scrollDownButton layoutIfNeeded];
// HACK: I've made the assumption that we are already in the context of an animation, in which case the
// above should be sufficient to smoothly move the scrollDown button in step with the keyboard presentation
// animation. Yet, setting the constraint doesn't animate the movement of the button - it "jumps" to it's final
// position. So here we manually lay out the scroll down button frame (seemingly redundantly), which allows it
// to be smoothly animated.
CGRect newButtonFrame = self.scrollDownButton.frame;
newButtonFrame.origin.y
= self.scrollDownButton.superview.height - (newInsets.bottom + self.scrollDownButton.height);
self.scrollDownButton.frame = newButtonFrame;
// Adjust content offset to prevent the presented keyboard from obscuring content.
if (wasScrolledToBottom) {
// If we were scrolled to the bottom, don't do any fancy math. Just stay at the bottom.
[self scrollToBottomAnimated:NO];
} else {
// If we were scrolled away from the bottom, shift the content in lockstep with the
// keyboard, up to the limits of the content bounds.
CGFloat insetChange = newInsets.bottom - oldInsets.bottom;
CGFloat oldYOffset = self.collectionView.contentOffset.y;
CGFloat newYOffset = Clamp(oldYOffset + insetChange, 0, self.safeContentHeight);
CGPoint newOffset = CGPointMake(0, newYOffset);
// If the user is dismissing the keyboard via interactive scrolling, any additional conset offset feels
// redundant, so we only adjust content offset when *presenting* the keyboard (i.e. when insetChange > 0).
if (insetChange > 0 && newYOffset > keyboardEndFrame.origin.y) {
[self.collectionView setContentOffset:newOffset animated:NO];
}
}
};
if (self.isViewCompletelyAppeared) {
adjustInsets();
} else {
// Even though we are scrolling without explicitly animating, the notification seems to occur within the context
// of a system animation, which is desirable when the view is visible, because the user sees the content rise
// in sync with the keyboard. However, when the view hasn't yet been presented, the animation conflicts and the
// result is that initial load causes the collection cells to visably "animate" to their final position once the
// view appears.
[UIView performWithoutAnimation:adjustInsets];
}
}
- (void)didApproveAttachment:(SignalAttachment *)attachment
{
OWSAssert(attachment);
@ -3707,13 +3763,6 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
return [self.collectionView.collectionViewLayout collectionViewContentSize].height;
}
- (void)scrollToBottomImmediately
{
OWSAssertIsOnMainThread();
[self scrollToBottomAnimated:NO];
}
- (void)scrollToBottomAnimated:(BOOL)animated
{
OWSAssertIsOnMainThread();
@ -3721,11 +3770,17 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
if (self.isUserScrolling) {
return;
}
// Ensure the view is fully layed out before we try to scroll to the bottom, since
// we use the collectionView bounds to determine where the "bottom" is.
[self.view layoutIfNeeded];
CGFloat contentHeight = self.safeContentHeight;
CGFloat dstY = MAX(0, contentHeight - self.collectionView.height);
[self.collectionView setContentOffset:CGPointMake(0, dstY) animated:animated];
CGFloat dstY
= MAX(0, contentHeight + self.collectionView.contentInset.bottom - self.collectionView.bounds.size.height);
[self.collectionView setContentOffset:CGPointMake(0, dstY) animated:NO];
[self didScrollToBottom];
}
@ -3735,22 +3790,6 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
{
[self updateLastVisibleTimestamp];
[self autoLoadMoreIfNecessary];
if (self.isUserScrolling && [self isScrolledAwayFromBottom]) {
[self.inputToolbar endEditingTextMessage];
}
}
// See the comments on isScrolledToBottom.
- (BOOL)isScrolledAwayFromBottom
{
CGFloat contentHeight = self.safeContentHeight;
// Note the usage of MAX() to handle the case where there isn't enough
// content to fill the collection view at its current size.
CGFloat contentOffsetYBottom = MAX(0.f, contentHeight - self.collectionView.bounds.size.height);
const CGFloat kThreshold = 250;
BOOL isScrolledAwayFromBottom = (self.collectionView.contentOffset.y < contentOffsetYBottom - kThreshold);
return isScrolledAwayFromBottom;
}
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView
@ -3877,10 +3916,10 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
[self messageWasSent:message];
if (updateKeyboardState) {
[self toggleDefaultKeyboard];
[self.inputToolbar toggleDefaultKeyboard];
}
[self clearDraft];
[self.inputToolbar clearTextMessage];
[self clearDraft];
if (didAddToProfileWhitelist) {
[self ensureDynamicInteractions];
}
@ -4053,8 +4092,6 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
- (void)collectionViewWillChangeLayout
{
OWSAssertIsOnMainThread();
self.wasScrolledToBottomBeforeLayoutChange = [self isScrolledToBottom];
}
- (void)collectionViewDidChangeLayout
@ -4062,15 +4099,6 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
OWSAssertIsOnMainThread();
[self updateLastVisibleTimestamp];
// JSQMessageView has glitchy behavior. When presenting/dismissing view
// controllers, the size of the input toolbar and/or collection view can
// repeatedly change, leaving scroll state in an invalid state. The
// simplest fix that covers most cases is to ensure that we remain
// "scrolled to bottom" across these changes.
if (self.wasScrolledToBottomBeforeLayoutChange) {
[self scrollToBottomImmediately];
}
}
#pragma mark - View Items

View File

@ -1,5 +1,5 @@
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "ConversationViewLayout.h"
@ -68,6 +68,7 @@ NS_ASSUME_NONNULL_BEGIN
[self clearState];
return;
}
if (self.collectionView.bounds.size.width <= 0.f || self.collectionView.bounds.size.height <= 0.f) {
OWSFail(
@"%@ Collection view has invalid size: %@", self.logTag, NSStringFromCGRect(self.collectionView.bounds));

View File

@ -1,5 +1,5 @@
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "OWSViewController.h"
@ -10,7 +10,7 @@ NS_ASSUME_NONNULL_BEGIN
@class SignalAttachment;
@class TSAttachmentStream;
@interface FullImageViewController : OWSViewController
@interface MediaDetailViewController : OWSViewController
// If viewItem is non-null, long press will show a menu controller.
- (instancetype)initWithAttachmentStream:(TSAttachmentStream *)attachmentStream
@ -19,7 +19,7 @@ NS_ASSUME_NONNULL_BEGIN
- (instancetype)initWithAttachment:(SignalAttachment *)attachment fromRect:(CGRect)rect;
- (void)presentFromViewController:(UIViewController *)viewController;
- (void)presentFromViewController:(UIViewController *)viewController replacingView:(UIView *)view;
@end

View File

@ -0,0 +1,986 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "MediaDetailViewController.h"
#import "AttachmentSharing.h"
#import "ConversationViewController.h"
#import "ConversationViewItem.h"
#import "OWSMessageCell.h"
#import "Signal-Swift.h"
#import "TSAttachmentStream.h"
#import "TSInteraction.h"
#import "UIColor+OWS.h"
#import "UIUtil.h"
#import "UIView+OWS.h"
#import <AVKit/AVKit.h>
#import <MediaPlayer/MPMoviePlayerViewController.h>
#import <MediaPlayer/MediaPlayer.h>
#import <SignalServiceKit/NSData+Image.h>
#import <YYImage/YYImage.h>
NS_ASSUME_NONNULL_BEGIN
// In order to use UIMenuController, the view from which it is
// presented must have certain custom behaviors.
@interface AttachmentMenuView : UIView
@end
#pragma mark -
@implementation AttachmentMenuView
- (BOOL)canBecomeFirstResponder {
return YES;
}
// We only use custom actions in UIMenuController.
- (BOOL)canPerformAction:(SEL)action withSender:(nullable id)sender
{
return NO;
}
@end
#pragma mark -
@interface MediaDetailViewController () <UIScrollViewDelegate, UIGestureRecognizerDelegate, PlayerProgressBarDelegate>
@property (nonatomic) UIScrollView *scrollView;
@property (nonatomic) UIView *mediaView;
@property (nonatomic) UIView *presentationView;
@property (nonatomic) UIView *replacingView;
@property (nonatomic) UIButton *shareButton;
@property (nonatomic) CGRect originRect;
@property (nonatomic) NSData *fileData;
@property (nonatomic, nullable) TSAttachmentStream *attachmentStream;
@property (nonatomic, nullable) SignalAttachment *attachment;
@property (nonatomic, nullable) ConversationViewItem *viewItem;
@property (nonatomic) UIToolbar *footerBar;
@property (nonatomic) BOOL areToolbarsHidden;
@property (nonatomic, nullable) AVPlayer *videoPlayer;
@property (nonatomic, nullable) UIButton *playVideoButton;
@property (nonatomic, nullable) PlayerProgressBar *videoProgressBar;
@property (nonatomic, nullable) UIBarButtonItem *videoPlayBarButton;
@property (nonatomic, nullable) UIBarButtonItem *videoPauseBarButton;
@property (nonatomic, nullable) NSArray<NSLayoutConstraint *> *presentationViewConstraints;
@property (nonatomic, nullable) NSLayoutConstraint *mediaViewBottomConstraint;
@property (nonatomic, nullable) NSLayoutConstraint *mediaViewLeadingConstraint;
@property (nonatomic, nullable) NSLayoutConstraint *mediaViewTopConstraint;
@property (nonatomic, nullable) NSLayoutConstraint *mediaViewTrailingConstraint;
@end
@implementation MediaDetailViewController
- (instancetype)initWithAttachmentStream:(TSAttachmentStream *)attachmentStream
fromRect:(CGRect)rect
viewItem:(ConversationViewItem *_Nullable)viewItem
{
self = [super initWithNibName:nil bundle:nil];
if (self) {
self.attachmentStream = attachmentStream;
self.originRect = rect;
self.viewItem = viewItem;
}
return self;
}
- (instancetype)initWithAttachment:(SignalAttachment *)attachment fromRect:(CGRect)rect
{
self = [super initWithNibName:nil bundle:nil];
if (self) {
self.attachment = attachment;
self.originRect = rect;
}
return self;
}
- (NSURL *_Nullable)attachmentUrl
{
if (self.attachmentStream) {
return self.attachmentStream.mediaURL;
} else if (self.attachment) {
return self.attachment.dataUrl;
} else {
return nil;
}
}
- (NSData *)fileData
{
if (!_fileData) {
NSURL *_Nullable url = self.attachmentUrl;
if (url) {
_fileData = [NSData dataWithContentsOfURL:url];
}
}
return _fileData;
}
- (UIImage *)image {
if (self.attachmentStream) {
return self.attachmentStream.image;
} else if (self.attachment) {
if (self.isVideo) {
return self.attachment.videoPreview;
} else {
return self.attachment.image;
}
} else {
return nil;
}
}
- (BOOL)isAnimated
{
if (self.attachmentStream) {
return self.attachmentStream.isAnimated;
} else if (self.attachment) {
return self.attachment.isAnimatedImage;
} else {
return NO;
}
}
- (BOOL)isVideo
{
if (self.attachmentStream) {
return self.attachmentStream.isVideo;
} else if (self.attachment) {
return self.attachment.isVideo;
} else {
return NO;
}
}
- (void)loadView
{
self.view = [AttachmentMenuView new];
self.view.backgroundColor = [UIColor clearColor];
}
- (void)viewDidLoad
{
[super viewDidLoad];
[self createContents];
[self initializeGestureRecognizers];
// Even though bars are opaque, we want content to be layed out behind them.
// The bars might obscure part of the content, but they can easily be hidden by tapping
// The alternative would be that content would shift when the navbars hide.
self.extendedLayoutIncludesOpaqueBars = YES;
// TODO better title.
self.title = @"Attachment";
self.navigationItem.leftBarButtonItem =
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemStop
target:self
action:@selector(didTapDismissButton:)];
}
- (void)viewWillDisappear:(BOOL)animated {
[super viewWillDisappear:animated];
if ([UIMenuController sharedMenuController].isMenuVisible) {
[[UIMenuController sharedMenuController] setMenuVisible:NO
animated:NO];
}
}
- (void)viewDidLayoutSubviews
{
[super viewDidLayoutSubviews];
[self updateMinZoomScale];
[self centerMediaViewConstraints];
}
- (void)updateMinZoomScale
{
CGSize viewSize = self.scrollView.bounds.size;
UIImage *image = self.image;
OWSAssert(image);
if (image.size.width == 0 || image.size.height == 0) {
OWSFail(@"%@ Invalid image dimensions. %@", self.logTag, NSStringFromCGSize(image.size));
return;
}
CGFloat scaleWidth = viewSize.width / image.size.width;
CGFloat scaleHeight = viewSize.height / image.size.height;
CGFloat minScale = MIN(scaleWidth, scaleHeight);
if (minScale != self.scrollView.minimumZoomScale) {
self.scrollView.minimumZoomScale = minScale;
self.scrollView.maximumZoomScale = minScale * 8;
self.scrollView.zoomScale = minScale;
}
}
#pragma mark - Initializers
- (void)createContents
{
CGFloat kFooterHeight = 44;
UIScrollView *scrollView = [UIScrollView new];
[self.view addSubview:scrollView];
self.scrollView = scrollView;
scrollView.delegate = self;
scrollView.showsVerticalScrollIndicator = NO;
scrollView.showsHorizontalScrollIndicator = NO;
scrollView.decelerationRate = UIScrollViewDecelerationRateFast;
self.automaticallyAdjustsScrollViewInsets = NO;
[scrollView autoPinToSuperviewEdges];
if (self.isAnimated) {
if ([self.fileData ows_isValidImage]) {
YYImage *animatedGif = [YYImage imageWithData:self.fileData];
YYAnimatedImageView *animatedView = [YYAnimatedImageView new];
animatedView.image = animatedGif;
self.mediaView = animatedView;
} else {
self.mediaView = [UIImageView new];
}
} else if (self.isVideo) {
self.mediaView = [self buildVideoPlayerView];
} else {
// Present the static image using standard UIImageView
UIImageView *imageView = [[UIImageView alloc] initWithImage:self.image];
self.mediaView = imageView;
}
OWSAssert(self.mediaView);
[scrollView addSubview:self.mediaView];
self.mediaViewLeadingConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeLeading];
self.mediaViewTopConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeTop];
self.mediaViewTrailingConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeTrailing];
self.mediaViewBottomConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeBottom];
self.mediaView.contentMode = UIViewContentModeScaleAspectFit;
self.mediaView.userInteractionEnabled = YES;
self.mediaView.clipsToBounds = YES;
self.mediaView.layer.allowsEdgeAntialiasing = YES;
self.mediaView.translatesAutoresizingMaskIntoConstraints = NO;
// Use trilinear filters for better scaling quality at
// some performance cost.
self.mediaView.layer.minificationFilter = kCAFilterTrilinear;
self.mediaView.layer.magnificationFilter = kCAFilterTrilinear;
// The presentationView is only used during present/dismiss animations.
// It's a static image of the media content.
UIImageView *presentationView = [[UIImageView alloc] initWithImage:self.image];
self.presentationView = presentationView;
[self.view addSubview:presentationView];
presentationView.hidden = YES;
presentationView.clipsToBounds = YES;
presentationView.layer.allowsEdgeAntialiasing = YES;
presentationView.layer.minificationFilter = kCAFilterTrilinear;
presentationView.layer.magnificationFilter = kCAFilterTrilinear;
presentationView.contentMode = UIViewContentModeScaleAspectFit;
[self applyInitialMediaViewConstraints];
if (self.isVideo) {
if (@available(iOS 9, *)) {
PlayerProgressBar *videoProgressBar = [PlayerProgressBar new];
videoProgressBar.delegate = self;
videoProgressBar.player = self.videoPlayer;
self.videoProgressBar = videoProgressBar;
[self.view addSubview:videoProgressBar];
[videoProgressBar autoPinWidthToSuperview];
[videoProgressBar autoPinToTopLayoutGuideOfViewController:self withInset:0];
CGFloat kVideoProgressBarHeight = 44;
[videoProgressBar autoSetDimension:ALDimensionHeight toSize:kVideoProgressBarHeight];
}
UIButton *playVideoButton = [UIButton new];
self.playVideoButton = playVideoButton;
[playVideoButton addTarget:self action:@selector(playVideo) forControlEvents:UIControlEventTouchUpInside];
UIImage *playImage = [UIImage imageNamed:@"play_button"];
[playVideoButton setBackgroundImage:playImage forState:UIControlStateNormal];
playVideoButton.contentMode = UIViewContentModeScaleAspectFill;
[self.view addSubview:playVideoButton];
CGFloat playVideoButtonWidth = ScaleFromIPhone5(70);
[playVideoButton autoSetDimensionsToSize:CGSizeMake(playVideoButtonWidth, playVideoButtonWidth)];
[playVideoButton autoCenterInSuperview];
}
// Don't show footer bar after tapping approval-view
if (self.viewItem) {
UIToolbar *footerBar = [UIToolbar new];
_footerBar = footerBar;
footerBar.barTintColor = [UIColor ows_signalBrandBlueColor];
self.videoPlayBarButton =
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemPlay
target:self
action:@selector(didPressPlayBarButton:)];
self.videoPauseBarButton =
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemPause
target:self
action:@selector(didPressPauseBarButton:)];
[self updateFooterBarButtonItemsWithIsPlayingVideo:YES];
[self.view addSubview:footerBar];
[footerBar autoPinWidthToSuperview];
[footerBar autoPinToBottomLayoutGuideOfViewController:self withInset:0];
[footerBar autoSetDimension:ALDimensionHeight toSize:kFooterHeight];
}
}
- (void)updateFooterBarButtonItemsWithIsPlayingVideo:(BOOL)isPlayingVideo
{
OWSAssert(self.footerBar);
NSMutableArray<UIBarButtonItem *> *toolbarItems = [NSMutableArray new];
[toolbarItems addObjectsFromArray:@[
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemAction
target:self
action:@selector(didPressShare:)],
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil],
]];
if (self.isVideo) {
// bar button video controls only work on iOS9+
if (@available(iOS 9.0, *)) {
UIBarButtonItem *playerButton = isPlayingVideo ? self.videoPauseBarButton : self.videoPlayBarButton;
[toolbarItems addObjectsFromArray:@[
playerButton,
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace
target:nil
action:nil],
]];
}
}
[toolbarItems addObject:[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemTrash
target:self
action:@selector(didPressDelete:)]];
[self.footerBar setItems:toolbarItems animated:NO];
}
- (void)applyInitialMediaViewConstraints
{
if (self.presentationViewConstraints.count > 0) {
[NSLayoutConstraint deactivateConstraints:self.presentationViewConstraints];
}
CGRect convertedRect = [self.presentationView.superview convertRect:self.originRect
fromView:[UIApplication sharedApplication].keyWindow];
NSMutableArray<NSLayoutConstraint *> *presentationViewConstraints = [NSMutableArray new];
self.presentationViewConstraints = presentationViewConstraints;
[presentationViewConstraints
addObjectsFromArray:[self.presentationView autoSetDimensionsToSize:convertedRect.size]];
[presentationViewConstraints addObjectsFromArray:@[
[self.presentationView autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:convertedRect.origin.y],
[self.presentationView autoPinEdgeToSuperviewEdge:ALEdgeLeft withInset:convertedRect.origin.x]
]];
}
- (void)applyFinalMediaViewConstraints
{
if (self.presentationViewConstraints.count > 0) {
[NSLayoutConstraint deactivateConstraints:self.presentationViewConstraints];
}
NSMutableArray<NSLayoutConstraint *> *presentationViewConstraints = [NSMutableArray new];
self.presentationViewConstraints = presentationViewConstraints;
[presentationViewConstraints addObjectsFromArray:@[
[self.presentationView autoPinEdgeToSuperviewEdge:ALEdgeLeading],
[self.presentationView autoPinEdgeToSuperviewEdge:ALEdgeTop],
[self.presentationView autoPinEdgeToSuperviewEdge:ALEdgeTrailing],
[self.presentationView autoPinEdgeToSuperviewEdge:ALEdgeBottom]
]];
}
- (UIView *)buildVideoPlayerView
{
NSFileManager *fileManager = [NSFileManager defaultManager];
if (![fileManager fileExistsAtPath:[self.attachmentUrl path]]) {
OWSFail(@"%@ Missing video file: %@", self.logTag, self.attachmentStream.mediaURL);
}
if (@available(iOS 9.0, *)) {
AVPlayer *player = [[AVPlayer alloc] initWithURL:self.attachmentUrl];
[player seekToTime:kCMTimeZero];
self.videoPlayer = player;
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(playerItemDidPlayToCompletion:)
name:AVPlayerItemDidPlayToEndTimeNotification
object:player.currentItem];
VideoPlayerView *playerView = [VideoPlayerView new];
playerView.player = player;
[NSLayoutConstraint autoSetPriority:UILayoutPriorityDefaultLow
forConstraints:^{
[playerView autoSetDimensionsToSize:self.image.size];
}];
return playerView;
} else {
return [[UIImageView alloc] initWithImage:self.image];
}
}
- (void)setAreToolbarsHidden:(BOOL)areToolbarsHidden
{
if (_areToolbarsHidden == areToolbarsHidden) {
return;
}
_areToolbarsHidden = areToolbarsHidden;
// Hiding the status bar affects the positioing of the navbar. We don't want to show that in an animation, it's
// better to just have everythign "flit" in/out.
[[UIApplication sharedApplication] setStatusBarHidden:areToolbarsHidden withAnimation:UIStatusBarAnimationNone];
[self.navigationController setNavigationBarHidden:areToolbarsHidden animated:NO];
self.videoProgressBar.hidden = areToolbarsHidden;
// We don't animate the background color change because the old color shows through momentarily
// behind where the status bar "used to be".
self.view.backgroundColor = areToolbarsHidden ? UIColor.blackColor : UIColor.whiteColor;
[UIView animateWithDuration:0.1
animations:^(void) {
self.footerBar.alpha = areToolbarsHidden ? 0 : 1;
}];
}
- (void)initializeGestureRecognizers
{
UITapGestureRecognizer *doubleTap =
[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(didDoubleTapImage:)];
doubleTap.numberOfTapsRequired = 2;
[self.view addGestureRecognizer:doubleTap];
UITapGestureRecognizer *singleTap =
[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(didTapImage:)];
[singleTap requireGestureRecognizerToFail:doubleTap];
[self.view addGestureRecognizer:singleTap];
// UISwipeGestureRecognizer supposedly supports multiple directions,
// but in practice it works better if you use a separate GR for each
// direction.
for (NSNumber *direction in @[
@(UISwipeGestureRecognizerDirectionRight),
@(UISwipeGestureRecognizerDirectionLeft),
@(UISwipeGestureRecognizerDirectionUp),
@(UISwipeGestureRecognizerDirectionDown),
]) {
UISwipeGestureRecognizer *swipe =
[[UISwipeGestureRecognizer alloc] initWithTarget:self action:@selector(didSwipeImage:)];
swipe.direction = (UISwipeGestureRecognizerDirection) direction.integerValue;
swipe.delegate = self;
[self.view addGestureRecognizer:swipe];
}
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self
action:@selector(longPressGesture:)];
longPress.delegate = self;
[self.view addGestureRecognizer:longPress];
}
#pragma mark - Gesture Recognizers
- (void)didTapDismissButton:(id)sender
{
[self dismissSelfAnimated:YES completion:nil];
}
- (void)didTapImage:(id)sender
{
DDLogVerbose(@"%@ did tap image.", self.logTag);
self.areToolbarsHidden = !self.areToolbarsHidden;
}
- (void)didDoubleTapImage:(UITapGestureRecognizer *)gesture
{
DDLogVerbose(@"%@ did double tap image.", self.logTag);
if (self.scrollView.zoomScale == self.scrollView.minimumZoomScale) {
CGFloat kDoubleTapZoomScale = 2;
CGFloat zoomWidth = self.scrollView.width / kDoubleTapZoomScale;
CGFloat zoomHeight = self.scrollView.height / kDoubleTapZoomScale;
// center zoom rect around tapLocation
CGPoint tapLocation = [gesture locationInView:self.scrollView];
CGFloat zoomX = MAX(0, tapLocation.x - zoomWidth / 2);
CGFloat zoomY = MAX(0, tapLocation.y - zoomHeight / 2);
CGRect zoomRect = CGRectMake(zoomX, zoomY, zoomWidth, zoomHeight);
CGRect translatedRect = [self.mediaView convertRect:zoomRect fromView:self.scrollView];
[self.scrollView zoomToRect:translatedRect animated:YES];
} else {
// If already zoomed in at all, zoom out all the way.
[self.scrollView setZoomScale:self.scrollView.minimumZoomScale animated:YES];
}
}
- (void)didSwipeImage:(UIGestureRecognizer *)sender
{
// Ignore if image is zoomed in at all.
// e.g. otherwise, for example, if the image is horizontally larger than the scroll
// view, but fits vertically, swiping left/right will scroll the image, but swiping up/down
// would dismiss the image. That would not be intuitive.
if (self.scrollView.zoomScale != self.scrollView.minimumZoomScale) {
return;
}
[self dismissSelfAnimated:YES completion:nil];
}
- (void)longPressGesture:(UIGestureRecognizer *)sender {
// We "eagerly" respond when the long press begins, not when it ends.
if (sender.state == UIGestureRecognizerStateBegan) {
if (!self.viewItem) {
return;
}
[self.view becomeFirstResponder];
if ([UIMenuController sharedMenuController].isMenuVisible) {
[[UIMenuController sharedMenuController] setMenuVisible:NO
animated:NO];
}
NSArray *menuItems = self.viewItem.mediaMenuControllerItems;
[UIMenuController sharedMenuController].menuItems = menuItems;
CGPoint location = [sender locationInView:self.view];
CGRect targetRect = CGRectMake(location.x,
location.y,
1, 1);
[[UIMenuController sharedMenuController] setTargetRect:targetRect
inView:self.view];
[[UIMenuController sharedMenuController] setMenuVisible:YES
animated:YES];
}
}
- (void)didPressShare:(id)sender
{
DDLogInfo(@"%@: didPressShare", self.logTag);
if (!self.viewItem) {
OWSFail(@"share should only be available when a viewItem is present");
return;
}
[self.viewItem shareMediaAction];
}
- (void)didPressDelete:(id)sender
{
DDLogInfo(@"%@: didPressDelete", self.logTag);
if (!self.viewItem) {
OWSFail(@"delete should only be available when a viewItem is present");
return;
}
UIAlertController *actionSheet =
[UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet];
[actionSheet
addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"TXT_DELETE_TITLE", nil)
style:UIAlertActionStyleDestructive
handler:^(UIAlertAction *action) {
OWSAssert([self.presentingViewController
isKindOfClass:[UINavigationController class]]);
UINavigationController *navController
= (UINavigationController *)self.presentingViewController;
if ([navController.topViewController
isKindOfClass:[ConversationViewController class]]) {
[self dismissSelfAnimated:YES
completion:^{
[self.viewItem deleteAction];
}];
} else if ([navController.topViewController
isKindOfClass:[MessageDetailViewController class]]) {
[self dismissSelfAnimated:NO
completion:^{
[self.viewItem deleteAction];
}];
[navController popViewControllerAnimated:YES];
} else {
OWSFail(@"Unexpected presentation context.");
[self dismissSelfAnimated:YES
completion:^{
[self.viewItem deleteAction];
}];
}
}]];
[actionSheet addAction:[OWSAlerts cancelAction]];
[self presentViewController:actionSheet animated:YES completion:nil];
}
- (BOOL)canPerformAction:(SEL)action withSender:(nullable id)sender
{
if (self.viewItem == nil) {
return NO;
}
// Already in detail view, so no link to "info"
if (action == self.viewItem.metadataActionSelector) {
return NO;
}
return [self.viewItem canPerformAction:action];
}
- (void)copyMediaAction:(nullable id)sender
{
if (!self.viewItem) {
OWSFail(@"copy should only be available when a viewItem is present");
return;
}
[self.viewItem copyMediaAction];
}
- (void)shareMediaAction:(nullable id)sender
{
if (!self.viewItem) {
OWSFail(@"share should only be available when a viewItem is present");
return;
}
[self didPressShare:sender];
}
- (void)saveMediaAction:(nullable id)sender
{
if (!self.viewItem) {
OWSFail(@"save should only be available when a viewItem is present");
return;
}
[self.viewItem saveMediaAction];
}
- (void)deleteAction:(nullable id)sender
{
if (!self.viewItem) {
OWSFail(@"delete should only be available when a viewItem is present");
return;
}
[self didPressDelete:sender];
}
- (void)didPressPlayBarButton:(id)sender
{
OWSAssert(self.isVideo);
OWSAssert(self.videoPlayer);
[self playVideo];
}
- (void)didPressPauseBarButton:(id)sender
{
OWSAssert(self.isVideo);
OWSAssert(self.videoPlayer);
[self pauseVideo];
}
#pragma mark - Presentation
- (void)presentFromViewController:(UIViewController *)viewController replacingView:(UIView *)view;
{
self.replacingView = view;
UINavigationController *navController = [[UINavigationController alloc] initWithRootViewController:self];
// UIModalPresentationCustom retains the current view context behind our VC, allowing us to manually
// animate in our view, over the existing context, similar to a cross disolve, but allowing us to have
// more fine grained control
navController.modalPresentationStyle = UIModalPresentationCustom;
navController.navigationBar.barTintColor = UIColor.ows_materialBlueColor;
navController.navigationBar.translucent = NO;
navController.navigationBar.opaque = YES;
self.view.userInteractionEnabled = NO;
// We want to animate the tapped media from it's position in the previous VC
// to it's resting place in the center of this view controller.
//
// Rather than animating the actual media view in place, we animate the presentationView, which is a static
// image of the media content. Animating the actual media view is problematic for a couple reasons:
// 1. The media view ultimately lives in a zoomable scrollView. Getting both original positioning and the final positioning
// correct, involves manipulating the zoomScale and position simultaneously, which results in non-linear movement,
// especially noticeable on high resolution images.
// 2. For Video views, the AVPlayerLayer content does not scale with the presentation animation. So you instead get a full scale
// video, wherein only the cropping is animated.
// Using a simple image view allows us to address both these problems relatively easily.
self.view.alpha = 0.0;
self.mediaView.hidden = YES;
self.presentationView.hidden = NO;
self.presentationView.layer.cornerRadius = OWSMessageCellCornerRadius;
[viewController presentViewController:navController
animated:NO
completion:^{
// 1. Fade in the entire view.
[UIView animateWithDuration:0.1
animations:^{
self.replacingView.alpha = 0.0;
self.view.alpha = 1.0;
}];
[self.presentationView.superview layoutIfNeeded];
[self applyFinalMediaViewConstraints];
// 2. Animate imageView from it's initial position, which should match where it was
// in the presenting view to it's final position, front and center in this view. This
// animation duration intentionally overlaps the previous
[UIView animateWithDuration:0.2
delay:0.08
options:UIViewAnimationOptionCurveEaseOut
animations:^(void) {
self.presentationView.layer.cornerRadius = 0;
[self.presentationView.superview layoutIfNeeded];
// We must lay out once *before* we centerMediaViewConstraints
// because it uses the imageView.frame to build the constraints
// that will center the imageView, and then once again *after*
// to ensure that the centered constraints are applied.
[self centerMediaViewConstraints];
[self.mediaView.superview layoutIfNeeded];
self.view.backgroundColor = UIColor.whiteColor;
}
completion:^(BOOL finished) {
// HACK: Setting the frame to itself *seems* like it should be a no-op, but
// it ensures the content is drawn at the right frame. In particular I was reproducibly
// some images squished (they were EXIF rotated, maybe relateed).
// similar to this report: https://stackoverflow.com/questions/27961884/swift-uiimageview-stretched-aspect
self.mediaView.frame = self.mediaView.frame;
// At this point our presentation view should be overlayed perfectly
// with our media view. Swapping them out should be imperceptible.
self.mediaView.hidden = NO;
self.presentationView.hidden = YES;
self.view.userInteractionEnabled = YES;
if (self.isVideo) {
[self playVideo];
}
}];
}];
}
- (void)dismissSelfAnimated:(BOOL)isAnimated completion:(void (^_Nullable)(void))completion
{
self.view.userInteractionEnabled = NO;
[UIApplication sharedApplication].statusBarHidden = NO;
// Swapping mediaView for presentationView will be perceptible if we're not zoomed out all the way.
if (self.scrollView.zoomScale != self.scrollView.minimumZoomScale) {
[self.scrollView setZoomScale:self.scrollView.minimumZoomScale animated:YES];
}
self.mediaView.hidden = YES;
self.presentationView.hidden = NO;
// Move the presentationView back to it's initial position, i.e. where
// it sits on the screen in the conversation view.
[self applyInitialMediaViewConstraints];
if (isAnimated) {
[UIView animateWithDuration:0.18
delay:0.0
options:UIViewAnimationOptionCurveEaseOut
animations:^(void) {
[self.presentationView.superview layoutIfNeeded];
self.presentationView.layer.cornerRadius = OWSMessageCellCornerRadius;
// In case user has hidden bars, which changes background to black.
self.view.backgroundColor = UIColor.whiteColor;
}
completion:nil];
[UIView animateWithDuration:0.1
delay:0.15
options:UIViewAnimationOptionCurveEaseInOut
animations:^(void) {
OWSAssert(self.replacingView);
self.replacingView.alpha = 1.0;
// fade out content and toolbars
self.navigationController.view.alpha = 0.0;
}
completion:^(BOOL finished) {
[self.presentingViewController dismissViewControllerAnimated:NO completion:completion];
}];
} else {
self.replacingView.alpha = 1.0;
[self.presentingViewController dismissViewControllerAnimated:NO completion:completion];
}
}
#pragma mark - UIScrollViewDelegate
- (nullable UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView
{
return self.mediaView;
}
- (void)centerMediaViewConstraints
{
OWSAssert(self.scrollView);
CGSize scrollViewSize = self.scrollView.bounds.size;
CGSize imageViewSize = self.mediaView.frame.size;
CGFloat yOffset = MAX(0, (scrollViewSize.height - imageViewSize.height) / 2);
self.mediaViewTopConstraint.constant = yOffset;
self.mediaViewBottomConstraint.constant = yOffset;
CGFloat xOffset = MAX(0, (scrollViewSize.width - imageViewSize.width) / 2);
self.mediaViewLeadingConstraint.constant = xOffset;
self.mediaViewTrailingConstraint.constant = xOffset;
}
- (void)scrollViewDidZoom:(UIScrollView *)scrollView
{
[self centerMediaViewConstraints];
[self.view layoutIfNeeded];
}
#pragma mark - Video Playback
- (void)playVideo
{
if (@available(iOS 9, *)) {
OWSAssert(self.videoPlayer);
AVPlayer *player = self.videoPlayer;
[self updateFooterBarButtonItemsWithIsPlayingVideo:YES];
self.playVideoButton.hidden = YES;
self.areToolbarsHidden = YES;
OWSAssert(player.currentItem);
AVPlayerItem *item = player.currentItem;
if (CMTIME_COMPARE_INLINE(item.currentTime, ==, item.duration)) {
// Rewind for repeated plays
[player seekToTime:kCMTimeZero];
}
[player play];
} else {
[self legacyPlayVideo];
return;
}
}
- (void)pauseVideo
{
OWSAssert(self.isVideo);
OWSAssert(self.videoPlayer);
[self updateFooterBarButtonItemsWithIsPlayingVideo:NO];
[self.videoPlayer pause];
}
- (void)playerItemDidPlayToCompletion:(NSNotification *)notification
{
OWSAssert(self.isVideo);
OWSAssert(self.videoPlayer);
DDLogVerbose(@"%@ %s", self.logTag, __PRETTY_FUNCTION__);
self.areToolbarsHidden = NO;
self.playVideoButton.hidden = NO;
[self updateFooterBarButtonItemsWithIsPlayingVideo:NO];
}
- (void)playerProgressBarDidStartScrubbing:(PlayerProgressBar *)playerProgressBar
{
OWSAssert(self.videoPlayer);
[self.videoPlayer pause];
}
- (void)playerProgressBar:(PlayerProgressBar *)playerProgressBar scrubbedToTime:(CMTime)time
{
OWSAssert(self.videoPlayer);
[self.videoPlayer seekToTime:time];
}
- (void)playerProgressBar:(PlayerProgressBar *)playerProgressBar
didFinishScrubbingAtTime:(CMTime)time
shouldResumePlayback:(BOOL)shouldResumePlayback
{
OWSAssert(self.videoPlayer);
[self.videoPlayer seekToTime:time];
if (shouldResumePlayback) {
[self.videoPlayer play];
}
}
#pragma mark iOS8 Video Playback
// AVPlayer was introduced in iOS9, so on iOS8 we fall back to MPMoviePlayer
// This causes an unforutnate "double present" since we present the full screen view and then the MPMovie view over top.
// And similarly a double dismiss.
- (void)legacyPlayVideo
{
if (@available(iOS 9.0, *)) {
OWSFail(@"legacy video is for iOS8 only");
}
MPMoviePlayerViewController *vc = [[MPMoviePlayerViewController alloc] initWithContentURL:self.attachmentUrl];
[self presentViewController:vc animated:YES completion:nil];
}
#pragma mark - Saving images to Camera Roll
- (void)image:(UIImage *)image didFinishSavingWithError:(NSError *)error contextInfo:(void *)contextInfo {
if (error) {
DDLogWarn(@"There was a problem saving <%@> to camera roll from %s ",
error.localizedDescription,
__PRETTY_FUNCTION__);
}
}
@end
NS_ASSUME_NONNULL_END

View File

@ -12,7 +12,7 @@ enum MessageMetadataViewMode: UInt {
case focusOnMetadata
}
class MessageDetailViewController: OWSViewController, UIScrollViewDelegate {
class MessageDetailViewController: OWSViewController, UIScrollViewDelegate, MediaDetailPresenter {
static let TAG = "[MessageDetailViewController]"
let TAG = "[MessageDetailViewController]"
@ -405,7 +405,7 @@ class MessageDetailViewController: OWSViewController, UIScrollViewDelegate {
}
guard let attachment = TSAttachment.fetch(uniqueId: attachmentId, transaction: transaction) else {
owsFail("Missing attachment")
Logger.warn("\(TAG) Missing attachment. Was it deleted?")
return nil
}
@ -414,9 +414,9 @@ class MessageDetailViewController: OWSViewController, UIScrollViewDelegate {
private func addAttachmentRows() -> [UIView] {
var rows = [UIView]()
guard let attachment = self.attachment else {
owsFail("no attachment to add.")
Logger.warn("\(TAG) Missing attachment. Was it deleted?")
return rows
}
@ -442,7 +442,8 @@ class MessageDetailViewController: OWSViewController, UIScrollViewDelegate {
let contentType = attachment.contentType
if let dataUTI = MIMETypeUtil.utiType(forMIMEType: contentType) {
let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: dataUTI, imageQuality: .original)
let mediaMessageView = MediaMessageView(attachment: attachment, mode: .small)
let mediaMessageView = MediaMessageView(attachment: attachment, mode: .small, mediaDetailPresenter: self)
mediaMessageView.backgroundColor = UIColor.white
self.mediaMessageView = mediaMessageView
rows.append(mediaMessageView)
@ -751,4 +752,18 @@ class MessageDetailViewController: OWSViewController, UIScrollViewDelegate {
updateTextLayout()
}
// MARK: MediaDetailPresenter
public func presentDetails(mediaMessageView: MediaMessageView, fromView: UIView) {
let window = UIApplication.shared.keyWindow
let convertedRect = fromView.convert(fromView.bounds, to:window)
guard let attachmentStream = self.attachmentStream else {
owsFail("attachment stream unexpectedly nil")
return
}
let mediaDetailViewController = MediaDetailViewController(attachmentStream: attachmentStream, from: convertedRect, viewItem: self.viewItem)
mediaDetailViewController.present(from: self, replacing: fromView)
}
}

View File

@ -0,0 +1,200 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
import Foundation
@available(iOS 9.0, *)
@objc
public class VideoPlayerView: UIView {
var player: AVPlayer? {
get {
return playerLayer.player
}
set {
playerLayer.player = newValue
}
}
var playerLayer: AVPlayerLayer {
return layer as! AVPlayerLayer
}
// Override UIView property
override public static var layerClass: AnyClass {
return AVPlayerLayer.self
}
}
@available(iOS 9.0, *)
@objc
public protocol PlayerProgressBarDelegate {
func playerProgressBarDidStartScrubbing(_ playerProgressBar: PlayerProgressBar)
func playerProgressBar(_ playerProgressBar: PlayerProgressBar, scrubbedToTime time: CMTime)
func playerProgressBar(_ playerProgressBar: PlayerProgressBar, didFinishScrubbingAtTime time: CMTime, shouldResumePlayback: Bool)
}
@available(iOS 9.0, *)
@objc
public class PlayerProgressBar: UIView {
public let TAG = "[PlayerProgressBar]"
@objc
public weak var delegate: PlayerProgressBarDelegate?
private lazy var formatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .positional
formatter.allowedUnits = [.minute, .second ]
formatter.zeroFormattingBehavior = [ .pad ]
return formatter
}()
// MARK: Subviews
private let positionLabel = UILabel()
private let remainingLabel = UILabel()
private let slider = UISlider()
private let blurEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .light))
weak private var progressObserver: AnyObject?
private let kPreferredTimeScale: CMTimeScale = 100
public var player: AVPlayer? {
didSet {
guard let item = player?.currentItem else {
owsFail("No player item")
return
}
slider.minimumValue = 0
let duration: CMTime = item.asset.duration
slider.maximumValue = Float(CMTimeGetSeconds(duration))
// OPTIMIZE We need a high frequency observer for smooth slider updates,
// but could use a much less frequent observer for label updates
progressObserver = player?.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.01, preferredTimescale: kPreferredTimeScale), queue: nil, using: { [weak self] (_) in
self?.updateState()
}) as AnyObject
updateState()
}
}
required public init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override public init(frame: CGRect) {
super.init(frame: frame)
// Background
backgroundColor = UIColor.lightGray.withAlphaComponent(0.5)
if !UIAccessibilityIsReduceTransparencyEnabled() {
addSubview(blurEffectView)
blurEffectView.autoPinToSuperviewEdges()
}
// Configure controls
let kLabelFont = UIFont.monospacedDigitSystemFont(ofSize: 12, weight: UIFontWeightRegular)
positionLabel.font = kLabelFont
remainingLabel.font = kLabelFont
// We use a smaller thumb for the progress slider.
slider.setThumbImage(#imageLiteral(resourceName: "sliderProgressThumb"), for: .normal)
slider.maximumTrackTintColor = UIColor.ows_black
slider.minimumTrackTintColor = UIColor.ows_black
slider.addTarget(self, action: #selector(handleSliderTouchDown), for: .touchDown)
slider.addTarget(self, action: #selector(handleSliderTouchUp), for: .touchUpInside)
slider.addTarget(self, action: #selector(handleSliderTouchUp), for: .touchUpOutside)
slider.addTarget(self, action: #selector(handleSliderValueChanged), for: .valueChanged)
// Layout Subviews
addSubview(positionLabel)
addSubview(remainingLabel)
addSubview(slider)
positionLabel.autoPinEdge(toSuperviewMargin: .leading)
positionLabel.autoVCenterInSuperview()
let kSliderMargin: CGFloat = 8
slider.autoPinEdge(.leading, to: .trailing, of: positionLabel, withOffset: kSliderMargin)
slider.autoVCenterInSuperview()
remainingLabel.autoPinEdge(.leading, to: .trailing, of: slider, withOffset: kSliderMargin)
remainingLabel.autoPinEdge(toSuperviewMargin: .trailing)
remainingLabel.autoVCenterInSuperview()
}
// MARK: Gesture handling
var wasPlayingWhenScrubbingStarted: Bool = false
@objc
private func handleSliderTouchDown(_ slider: UISlider) {
guard let player = self.player else {
owsFail("player was nil")
return
}
self.wasPlayingWhenScrubbingStarted = (player.rate != 0) && (player.error == nil)
self.delegate?.playerProgressBarDidStartScrubbing(self)
}
@objc
private func handleSliderTouchUp(_ slider: UISlider) {
let sliderTime = time(slider: slider)
self.delegate?.playerProgressBar(self, didFinishScrubbingAtTime: sliderTime, shouldResumePlayback:wasPlayingWhenScrubbingStarted)
}
@objc
private func handleSliderValueChanged(_ slider: UISlider) {
let sliderTime = time(slider: slider)
self.delegate?.playerProgressBar(self, scrubbedToTime: sliderTime)
}
// MARK: Render cycle
private func updateState() {
guard let player = player else {
owsFail("\(TAG) player isn't set.")
return
}
guard let item = player.currentItem else {
owsFail("\(TAG) player has no item.")
return
}
let position = player.currentTime()
let positionSeconds: Float64 = CMTimeGetSeconds(position)
positionLabel.text = formatter.string(from: positionSeconds)
let duration: CMTime = item.asset.duration
let remainingTime = duration - position
let remainingSeconds = CMTimeGetSeconds(remainingTime)
guard let remainingString = formatter.string(from: remainingSeconds) else {
owsFail("unable to format time remaining")
remainingLabel.text = "0:00"
return
}
// show remaining time as negative
remainingLabel.text = "-\(remainingString)"
slider.setValue(Float(positionSeconds), animated: false)
}
// MARK: Util
private func time(slider: UISlider) -> CMTime {
let seconds: Double = Double(slider.value)
return CMTime(seconds: seconds, preferredTimescale: kPreferredTimeScale)
}
}

View File

@ -18,7 +18,6 @@ FOUNDATION_EXPORT const unsigned char SignalMessagingVersionString[];
#import <SignalMessaging/ContactsViewHelper.h>
#import <SignalMessaging/DebugLogger.h>
#import <SignalMessaging/Environment.h>
#import <SignalMessaging/FullImageViewController.h>
#import <SignalMessaging/NSString+OWS.h>
#import <SignalMessaging/OWSAudioAttachmentPlayer.h>
#import <SignalMessaging/OWSContactAvatarBuilder.h>

View File

@ -202,7 +202,8 @@ public class AttachmentApprovalViewController: OWSViewController, CaptioningTool
@objc
public func playButtonTapped() {
mediaMessageView.playVideo()
// FIXME - use built in AVPlayer controls like MediaDetailViewController
// mediaMessageView.playVideo()
}
func cancelPressed(sender: UIButton) {

View File

@ -1,578 +0,0 @@
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
//
#import "FullImageViewController.h"
#import "AttachmentSharing.h"
#import "ConversationViewItem.h"
#import "TSAttachmentStream.h"
#import "TSInteraction.h"
#import "UIColor+OWS.h"
#import "UIUtil.h"
#import "UIView+OWS.h"
#import <SignalMessaging/SignalMessaging-Swift.h>
#import <SignalServiceKit/AppContext.h>
#import <SignalServiceKit/NSData+Image.h>
#import <YYImage/YYImage.h>
NS_ASSUME_NONNULL_BEGIN
#define kMinZoomScale 1.0f
#define kMaxZoomScale 8.0f
#define kBackgroundAlpha 0.6f
// In order to use UIMenuController, the view from which it is
// presented must have certain custom behaviors.
@interface AttachmentMenuView : UIView
@end
#pragma mark -
@implementation AttachmentMenuView
- (BOOL)canBecomeFirstResponder
{
return YES;
}
// We only use custom actions in UIMenuController.
- (BOOL)canPerformAction:(SEL)action withSender:(nullable id)sender
{
return NO;
}
@end
#pragma mark -
@interface FullImageViewController () <UIScrollViewDelegate, UIGestureRecognizerDelegate>
@property (nonatomic) UIView *backgroundView;
@property (nonatomic) UIScrollView *scrollView;
@property (nonatomic) UIImageView *imageView;
@property (nonatomic) UIButton *shareButton;
@property (nonatomic) UIView *contentView;
@property (nonatomic) CGRect originRect;
@property (nonatomic) BOOL isPresenting;
@property (nonatomic) NSData *fileData;
@property (nonatomic, nullable) TSAttachmentStream *attachmentStream;
@property (nonatomic, nullable) SignalAttachment *attachment;
@property (nonatomic, nullable) ConversationViewItem *viewItem;
@property (nonatomic) UIToolbar *footerBar;
@end
@implementation FullImageViewController
- (instancetype)initWithAttachmentStream:(TSAttachmentStream *)attachmentStream
fromRect:(CGRect)rect
viewItem:(ConversationViewItem *_Nullable)viewItem
{
self = [super initWithNibName:nil bundle:nil];
if (self) {
self.attachmentStream = attachmentStream;
self.originRect = rect;
self.viewItem = viewItem;
}
return self;
}
- (instancetype)initWithAttachment:(SignalAttachment *)attachment fromRect:(CGRect)rect
{
self = [super initWithNibName:nil bundle:nil];
if (self) {
self.attachment = attachment;
self.originRect = rect;
}
return self;
}
- (NSURL *_Nullable)attachmentUrl
{
if (self.attachmentStream) {
return self.attachmentStream.mediaURL;
} else if (self.attachment) {
return self.attachment.dataUrl;
} else {
return nil;
}
}
- (NSData *)fileData
{
if (!_fileData) {
NSURL *_Nullable url = self.attachmentUrl;
if (url) {
_fileData = [NSData dataWithContentsOfURL:url];
}
}
return _fileData;
}
- (UIImage *)image
{
if (self.attachmentStream) {
return self.attachmentStream.image;
} else if (self.attachment) {
return self.attachment.image;
} else {
return nil;
}
}
- (BOOL)isAnimated
{
if (self.attachmentStream) {
return self.attachmentStream.isAnimated;
} else if (self.attachment) {
return self.attachment.isAnimatedImage;
} else {
return NO;
}
}
- (void)loadView
{
self.view = [AttachmentMenuView new];
self.view.backgroundColor = [UIColor colorWithWhite:0 alpha:kBackgroundAlpha];
self.view.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
}
- (void)viewDidLoad
{
[super viewDidLoad];
[self initializeBackground];
[self initializeContentViewAndFooterBar];
[self initializeScrollView];
[self initializeImageView];
[self initializeGestureRecognizers];
[self populateImageView:self.image];
}
- (void)viewWillDisappear:(BOOL)animated
{
[super viewWillDisappear:animated];
if ([UIMenuController sharedMenuController].isMenuVisible) {
[[UIMenuController sharedMenuController] setMenuVisible:NO animated:NO];
}
}
#pragma mark - Initializers
- (void)initializeBackground
{
self.imageView.backgroundColor = [UIColor colorWithWhite:0 alpha:kBackgroundAlpha];
self.backgroundView = [UIView new];
self.backgroundView.backgroundColor = [UIColor colorWithWhite:0 alpha:kBackgroundAlpha];
[self.view addSubview:self.backgroundView];
[self.backgroundView autoPinEdgesToSuperviewEdges];
}
- (void)initializeContentViewAndFooterBar
{
self.contentView = [UIView new];
[self.backgroundView addSubview:self.contentView];
[self.contentView autoPinWidthToSuperview];
[self.contentView autoPinToTopLayoutGuideOfViewController:self withInset:0];
self.footerBar = [UIToolbar new];
_footerBar.barTintColor = [UIColor ows_signalBrandBlueColor];
if (CurrentAppContext().isMainApp) {
[self.footerBar setItems:@[
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace
target:nil
action:nil],
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemAction
target:self
action:@selector(shareWasPressed:)],
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace
target:nil
action:nil],
]
animated:NO];
}
[self.backgroundView addSubview:self.footerBar];
[self.footerBar autoPinWidthToSuperview];
[self.footerBar autoPinToBottomLayoutGuideOfViewController:self withInset:0];
[self.footerBar autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:self.contentView];
}
- (void)shareWasPressed:(id)sender
{
DDLogInfo(@"%@: sharing image.", self.logTag);
OWSAssert(CurrentAppContext().isMainApp);
[AttachmentSharing showShareUIForURL:self.attachmentUrl];
}
- (void)initializeScrollView
{
self.scrollView = [[UIScrollView alloc] initWithFrame:self.view.bounds];
self.scrollView.delegate = self;
self.scrollView.zoomScale = 1.0f;
self.scrollView.maximumZoomScale = kMaxZoomScale;
self.scrollView.scrollEnabled = NO;
[self.contentView addSubview:self.scrollView];
}
- (void)initializeImageView
{
if (self.isAnimated) {
if ([self.fileData ows_isValidImage]) {
YYImage *animatedGif = [YYImage imageWithData:self.fileData];
YYAnimatedImageView *imageView = [[YYAnimatedImageView alloc] init];
imageView.image = animatedGif;
imageView.frame = self.originRect;
imageView.contentMode = UIViewContentModeScaleAspectFill;
imageView.clipsToBounds = YES;
self.imageView = imageView;
} else {
self.imageView = [[UIImageView alloc] initWithFrame:self.originRect];
}
} else {
// Present the static image using standard UIImageView
self.imageView = [[UIImageView alloc] initWithFrame:self.originRect];
self.imageView.contentMode = UIViewContentModeScaleAspectFill;
self.imageView.userInteractionEnabled = YES;
self.imageView.clipsToBounds = YES;
self.imageView.layer.allowsEdgeAntialiasing = YES;
// Use trilinear filters for better scaling quality at
// some performance cost.
self.imageView.layer.minificationFilter = kCAFilterTrilinear;
self.imageView.layer.magnificationFilter = kCAFilterTrilinear;
}
[self.scrollView addSubview:self.imageView];
}
- (void)populateImageView:(UIImage *)image
{
if (image && !self.isAnimated) {
self.imageView.image = image;
}
}
- (void)initializeGestureRecognizers
{
UITapGestureRecognizer *singleTap =
[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(imageDismissGesture:)];
singleTap.delegate = self;
[self.view addGestureRecognizer:singleTap];
UITapGestureRecognizer *doubleTap =
[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(imageDismissGesture:)];
doubleTap.numberOfTapsRequired = 2;
doubleTap.delegate = self;
[self.view addGestureRecognizer:doubleTap];
// UISwipeGestureRecognizer supposedly supports multiple directions,
// but in practice it works better if you use a separate GR for each
// direction.
for (NSNumber *direction in @[
@(UISwipeGestureRecognizerDirectionRight),
@(UISwipeGestureRecognizerDirectionLeft),
@(UISwipeGestureRecognizerDirectionUp),
@(UISwipeGestureRecognizerDirectionDown),
]) {
UISwipeGestureRecognizer *swipe =
[[UISwipeGestureRecognizer alloc] initWithTarget:self action:@selector(imageDismissGesture:)];
swipe.direction = (UISwipeGestureRecognizerDirection)direction.integerValue;
swipe.delegate = self;
[self.view addGestureRecognizer:swipe];
}
UILongPressGestureRecognizer *longPress =
[[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(longPressGesture:)];
longPress.delegate = self;
[self.view addGestureRecognizer:longPress];
}
#pragma mark - Gesture Recognizers
- (void)imageDismissGesture:(UIGestureRecognizer *)sender
{
if (sender.state == UIGestureRecognizerStateRecognized) {
[self dismiss];
}
}
- (void)longPressGesture:(UIGestureRecognizer *)sender
{
// We "eagerly" respond when the long press begins, not when it ends.
if (sender.state == UIGestureRecognizerStateBegan) {
if (!self.viewItem) {
return;
}
[self.view becomeFirstResponder];
if ([UIMenuController sharedMenuController].isMenuVisible) {
[[UIMenuController sharedMenuController] setMenuVisible:NO animated:NO];
}
NSArray *menuItems = self.viewItem.mediaMenuControllerItems;
[UIMenuController sharedMenuController].menuItems = menuItems;
CGPoint location = [sender locationInView:self.view];
CGRect targetRect = CGRectMake(location.x, location.y, 1, 1);
[[UIMenuController sharedMenuController] setTargetRect:targetRect inView:self.view];
[[UIMenuController sharedMenuController] setMenuVisible:YES animated:YES];
}
}
- (BOOL)canPerformAction:(SEL)action withSender:(nullable id)sender
{
if (action == self.viewItem.metadataActionSelector) {
return NO;
}
return [self.viewItem canPerformAction:action];
}
- (void)copyMediaAction:(nullable id)sender
{
[self.viewItem copyMediaAction];
}
- (void)shareMediaAction:(nullable id)sender
{
[self.viewItem shareMediaAction];
}
- (void)saveMediaAction:(nullable id)sender
{
[self.viewItem saveMediaAction];
}
- (void)deleteAction:(nullable id)sender
{
[self.viewItem deleteAction];
[self dismiss];
}
- (BOOL)canBecomeFirstResponder
{
return YES;
}
#pragma mark - Presentation
- (void)presentFromViewController:(UIViewController *)viewController
{
_isPresenting = YES;
self.view.userInteractionEnabled = NO;
[self.view addSubview:self.imageView];
self.modalPresentationStyle = UIModalPresentationOverCurrentContext;
self.view.alpha = 0;
[viewController
presentViewController:self
animated:NO
completion:^{
UIView *window = CurrentAppContext().rootReferenceView;
// During the presentation animation, we want to seamlessly animate the image
// from its location in the conversation view. To do so, we need a
// consistent coordinate system, so we pass the `originRect` in the
// coordinate system of the window.
self.imageView.frame = [self.view convertRect:self.originRect fromView:window];
[UIView animateWithDuration:0.25f
delay:0
options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseOut
animations:^() {
self.view.alpha = 1.0f;
// During the presentation animation, we want to seamlessly animate the image
// to its resting location in this view. We use `resizedFrameForImageView`
// to determine its size "at rest" in the content view, and then convert
// from the content view's coordinate system to the root view coordinate
// system because the image view is temporarily hosted by the root view during
// the presentation animation.
self.imageView.frame = [self resizedFrameForImageView:self.image.size];
self.imageView.center =
[self.contentView convertPoint:self.contentView.center fromView:self.contentView];
}
completion:^(BOOL completed) {
self.scrollView.frame = self.contentView.bounds;
[self.scrollView addSubview:self.imageView];
[self updateLayouts];
self.view.userInteractionEnabled = YES;
_isPresenting = NO;
}];
[UIUtil modalCompletionBlock]();
}];
}
- (void)dismiss
{
self.view.userInteractionEnabled = NO;
[UIView animateWithDuration:0.25f
delay:0
options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveLinear
animations:^() {
self.backgroundView.backgroundColor = [UIColor clearColor];
self.scrollView.alpha = 0;
self.view.alpha = 0;
}
completion:^(BOOL completed) {
[self.presentingViewController dismissViewControllerAnimated:NO completion:nil];
}];
}
#pragma mark - Update Layout
- (void)viewDidLayoutSubviews
{
[self updateLayouts];
}
- (void)updateLayouts
{
if (_isPresenting) {
return;
}
self.scrollView.frame = self.contentView.bounds;
self.imageView.frame = [self resizedFrameForImageView:self.image.size];
self.scrollView.contentSize = self.imageView.frame.size;
self.scrollView.contentInset = [self contentInsetForScrollView:self.scrollView.zoomScale];
}
#pragma mark - Resizing
- (CGRect)resizedFrameForImageView:(CGSize)imageSize
{
CGRect frame = self.contentView.bounds;
CGSize screenSize
= CGSizeMake(frame.size.width * self.scrollView.zoomScale, frame.size.height * self.scrollView.zoomScale);
CGSize targetSize = screenSize;
if ([self isImagePortrait]) {
if ([self getAspectRatioForCGSize:screenSize] < [self getAspectRatioForCGSize:imageSize]) {
targetSize.width = screenSize.height / [self getAspectRatioForCGSize:imageSize];
} else {
targetSize.height = screenSize.width * [self getAspectRatioForCGSize:imageSize];
}
} else {
if ([self getAspectRatioForCGSize:screenSize] > [self getAspectRatioForCGSize:imageSize]) {
targetSize.height = screenSize.width * [self getAspectRatioForCGSize:imageSize];
} else {
targetSize.width = screenSize.height / [self getAspectRatioForCGSize:imageSize];
}
}
frame.size = targetSize;
frame.origin = CGPointMake(0, 0);
return frame;
}
- (UIEdgeInsets)contentInsetForScrollView:(CGFloat)targetZoomScale
{
UIEdgeInsets inset = UIEdgeInsetsZero;
CGSize boundsSize = self.scrollView.bounds.size;
CGSize contentSize = self.image.size;
CGSize minSize;
if ([self isImagePortrait]) {
if ([self getAspectRatioForCGSize:boundsSize] < [self getAspectRatioForCGSize:contentSize]) {
minSize.height = boundsSize.height;
minSize.width = minSize.height / [self getAspectRatioForCGSize:contentSize];
} else {
minSize.width = boundsSize.width;
minSize.height = minSize.width * [self getAspectRatioForCGSize:contentSize];
}
} else {
if ([self getAspectRatioForCGSize:boundsSize] > [self getAspectRatioForCGSize:contentSize]) {
minSize.width = boundsSize.width;
minSize.height = minSize.width * [self getAspectRatioForCGSize:contentSize];
} else {
minSize.height = boundsSize.height;
minSize.width = minSize.height / [self getAspectRatioForCGSize:contentSize];
}
}
CGSize finalSize = self.view.bounds.size;
minSize.width *= targetZoomScale;
minSize.height *= targetZoomScale;
if (minSize.height > finalSize.height && minSize.width > finalSize.width) {
inset = UIEdgeInsetsZero;
} else {
CGFloat dy = boundsSize.height - minSize.height;
CGFloat dx = boundsSize.width - minSize.width;
dy = (dy > 0) ? dy : 0;
dx = (dx > 0) ? dx : 0;
inset.top = dy / 2.0f;
inset.bottom = dy / 2.0f;
inset.left = dx / 2.0f;
inset.right = dx / 2.0f;
}
return inset;
}
#pragma mark - UIScrollViewDelegate
- (nullable UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView
{
return self.imageView;
}
- (void)scrollViewDidZoom:(UIScrollView *)scrollView
{
scrollView.contentInset = [self contentInsetForScrollView:scrollView.zoomScale];
if (self.scrollView.scrollEnabled == NO) {
self.scrollView.scrollEnabled = YES;
}
}
- (void)scrollViewDidEndZooming:(UIScrollView *)scrollView withView:(nullable UIView *)view atScale:(CGFloat)scale
{
self.scrollView.scrollEnabled = (scale > 1);
self.scrollView.contentInset = [self contentInsetForScrollView:scale];
}
#pragma mark - Utility
- (BOOL)isImagePortrait
{
return ([self getAspectRatioForCGSize:self.image.size] > 1.0f);
}
- (CGFloat)getAspectRatioForCGSize:(CGSize)size
{
return size.height / size.width;
}
#pragma mark - Saving images to Camera Roll
- (void)image:(UIImage *)image didFinishSavingWithError:(NSError *)error contextInfo:(void *)contextInfo
{
if (error) {
DDLogWarn(@"There was a problem saving <%@> to camera roll from %s ",
error.localizedDescription,
__PRETTY_FUNCTION__);
}
}
@end
NS_ASSUME_NONNULL_END

View File

@ -1,5 +1,5 @@
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
import Foundation
@ -14,6 +14,11 @@ public enum MediaMessageViewMode: UInt {
case attachmentApproval
}
@objc
public protocol MediaDetailPresenter: class {
func presentDetails(mediaMessageView: MediaMessageView, fromView: UIView)
}
@objc
public class MediaMessageView: UIView, OWSAudioAttachmentPlayerDelegate {
@ -56,6 +61,8 @@ public class MediaMessageView: UIView, OWSAudioAttachmentPlayerDelegate {
@objc
public var contentView: UIView?
private let mediaDetailPresenter: MediaDetailPresenter?
// MARK: Initializers
@ -65,10 +72,15 @@ public class MediaMessageView: UIView, OWSAudioAttachmentPlayerDelegate {
}
@objc
public required init(attachment: SignalAttachment, mode: MediaMessageViewMode) {
public convenience init(attachment: SignalAttachment, mode: MediaMessageViewMode) {
self.init(attachment: attachment, mode: mode, mediaDetailPresenter: nil)
}
public required init(attachment: SignalAttachment, mode: MediaMessageViewMode, mediaDetailPresenter: MediaDetailPresenter?) {
assert(!attachment.hasError)
self.mode = mode
self.attachment = attachment
self.mode = mode
self.mediaDetailPresenter = mediaDetailPresenter
super.init(frame: CGRect.zero)
createViews()
@ -463,14 +475,8 @@ public class MediaMessageView: UIView, OWSAudioAttachmentPlayerDelegate {
guard let fromView = sender.view else {
return
}
guard let fromViewController = CurrentAppContext().frontmostViewController() else {
return
}
let window = CurrentAppContext().rootReferenceView
let convertedRect = fromView.convert(fromView.bounds, to:window)
let viewController = FullImageViewController(attachment:attachment, from:convertedRect)
viewController.present(from:fromViewController)
showMediaDetailViewController(fromView: fromView)
}
// MARK: - Video Playback
@ -484,60 +490,14 @@ public class MediaMessageView: UIView, OWSAudioAttachmentPlayerDelegate {
guard sender.state == .recognized else {
return
}
playVideo()
}
@objc
public func playVideo() {
guard let dataUrl = attachment.dataUrl else {
owsFail("\(self.logTag) attachment is missing dataUrl")
guard let fromView = sender.view else {
return
}
let filePath = dataUrl.path
guard FileManager.default.fileExists(atPath: filePath) else {
owsFail("\(self.logTag) file at \(filePath) doesn't exist")
return
}
guard let videoPlayer = MPMoviePlayerController(contentURL: dataUrl) else {
owsFail("\(self.logTag) unable to build moview player controller")
return
}
videoPlayer.prepareToPlay()
NotificationCenter.default.addObserver(forName: .MPMoviePlayerWillExitFullscreen, object: nil, queue: nil) { [weak self] _ in
self?.moviePlayerWillExitFullscreen()
}
NotificationCenter.default.addObserver(forName: .MPMoviePlayerDidExitFullscreen, object: nil, queue: nil) { [weak self] _ in
self?.moviePlayerDidExitFullscreen()
}
videoPlayer.controlStyle = .default
videoPlayer.shouldAutoplay = true
self.addSubview(videoPlayer.view)
videoPlayer.view.frame = self.bounds
self.videoPlayer = videoPlayer
videoPlayer.view.autoPinToSuperviewEdges()
OWSAudioAttachmentPlayer.setAudioIgnoresHardwareMuteSwitch(true)
videoPlayer.setFullscreen(true, animated:false)
showMediaDetailViewController(fromView: fromView)
}
private func moviePlayerWillExitFullscreen() {
clearVideoPlayer()
}
private func moviePlayerDidExitFullscreen() {
clearVideoPlayer()
}
private func clearVideoPlayer() {
videoPlayer?.stop()
videoPlayer?.view.removeFromSuperview()
videoPlayer = nil
OWSAudioAttachmentPlayer.setAudioIgnoresHardwareMuteSwitch(false)
func showMediaDetailViewController(fromView: UIView) {
self.mediaDetailPresenter?.presentDetails(mediaMessageView: self, fromView: fromView)
}
}

View File

@ -83,6 +83,9 @@ CGFloat ScaleFromIPhone5(CGFloat iPhone5Value);
- (NSLayoutConstraint *)autoPinLeadingToSuperviewWithMargin:(CGFloat)margin;
- (NSLayoutConstraint *)autoPinTrailingToSuperview;
- (NSLayoutConstraint *)autoPinTrailingToSuperviewWithMargin:(CGFloat)margin;
- (NSLayoutConstraint *)autoPinTopToSuperviewWithMargin:(CGFloat)margin;
- (NSLayoutConstraint *)autoPinBottomToSuperviewWithMargin:(CGFloat)margin;
- (NSLayoutConstraint *)autoPinLeadingToTrailingOfView:(UIView *)view;
- (NSLayoutConstraint *)autoPinLeadingToTrailingOfView:(UIView *)view margin:(CGFloat)margin;
- (NSLayoutConstraint *)autoPinTrailingToLeadingOfView:(UIView *)view;

View File

@ -299,6 +299,32 @@ CGFloat ScaleFromIPhone5(CGFloat iPhone5Value)
}
}
- (NSLayoutConstraint *)autoPinBottomToSuperviewWithMargin:(CGFloat)margin
{
if (@available(iOS 9.0, *)) {
NSLayoutConstraint *constraint =
[self.bottomAnchor constraintEqualToAnchor:self.superview.layoutMarginsGuide.bottomAnchor
constant:-margin];
constraint.active = YES;
return constraint;
} else {
return [self autoPinEdgeToSuperviewEdge:ALEdgeBottom withInset:margin];
}
}
- (NSLayoutConstraint *)autoPinTopToSuperviewWithMargin:(CGFloat)margin
{
if (@available(iOS 9.0, *)) {
NSLayoutConstraint *constraint =
[self.topAnchor constraintEqualToAnchor:self.superview.layoutMarginsGuide.topAnchor
constant:margin];
constraint.active = YES;
return constraint;
} else {
return [self autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:margin];
}
}
- (NSLayoutConstraint *)autoPinLeadingToTrailingOfView:(UIView *)view
{
OWSAssert(view);

View File

@ -1,5 +1,5 @@
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "OWSOrphanedDataCleaner.h"