diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 7e86230613..ec40b76848 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -44,7 +44,7 @@ 34B3F8791E8DF1700035BE1A /* CountryCodeViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F8411E8DF1700035BE1A /* CountryCodeViewController.m */; }; 34B3F87B1E8DF1700035BE1A /* ExperienceUpgradesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F8441E8DF1700035BE1A /* ExperienceUpgradesPageViewController.swift */; }; 34B3F87C1E8DF1700035BE1A /* FingerprintViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F8461E8DF1700035BE1A /* FingerprintViewController.m */; }; - 34B3F87D1E8DF1700035BE1A /* FullImageViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F8481E8DF1700035BE1A /* FullImageViewController.m */; }; + 34B3F87D1E8DF1700035BE1A /* MediaDetailViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F8481E8DF1700035BE1A /* MediaDetailViewController.m */; }; 34B3F87E1E8DF1700035BE1A /* InboxTableViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F84A1E8DF1700035BE1A /* InboxTableViewCell.m */; }; 34B3F8801E8DF1700035BE1A /* InviteFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F84C1E8DF1700035BE1A /* InviteFlow.swift */; }; 34B3F8811E8DF1700035BE1A /* LockInteractionController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F84E1E8DF1700035BE1A /* LockInteractionController.m */; }; @@ -160,6 +160,7 @@ 452EA09E1EA7ABE00078744B /* AttachmentPointerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452EA09D1EA7ABE00078744B /* AttachmentPointerView.swift */; }; 452ECA4D1E087E7200E2F016 /* MessageFetcherJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452ECA4C1E087E7200E2F016 /* MessageFetcherJob.swift */; }; 452ECA4E1E087E7200E2F016 /* MessageFetcherJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452ECA4C1E087E7200E2F016 /* MessageFetcherJob.swift */; }; + 453034AB200289F50018945D /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 453034AA200289F50018945D /* VideoPlayerView.swift */; }; 45360B8D1F9521F800FA666C /* Searcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45360B8C1F9521F800FA666C /* Searcher.swift */; }; 45360B8E1F9521F800FA666C /* Searcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45360B8C1F9521F800FA666C /* Searcher.swift */; }; 45360B901F9527DA00FA666C /* SearcherTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45360B8F1F9527DA00FA666C /* SearcherTest.swift */; }; @@ -466,8 +467,8 @@ 34B3F8441E8DF1700035BE1A /* ExperienceUpgradesPageViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExperienceUpgradesPageViewController.swift; sourceTree = ""; }; 34B3F8451E8DF1700035BE1A /* FingerprintViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FingerprintViewController.h; sourceTree = ""; }; 34B3F8461E8DF1700035BE1A /* FingerprintViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FingerprintViewController.m; sourceTree = ""; }; - 34B3F8471E8DF1700035BE1A /* FullImageViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FullImageViewController.h; sourceTree = ""; }; - 34B3F8481E8DF1700035BE1A /* FullImageViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FullImageViewController.m; sourceTree = ""; }; + 34B3F8471E8DF1700035BE1A /* MediaDetailViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MediaDetailViewController.h; sourceTree = ""; }; + 34B3F8481E8DF1700035BE1A /* MediaDetailViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MediaDetailViewController.m; sourceTree = ""; }; 34B3F8491E8DF1700035BE1A /* InboxTableViewCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = InboxTableViewCell.h; sourceTree = ""; }; 34B3F84A1E8DF1700035BE1A /* InboxTableViewCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = InboxTableViewCell.m; sourceTree = ""; }; 34B3F84C1E8DF1700035BE1A /* InviteFlow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InviteFlow.swift; sourceTree = ""; }; @@ -633,6 +634,7 @@ 452D1EE71DCA90D100A57EC4 /* MesssagesBubblesSizeCalculatorTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = MesssagesBubblesSizeCalculatorTest.swift; path = Models/MesssagesBubblesSizeCalculatorTest.swift; sourceTree = ""; }; 452EA09D1EA7ABE00078744B /* AttachmentPointerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentPointerView.swift; sourceTree = ""; }; 452ECA4C1E087E7200E2F016 /* MessageFetcherJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = MessageFetcherJob.swift; path = Jobs/MessageFetcherJob.swift; sourceTree = ""; }; + 453034AA200289F50018945D /* VideoPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = ""; }; 45360B8C1F9521F800FA666C /* Searcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Searcher.swift; sourceTree = ""; }; 45360B8F1F9527DA00FA666C /* SearcherTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearcherTest.swift; sourceTree = ""; }; 45387B021E36D650005D00B3 /* OWS102MoveLoggingPreferenceToUserDefaults.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWS102MoveLoggingPreferenceToUserDefaults.h; path = Migrations/OWS102MoveLoggingPreferenceToUserDefaults.h; sourceTree = ""; }; @@ -1027,8 +1029,8 @@ 34B3F8461E8DF1700035BE1A /* FingerprintViewController.m */, 34E8BF361EE9E2FD00F5F4CA /* FingerprintViewScanController.h */, 34E8BF371EE9E2FD00F5F4CA /* FingerprintViewScanController.m */, - 34B3F8471E8DF1700035BE1A /* FullImageViewController.h */, - 34B3F8481E8DF1700035BE1A /* FullImageViewController.m */, + 34B3F8471E8DF1700035BE1A /* MediaDetailViewController.h */, + 34B3F8481E8DF1700035BE1A /* MediaDetailViewController.m */, 34BECE2C1F7ABCE000D7438D /* GifPicker */, 34B3F86F1E8DF1700035BE1A /* HomeViewController.h */, 34B3F8701E8DF1700035BE1A /* HomeViewController.m */, @@ -1507,6 +1509,7 @@ 76EB052B18170B33006006FC /* Views */ = { isa = PBXGroup; children = ( + 453034AA200289F50018945D /* VideoPlayerView.swift */, 45E5A6981F61E6DD001E4A8A /* MarqueeLabel.swift */, 452EA09D1EA7ABE00078744B /* AttachmentPointerView.swift */, 34E3E5671EC4B19400495BAC /* AudioProgressView.swift */, @@ -2327,6 +2330,7 @@ 45C0DC1B1E68FE9000E04C47 /* UIApplication+OWS.swift in Sources */, 45638BDF1F3DDB2200128435 /* MessageSender+Promise.swift in Sources */, 34535D821E256BE9008A4747 /* UIView+OWS.m in Sources */, + 453034AB200289F50018945D /* VideoPlayerView.swift in Sources */, 45F3AEB61DFDE7900080CE33 /* AvatarImageView.swift in Sources */, 7038632718F70C0700D4A43F /* CryptoTools.m in Sources */, 45FBC5C81DF8575700E9B410 /* CallKitCallManager.swift in Sources */, @@ -2385,7 +2389,7 @@ 45E5A6991F61E6DE001E4A8A /* MarqueeLabel.swift in Sources */, 34D1F0B01F867BFC0066283D /* OWSSystemMessageCell.m in Sources */, 45A663C51F92EC760027B59E /* GroupTableViewCell.swift in Sources */, - 34B3F87D1E8DF1700035BE1A /* FullImageViewController.m in Sources */, + 34B3F87D1E8DF1700035BE1A /* MediaDetailViewController.m in Sources */, 45666F7B1D9C0533008FE134 /* OWSDatabaseMigration.m in Sources */, 34D1F0861F8678AA0066283D /* ConversationViewController.m in Sources */, B90418E6183E9DD40038554A /* DateUtil.m in Sources */, diff --git a/Signal/Images.xcassets/sliderProgressThumb.imageset/Contents.json b/Signal/Images.xcassets/sliderProgressThumb.imageset/Contents.json new file mode 100644 index 0000000000..6c8bfcfad4 --- /dev/null +++ b/Signal/Images.xcassets/sliderProgressThumb.imageset/Contents.json @@ -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" + } +} \ No newline at end of file diff --git a/Signal/Images.xcassets/sliderProgressThumb.imageset/VideoPlayer_Slider_Thumb_15x15_@1x.png b/Signal/Images.xcassets/sliderProgressThumb.imageset/VideoPlayer_Slider_Thumb_15x15_@1x.png new file mode 100644 index 0000000000..194cce88a4 Binary files /dev/null and b/Signal/Images.xcassets/sliderProgressThumb.imageset/VideoPlayer_Slider_Thumb_15x15_@1x.png differ diff --git a/Signal/Images.xcassets/sliderProgressThumb.imageset/VideoPlayer_Slider_Thumb_15x15_@2x.png b/Signal/Images.xcassets/sliderProgressThumb.imageset/VideoPlayer_Slider_Thumb_15x15_@2x.png new file mode 100644 index 0000000000..06b9509e51 Binary files /dev/null and b/Signal/Images.xcassets/sliderProgressThumb.imageset/VideoPlayer_Slider_Thumb_15x15_@2x.png differ diff --git a/Signal/Images.xcassets/sliderProgressThumb.imageset/VideoPlayer_Slider_Thumb_15x15_@3x.png b/Signal/Images.xcassets/sliderProgressThumb.imageset/VideoPlayer_Slider_Thumb_15x15_@3x.png new file mode 100644 index 0000000000..f6cfe9c7a2 Binary files /dev/null and b/Signal/Images.xcassets/sliderProgressThumb.imageset/VideoPlayer_Slider_Thumb_15x15_@3x.png differ diff --git a/Signal/src/Signal-Bridging-Header.h b/Signal/src/Signal-Bridging-Header.h index 9b955a4b6e..e6808cc49c 100644 --- a/Signal/src/Signal-Bridging-Header.h +++ b/Signal/src/Signal-Bridging-Header.h @@ -1,5 +1,5 @@ // -// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. // #import "AppSettingsViewController.h" @@ -10,7 +10,7 @@ #import "DebugUIPage.h" #import "Environment.h" #import "FingerprintViewController.h" -#import "FullImageViewController.h" +#import "MediaDetailViewController.h" #import "HomeViewController.h" #import "NSString+OWS.h" #import "NotificationsManager.h" diff --git a/Signal/src/UIView+OWS.h b/Signal/src/UIView+OWS.h index d8287e78b8..1c1e89aea3 100644 --- a/Signal/src/UIView+OWS.h +++ b/Signal/src/UIView+OWS.h @@ -1,5 +1,5 @@ // -// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. // #import @@ -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; diff --git a/Signal/src/UIView+OWS.m b/Signal/src/UIView+OWS.m index cc09436498..0b5724d7e4 100644 --- a/Signal/src/UIView+OWS.m +++ b/Signal/src/UIView+OWS.m @@ -1,5 +1,5 @@ // -// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. // #import "UIView+OWS.h" @@ -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); diff --git a/Signal/src/ViewControllers/ConversationView/Cells/ConversationViewCell.h b/Signal/src/ViewControllers/ConversationView/Cells/ConversationViewCell.h index 22b18ae080..db315d5f7d 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/ConversationViewCell.h +++ b/Signal/src/ViewControllers/ConversationView/Cells/ConversationViewCell.h @@ -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 diff --git a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageCell.m b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageCell.m index da153cbc48..2aedc8d451 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageCell.m +++ b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageCell.m @@ -1,5 +1,5 @@ // -// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. // #import "OWSMessageCell.h" @@ -1172,7 +1172,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: [AttachmentSharing showShareUIForAttachment:self.attachmentStream]; diff --git a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.h b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.h index c1a0798c77..802d31d02a 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.h +++ b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.h @@ -26,6 +26,8 @@ NS_ASSUME_NONNULL_BEGIN - (void)didApproveAttachment:(SignalAttachment *)attachment; +- (void)toolbarHeightDidChange:(CGFloat)newHeight; + @end #pragma mark - diff --git a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m index 0753c686d4..c5ddb7be1a 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m @@ -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" @@ -15,7 +15,7 @@ NS_ASSUME_NONNULL_BEGIN static void *kConversationInputTextViewObservingContext = &kConversationInputTextViewObservingContext; - +static const CGFloat ConversationInputToolbarBorderViewHeight = 0.5; @interface ConversationInputToolbar () @property (nonatomic, readonly) UIView *contentView; @@ -30,6 +30,8 @@ static void *kConversationInputTextViewObservingContext = &kConversationInputTex @property (nonatomic) NSArray *contentContraints; @property (nonatomic) NSValue *lastTextContentSize; +@property (nonatomic) CGFloat toolbarHeight; +@property (nonatomic) CGFloat textViewHeight; #pragma mark - Voice Memo Recording UI @@ -68,18 +70,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]; @@ -223,12 +232,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 +260,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 +329,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 +338,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 +722,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,8 +825,6 @@ static void *kConversationInputTextViewObservingContext = &kConversationInputTex - (void)viewWillDisappear:(BOOL)animated { [self.attachmentView viewWillDisappear:animated]; - - [self endEditingTextMessage]; } - (nullable NSString *)textInputPrimaryLanguage diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index 99606fc8eb..a7d1a071f4 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -1,5 +1,5 @@ // -// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. // #import "ConversationViewController.h" @@ -19,7 +19,7 @@ #import "DebugUITableViewController.h" #import "Environment.h" #import "FingerprintViewController.h" -#import "FullImageViewController.h" +#import "MediaDetailViewController.h" #import "NSAttributedString+OWS.h" #import "NSString+OWS.h" #import "NewGroupViewController.h" @@ -64,7 +64,6 @@ #import #import #import -#import #import #import #import @@ -170,7 +169,6 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { @property (nonatomic) NSArray *viewItems; @property (nonatomic) NSMutableDictionary *viewItemCache; -@property (nonatomic, nullable) MPMoviePlayerController *videoPlayer; @property (nonatomic, nullable) AVAudioRecorder *audioRecorder; @property (nonatomic, nullable) OWSAudioAttachmentPlayer *audioAttachmentPlayer; @property (nonatomic, nullable) NSUUID *voiceMessageUUID; @@ -215,17 +213,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 @@ -319,6 +318,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 @@ -452,13 +455,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; } @@ -501,6 +504,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]; @@ -517,10 +521,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", @@ -534,6 +535,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] @@ -883,6 +894,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { }]; [actionSheetController addAction:dismissAction]; + [self dismissKeyBoard]; [self presentViewController:actionSheetController animated:YES completion:nil]; } } @@ -999,6 +1011,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { _callOnOpen = NO; } + self.isViewCompletelyAppeared = YES; self.viewHasEverAppeared = YES; } @@ -1012,6 +1025,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { [super viewWillDisappear:animated]; + self.isViewCompletelyAppeared = NO; [self.inputToolbar viewWillDisappear:animated]; } @@ -1029,7 +1043,6 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { [self markVisibleMessagesAsRead]; [self cancelVoiceMemo]; [self.cellMediaCache removeAllObjects]; - [self.inputToolbar endEditingTextMessage]; self.isUserScrolling = NO; } @@ -1388,11 +1401,8 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { 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 @@ -1635,6 +1645,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { [actionSheetController addAction:resendMessageAction]; + [self dismissKeyBoard]; [self presentViewController:actionSheetController animated:YES completion:nil]; } @@ -1669,6 +1680,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { [actionSheetController addAction:resendMessageAction]; + [self dismissKeyBoard]; [self presentViewController:actionSheetController animated:YES completion:nil]; } @@ -1796,6 +1808,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { }]; [alertController addAction:resetSessionAction]; + [self dismissKeyBoard]; [self presentViewController:alertController animated:YES completion:nil]; } @@ -1836,6 +1849,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { }]; [actionSheetController addAction:acceptSafetyNumberAction]; + [self dismissKeyBoard]; [self presentViewController:actionSheetController animated:YES completion:nil]; } @@ -1865,9 +1879,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 @@ -1916,6 +1929,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { }]; [actionSheetController addAction:blockAction]; + [self dismissKeyBoard]; [self presentViewController:actionSheetController animated:YES completion:nil]; } @@ -1979,46 +1993,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]; } -- (void)didTapVideoViewItem:(ConversationViewItem *)viewItem attachmentStream:(TSAttachmentStream *)attachmentStream +- (void)didTapVideoViewItem:(ConversationViewItem *)viewItem + attachmentStream:(TSAttachmentStream *)attachmentStream + imageView:(UIImageView *)imageView { OWSAssert([NSThread isMainThread]); 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]; } - (void)didTapAudioViewItem:(ConversationViewItem *)viewItem attachmentStream:(TSAttachmentStream *)attachmentStream @@ -2099,42 +2099,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 @@ -2235,7 +2199,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 @@ -2351,6 +2317,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { [[UIDocumentMenuViewController alloc] initWithDocumentTypes:documentTypes inMode:pickerMode]; menuController.delegate = self; + [self dismissKeyBoard]; [self presentViewController:menuController animated:YES completion:nil]; } @@ -2362,6 +2329,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]; } @@ -2401,6 +2370,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]; } @@ -2497,8 +2468,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]]; }); }]; @@ -2518,6 +2490,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]]; } @@ -3081,7 +3054,8 @@ 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); + CGFloat contentOffsetYBottom + = MAX(0.f, contentHeight + self.collectionView.contentInset.bottom - self.collectionView.bounds.size.height); BOOL isScrolledToBottom = (self.collectionView.contentOffset.y > contentOffsetYBottom - kIsAtBottomTolerancePts); return isScrolledToBottom; @@ -3274,6 +3248,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { - (void)attachmentButtonPressed { + [self dismissKeyBoard]; __weak ConversationViewController *weakSelf = self; if ([self isBlockedContactConversation]) { @@ -3349,6 +3324,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { [gifAction setValue:gifImage forKey:@"image"]; [actionSheetController addAction:gifAction]; + [self dismissKeyBoard]; [self presentViewController:actionSheetController animated:true completion:nil]; } @@ -3658,6 +3634,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. + 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); @@ -3690,13 +3755,6 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { return [self.collectionView.collectionViewLayout collectionViewContentSize].height; } -- (void)scrollToBottomImmediately -{ - OWSAssert([NSThread isMainThread]); - - [self scrollToBottomAnimated:NO]; -} - - (void)scrollToBottomAnimated:(BOOL)animated { OWSAssert([NSThread isMainThread]); @@ -3706,9 +3764,11 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { } 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]; } @@ -3718,22 +3778,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 @@ -4034,8 +4078,6 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { - (void)collectionViewWillChangeLayout { OWSAssert([NSThread isMainThread]); - - self.wasScrolledToBottomBeforeLayoutChange = [self isScrolledToBottom]; } - (void)collectionViewDidChangeLayout @@ -4043,15 +4085,6 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { OWSAssert([NSThread isMainThread]); [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 diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewLayout.m b/Signal/src/ViewControllers/ConversationView/ConversationViewLayout.m index 76398a7476..f0dbdfb273 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewLayout.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewLayout.m @@ -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)); diff --git a/Signal/src/ViewControllers/FullImageViewController.m b/Signal/src/ViewControllers/FullImageViewController.m deleted file mode 100644 index 49a4c6bcb4..0000000000 --- a/Signal/src/ViewControllers/FullImageViewController.m +++ /dev/null @@ -1,553 +0,0 @@ -// -// Copyright (c) 2017 Open Whisper Systems. All rights reserved. -// - -#import "FullImageViewController.h" -#import "AttachmentSharing.h" -#import "ConversationViewItem.h" -#import "Signal-Swift.h" -#import "TSAttachmentStream.h" -#import "TSInteraction.h" -#import "UIColor+OWS.h" -#import "UIUtil.h" -#import "UIView+OWS.h" -#import -#import - -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 () - -@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]; - [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); - - [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.menuControllerItems; - [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)copyAction:(nullable id)sender -{ - [self.viewItem copyAction]; -} - -- (void)shareAction:(nullable id)sender -{ - [self.viewItem shareAction]; -} - -- (void)saveAction:(nullable id)sender -{ - [self.viewItem saveAction]; -} - -- (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:^{ - UIWindow *window = [UIApplication sharedApplication].keyWindow; - // 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 diff --git a/Signal/src/ViewControllers/FullImageViewController.h b/Signal/src/ViewControllers/MediaDetailViewController.h similarity index 83% rename from Signal/src/ViewControllers/FullImageViewController.h rename to Signal/src/ViewControllers/MediaDetailViewController.h index 6ade09366f..e93fa7e96f 100644 --- a/Signal/src/ViewControllers/FullImageViewController.h +++ b/Signal/src/ViewControllers/MediaDetailViewController.h @@ -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 diff --git a/Signal/src/ViewControllers/MediaDetailViewController.m b/Signal/src/ViewControllers/MediaDetailViewController.m new file mode 100644 index 0000000000..4b7b094a6e --- /dev/null +++ b/Signal/src/ViewControllers/MediaDetailViewController.m @@ -0,0 +1,917 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +#import "MediaDetailViewController.h" +#import "AttachmentSharing.h" +#import "ConversationViewController.h" +#import "ConversationViewItem.h" +#import "Signal-Swift.h" +#import "TSAttachmentStream.h" +#import "TSInteraction.h" +#import "UIColor+OWS.h" +#import "UIUtil.h" +#import "UIView+OWS.h" +#import +#import +#import +#import +#import + +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 () + +@property (nonatomic) UIScrollView *scrollView; +@property (nonatomic) UIView *mediaView; + +@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 *imageViewConstraints; +@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 centerImageViewConstraints]; +} + +- (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.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; + + [self applyInitialImageViewConstraints]; + + 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 *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)applyInitialImageViewConstraints +{ + if (self.imageViewConstraints.count > 0) { + [NSLayoutConstraint deactivateConstraints:self.imageViewConstraints]; + } + + CGRect convertedRect = + [self.mediaView.superview convertRect:self.originRect fromView:[UIApplication sharedApplication].keyWindow]; + + NSMutableArray *imageViewConstraints = [NSMutableArray new]; + self.imageViewConstraints = imageViewConstraints; + + [imageViewConstraints addObjectsFromArray:[self.mediaView autoSetDimensionsToSize:convertedRect.size]]; + [imageViewConstraints addObjectsFromArray:@[ + [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:convertedRect.origin.y], + [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeLeft withInset:convertedRect.origin.x] + ]]; +} + +- (void)applyFinalImageViewConstraints +{ + if (self.imageViewConstraints.count > 0) { + [NSLayoutConstraint deactivateConstraints:self.imageViewConstraints]; + } + + NSMutableArray *imageViewConstraints = [NSMutableArray new]; + self.imageViewConstraints = imageViewConstraints; + + self.mediaViewLeadingConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeLeading]; + self.mediaViewTopConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeTop]; + self.mediaViewTrailingConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeTrailing]; + self.mediaViewBottomConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeBottom]; + + [imageViewConstraints addObjectsFromArray:@[ + self.mediaViewTopConstraint, + self.mediaViewTrailingConstraint, + self.mediaViewBottomConstraint, + self.mediaViewLeadingConstraint + ]]; +} + +- (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; + + [UIView animateWithDuration:0.1 + animations:^(void) { + self.view.backgroundColor = areToolbarsHidden ? UIColor.blackColor : UIColor.whiteColor; + 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.menuControllerItems; + [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 shareAction]; +} + +- (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; + } + + if (action == self.viewItem.metadataActionSelector) { + return NO; + } + return [self.viewItem canPerformAction:action]; +} + +- (void)copyAction:(nullable id)sender +{ + if (!self.viewItem) { + OWSFail(@"copy should only be available when a viewItem is present"); + return; + } + + [self.viewItem copyAction]; +} + +- (void)shareAction:(nullable id)sender +{ + if (!self.viewItem) { + OWSFail(@"share should only be available when a viewItem is present"); + return; + } + + [self didPressShare:sender]; +} + +- (void)saveAction:(nullable id)sender +{ + if (!self.viewItem) { + OWSFail(@"save should only be available when a viewItem is present"); + return; + } + + [self.viewItem saveAction]; +} + +- (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 +{ + 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; + + self.view.alpha = 0.0; + [viewController presentViewController:navController + animated:NO + completion:^{ + + // 1. Fade in the entire view. + [UIView animateWithDuration:0.1 + animations:^{ + self.view.alpha = 1.0; + }]; + + // Make sure imageView is layed out before we update it's frame in the next + // animation. + [self.mediaView.superview layoutIfNeeded]; + + // 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 intentionally overlaps the previous + [UIView animateWithDuration:0.2 + delay:0.08 + options:UIViewAnimationOptionCurveEaseOut + animations:^(void) { + [self applyFinalImageViewConstraints]; + [self.mediaView.superview layoutIfNeeded]; + // We must lay out *before* we centerImageViewConstraints + // because it uses the imageView.frame to build the contstraints + // that will center the imageView, and then once again + // to ensure that the centered constraints are applied. + [self centerImageViewConstraints]; + [self.mediaView.superview layoutIfNeeded]; + self.view.backgroundColor = UIColor.whiteColor; + } + completion:^(BOOL finished) { + 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; + + OWSAssert(self.mediaView.superview); + + [self.mediaView.superview layoutIfNeeded]; + + // Move the image view pack to it's initial position, i.e. where + // it sits on the screen in the conversation view. + [self applyInitialImageViewConstraints]; + + if (isAnimated) { + [UIView animateWithDuration:0.2 + delay:0.0 + options:UIViewAnimationOptionCurveEaseInOut + animations:^(void) { + [self.mediaView.superview layoutIfNeeded]; + + // In case user has hidden bars, which changes background to black. + self.view.backgroundColor = UIColor.whiteColor; + + // fade out content and toolbars + self.navigationController.view.alpha = 0.0; + } + completion:^(BOOL finished) { + [self.presentingViewController dismissViewControllerAnimated:NO completion:completion]; + }]; + } else { + [self.presentingViewController dismissViewControllerAnimated:NO completion:completion]; + } +} + +#pragma mark - UIScrollViewDelegate + +- (nullable UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView +{ + return self.mediaView; +} + +- (void)centerImageViewConstraints +{ + 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 centerImageViewConstraints]; + [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 diff --git a/Signal/src/ViewControllers/MediaMessageView.swift b/Signal/src/ViewControllers/MediaMessageView.swift index 5f4fdd20b5..c909f0f70c 100644 --- a/Signal/src/ViewControllers/MediaMessageView.swift +++ b/Signal/src/ViewControllers/MediaMessageView.swift @@ -1,5 +1,5 @@ // -// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. // import Foundation @@ -19,6 +19,11 @@ class MediaMessageView: UIView, OWSAudioAttachmentPlayerDelegate { let mode: MediaMessageViewMode + // If this is for a persisted interaction, viewItem and attachmentStream will not be nil + // If this is for an attachmet draft, before sending, viewItem and attachmentStream will be nil + var viewItem: ConversationViewItem? + var attachmentStream: TSAttachmentStream? + let attachment: SignalAttachment var videoPlayer: MPMoviePlayerController? @@ -45,10 +50,16 @@ class MediaMessageView: UIView, OWSAudioAttachmentPlayerDelegate { fatalError("\(#function) is unimplemented.") } - required init(attachment: SignalAttachment, mode: MediaMessageViewMode) { + convenience init(attachment: SignalAttachment, mode: MediaMessageViewMode) { + self.init(attachment: attachment, mode: mode, viewItem: nil, attachmentStream: nil) + } + + required init(attachment: SignalAttachment, mode: MediaMessageViewMode, viewItem: ConversationViewItem?, attachmentStream: TSAttachmentStream?) { assert(!attachment.hasError) - self.mode = mode self.attachment = attachment + self.mode = mode + self.viewItem = viewItem + self.attachmentStream = attachmentStream super.init(frame: CGRect.zero) createViews() @@ -401,13 +412,7 @@ class MediaMessageView: UIView, OWSAudioAttachmentPlayerDelegate { guard let fromView = sender.view else { return } - guard let fromViewController = fromViewController() else { - return - } - let window = UIApplication.shared.keyWindow - let convertedRect = fromView.convert(fromView.bounds, to:window) - let viewController = FullImageViewController(attachment:attachment, from:convertedRect) - viewController.present(from:fromViewController) + showMediaDetailViewController(fromView: fromView) } private func fromViewController() -> UIViewController? { @@ -429,44 +434,28 @@ class MediaMessageView: UIView, OWSAudioAttachmentPlayerDelegate { guard sender.state == .recognized else { return } - guard let dataUrl = attachment.dataUrl else { + guard let fromView = sender.view else { return } - guard let videoPlayer = MPMoviePlayerController(contentURL: dataUrl) else { + showMediaDetailViewController(fromView: fromView) + } + + func showMediaDetailViewController(fromView: UIView) { + guard let fromViewController = fromViewController() else { return } - videoPlayer.prepareToPlay() + let window = UIApplication.shared.keyWindow + let convertedRect = fromView.convert(fromView.bounds, to:window) - 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() - } + let viewController: MediaDetailViewController = { + if let viewItem = self.viewItem, let attachmentStream = self.attachmentStream { + return MediaDetailViewController(attachmentStream: attachmentStream, from: convertedRect, viewItem: viewItem) + } else { + // e.g. when MediaMessageView does not belong to a persisted interaction, e.g. approval view. + return MediaDetailViewController(attachment: attachment, from:convertedRect) + } + }() - videoPlayer.controlStyle = .default - videoPlayer.shouldAutoplay = true - - self.addSubview(videoPlayer.view) - videoPlayer.view.frame = self.bounds - self.videoPlayer = videoPlayer - videoPlayer.view.autoPinToSuperviewEdges() - ViewControllerUtils.setAudioIgnoresHardwareMuteSwitch(true) - videoPlayer.setFullscreen(true, animated:false) - } - - private func moviePlayerWillExitFullscreen() { - clearVideoPlayer() - } - - private func moviePlayerDidExitFullscreen() { - clearVideoPlayer() - } - - private func clearVideoPlayer() { - videoPlayer?.stop() - videoPlayer?.view.removeFromSuperview() - videoPlayer = nil - ViewControllerUtils.setAudioIgnoresHardwareMuteSwitch(false) + viewController.present(from:fromViewController) } } diff --git a/Signal/src/ViewControllers/MessageDetailViewController.swift b/Signal/src/ViewControllers/MessageDetailViewController.swift index d9e0ec6866..b6eb060379 100644 --- a/Signal/src/ViewControllers/MessageDetailViewController.swift +++ b/Signal/src/ViewControllers/MessageDetailViewController.swift @@ -1,5 +1,5 @@ // -// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. // import Foundation @@ -406,7 +406,7 @@ class MessageDetailViewController: OWSViewController, UIScrollViewDelegate { } guard let attachment = TSAttachment.fetch(uniqueId: attachmentId) else { - owsFail("Missing attachment") + Logger.warn("\(TAG) Missing attachment. Was it deleted?") return rows } self.attachment = attachment @@ -433,7 +433,7 @@ class MessageDetailViewController: OWSViewController, UIScrollViewDelegate { let contentType = attachment.contentType if let dataUTI = MIMETypeUtil.utiType(forMIMEType: contentType) { let attachment = SignalAttachment(dataSource: dataSource, dataUTI: dataUTI) - let mediaMessageView = MediaMessageView(attachment: attachment, mode: .small) + let mediaMessageView = MediaMessageView(attachment: attachment, mode: .small, viewItem: viewItem, attachmentStream: attachmentStream) mediaMessageView.backgroundColor = UIColor.white self.mediaMessageView = mediaMessageView rows.append(mediaMessageView) diff --git a/Signal/src/views/VideoPlayerView.swift b/Signal/src/views/VideoPlayerView.swift new file mode 100644 index 0000000000..107f746a00 --- /dev/null +++ b/Signal/src/views/VideoPlayerView.swift @@ -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) + } +} diff --git a/SignalServiceKit/src/Storage/OWSOrphanedDataCleaner.m b/SignalServiceKit/src/Storage/OWSOrphanedDataCleaner.m index 23997a5b85..4fa9b90d93 100644 --- a/SignalServiceKit/src/Storage/OWSOrphanedDataCleaner.m +++ b/SignalServiceKit/src/Storage/OWSOrphanedDataCleaner.m @@ -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"