diff --git a/Signal/src/Signal-Bridging-Header.h b/Signal/src/Signal-Bridging-Header.h index 9dcf0dadb..55fadea30 100644 --- a/Signal/src/Signal-Bridging-Header.h +++ b/Signal/src/Signal-Bridging-Header.h @@ -18,7 +18,6 @@ #import "OWSWebRTCDataProtos.pb.h" #import "PhoneManager.h" #import "PropertyListPreferences.h" -#import "PureLayout.h" #import "PushManager.h" #import "RPAccountManager.h" #import "TSSocketManager.h" @@ -27,6 +26,7 @@ #import "UIUtil.h" #import "UIView+OWS.h" #import +#import #import #import #import diff --git a/Signal/src/UIView+OWS.h b/Signal/src/UIView+OWS.h index e31139d83..f885d1c44 100644 --- a/Signal/src/UIView+OWS.h +++ b/Signal/src/UIView+OWS.h @@ -2,6 +2,7 @@ // Copyright (c) 2017 Open Whisper Systems. All rights reserved. // +#import #import // A convenience method for doing responsive layout. Scales between two diff --git a/Signal/src/UIView+OWS.m b/Signal/src/UIView+OWS.m index 05f5e8526..87cfe1458 100644 --- a/Signal/src/UIView+OWS.m +++ b/Signal/src/UIView+OWS.m @@ -2,7 +2,6 @@ // Copyright (c) 2017 Open Whisper Systems. All rights reserved. // -#import "PureLayout.h" #import "UIView+OWS.h" // TODO: We'll eventually want to promote these into an OWSMath.h header. diff --git a/Signal/src/view controllers/FullImageViewController.h b/Signal/src/view controllers/FullImageViewController.h index ca8a181b8..7d7acba32 100644 --- a/Signal/src/view controllers/FullImageViewController.h +++ b/Signal/src/view controllers/FullImageViewController.h @@ -1,20 +1,18 @@ // -// FullImageViewController.h -// Signal -// -// Created by Dylan Bourgeois on 11/11/14. -// Copyright (c) 2014 Open Whisper Systems. All rights reserved. +// Copyright (c) 2017 Open Whisper Systems. All rights reserved. // #import #import "TSAttachmentStream.h" #import "TSInteraction.h" +#import "OWSMessageData.h" @interface FullImageViewController : UIViewController - (instancetype)initWithAttachment:(TSAttachmentStream *)attachment fromRect:(CGRect)rect forInteraction:(TSInteraction *)interaction + messageItem:(id)messageItem isAnimated:(BOOL)animated; - (void)presentFromViewController:(UIViewController *)viewController; diff --git a/Signal/src/view controllers/FullImageViewController.m b/Signal/src/view controllers/FullImageViewController.m index 76e3842d7..89b849053 100644 --- a/Signal/src/view controllers/FullImageViewController.m +++ b/Signal/src/view controllers/FullImageViewController.m @@ -1,44 +1,60 @@ // -// FullImageViewController.m -// Signal -// -// Created by Dylan Bourgeois on 11/11/14. -// Animated GIF support added by Mike Okner (@mikeokner) on 11/27/15. -// Copyright (c) 2014 Open Whisper Systems. All rights reserved. +// Copyright (c) 2017 Open Whisper Systems. All rights reserved. // #import "FLAnimatedImage.h" #import "FullImageViewController.h" #import "UIUtil.h" - -#define kImageViewCornerRadius 5.0f +#import "UIView+OWS.h" +#import "TSPhotoAdapter.h" +#import "TSMessageAdapter.h" +#import "TSAnimatedAdapter.h" #define kMinZoomScale 1.0f #define kMaxZoomScale 8.0f -#define kTargetDoubleTapZoom 3.0f #define kBackgroundAlpha 0.6f -@interface FullImageViewController () +// In order to use UIMenuController, the view from which it is +// presented must have certain custom behaviors. +@interface AttachmentMenuView : UIView -@property (nonatomic, strong) UIView *backgroundView; +@end -@property (nonatomic, strong) UIScrollView *scrollView; +#pragma mark - -@property (nonatomic, strong) UIImageView *imageView; +@implementation AttachmentMenuView -@property (nonatomic, strong) UITapGestureRecognizer *singleTap; -@property (nonatomic, strong) UITapGestureRecognizer *doubleTap; +- (BOOL)canBecomeFirstResponder { + return YES; +} -@property (nonatomic, strong) UIButton *shareButton; +// We only use custom actions in UIMenuController. +- (BOOL)canPerformAction:(SEL)action + withSender:(id)sender +{ + return NO; +} + +@end -@property CGRect originRect; -@property BOOL isPresenting; -@property BOOL isAnimated; -@property NSData *fileData; +#pragma mark - -@property TSAttachmentStream *attachment; -@property TSInteraction *interaction; +@interface FullImageViewController () + +@property (nonatomic) UIView *backgroundView; +@property (nonatomic) UIScrollView *scrollView; +@property (nonatomic) UIImageView *imageView; +@property (nonatomic) UIButton *shareButton; + +@property (nonatomic) CGRect originRect; +@property (nonatomic) BOOL isPresenting; +@property (nonatomic) BOOL isAnimated; +@property (nonatomic) NSData *fileData; + +@property (nonatomic) TSAttachmentStream *attachment; +@property (nonatomic) TSInteraction *interaction; +@property (nonatomic) id messageItem; @end @@ -48,6 +64,7 @@ - (instancetype)initWithAttachment:(TSAttachmentStream *)attachment fromRect:(CGRect)rect forInteraction:(TSInteraction *)interaction + messageItem:(id)messageItem isAnimated:(BOOL)animated { self = [super initWithNibName:nil bundle:nil]; @@ -55,6 +72,7 @@ self.attachment = attachment; self.originRect = rect; self.interaction = interaction; + self.messageItem = messageItem; self.isAnimated = animated; self.fileData = [NSData dataWithContentsOfURL:[attachment mediaURL]]; } @@ -66,6 +84,12 @@ return self.attachment.image; } +- (void)loadView { + self.view = [AttachmentMenuView new]; + self.view.backgroundColor = [UIColor colorWithWhite:0 alpha:kBackgroundAlpha]; + self.view.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; +} + - (void)viewDidLoad { [super viewDidLoad]; @@ -77,16 +101,24 @@ [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.view.backgroundColor = [UIColor colorWithWhite:0 alpha:kBackgroundAlpha]; - self.view.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; - self.backgroundView = [[UIView alloc] initWithFrame:CGRectInset(self.view.bounds, -512, -512)]; + + self.backgroundView = [UIView new]; self.backgroundView.backgroundColor = [UIColor colorWithWhite:0 alpha:kBackgroundAlpha]; - [self.view addSubview:self.backgroundView]; + [self.backgroundView autoPinEdgesToSuperviewEdges]; } - (void)initializeScrollView { @@ -111,7 +143,6 @@ } else { // Present the static image using standard UIImageView self.imageView = [[UIImageView alloc] initWithFrame:self.originRect]; - self.imageView.layer.cornerRadius = kImageViewCornerRadius; self.imageView.contentMode = UIViewContentModeScaleAspectFill; self.imageView.userInteractionEnabled = YES; self.imageView.clipsToBounds = YES; @@ -128,56 +159,106 @@ } - (void)initializeGestureRecognizers { - self.doubleTap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(imageDoubleTapped:)]; - self.doubleTap.numberOfTapsRequired = 2; - - self.singleTap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(imageSingleTapped:)]; - [self.singleTap requireGestureRecognizerToFail:self.doubleTap]; - - self.singleTap.delegate = self; - self.doubleTap.delegate = self; + 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]; + } - [self.view addGestureRecognizer:self.singleTap]; - [self.view addGestureRecognizer:self.doubleTap]; + UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self + action:@selector(longPressGesture:)]; + longPress.delegate = self; + [self.view addGestureRecognizer:longPress]; } #pragma mark - Gesture Recognizers -- (void)imageDoubleTapped:(UITapGestureRecognizer *)doubleTap { - CGPoint tap = [doubleTap locationInView:doubleTap.view]; - CGPoint convertCoord = [self.scrollView convertPoint:tap fromView:doubleTap.view]; - CGRect targetZoomRect; - UIEdgeInsets targetInsets; +- (void)imageDismissGesture:(UIGestureRecognizer *)sender { + if (sender.state == UIGestureRecognizerStateRecognized) { + [self dismiss]; + } +} - CGSize zoom; +- (void)longPressGesture:(UIGestureRecognizer *)sender { + // We "eagerly" respond when the long press begins, not when it ends. + if (sender.state == UIGestureRecognizerStateBegan) { - if (self.scrollView.zoomScale == 1.0f) { - zoom = CGSizeMake(self.view.bounds.size.width / kTargetDoubleTapZoom, - self.view.bounds.size.height / kTargetDoubleTapZoom); - targetZoomRect = CGRectMake( - convertCoord.x - (zoom.width / 2.0f), convertCoord.y - (zoom.height / 2.0f), zoom.width, zoom.height); - targetInsets = [self contentInsetForScrollView:kTargetDoubleTapZoom]; - } else { - zoom = CGSizeMake(self.view.bounds.size.width * self.scrollView.zoomScale, - self.view.bounds.size.height * self.scrollView.zoomScale); - targetZoomRect = CGRectMake( - convertCoord.x - (zoom.width / 2.0f), convertCoord.y - (zoom.height / 2.0f), zoom.width, zoom.height); - targetInsets = [self contentInsetForScrollView:1.0f]; + [self.view becomeFirstResponder]; + + if ([UIMenuController sharedMenuController].isMenuVisible) { + [[UIMenuController sharedMenuController] setMenuVisible:NO + animated:NO]; + } + + NSArray *menuItems = @[ + [[UIMenuItem alloc] initWithTitle:NSLocalizedString(@"ATTACHMENT_VIEW_COPY_ACTION", @"Short name for edit menu item to copy contents of media message.") + action:@selector(copyAttachment:)], + [[UIMenuItem alloc] initWithTitle:NSLocalizedString(@"ATTACHMENT_VIEW_SAVE_ACTION", @"Short name for edit menu item to save contents of media message.") + action:@selector(saveAttachment:)], + // TODO: We should implement sharing. + // [[UIMenuItem alloc] initWithTitle:NSLocalizedString(@"ATTACHMENT_VIEW_SHARE_ACTION", @"Short name for edit menu item to share contents of media message.") + // action:@selector(shareAttachment:)], + ]; + [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]; } +} - self.view.userInteractionEnabled = NO; +- (void)performEditingActionWithSelector:(SEL)selector { + OWSAssert(self.messageItem.messageType == TSIncomingMessageAdapter || + self.messageItem.messageType == TSOutgoingMessageAdapter); + OWSAssert([self.messageItem isMediaMessage]); + OWSAssert([self.messageItem isKindOfClass:[TSMessageAdapter class]]); + OWSAssert([self.messageItem conformsToProtocol:@protocol(OWSMessageEditing)]); + OWSAssert([[self.messageItem media] isKindOfClass:[TSPhotoAdapter class]] || + [[self.messageItem media] isKindOfClass:[TSAnimatedAdapter class]]); + + id messageEditing = (id) self.messageItem.media; + OWSAssert([messageEditing canPerformEditingAction:selector]); + [messageEditing performEditingAction:selector]; +} - [CATransaction begin]; - [CATransaction setCompletionBlock:^{ - self.scrollView.contentInset = targetInsets; - self.view.userInteractionEnabled = YES; - }]; - [self.scrollView zoomToRect:targetZoomRect animated:YES]; - [CATransaction commit]; +- (void)copyAttachment:(id)sender { + [self performEditingActionWithSelector:NSSelectorFromString(@"copy:")]; } -- (void)imageSingleTapped:(UITapGestureRecognizer *)singleTap { - [self dismiss]; +- (void)saveAttachment:(id)sender { + [self performEditingActionWithSelector:NSSelectorFromString(@"save:")]; +} + +- (void)shareAttachment:(id)sender { + // TODO: We should implement sharing with UIActivityViewController. + // + // It seems that loading of the contents of the attachment is done + // with TSAttachment and TSAttachmentStream. } #pragma mark - Presentation @@ -193,14 +274,19 @@ presentViewController:self animated:NO completion:^{ - [UIView animateWithDuration:0.4f + UIWindow *window = [UIApplication sharedApplication].keyWindow; + self.imageView.frame = [self.view convertRect:self.originRect + fromView:window]; + + [UIView animateWithDuration:0.25f delay:0 - options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseInOut + options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseOut animations:^() { self.view.alpha = 1.0f; self.imageView.frame = [self resizedFrameForImageView:self.image.size]; self.imageView.center = - CGPointMake(self.view.bounds.size.width / 2.0f, self.view.bounds.size.height / 2.0f); + CGPointMake(self.view.bounds.size.width / 2.0f, + self.view.bounds.size.height / 2.0f); } completion:^(BOOL completed) { self.scrollView.frame = self.view.bounds; @@ -215,9 +301,9 @@ - (void)dismiss { self.view.userInteractionEnabled = NO; - [UIView animateWithDuration:0.4f + [UIView animateWithDuration:0.25f delay:0 - options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseInOut + options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveLinear animations:^() { self.backgroundView.backgroundColor = [UIColor clearColor]; self.scrollView.alpha = 0; @@ -234,7 +320,6 @@ [self updateLayouts]; } - - (void)updateLayouts { if (_isPresenting) { return; @@ -246,7 +331,6 @@ self.scrollView.contentInset = [self contentInsetForScrollView:self.scrollView.zoomScale]; } - #pragma mark - Resizing - (CGRect)resizedFrameForImageView:(CGSize)imageSize { diff --git a/Signal/src/view controllers/MessagesViewController.m b/Signal/src/view controllers/MessagesViewController.m index 56eb94112..a18fade99 100644 --- a/Signal/src/view controllers/MessagesViewController.m +++ b/Signal/src/view controllers/MessagesViewController.m @@ -252,8 +252,6 @@ typedef enum : NSUInteger { [JSQMessagesCollectionViewCell registerMenuAction:@selector(delete:)]; SEL saveSelector = NSSelectorFromString(@"save:"); [JSQMessagesCollectionViewCell registerMenuAction:saveSelector]; - [UIMenuController sharedMenuController].menuItems = @[ [[UIMenuItem alloc] initWithTitle:NSLocalizedString(@"EDIT_ITEM_SAVE_ACTION", @"Short name for edit menu item to save contents of media message.") - action:saveSelector] ]; [self initializeCollectionViewLayout]; [self registerCustomMessageNibs]; @@ -391,6 +389,13 @@ typedef enum : NSUInteger { atScrollPosition:UICollectionViewScrollPositionBottom animated:NO]; } + + // Other views might change these custom menu items, so we + // need to set them every time we enter this view. + SEL saveSelector = NSSelectorFromString(@"save:"); + [UIMenuController sharedMenuController].menuItems = @[ [[UIMenuItem alloc] initWithTitle:NSLocalizedString(@"EDIT_ITEM_SAVE_ACTION", + @"Short name for edit menu item to save contents of media message.") + action:saveSelector]]; } - (void)startReadTimer { @@ -1160,9 +1165,12 @@ typedef enum : NSUInteger { if(tappedImage == nil) { DDLogWarn(@"tapped TSPhotoAdapter with nil image"); } else { - CGRect convertedRect = - [self.collectionView convertRect:[collectionView cellForItemAtIndexPath:indexPath].frame - toView:nil]; + UIWindow *window = [UIApplication sharedApplication].keyWindow; + JSQMessagesCollectionViewCell *cell = (JSQMessagesCollectionViewCell *) [collectionView cellForItemAtIndexPath:indexPath]; + OWSAssert([cell isKindOfClass:[JSQMessagesCollectionViewCell class]]); + CGRect convertedRect = [cell.mediaView convertRect:cell.mediaView.bounds + toView:window]; + __block TSAttachment *attachment = nil; [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { attachment = @@ -1174,7 +1182,8 @@ typedef enum : NSUInteger { FullImageViewController *vc = [[FullImageViewController alloc] initWithAttachment:attStream fromRect:convertedRect - forInteraction:[self interactionAtIndexPath:indexPath] + forInteraction:interaction + messageItem:messageItem isAnimated:NO]; [vc presentFromViewController:self.navigationController]; @@ -1187,9 +1196,12 @@ typedef enum : NSUInteger { if(tappedImage == nil) { DDLogWarn(@"tapped TSAnimatedAdapter with nil image"); } else { - CGRect convertedRect = - [self.collectionView convertRect:[collectionView cellForItemAtIndexPath:indexPath].frame - toView:nil]; + UIWindow *window = [UIApplication sharedApplication].keyWindow; + JSQMessagesCollectionViewCell *cell = (JSQMessagesCollectionViewCell *) [collectionView cellForItemAtIndexPath:indexPath]; + OWSAssert([cell isKindOfClass:[JSQMessagesCollectionViewCell class]]); + CGRect convertedRect = [cell.mediaView convertRect:cell.mediaView.bounds + toView:window]; + __block TSAttachment *attachment = nil; [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { attachment = @@ -1200,7 +1212,8 @@ typedef enum : NSUInteger { FullImageViewController *vc = [[FullImageViewController alloc] initWithAttachment:attStream fromRect:convertedRect - forInteraction:[self interactionAtIndexPath:indexPath] + forInteraction:interaction + messageItem:messageItem isAnimated:YES]; [vc presentFromViewController:self.navigationController]; } @@ -1225,16 +1238,23 @@ typedef enum : NSUInteger { _videoPlayer = [[MPMoviePlayerController alloc] initWithContentURL:attStream.mediaURL]; [_videoPlayer prepareToPlay]; - [[NSNotificationCenter defaultCenter] - addObserver:self - selector:@selector(moviePlayBackDidFinish:) - name:MPMoviePlayerPlaybackDidFinishNotification - object:_videoPlayer]; + [[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.controlStyle = MPMovieControlStyleDefault; _videoPlayer.shouldAutoplay = YES; [self.view addSubview:_videoPlayer.view]; - [_videoPlayer setFullscreen:YES animated:YES]; + // 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]; } } else if ([messageMedia isAudio]) { if (messageMedia.isAudioPlaying) { @@ -1365,9 +1385,28 @@ typedef enum : NSUInteger { } } +// 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.tag, __PRETTY_FUNCTION__); + + [self clearVideoPlayer]; +} + +// See comment on moviePlayerWillExitFullscreen: +- (void)moviePlayerDidExitFullscreen:(id)sender { + DDLogDebug(@"%@ %s", self.tag, __PRETTY_FUNCTION__); + + [self clearVideoPlayer]; +} -- (void)moviePlayBackDidFinish:(id)sender { - DDLogDebug(@"playback finished"); +- (void)clearVideoPlayer { + [_videoPlayer stop]; + [_videoPlayer.view removeFromSuperview]; + _videoPlayer = nil; } - (void)collectionView:(JSQMessagesCollectionView *)collectionView diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 2c13c6b34..eeefad8c0 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -67,6 +67,15 @@ /* No comment provided by engineer. */ "ATTACHMENT_QUEUED" = "New attachment queued for retrieval."; +/* Short name for edit menu item to copy contents of media message. */ +"ATTACHMENT_VIEW_COPY_ACTION" = "Copy"; + +/* Short name for edit menu item to save contents of media message. */ +"ATTACHMENT_VIEW_SAVE_ACTION" = "Save"; + +/* Short name for edit menu item to share contents of media message. */ +"ATTACHMENT_VIEW_SHARE_ACTION" = "Share"; + /* No comment provided by engineer. */ "AUDIO_PERMISSION_MESSAGE" = "Signal requires access to your microphone to work properly. You can grant this permission in the Settings app >> Privacy >> Microphone >> Signal";