Merge branch 'mkirk/input-accessory-view' into hotfix/2.19.4
This commit is contained in:
commit
2b81d45530
@ -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 = "<group>"; };
|
||||
34B3F8451E8DF1700035BE1A /* FingerprintViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FingerprintViewController.h; sourceTree = "<group>"; };
|
||||
34B3F8461E8DF1700035BE1A /* FingerprintViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FingerprintViewController.m; sourceTree = "<group>"; };
|
||||
34B3F8471E8DF1700035BE1A /* FullImageViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FullImageViewController.h; sourceTree = "<group>"; };
|
||||
34B3F8481E8DF1700035BE1A /* FullImageViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FullImageViewController.m; sourceTree = "<group>"; };
|
||||
34B3F8471E8DF1700035BE1A /* MediaDetailViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MediaDetailViewController.h; sourceTree = "<group>"; };
|
||||
34B3F8481E8DF1700035BE1A /* MediaDetailViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MediaDetailViewController.m; sourceTree = "<group>"; };
|
||||
34B3F8491E8DF1700035BE1A /* InboxTableViewCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = InboxTableViewCell.h; sourceTree = "<group>"; };
|
||||
34B3F84A1E8DF1700035BE1A /* InboxTableViewCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = InboxTableViewCell.m; sourceTree = "<group>"; };
|
||||
34B3F84C1E8DF1700035BE1A /* InviteFlow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InviteFlow.swift; sourceTree = "<group>"; };
|
||||
@ -633,6 +634,7 @@
|
||||
452D1EE71DCA90D100A57EC4 /* MesssagesBubblesSizeCalculatorTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = MesssagesBubblesSizeCalculatorTest.swift; path = Models/MesssagesBubblesSizeCalculatorTest.swift; sourceTree = "<group>"; };
|
||||
452EA09D1EA7ABE00078744B /* AttachmentPointerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentPointerView.swift; sourceTree = "<group>"; };
|
||||
452ECA4C1E087E7200E2F016 /* MessageFetcherJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = MessageFetcherJob.swift; path = Jobs/MessageFetcherJob.swift; sourceTree = "<group>"; };
|
||||
453034AA200289F50018945D /* VideoPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = "<group>"; };
|
||||
45360B8C1F9521F800FA666C /* Searcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Searcher.swift; sourceTree = "<group>"; };
|
||||
45360B8F1F9527DA00FA666C /* SearcherTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearcherTest.swift; sourceTree = "<group>"; };
|
||||
45387B021E36D650005D00B3 /* OWS102MoveLoggingPreferenceToUserDefaults.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWS102MoveLoggingPreferenceToUserDefaults.h; path = Migrations/OWS102MoveLoggingPreferenceToUserDefaults.h; sourceTree = "<group>"; };
|
||||
@ -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 */,
|
||||
|
||||
23
Signal/Images.xcassets/sliderProgressThumb.imageset/Contents.json
vendored
Normal file
23
Signal/Images.xcassets/sliderProgressThumb.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "VideoPlayer_Slider_Thumb_15x15_@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "VideoPlayer_Slider_Thumb_15x15_@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "VideoPlayer_Slider_Thumb_15x15_@3x.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
BIN
Signal/Images.xcassets/sliderProgressThumb.imageset/VideoPlayer_Slider_Thumb_15x15_@1x.png
vendored
Normal file
BIN
Signal/Images.xcassets/sliderProgressThumb.imageset/VideoPlayer_Slider_Thumb_15x15_@1x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
BIN
Signal/Images.xcassets/sliderProgressThumb.imageset/VideoPlayer_Slider_Thumb_15x15_@2x.png
vendored
Normal file
BIN
Signal/Images.xcassets/sliderProgressThumb.imageset/VideoPlayer_Slider_Thumb_15x15_@2x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
BIN
Signal/Images.xcassets/sliderProgressThumb.imageset/VideoPlayer_Slider_Thumb_15x15_@3x.png
vendored
Normal file
BIN
Signal/Images.xcassets/sliderProgressThumb.imageset/VideoPlayer_Slider_Thumb_15x15_@3x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
@ -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"
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
//
|
||||
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import <PureLayout/PureLayout.h>
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -26,6 +26,8 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
- (void)didApproveAttachment:(SignalAttachment *)attachment;
|
||||
|
||||
- (void)toolbarHeightDidChange:(CGFloat)newHeight;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@ -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 () <UIGestureRecognizerDelegate, ConversationTextViewToolbarDelegate>
|
||||
|
||||
@property (nonatomic, readonly) UIView *contentView;
|
||||
@ -30,6 +30,8 @@ static void *kConversationInputTextViewObservingContext = &kConversationInputTex
|
||||
|
||||
@property (nonatomic) NSArray<NSLayoutConstraint *> *contentContraints;
|
||||
@property (nonatomic) NSValue *lastTextContentSize;
|
||||
@property (nonatomic) CGFloat toolbarHeight;
|
||||
@property (nonatomic) CGFloat textViewHeight;
|
||||
|
||||
#pragma mark - Voice Memo Recording UI
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 <JSQMessagesViewController/JSQSystemSoundPlayer+JSQMessages.h>
|
||||
#import <JSQMessagesViewController/UIColor+JSQMessages.h>
|
||||
#import <JSQSystemSoundPlayer.h>
|
||||
#import <MediaPlayer/MediaPlayer.h>
|
||||
#import <MobileCoreServices/UTCoreTypes.h>
|
||||
#import <SignalServiceKit/ContactsUpdater.h>
|
||||
#import <SignalServiceKit/MimeTypeUtil.h>
|
||||
@ -170,7 +169,6 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
|
||||
@property (nonatomic) NSArray<ConversationViewItem *> *viewItems;
|
||||
@property (nonatomic) NSMutableDictionary<NSString *, ConversationViewItem *> *viewItemCache;
|
||||
|
||||
@property (nonatomic, nullable) MPMoviePlayerController *videoPlayer;
|
||||
@property (nonatomic, nullable) AVAudioRecorder *audioRecorder;
|
||||
@property (nonatomic, nullable) OWSAudioAttachmentPlayer *audioAttachmentPlayer;
|
||||
@property (nonatomic, nullable) NSUUID *voiceMessageUUID;
|
||||
@ -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
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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 <SignalServiceKit/NSData+Image.h>
|
||||
#import <YYImage/YYImage.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
#define kMinZoomScale 1.0f
|
||||
#define kMaxZoomScale 8.0f
|
||||
|
||||
#define kBackgroundAlpha 0.6f
|
||||
|
||||
// In order to use UIMenuController, the view from which it is
|
||||
// presented must have certain custom behaviors.
|
||||
@interface AttachmentMenuView : UIView
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@implementation AttachmentMenuView
|
||||
|
||||
- (BOOL)canBecomeFirstResponder {
|
||||
return YES;
|
||||
}
|
||||
|
||||
// We only use custom actions in UIMenuController.
|
||||
- (BOOL)canPerformAction:(SEL)action withSender:(nullable id)sender
|
||||
{
|
||||
return NO;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface FullImageViewController () <UIScrollViewDelegate, UIGestureRecognizerDelegate>
|
||||
|
||||
@property (nonatomic) UIView *backgroundView;
|
||||
@property (nonatomic) UIScrollView *scrollView;
|
||||
@property (nonatomic) UIImageView *imageView;
|
||||
@property (nonatomic) UIButton *shareButton;
|
||||
@property (nonatomic) UIView *contentView;
|
||||
|
||||
@property (nonatomic) CGRect originRect;
|
||||
@property (nonatomic) BOOL isPresenting;
|
||||
@property (nonatomic) NSData *fileData;
|
||||
|
||||
@property (nonatomic, nullable) TSAttachmentStream *attachmentStream;
|
||||
@property (nonatomic, nullable) SignalAttachment *attachment;
|
||||
@property (nonatomic, nullable) ConversationViewItem *viewItem;
|
||||
|
||||
@property (nonatomic) UIToolbar *footerBar;
|
||||
|
||||
@end
|
||||
|
||||
@implementation FullImageViewController
|
||||
|
||||
- (instancetype)initWithAttachmentStream:(TSAttachmentStream *)attachmentStream
|
||||
fromRect:(CGRect)rect
|
||||
viewItem:(ConversationViewItem *_Nullable)viewItem
|
||||
{
|
||||
|
||||
self = [super initWithNibName:nil bundle:nil];
|
||||
|
||||
if (self) {
|
||||
self.attachmentStream = attachmentStream;
|
||||
self.originRect = rect;
|
||||
self.viewItem = viewItem;
|
||||
}
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
- (instancetype)initWithAttachment:(SignalAttachment *)attachment fromRect:(CGRect)rect
|
||||
{
|
||||
|
||||
self = [super initWithNibName:nil bundle:nil];
|
||||
|
||||
if (self) {
|
||||
self.attachment = attachment;
|
||||
self.originRect = rect;
|
||||
}
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
- (NSURL *_Nullable)attachmentUrl
|
||||
{
|
||||
if (self.attachmentStream) {
|
||||
return self.attachmentStream.mediaURL;
|
||||
} else if (self.attachment) {
|
||||
return self.attachment.dataUrl;
|
||||
} else {
|
||||
return nil;
|
||||
}
|
||||
}
|
||||
|
||||
- (NSData *)fileData
|
||||
{
|
||||
if (!_fileData) {
|
||||
NSURL *_Nullable url = self.attachmentUrl;
|
||||
if (url) {
|
||||
_fileData = [NSData dataWithContentsOfURL:url];
|
||||
}
|
||||
}
|
||||
return _fileData;
|
||||
}
|
||||
|
||||
- (UIImage *)image {
|
||||
if (self.attachmentStream) {
|
||||
return self.attachmentStream.image;
|
||||
} else if (self.attachment) {
|
||||
return self.attachment.image;
|
||||
} else {
|
||||
return nil;
|
||||
}
|
||||
}
|
||||
|
||||
- (BOOL)isAnimated
|
||||
{
|
||||
if (self.attachmentStream) {
|
||||
return self.attachmentStream.isAnimated;
|
||||
} else if (self.attachment) {
|
||||
return self.attachment.isAnimatedImage;
|
||||
} else {
|
||||
return NO;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)loadView {
|
||||
self.view = [AttachmentMenuView new];
|
||||
self.view.backgroundColor = [UIColor colorWithWhite:0 alpha:kBackgroundAlpha];
|
||||
self.view.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
}
|
||||
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
|
||||
[self initializeBackground];
|
||||
[self initializeContentViewAndFooterBar];
|
||||
[self initializeScrollView];
|
||||
[self initializeImageView];
|
||||
[self initializeGestureRecognizers];
|
||||
|
||||
[self populateImageView:self.image];
|
||||
}
|
||||
|
||||
- (void)viewWillDisappear:(BOOL)animated {
|
||||
[super viewWillDisappear:animated];
|
||||
|
||||
if ([UIMenuController sharedMenuController].isMenuVisible) {
|
||||
[[UIMenuController sharedMenuController] setMenuVisible:NO
|
||||
animated:NO];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Initializers
|
||||
|
||||
- (void)initializeBackground {
|
||||
self.imageView.backgroundColor = [UIColor colorWithWhite:0 alpha:kBackgroundAlpha];
|
||||
|
||||
self.backgroundView = [UIView new];
|
||||
self.backgroundView.backgroundColor = [UIColor colorWithWhite:0 alpha:kBackgroundAlpha];
|
||||
[self.view addSubview:self.backgroundView];
|
||||
[self.backgroundView autoPinEdgesToSuperviewEdges];
|
||||
}
|
||||
|
||||
- (void)initializeContentViewAndFooterBar {
|
||||
self.contentView = [UIView new];
|
||||
[self.backgroundView addSubview:self.contentView];
|
||||
[self.contentView autoPinWidthToSuperview];
|
||||
[self.contentView autoPinToTopLayoutGuideOfViewController:self withInset:0];
|
||||
|
||||
self.footerBar = [UIToolbar new];
|
||||
_footerBar.barTintColor = [UIColor ows_signalBrandBlueColor];
|
||||
[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
|
||||
@ -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
|
||||
917
Signal/src/ViewControllers/MediaDetailViewController.m
Normal file
917
Signal/src/ViewControllers/MediaDetailViewController.m
Normal file
@ -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 <AVKit/AVKit.h>
|
||||
#import <MediaPlayer/MPMoviePlayerViewController.h>
|
||||
#import <MediaPlayer/MediaPlayer.h>
|
||||
#import <SignalServiceKit/NSData+Image.h>
|
||||
#import <YYImage/YYImage.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
// In order to use UIMenuController, the view from which it is
|
||||
// presented must have certain custom behaviors.
|
||||
@interface AttachmentMenuView : UIView
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@implementation AttachmentMenuView
|
||||
|
||||
- (BOOL)canBecomeFirstResponder {
|
||||
return YES;
|
||||
}
|
||||
|
||||
// We only use custom actions in UIMenuController.
|
||||
- (BOOL)canPerformAction:(SEL)action withSender:(nullable id)sender
|
||||
{
|
||||
return NO;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface MediaDetailViewController () <UIScrollViewDelegate, UIGestureRecognizerDelegate, PlayerProgressBarDelegate>
|
||||
|
||||
@property (nonatomic) UIScrollView *scrollView;
|
||||
@property (nonatomic) UIView *mediaView;
|
||||
|
||||
@property (nonatomic) UIButton *shareButton;
|
||||
|
||||
@property (nonatomic) CGRect originRect;
|
||||
@property (nonatomic) NSData *fileData;
|
||||
|
||||
@property (nonatomic, nullable) TSAttachmentStream *attachmentStream;
|
||||
@property (nonatomic, nullable) SignalAttachment *attachment;
|
||||
@property (nonatomic, nullable) ConversationViewItem *viewItem;
|
||||
|
||||
@property (nonatomic) UIToolbar *footerBar;
|
||||
@property (nonatomic) BOOL areToolbarsHidden;
|
||||
|
||||
@property (nonatomic, nullable) AVPlayer *videoPlayer;
|
||||
@property (nonatomic, nullable) UIButton *playVideoButton;
|
||||
@property (nonatomic, nullable) PlayerProgressBar *videoProgressBar;
|
||||
@property (nonatomic, nullable) UIBarButtonItem *videoPlayBarButton;
|
||||
@property (nonatomic, nullable) UIBarButtonItem *videoPauseBarButton;
|
||||
|
||||
@property (nonatomic, nullable) NSArray<NSLayoutConstraint *> *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<UIBarButtonItem *> *toolbarItems = [NSMutableArray new];
|
||||
|
||||
[toolbarItems addObjectsFromArray:@[
|
||||
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemAction
|
||||
target:self
|
||||
action:@selector(didPressShare:)],
|
||||
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil],
|
||||
]];
|
||||
|
||||
if (self.isVideo) {
|
||||
// bar button video controls only work on iOS9+
|
||||
if (@available(iOS 9.0, *)) {
|
||||
UIBarButtonItem *playerButton = isPlayingVideo ? self.videoPauseBarButton : self.videoPlayBarButton;
|
||||
[toolbarItems addObjectsFromArray:@[
|
||||
playerButton,
|
||||
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace
|
||||
target:nil
|
||||
action:nil],
|
||||
]];
|
||||
}
|
||||
}
|
||||
|
||||
[toolbarItems addObject:[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemTrash
|
||||
target:self
|
||||
action:@selector(didPressDelete:)]];
|
||||
|
||||
[self.footerBar setItems:toolbarItems animated:NO];
|
||||
}
|
||||
|
||||
- (void)applyInitialImageViewConstraints
|
||||
{
|
||||
if (self.imageViewConstraints.count > 0) {
|
||||
[NSLayoutConstraint deactivateConstraints:self.imageViewConstraints];
|
||||
}
|
||||
|
||||
CGRect convertedRect =
|
||||
[self.mediaView.superview convertRect:self.originRect fromView:[UIApplication sharedApplication].keyWindow];
|
||||
|
||||
NSMutableArray<NSLayoutConstraint *> *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<NSLayoutConstraint *> *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
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
200
Signal/src/views/VideoPlayerView.swift
Normal file
200
Signal/src/views/VideoPlayerView.swift
Normal file
@ -0,0 +1,200 @@
|
||||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@available(iOS 9.0, *)
|
||||
@objc
|
||||
public class VideoPlayerView: UIView {
|
||||
var player: AVPlayer? {
|
||||
get {
|
||||
return playerLayer.player
|
||||
}
|
||||
set {
|
||||
playerLayer.player = newValue
|
||||
}
|
||||
}
|
||||
|
||||
var playerLayer: AVPlayerLayer {
|
||||
return layer as! AVPlayerLayer
|
||||
}
|
||||
|
||||
// Override UIView property
|
||||
override public static var layerClass: AnyClass {
|
||||
return AVPlayerLayer.self
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 9.0, *)
|
||||
@objc
|
||||
public protocol PlayerProgressBarDelegate {
|
||||
func playerProgressBarDidStartScrubbing(_ playerProgressBar: PlayerProgressBar)
|
||||
func playerProgressBar(_ playerProgressBar: PlayerProgressBar, scrubbedToTime time: CMTime)
|
||||
func playerProgressBar(_ playerProgressBar: PlayerProgressBar, didFinishScrubbingAtTime time: CMTime, shouldResumePlayback: Bool)
|
||||
}
|
||||
|
||||
@available(iOS 9.0, *)
|
||||
@objc
|
||||
public class PlayerProgressBar: UIView {
|
||||
public let TAG = "[PlayerProgressBar]"
|
||||
|
||||
@objc
|
||||
public weak var delegate: PlayerProgressBarDelegate?
|
||||
|
||||
private lazy var formatter: DateComponentsFormatter = {
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.unitsStyle = .positional
|
||||
formatter.allowedUnits = [.minute, .second ]
|
||||
formatter.zeroFormattingBehavior = [ .pad ]
|
||||
|
||||
return formatter
|
||||
}()
|
||||
|
||||
// MARK: Subviews
|
||||
private let positionLabel = UILabel()
|
||||
private let remainingLabel = UILabel()
|
||||
private let slider = UISlider()
|
||||
private let blurEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .light))
|
||||
weak private var progressObserver: AnyObject?
|
||||
|
||||
private let kPreferredTimeScale: CMTimeScale = 100
|
||||
|
||||
public var player: AVPlayer? {
|
||||
didSet {
|
||||
guard let item = player?.currentItem else {
|
||||
owsFail("No player item")
|
||||
return
|
||||
}
|
||||
|
||||
slider.minimumValue = 0
|
||||
|
||||
let duration: CMTime = item.asset.duration
|
||||
slider.maximumValue = Float(CMTimeGetSeconds(duration))
|
||||
|
||||
// OPTIMIZE We need a high frequency observer for smooth slider updates,
|
||||
// but could use a much less frequent observer for label updates
|
||||
progressObserver = player?.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.01, preferredTimescale: kPreferredTimeScale), queue: nil, using: { [weak self] (_) in
|
||||
self?.updateState()
|
||||
}) as AnyObject
|
||||
updateState()
|
||||
}
|
||||
}
|
||||
|
||||
required public init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override public init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
// Background
|
||||
backgroundColor = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
if !UIAccessibilityIsReduceTransparencyEnabled() {
|
||||
addSubview(blurEffectView)
|
||||
blurEffectView.autoPinToSuperviewEdges()
|
||||
}
|
||||
|
||||
// Configure controls
|
||||
|
||||
let kLabelFont = UIFont.monospacedDigitSystemFont(ofSize: 12, weight: UIFontWeightRegular)
|
||||
positionLabel.font = kLabelFont
|
||||
remainingLabel.font = kLabelFont
|
||||
|
||||
// We use a smaller thumb for the progress slider.
|
||||
slider.setThumbImage(#imageLiteral(resourceName: "sliderProgressThumb"), for: .normal)
|
||||
slider.maximumTrackTintColor = UIColor.ows_black()
|
||||
slider.minimumTrackTintColor = UIColor.ows_black()
|
||||
|
||||
slider.addTarget(self, action: #selector(handleSliderTouchDown), for: .touchDown)
|
||||
slider.addTarget(self, action: #selector(handleSliderTouchUp), for: .touchUpInside)
|
||||
slider.addTarget(self, action: #selector(handleSliderTouchUp), for: .touchUpOutside)
|
||||
slider.addTarget(self, action: #selector(handleSliderValueChanged), for: .valueChanged)
|
||||
|
||||
// Layout Subviews
|
||||
|
||||
addSubview(positionLabel)
|
||||
addSubview(remainingLabel)
|
||||
addSubview(slider)
|
||||
|
||||
positionLabel.autoPinEdge(toSuperviewMargin: .leading)
|
||||
positionLabel.autoVCenterInSuperview()
|
||||
|
||||
let kSliderMargin: CGFloat = 8
|
||||
|
||||
slider.autoPinEdge(.leading, to: .trailing, of: positionLabel, withOffset: kSliderMargin)
|
||||
slider.autoVCenterInSuperview()
|
||||
|
||||
remainingLabel.autoPinEdge(.leading, to: .trailing, of: slider, withOffset: kSliderMargin)
|
||||
remainingLabel.autoPinEdge(toSuperviewMargin: .trailing)
|
||||
remainingLabel.autoVCenterInSuperview()
|
||||
}
|
||||
|
||||
// MARK: Gesture handling
|
||||
|
||||
var wasPlayingWhenScrubbingStarted: Bool = false
|
||||
|
||||
@objc
|
||||
private func handleSliderTouchDown(_ slider: UISlider) {
|
||||
guard let player = self.player else {
|
||||
owsFail("player was nil")
|
||||
return
|
||||
}
|
||||
|
||||
self.wasPlayingWhenScrubbingStarted = (player.rate != 0) && (player.error == nil)
|
||||
|
||||
self.delegate?.playerProgressBarDidStartScrubbing(self)
|
||||
}
|
||||
|
||||
@objc
|
||||
private func handleSliderTouchUp(_ slider: UISlider) {
|
||||
let sliderTime = time(slider: slider)
|
||||
self.delegate?.playerProgressBar(self, didFinishScrubbingAtTime: sliderTime, shouldResumePlayback:wasPlayingWhenScrubbingStarted)
|
||||
}
|
||||
|
||||
@objc
|
||||
private func handleSliderValueChanged(_ slider: UISlider) {
|
||||
let sliderTime = time(slider: slider)
|
||||
self.delegate?.playerProgressBar(self, scrubbedToTime: sliderTime)
|
||||
}
|
||||
|
||||
// MARK: Render cycle
|
||||
|
||||
private func updateState() {
|
||||
guard let player = player else {
|
||||
owsFail("\(TAG) player isn't set.")
|
||||
return
|
||||
}
|
||||
|
||||
guard let item = player.currentItem else {
|
||||
owsFail("\(TAG) player has no item.")
|
||||
return
|
||||
}
|
||||
|
||||
let position = player.currentTime()
|
||||
let positionSeconds: Float64 = CMTimeGetSeconds(position)
|
||||
positionLabel.text = formatter.string(from: positionSeconds)
|
||||
|
||||
let duration: CMTime = item.asset.duration
|
||||
let remainingTime = duration - position
|
||||
let remainingSeconds = CMTimeGetSeconds(remainingTime)
|
||||
|
||||
guard let remainingString = formatter.string(from: remainingSeconds) else {
|
||||
owsFail("unable to format time remaining")
|
||||
remainingLabel.text = "0:00"
|
||||
return
|
||||
}
|
||||
|
||||
// show remaining time as negative
|
||||
remainingLabel.text = "-\(remainingString)"
|
||||
|
||||
slider.setValue(Float(positionSeconds), animated: false)
|
||||
}
|
||||
|
||||
// MARK: Util
|
||||
|
||||
private func time(slider: UISlider) -> CMTime {
|
||||
let seconds: Double = Double(slider.value)
|
||||
return CMTime(seconds: seconds, preferredTimescale: kPreferredTimeScale)
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user