diff --git a/Pods b/Pods index 93e79025c..594b44bf1 160000 --- a/Pods +++ b/Pods @@ -1 +1 @@ -Subproject commit 93e79025cf285042cb397f3f4d1e0d52c68b9ecc +Subproject commit 594b44bf169e0ee2a690507ad09ff396888e81f9 diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 1ea6d1a83..dfc804291 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -287,6 +287,7 @@ 454A965B1FD601BF008D2A0E /* MediaMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34CA1C281F7164F700E51C51 /* MediaMessageView.swift */; }; 454A965F1FD60EA3008D2A0E /* OWSFlatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 454A965E1FD60EA2008D2A0E /* OWSFlatButton.swift */; }; 454EBAB41F2BE14C00ACE0BB /* OWSAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D99C911F2937CC00D284D6 /* OWSAnalytics.swift */; }; + 4551DB5A205C562300C8AE75 /* Collection+OWS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4551DB59205C562300C8AE75 /* Collection+OWS.swift */; }; 4556FA681F54AA9500AF40DD /* DebugUIProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4556FA671F54AA9500AF40DD /* DebugUIProfile.swift */; }; 455A16DD1F1FEA0000F86704 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 455A16DB1F1FEA0000F86704 /* Metal.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 455A16DE1F1FEA0000F86704 /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 455A16DC1F1FEA0000F86704 /* MetalKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; @@ -867,6 +868,7 @@ 45464DBB1DFA041F001D3FD6 /* DataChannelMessage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataChannelMessage.swift; sourceTree = ""; }; 454A965E1FD60EA2008D2A0E /* OWSFlatButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = OWSFlatButton.swift; path = SignalMessaging/Views/OWSFlatButton.swift; sourceTree = SOURCE_ROOT; }; 454B35071D08EED80026D658 /* mk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = mk; path = translations/mk.lproj/Localizable.strings; sourceTree = ""; }; + 4551DB59205C562300C8AE75 /* Collection+OWS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+OWS.swift"; sourceTree = ""; }; 4556FA671F54AA9500AF40DD /* DebugUIProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DebugUIProfile.swift; sourceTree = ""; }; 455A16DB1F1FEA0000F86704 /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; }; 455A16DC1F1FEA0000F86704 /* MetalKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MetalKit.framework; path = System/Library/Frameworks/MetalKit.framework; sourceTree = SDKROOT; }; @@ -1376,6 +1378,7 @@ 34480B601FD0A98800BC14EF /* UIView+OWS.m */, 346129D41FD20ADC00532771 /* UIViewController+OWS.h */, 346129D31FD20ADB00532771 /* UIViewController+OWS.m */, + 4551DB59205C562300C8AE75 /* Collection+OWS.swift */, ); path = categories; sourceTree = ""; @@ -3065,6 +3068,7 @@ 451F8A381FD7117E005CB9DA /* OWSViewController.m in Sources */, 346129721FD1D74C00532771 /* SignalKeyingStorage.m in Sources */, 34480B561FD0A7A400BC14EF /* DebugLogger.m in Sources */, + 4551DB5A205C562300C8AE75 /* Collection+OWS.swift in Sources */, 3461293C1FD1D46A00532771 /* OWSMath.m in Sources */, 451F8A391FD711D6005CB9DA /* ContactsViewHelper.m in Sources */, 346129AF1FD1F5D900532771 /* SystemContactsFetcher.swift in Sources */, diff --git a/Signal/src/Signal-Bridging-Header.h b/Signal/src/Signal-Bridging-Header.h index 6d274acb6..694ed0779 100644 --- a/Signal/src/Signal-Bridging-Header.h +++ b/Signal/src/Signal-Bridging-Header.h @@ -21,6 +21,7 @@ #import "OWSBezierPathView.h" #import "OWSCallNotificationsAdaptee.h" #import "OWSDatabaseMigration.h" +#import "OWSMessageCell.h" #import "OWSNavigationController.h" #import "OWSProgressView.h" #import "OWSWebRTCDataProtos.pb.h" @@ -90,6 +91,7 @@ #import #import #import +#import #import #import #import diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index c705d2aa2..ffb07204e 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -18,7 +18,6 @@ #import "DateUtil.h" #import "DebugUITableViewController.h" #import "FingerprintViewController.h" -#import "MediaDetailViewController.h" #import "NSAttributedString+OWS.h" #import "NewGroupViewController.h" #import "OWSAudioPlayer.h" @@ -2028,8 +2027,15 @@ typedef enum : NSUInteger { [self dismissKeyBoard]; - MediaDetailViewController *vc = [[MediaDetailViewController alloc] initWithAttachmentStream:attachmentStream - viewItem:viewItem]; + if (![viewItem.interaction isKindOfClass:[TSMessage class]]) { + OWSFail(@"Unexpected viewItem.interaction"); + return; + } + TSMessage *mediaMessage = (TSMessage *)viewItem.interaction; + + MediaPageViewController *vc = + [[MediaPageViewController alloc] initWithThread:self.thread mediaMessage:mediaMessage]; + [vc presentFromViewController:self replacingView:imageView]; } @@ -2042,8 +2048,15 @@ typedef enum : NSUInteger { OWSAssert(attachmentStream); [self dismissKeyBoard]; - MediaDetailViewController *vc = [[MediaDetailViewController alloc] initWithAttachmentStream:attachmentStream - viewItem:viewItem]; + + if (![viewItem.interaction isKindOfClass:[TSMessage class]]) { + OWSFail(@"Unexpected viewItem.interaction"); + return; + } + TSMessage *mediaMessage = (TSMessage *)viewItem.interaction; + + MediaPageViewController *vc = + [[MediaPageViewController alloc] initWithThread:self.thread mediaMessage:mediaMessage]; [vc presentFromViewController:self replacingView:imageView]; } diff --git a/Signal/src/ViewControllers/MediaDetailViewController.h b/Signal/src/ViewControllers/MediaDetailViewController.h index d704e6481..37aca540c 100644 --- a/Signal/src/ViewControllers/MediaDetailViewController.h +++ b/Signal/src/ViewControllers/MediaDetailViewController.h @@ -7,18 +7,42 @@ NS_ASSUME_NONNULL_BEGIN @class ConversationViewItem; +@class MediaDetailViewController; @class SignalAttachment; @class TSAttachmentStream; +@protocol MediaDetailViewControllerDelegate + +- (void)dismissSelfAnimated:(BOOL)isAnimated completion:(void (^_Nullable)(void))completionBlock; +- (void)mediaDetailViewController:(MediaDetailViewController *)mediaDetailViewController + isPlayingVideo:(BOOL)isPlayingVideo; + +@end + @interface MediaDetailViewController : OWSViewController +@property (nonatomic, weak) id delegate; + // If viewItem is non-null, long press will show a menu controller. - (instancetype)initWithAttachmentStream:(TSAttachmentStream *)attachmentStream viewItem:(ConversationViewItem *_Nullable)viewItem; -- (instancetype)initWithAttachment:(SignalAttachment *)attachment; +- (void)presentFromViewController:(UIViewController *)viewController + replacingView:(UIView *)view NS_SWIFT_NAME(present(fromViewController:replacingView:)); -- (void)presentFromViewController:(UIViewController *)viewController replacingView:(UIView *)view; +#pragma mark - Actions + +- (void)didPressShare:(id)sender; +- (void)didPressDelete:(id)sender; +- (void)didPressPlayBarButton:(id)sender; +- (void)didPressPauseBarButton:(id)sender; +- (void)playVideo; + +// Stops playback and rewinds +- (void)stopVideo; + +- (void)setShouldHideToolbars:(BOOL)shouldHideToolbars; +- (void)zoomOutAnimated:(BOOL)isAnimated; @end diff --git a/Signal/src/ViewControllers/MediaDetailViewController.m b/Signal/src/ViewControllers/MediaDetailViewController.m index 4c56a18b1..2bfaac8fd 100644 --- a/Signal/src/ViewControllers/MediaDetailViewController.m +++ b/Signal/src/ViewControllers/MediaDetailViewController.m @@ -62,12 +62,8 @@ NS_ASSUME_NONNULL_BEGIN @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) OWSVideoPlayer *videoPlayer; @property (nonatomic, nullable) UIButton *playVideoButton; @property (nonatomic, nullable) PlayerProgressBar *videoProgressBar; @@ -98,27 +94,9 @@ NS_ASSUME_NONNULL_BEGIN return self; } -- (instancetype)initWithAttachment:(SignalAttachment *)attachment -{ - self = [super initWithNibName:nil bundle:nil]; - if (!self) { - return self; - } - - self.attachment = attachment; - - return self; -} - - (NSURL *_Nullable)attachmentUrl { - if (self.attachmentStream) { - return self.attachmentStream.mediaURL; - } else if (self.attachment) { - return self.attachment.dataUrl; - } else { - return nil; - } + return self.attachmentStream.mediaURL; } - (NSData *)fileData @@ -134,39 +112,17 @@ NS_ASSUME_NONNULL_BEGIN - (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; - } + return self.attachmentStream.image; } - (BOOL)isAnimated { - if (self.attachmentStream) { - return self.attachmentStream.isAnimated; - } else if (self.attachment) { - return self.attachment.isAnimatedImage; - } else { - return NO; - } + return self.attachmentStream.isAnimated; } - (BOOL)isVideo { - if (self.attachmentStream) { - return self.attachmentStream.isVideo; - } else if (self.attachment) { - return self.attachment.isVideo; - } else { - return NO; - } + return self.attachmentStream.isVideo; } - (void)loadView @@ -186,14 +142,12 @@ NS_ASSUME_NONNULL_BEGIN // 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; +} - // FIXME better title. - self.title = @"Attachment"; - - self.navigationItem.leftBarButtonItem = - [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemStop - target:self - action:@selector(didTapDismissButton:)]; +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + [self resetMediaFrame]; } - (void)viewWillDisappear:(BOOL)animated @@ -235,12 +189,17 @@ NS_ASSUME_NONNULL_BEGIN } } +- (void)zoomOutAnimated:(BOOL)isAnimated +{ + if (self.scrollView.zoomScale != self.scrollView.minimumZoomScale) { + [self.scrollView setZoomScale:self.scrollView.minimumZoomScale animated:isAnimated]; + } +} + #pragma mark - Initializers - (void)createContents { - CGFloat kFooterHeight = 44; - UIScrollView *scrollView = [UIScrollView new]; [self.view addSubview:scrollView]; self.scrollView = scrollView; @@ -295,24 +254,16 @@ NS_ASSUME_NONNULL_BEGIN self.mediaView.layer.minificationFilter = kCAFilterTrilinear; self.mediaView.layer.magnificationFilter = kCAFilterTrilinear; - // The presentationView is only used during present/dismiss animations. - // It's a static image of the media content. - UIImageView *presentationView = [[UIImageView alloc] initWithImage:self.image]; - self.presentationView = presentationView; - - [self.view addSubview:presentationView]; - presentationView.hidden = YES; - presentationView.clipsToBounds = YES; - presentationView.layer.allowsEdgeAntialiasing = YES; - presentationView.layer.minificationFilter = kCAFilterTrilinear; - presentationView.layer.magnificationFilter = kCAFilterTrilinear; - presentationView.contentMode = UIViewContentModeScaleAspectFit; - if (self.isVideo) { PlayerProgressBar *videoProgressBar = [PlayerProgressBar new]; videoProgressBar.delegate = self; videoProgressBar.player = self.videoPlayer.avPlayer; + // We hide the progress bar until either: + // 1. Video completes playing + // 2. User taps the screen + videoProgressBar.hidden = YES; + self.videoProgressBar = videoProgressBar; [self.view addSubview:videoProgressBar]; [videoProgressBar autoPinWidthToSuperview]; @@ -335,98 +286,6 @@ NS_ASSUME_NONNULL_BEGIN [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 -{ - if (!self.footerBar) { - DDLogVerbose(@"%@ No footer bar visible.", self.logTag); - return; - } - - NSMutableArray *toolbarItems = [NSMutableArray new]; - - [toolbarItems addObjectsFromArray:@[ - [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemAction - target:self - action:@selector(didPressShare:)], - [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil], - ]]; - - if (self.isVideo) { - UIBarButtonItem *playerButton = isPlayingVideo ? self.videoPauseBarButton : self.videoPlayBarButton; - [toolbarItems addObjectsFromArray:@[ - playerButton, - [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace - target:nil - action:nil], - ]]; - } - - [toolbarItems addObject:[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemTrash - target:self - action:@selector(didPressDelete:)]]; - - [self.footerBar setItems:toolbarItems animated:NO]; -} - -- (void)applyInitialMediaViewConstraints -{ - if (self.presentationViewConstraints.count > 0) { - [NSLayoutConstraint deactivateConstraints:self.presentationViewConstraints]; - } - - OWSAssert(!CGRectEqualToRect(CGRectZero, self.originRect)); - CGRect convertedRect = [self.presentationView.superview convertRect:self.originRect - fromView:[UIApplication sharedApplication].keyWindow]; - - NSMutableArray *presentationViewConstraints = [NSMutableArray new]; - self.presentationViewConstraints = presentationViewConstraints; - - [presentationViewConstraints - addObjectsFromArray:[self.presentationView autoSetDimensionsToSize:convertedRect.size]]; - [presentationViewConstraints addObjectsFromArray:@[ - [self.presentationView autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:convertedRect.origin.y], - [self.presentationView autoPinEdgeToSuperviewEdge:ALEdgeLeft withInset:convertedRect.origin.x] - ]]; -} - -- (void)applyFinalMediaViewConstraints -{ - if (self.presentationViewConstraints.count > 0) { - [NSLayoutConstraint deactivateConstraints:self.presentationViewConstraints]; - } - - NSMutableArray *presentationViewConstraints = [NSMutableArray new]; - self.presentationViewConstraints = presentationViewConstraints; - - [presentationViewConstraints addObjectsFromArray:@[ - [self.presentationView autoPinEdgeToSuperviewEdge:ALEdgeLeading], - [self.presentationView autoPinEdgeToSuperviewEdge:ALEdgeTop], - [self.presentationView autoPinEdgeToSuperviewEdge:ALEdgeTrailing], - [self.presentationView autoPinEdgeToSuperviewEdge:ALEdgeBottom] - ]]; } - (UIView *)buildVideoPlayerView @@ -452,28 +311,9 @@ NS_ASSUME_NONNULL_BEGIN return playerView; } -- (void)setAreToolbarsHidden:(BOOL)areToolbarsHidden +- (void)setShouldHideToolbars:(BOOL)shouldHideToolbars { - if (_areToolbarsHidden == areToolbarsHidden) { - return; - } - - _areToolbarsHidden = areToolbarsHidden; - - // Hiding the status bar affects the positioing of the navbar. We don't want to show that in an animation, it's - // better to just have everythign "flit" in/out. - [[UIApplication sharedApplication] setStatusBarHidden:areToolbarsHidden withAnimation:UIStatusBarAnimationNone]; - [self.navigationController setNavigationBarHidden:areToolbarsHidden animated:NO]; - self.videoProgressBar.hidden = areToolbarsHidden; - - // We don't animate the background color change because the old color shows through momentarily - // behind where the status bar "used to be". - self.view.backgroundColor = areToolbarsHidden ? UIColor.blackColor : UIColor.whiteColor; - - [UIView animateWithDuration:0.1 - animations:^(void) { - self.footerBar.alpha = areToolbarsHidden ? 0 : 1; - }]; + self.videoProgressBar.hidden = shouldHideToolbars; } - (void)initializeGestureRecognizers @@ -483,28 +323,6 @@ NS_ASSUME_NONNULL_BEGIN 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; @@ -513,17 +331,6 @@ NS_ASSUME_NONNULL_BEGIN #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); @@ -545,23 +352,10 @@ NS_ASSUME_NONNULL_BEGIN [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]; + [self zoomOutAnimated: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. @@ -618,23 +412,23 @@ NS_ASSUME_NONNULL_BEGIN if ([navController.topViewController isKindOfClass:[ConversationViewController class]]) { - [self dismissSelfAnimated:YES - completion:^{ - [self.viewItem deleteAction]; - }]; + [self.delegate dismissSelfAnimated:YES + completion:^{ + [self.viewItem deleteAction]; + }]; } else if ([navController.topViewController isKindOfClass:[MessageDetailViewController class]]) { - [self dismissSelfAnimated:NO - completion:^{ - [self.viewItem deleteAction]; - }]; + [self.delegate dismissSelfAnimated:YES + completion:^{ + [self.viewItem deleteAction]; + }]; [navController popViewControllerAnimated:YES]; } else { OWSFail(@"Unexpected presentation context."); - [self dismissSelfAnimated:YES - completion:^{ - [self.viewItem deleteAction]; - }]; + [self.delegate dismissSelfAnimated:YES + completion:^{ + [self.viewItem deleteAction]; + }]; } }]]; @@ -710,156 +504,6 @@ NS_ASSUME_NONNULL_BEGIN [self pauseVideo]; } -#pragma mark - Presentation - -- (void)presentFromViewController:(UIViewController *)viewController replacingView:(UIView *)replacingView -{ - self.replacingView = replacingView; - - UIWindow *window = [UIApplication sharedApplication].keyWindow; - CGRect convertedRect = [replacingView convertRect:replacingView.bounds toView:window]; - self.originRect = convertedRect; - - // loadView hasn't necesarily been called yet. - [self loadViewIfNeeded]; - [self applyInitialMediaViewConstraints]; - - UINavigationController *navController = [[UINavigationController alloc] initWithRootViewController:self]; - - // UIModalPresentationCustom retains the current view context behind our VC, allowing us to manually - // animate in our view, over the existing context, similar to a cross disolve, but allowing us to have - // more fine grained control - navController.modalPresentationStyle = UIModalPresentationCustom; - navController.navigationBar.barTintColor = UIColor.ows_materialBlueColor; - navController.navigationBar.translucent = NO; - navController.navigationBar.opaque = YES; - - self.view.userInteractionEnabled = NO; - - // We want to animate the tapped media from it's position in the previous VC - // to it's resting place in the center of this view controller. - // - // Rather than animating the actual media view in place, we animate the presentationView, which is a static - // image of the media content. Animating the actual media view is problematic for a couple reasons: - // 1. The media view ultimately lives in a zoomable scrollView. Getting both original positioning and the final positioning - // correct, involves manipulating the zoomScale and position simultaneously, which results in non-linear movement, - // especially noticeable on high resolution images. - // 2. For Video views, the AVPlayerLayer content does not scale with the presentation animation. So you instead get a full scale - // video, wherein only the cropping is animated. - // Using a simple image view allows us to address both these problems relatively easily. - self.view.alpha = 0.0; - - self.mediaView.hidden = YES; - self.presentationView.hidden = NO; - self.presentationView.layer.cornerRadius = OWSMessageCellCornerRadius; - - [viewController presentViewController:navController - animated:NO - completion:^{ - - // 1. Fade in the entire view. - [UIView animateWithDuration:0.1 - animations:^{ - self.replacingView.alpha = 0.0; - self.view.alpha = 1.0; - }]; - - [self.presentationView.superview layoutIfNeeded]; - [self applyFinalMediaViewConstraints]; - - // 2. Animate imageView from it's initial position, which should match where it was - // in the presenting view to it's final position, front and center in this view. This - // animation duration intentionally overlaps the previous - [UIView animateWithDuration:0.2 - delay:0.08 - options:UIViewAnimationOptionCurveEaseOut - animations:^(void) { - self.presentationView.layer.cornerRadius = 0; - [self.presentationView.superview layoutIfNeeded]; - - // We must lay out once *before* we centerMediaViewConstraints - // because it uses the imageView.frame to build the constraints - // that will center the imageView, and then once again *after* - // to ensure that the centered constraints are applied. - [self centerMediaViewConstraints]; - [self.mediaView.superview layoutIfNeeded]; - self.view.backgroundColor = UIColor.whiteColor; - } - completion:^(BOOL finished) { - // HACK: Setting the frame to itself *seems* like it should be a no-op, but - // it ensures the content is drawn at the right frame. In particular I was - // reproducibly some images squished (they were EXIF rotated, maybe - // relateed). similar to this report: - // https://stackoverflow.com/questions/27961884/swift-uiimageview-stretched-aspect - self.mediaView.frame = self.mediaView.frame; - - // At this point our presentation view should be overlayed perfectly - // with our media view. Swapping them out should be imperceptible. - self.mediaView.hidden = NO; - self.presentationView.hidden = YES; - - self.view.userInteractionEnabled = YES; - - if (self.isVideo) { - [self playVideo]; - } - }]; - }]; -} - -- (void)dismissSelfAnimated:(BOOL)isAnimated completion:(void (^_Nullable)(void))completion -{ - - self.view.userInteractionEnabled = NO; - [UIApplication sharedApplication].statusBarHidden = NO; - - // Swapping mediaView for presentationView will be perceptible if we're not zoomed out all the way. - if (self.scrollView.zoomScale != self.scrollView.minimumZoomScale) { - [self.scrollView setZoomScale:self.scrollView.minimumZoomScale animated:YES]; - } - - self.mediaView.hidden = YES; - self.presentationView.hidden = NO; - - // Move the presentationView back to it's initial position, i.e. where - // it sits on the screen in the conversation view. - [self applyInitialMediaViewConstraints]; - - if (isAnimated) { - [UIView animateWithDuration:0.18 - delay:0.0 - options:UIViewAnimationOptionCurveEaseOut - animations:^(void) { - [self.presentationView.superview layoutIfNeeded]; - self.presentationView.layer.cornerRadius = OWSMessageCellCornerRadius; - - // In case user has hidden bars, which changes background to black. - self.view.backgroundColor = UIColor.whiteColor; - - } - completion:nil]; - - [UIView animateWithDuration:0.1 - delay:0.15 - options:UIViewAnimationOptionCurveEaseInOut - animations:^(void) { - - OWSAssert(self.replacingView); - self.replacingView.alpha = 1.0; - - // fade out content and toolbars - self.navigationController.view.alpha = 0.0; - } - completion:^(BOOL finished) { - [self.presentingViewController dismissViewControllerAnimated:NO completion:completion]; - }]; - - } else { - self.replacingView.alpha = 1.0; - [self.presentingViewController dismissViewControllerAnimated:NO completion:completion]; - } -} - #pragma mark - UIScrollViewDelegate - (nullable UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView @@ -889,17 +533,28 @@ NS_ASSUME_NONNULL_BEGIN [self.view layoutIfNeeded]; } +- (void)resetMediaFrame +{ + // HACK: Setting the frame to itself *seems* like it should be a no-op, but + // it ensures the content is drawn at the right frame. In particular I was + // reproducibly seeing some images squished (they were EXIF rotated, maybe + // related). similar to this report: + // https://stackoverflow.com/questions/27961884/swift-uiimageview-stretched-aspect + [self.view layoutIfNeeded]; + self.mediaView.frame = self.mediaView.frame; +} + #pragma mark - Video Playback - (void)playVideo { OWSAssert(self.videoPlayer); - [self updateFooterBarButtonItemsWithIsPlayingVideo:YES]; self.playVideoButton.hidden = YES; - self.areToolbarsHidden = YES; [self.videoPlayer play]; + + [self.delegate mediaDetailViewController:self isPlayingVideo:YES]; } - (void)pauseVideo @@ -907,8 +562,21 @@ NS_ASSUME_NONNULL_BEGIN OWSAssert(self.isVideo); OWSAssert(self.videoPlayer); - [self updateFooterBarButtonItemsWithIsPlayingVideo:NO]; [self.videoPlayer pause]; + + [self.delegate mediaDetailViewController:self isPlayingVideo:NO]; +} + +- (void)stopVideo +{ + OWSAssert(self.isVideo); + OWSAssert(self.videoPlayer); + + [self.videoPlayer stop]; + + self.playVideoButton.hidden = NO; + + [self.delegate mediaDetailViewController:self isPlayingVideo:NO]; } #pragma mark - OWSVideoPlayer @@ -919,10 +587,7 @@ NS_ASSUME_NONNULL_BEGIN OWSAssert(self.videoPlayer); DDLogVerbose(@"%@ %s", self.logTag, __PRETTY_FUNCTION__); - self.areToolbarsHidden = NO; - self.playVideoButton.hidden = NO; - - [self updateFooterBarButtonItemsWithIsPlayingVideo:NO]; + [self stopVideo]; } #pragma mark - PlayerProgressBarDelegate diff --git a/Signal/src/ViewControllers/MediaPageViewController.swift b/Signal/src/ViewControllers/MediaPageViewController.swift new file mode 100644 index 000000000..a4df3da2f --- /dev/null +++ b/Signal/src/ViewControllers/MediaPageViewController.swift @@ -0,0 +1,688 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +import UIKit + +class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate, MediaDetailViewControllerDelegate { + + private struct MediaGalleryItem: Equatable { + let message: TSMessage + let attachmentStream: TSAttachmentStream + let viewController: MediaDetailViewController + + var isVideo: Bool { + return attachmentStream.isVideo() + } + + var image: UIImage { + guard let image = attachmentStream.image() else { + owsFail("\(logTag) in \(#function) unexpectedly unable to build attachment image") + return UIImage() + } + + return image + } + } + + private var cachedItems: [MediaGalleryItem] = [] + private var initialItem: MediaGalleryItem! + private var currentItem: MediaGalleryItem! { + return cachedItems.first { $0.viewController == viewControllers?.first } + } + + private let includeGallery: Bool + private let thread: TSThread + + private let mediaGalleryFinder: OWSMediaGalleryFinder + private let uiDatabaseConnection: YapDatabaseConnection + + private var mediaMessages: [TSMessage] = [] + + convenience init(thread: TSThread, mediaMessage: TSMessage) { + self.init(thread: thread, mediaMessage: mediaMessage, includeGallery: true) + } + + init(thread: TSThread, mediaMessage: TSMessage, includeGallery: Bool) { + self.thread = thread + self.uiDatabaseConnection = OWSPrimaryStorage.shared().newDatabaseConnection() + self.mediaGalleryFinder = OWSMediaGalleryFinder() + self.includeGallery = includeGallery + + let kSpacingBetweenItems: CGFloat = 20 + + super.init(transitionStyle: .scroll, + navigationOrientation: .horizontal, + options: [UIPageViewControllerOptionInterPageSpacingKey: kSpacingBetweenItems]) + + self.dataSource = self + self.delegate = self + + uiDatabaseConnection.beginLongLivedReadTransaction() + + if includeGallery { + uiDatabaseConnection.read { transaction in + // TODO don't read all media messages in at once. Use Mapping? + self.mediaGalleryFinder.enumerateMediaMessages(with: thread, transaction: transaction) { message in + self.mediaMessages.append(message) + } + } + } else { + self.mediaMessages = [mediaMessage] + } + + guard let initialItem = self.buildGalleryItem(mediaMessage: mediaMessage, thread: thread) else { + owsFail("unexpetedly unable to build initial gallery item") + return + } + self.initialItem = initialItem + cachedItems.insert(initialItem, at: 0) + + self.setViewControllers([initialItem.viewController], direction: .forward, animated: false, completion: nil) + } + + @available(*, unavailable, message: "Unimplemented") + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + Logger.debug("\(logTag) deinit") + } + + var presentationView: UIImageView! + var footerBar: UIToolbar! + var videoPlayBarButton: UIBarButtonItem! + var videoPauseBarButton: UIBarButtonItem! + var pagerScrollView: UIScrollView! + + override func viewDidLoad() { + super.viewDidLoad() + + // Navigation + + self.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .stop, target: self, action: #selector(didPressDismissButton)) + + // 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 = true + self.automaticallyAdjustsScrollViewInsets = false + + // Get reference to paged content which lives in a scrollView created by the superclass + // We show/hide this content during presentation + for view in self.view.subviews { + if let pagerScrollView = view as? UIScrollView { + self.pagerScrollView = pagerScrollView + } + } + + // Hack to avoid "page" bouncing when not in gallery view. + // e.g. when getting to media details via message details screen, there's only + // one "Page" so the bounce doesn't make sense. + if !self.includeGallery { + pagerScrollView.isScrollEnabled = false + } + + // FIXME dynamic title with sender/date + self.title = "Attachment" + + // Views + + let kFooterHeight: CGFloat = 44 + + view.backgroundColor = UIColor.white + + let footerBar = UIToolbar() + self.footerBar = footerBar + footerBar.barTintColor = UIColor.ows_signalBrandBlue + + self.videoPlayBarButton = UIBarButtonItem(barButtonSystemItem: .play, target: self, action: #selector(didPressPlayBarButton)) + self.videoPauseBarButton = UIBarButtonItem(barButtonSystemItem: .pause, target: self, action: #selector(didPressPauseBarButton)) + + self.updateFooterBarButtonItems(isPlayingVideo: true) + self.view.addSubview(footerBar) + footerBar.autoPinWidthToSuperview() + footerBar.autoPin(toBottomLayoutGuideOf: self, withInset: 0) + footerBar.autoSetDimension(.height, toSize:kFooterHeight) + + // The presentationView is only used during present/dismiss animations. + // It's a static image of the media content. + let presentationView = UIImageView(image: currentItem.image) + self.presentationView = presentationView + self.view.addSubview(presentationView) + presentationView.isHidden = true + presentationView.clipsToBounds = true + presentationView.layer.allowsEdgeAntialiasing = true + presentationView.layer.minificationFilter = kCAFilterTrilinear + presentationView.layer.magnificationFilter = kCAFilterTrilinear + presentationView.contentMode = .scaleAspectFit + + // Gestures + + let doubleTap = UITapGestureRecognizer(target: nil, action: nil) + doubleTap.numberOfTapsRequired = 2 + view.addGestureRecognizer(doubleTap) + + let singleTap = UITapGestureRecognizer(target: self, action: #selector(didTapView)) + singleTap.require(toFail: doubleTap) + view.addGestureRecognizer(singleTap) + + let verticalSwipe = UISwipeGestureRecognizer(target: self, action: #selector(didSwipeView)) + verticalSwipe.direction = [.up, .down] + view.addGestureRecognizer(verticalSwipe) + } + + // MARK: View Helpers + + @objc + public func didSwipeView(sender: Any) { + Logger.debug("\(logTag) in \(#function)") + + self.dismissSelf(animated: true) + } + + @objc + public func didTapView(sender: Any) { + Logger.debug("\(logTag) in \(#function)") + + self.shouldHideToolbars = !self.shouldHideToolbars + } + + private var shouldHideToolbars: Bool = false { + didSet { + if (oldValue == shouldHideToolbars) { + return + } + + // Hiding the status bar affects the positioning of the navbar. We don't want to show that in an animation, it's + // better to just have everythign "flit" in/out. + UIApplication.shared.setStatusBarHidden(shouldHideToolbars, with:.none) + self.navigationController?.setNavigationBarHidden(shouldHideToolbars, animated: false) + + // We don't animate the background color change because the old color shows through momentarily + // behind where the status bar "used to be". + self.view.backgroundColor = shouldHideToolbars ? UIColor.black : UIColor.white + + UIView.animate(withDuration: 0.1) { + self.currentItem.viewController.setShouldHideToolbars(self.shouldHideToolbars) + self.footerBar.alpha = self.shouldHideToolbars ? 0 : 1 + } + } + } + + private func updateFooterBarButtonItems(isPlayingVideo: Bool) { + // TODO do we still need this? seems like a vestige + // from when media detail view was used for attachment approval + if (self.footerBar == nil) { + owsFail("\(logTag) No footer bar visible.") + return + } + + var toolbarItems: [UIBarButtonItem] = [ + UIBarButtonItem(barButtonSystemItem: .action, target:self, action: #selector(didPressShare)), + UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target:nil, action:nil) + ] + + if (self.currentItem.isVideo) { + toolbarItems += [ + isPlayingVideo ? self.videoPauseBarButton : self.videoPlayBarButton, + UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target:nil, action:nil) + ] + } + + toolbarItems.append(UIBarButtonItem(barButtonSystemItem: .trash, + target:self, + action:#selector(didPressDelete))) + + self.footerBar.setItems(toolbarItems, animated: false) + } + + var replacingView: UIView? + + // TODO Default to bottom of screen? + // TODO rename to replacingOriginRect + var originRect: CGRect? + + func present(fromViewController: UIViewController, replacingView: UIView) { + + self.replacingView = replacingView + + let convertedRect: CGRect = replacingView.convert(replacingView.bounds, to: UIApplication.shared.keyWindow) + self.originRect = convertedRect + + // loadView hasn't necessarily been called yet. + self.loadViewIfNeeded() + self.applyInitialMediaViewConstraints() + + let navController = UINavigationController(rootViewController: 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 = .custom + navController.navigationBar.barTintColor = UIColor.ows_materialBlue + navController.navigationBar.isTranslucent = false + navController.navigationBar.isOpaque = true + + // We want to animate the tapped media from it's position in the previous VC + // to it's resting place in the center of this view controller. + // + // Rather than animating the actual media view in place, we animate the presentationView, which is a static + // image of the media content. Animating the actual media view is problematic for a couple reasons: + // 1. The media view ultimately lives in a zoomable scrollView. Getting both original positioning and the final positioning + // correct, involves manipulating the zoomScale and position simultaneously, which results in non-linear movement, + // especially noticeable on high resolution images. + // 2. For Video views, the AVPlayerLayer content does not scale with the presentation animation. So you instead get a full scale + // video, wherein only the cropping is animated. + // Using a simple image view allows us to address both these problems relatively easily. + self.view.alpha = 0.0 + + self.pagerScrollView.isHidden = true + self.presentationView.isHidden = false + self.presentationView.layer.cornerRadius = OWSMessageCellCornerRadius + + fromViewController.present(navController, animated: false) { + + // 1. Fade in the entire view. + UIView.animate(withDuration: 0.1) { + self.replacingView?.alpha = 0.0 + self.view.alpha = 1.0 + } + + self.presentationView.superview?.layoutIfNeeded() + self.applyFinalMediaViewConstraints() + + // 2. Animate imageView from it's initial position, which should match where it was + // in the presenting view to it's final position, front and center in this view. This + // animation duration intentionally overlaps the previous + UIView.animate(withDuration: 0.2, + delay: 0.08, + options: .curveEaseOut, + animations: { + + self.presentationView.layer.cornerRadius = 0 + self.presentationView.superview?.layoutIfNeeded() + + self.view.backgroundColor = UIColor.white + }, + completion: { (_: Bool) in + // At this point our presentation view should be overlayed perfectly + // with our media view. Swapping them out should be imperceptible. + self.pagerScrollView.isHidden = false + self.presentationView.isHidden = true + + self.view.isUserInteractionEnabled = true + + guard let currentItem = self.currentItem else { + owsFail("\(self.logTag) in \(#function) currentItem unexepcetdly nil") + return + } + if currentItem.isVideo { + currentItem.viewController.playVideo() + } + }) + } + } + + private var presentationViewConstraints: [NSLayoutConstraint] = [] + + private func applyInitialMediaViewConstraints() { + if (self.presentationViewConstraints.count > 0) { + NSLayoutConstraint.deactivate(self.presentationViewConstraints) + self.presentationViewConstraints = [] + } + + guard let originRect = self.originRect else { + owsFail("\(logTag) in \(#function) originRect was unexpectedly nil") + return + } + + guard let presentationSuperview = self.presentationView.superview else { + owsFail("\(logTag) in \(#function) presentationView.superview was unexpectedly nil") + return + } + + let convertedRect: CGRect = presentationSuperview.convert(originRect, from: UIApplication.shared.keyWindow) + + self.presentationViewConstraints += self.presentationView.autoSetDimensions(to: convertedRect.size) + self.presentationViewConstraints += [ + self.presentationView.autoPinEdge(toSuperviewEdge: .top, withInset:convertedRect.origin.y), + self.presentationView.autoPinEdge(toSuperviewEdge: .left, withInset:convertedRect.origin.x) + ] + } + + private func applyFinalMediaViewConstraints() { + if (self.presentationViewConstraints.count > 0) { + NSLayoutConstraint.deactivate(self.presentationViewConstraints) + self.presentationViewConstraints = [] + } + + self.presentationViewConstraints = [ + self.presentationView.autoPinEdge(toSuperviewEdge: .leading), + self.presentationView.autoPinEdge(toSuperviewEdge: .top), + self.presentationView.autoPinEdge(toSuperviewEdge: .trailing), + self.presentationView.autoPinEdge(toSuperviewEdge: .bottom) + ] + } + + private func applyOffscreenMediaViewConstraints() { + if (self.presentationViewConstraints.count > 0) { + NSLayoutConstraint.deactivate(self.presentationViewConstraints) + self.presentationViewConstraints = [] + } + + self.presentationViewConstraints += [ + self.presentationView.autoPinEdge(toSuperviewEdge: .leading), + self.presentationView.autoPinEdge(toSuperviewEdge: .trailing), + self.presentationView.autoPinEdge(.top, to: .bottom, of: self.view) + ] + } + + // MARK: Actions + + @objc + public func didPressDismissButton(_ sender: Any) { + dismissSelf(animated: true) + } + + @objc + public func didPressShare(_ sender: Any) { + guard let currentViewController = self.viewControllers?[0] as? MediaDetailViewController else { + owsFail("\(logTag) in \(#function) currentViewController was unexpectedly nil") + return + } + currentViewController.didPressShare(sender) + } + + @objc + public func didPressDelete(_ sender: Any) { + guard let currentViewController = self.viewControllers?[0] as? MediaDetailViewController else { + owsFail("\(logTag) in \(#function) currentViewController was unexpectedly nil") + return + } + currentViewController.didPressDelete(sender) + } + + @objc + public func didPressPlayBarButton(_ sender: Any) { + guard let currentViewController = self.viewControllers?[0] as? MediaDetailViewController else { + owsFail("\(logTag) in \(#function) currentViewController was unexpectedly nil") + return + } + currentViewController.didPressPlayBarButton(sender) + } + + @objc + public func didPressPauseBarButton(_ sender: Any) { + guard let currentViewController = self.viewControllers?[0] as? MediaDetailViewController else { + owsFail("\(logTag) in \(#function) currentViewController was unexpectedly nil") + return + } + currentViewController.didPressPauseBarButton(sender) + } + + // MARK: UIPageViewControllerDelegate + + public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) { + Logger.debug("\(logTag) in \(#function)") + + assert(pendingViewControllers.count == 1) + pendingViewControllers.forEach { viewController in + guard let pendingItem = self.cachedItems.first(where: { $0.viewController == viewController}) else { + owsFail("\(logTag) in \(#function) unexpected mediaDetailViewController: \(viewController)") + return + } + + // Ensure upcoming page respects current toolbar status + pendingItem.viewController.setShouldHideToolbars(self.shouldHideToolbars) + } + } + + public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted: Bool) { + Logger.debug("\(logTag) in \(#function)") + + assert(previousViewControllers.count == 1) + previousViewControllers.forEach { viewController in + guard let previousItem = self.cachedItems.first(where: { $0.viewController == viewController}) else { + owsFail("\(logTag) in \(#function) unexpected mediaDetailViewController: \(viewController)") + return + } + + // Do any cleanup for the no-longer visible view controller + if transitionCompleted { + previousItem.viewController.zoomOut(animated: false) + if previousItem.isVideo { + previousItem.viewController.stopVideo() + } + updateFooterBarButtonItems(isPlayingVideo: false) + } + } + } + + // MARK: UIPageViewControllerDataSource + + public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { + Logger.debug("\(logTag) in \(#function)") + guard let currentIndex = cachedItems.index(where: { $0.viewController == viewController }) else { + owsFail("\(self.logTag) unknown view controller. \(viewController)") + return nil + } + let currentItem = cachedItems[currentIndex] + + let newIndex = currentIndex - 1 + if let cachedItem = cachedItems[safe: newIndex] { + return cachedItem.viewController + } + + guard let previousMediaMessage = previousMediaMessage(currentItem.message) else { + return nil + } + + guard let previousItem = buildGalleryItem(mediaMessage: previousMediaMessage, thread: thread) else { + return nil + } + + cachedItems.insert(previousItem, at: currentIndex) + return previousItem.viewController + } + + public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { + Logger.debug("\(logTag) in \(#function)") + + guard let currentIndex = cachedItems.index(where: { $0.viewController == viewController }) else { + owsFail("\(self.logTag) unknown view controller. \(viewController)") + return nil + } + let currentItem = cachedItems[currentIndex] + + let newIndex = currentIndex + 1 + if let cachedItem = cachedItems[safe: newIndex] { + return cachedItem.viewController + } + + guard let nextMediaMessage = nextMediaMessage(currentItem.message) else { + return nil + } + + guard let nextItem = buildGalleryItem(mediaMessage: nextMediaMessage, thread: thread) else { + return nil + } + + cachedItems.insert(nextItem, at: newIndex) + return nextItem.viewController + } + + private func buildGalleryItem(mediaMessage: TSMessage, thread: TSThread) -> MediaGalleryItem? { + var fetchedAttachment: TSAttachment? = nil + var fetchedItem: ConversationViewItem? = nil + self.uiDatabaseConnection.read { transaction in + fetchedAttachment = mediaMessage.attachment(with: transaction) + fetchedItem = ConversationViewItem(interaction: mediaMessage, isGroupThread: thread.isGroupThread(), transaction: transaction) + } + + guard let attachmentStream = fetchedAttachment as? TSAttachmentStream else { + owsFail("attachment stream unexpectedly nil") + return nil + } + + guard let viewItem = fetchedItem else { + owsFail("viewItem stream unexpectedly nil") + return nil + } + + let viewController = MediaDetailViewController(attachmentStream: attachmentStream, viewItem: viewItem) + viewController.delegate = self + return MediaGalleryItem(message: mediaMessage, + attachmentStream: attachmentStream, + viewController: viewController) + } + + @nonobjc + public func presentationCount(for: UIPageViewController) -> Int { + Logger.debug("\(logTag) in \(#function)") + + var count: UInt = 0 + self.uiDatabaseConnection.read { (transaction: YapDatabaseReadTransaction) in + count = self.mediaGalleryFinder.mediaCount(thread: self.thread, transaction: transaction) + } + return Int(count) + } + + @nonobjc + public func presentationIndex(for pageViewController: UIPageViewController) -> Int { + Logger.debug("\(logTag) in \(#function)") + + guard let mediaPageViewController = pageViewController as? MediaPageViewController else { + owsFail("\(self.logTag) unknown view controller. \(pageViewController)") + return 0 + } + + var index: UInt = 0 + self.uiDatabaseConnection.read { (transaction: YapDatabaseReadTransaction) in + index = self.mediaGalleryFinder.mediaIndex(message: self.currentItem.message, transaction: transaction) + } + return Int(index) + } + + // MARK: MediaDetailViewControllerDelegate + + public func dismissSelf(animated isAnimated: Bool, completion: (() -> Void)? = nil) { + self.view.isUserInteractionEnabled = false + UIApplication.shared.isStatusBarHidden = false + + guard let currentItem = self.currentItem else { + owsFail("\(logTag) in \(#function) currentItem was unexpectedly nil") + self.presentingViewController?.dismiss(animated: false, completion: completion) + return + } + + // Swapping mediaView for presentationView will be perceptible if we're not zoomed out all the way. + currentItem.viewController.zoomOut(animated: true) + + self.pagerScrollView.isHidden = true + self.presentationView.isHidden = false + + // Move the presentationView back to it's initial position, i.e. where + // it sits on the screen in the conversation view. + let changedItems = currentItem != initialItem + if changedItems { + self.presentationView.image = currentItem.image + self.applyOffscreenMediaViewConstraints() + } else { + self.applyInitialMediaViewConstraints() + } + + if isAnimated { + UIView.animate(withDuration: changedItems ? 0.25 : 0.18, + delay: 0.0, + options:.curveEaseOut, + animations: { + self.presentationView.superview?.layoutIfNeeded() + + // In case user has hidden bars, which changes background to black. + self.view.backgroundColor = UIColor.white + + if changedItems { + self.presentationView.alpha = 0 + } else { + self.presentationView.layer.cornerRadius = OWSMessageCellCornerRadius + } + }, + completion:nil) + + // This intentionally overlaps the previous animation a bit + UIView.animate(withDuration: 0.1, + delay: 0.15, + options: .curveEaseInOut, + animations: { + guard let replacingView = self.replacingView else { + owsFail("\(self.logTag) in \(#function) replacingView was unexpectedly nil") + self.presentingViewController?.dismiss(animated: false, completion: completion) + return + } + replacingView.alpha = 1.0 + + // fade out content and toolbars + self.navigationController?.view.alpha = 0.0 + }, + completion: { (_: Bool) in + self.presentingViewController?.dismiss(animated: false, completion: completion) + }) + } else { + guard let replacingView = self.replacingView else { + owsFail("\(self.logTag) in \(#function) replacingView was unexpectedly nil") + self.presentingViewController?.dismiss(animated: false, completion: completion) + return + } + replacingView.alpha = 1.0 + self.presentingViewController?.dismiss(animated: false, completion: completion) + } + } + + public func mediaDetailViewController(_ mediaDetailViewController: MediaDetailViewController, isPlayingVideo: Bool) { + guard mediaDetailViewController == currentItem.viewController else { + Logger.verbose("\(logTag) in \(#function) ignoring stale delegate.") + return + } + + self.shouldHideToolbars = isPlayingVideo + self.updateFooterBarButtonItems(isPlayingVideo: isPlayingVideo) + } + + // MARK: Helpers + + private var threadId: String { + guard let unqiueThreadId = self.thread.uniqueId else { + owsFail("thread missing id in \(#function)") + return "" + } + + return unqiueThreadId + } + + private func nextMediaMessage(_ message: TSMessage) -> TSMessage? { + Logger.debug("\(logTag) in \(#function)") + + guard let currentIndex = mediaMessages.index(of: message) else { + owsFail("currentIndex was unexpectedly nil in \(#function)") + return nil + } + + let index: Int = mediaMessages.index(after: currentIndex) + return mediaMessages[safe: index] + } + + private func previousMediaMessage(_ message: TSMessage) -> TSMessage? { + Logger.debug("\(logTag) in \(#function)") + + guard let currentIndex = mediaMessages.index(of: message) else { + owsFail("currentIndex was unexpectedly nil in \(#function)") + return nil + } + + let index: Int = mediaMessages.index(before: currentIndex) + return mediaMessages[safe: index] + } +} + diff --git a/Signal/src/ViewControllers/MessageDetailViewController.swift b/Signal/src/ViewControllers/MessageDetailViewController.swift index 6e1f5b44a..72bd0cd83 100644 --- a/Signal/src/ViewControllers/MessageDetailViewController.swift +++ b/Signal/src/ViewControllers/MessageDetailViewController.swift @@ -21,7 +21,7 @@ class MessageDetailViewController: OWSViewController, UIScrollViewDelegate, Medi let contactsManager: OWSContactsManager - let databaseConnection: YapDatabaseConnection + let uiDatabaseConnection: YapDatabaseConnection let bubbleFactory = OWSMessagesBubbleImageFactory() var bubbleView: UIView? @@ -60,7 +60,7 @@ class MessageDetailViewController: OWSViewController, UIScrollViewDelegate, Medi self.viewItem = viewItem self.message = message self.mode = mode - self.databaseConnection = OWSPrimaryStorage.shared().newDatabaseConnection() + self.uiDatabaseConnection = OWSPrimaryStorage.shared().newDatabaseConnection() super.init(nibName: nil, bundle: nil) } @@ -70,7 +70,7 @@ class MessageDetailViewController: OWSViewController, UIScrollViewDelegate, Medi override func viewDidLoad() { super.viewDidLoad() - self.databaseConnection.beginLongLivedReadTransaction() + self.uiDatabaseConnection.beginLongLivedReadTransaction() updateDBConnectionAndMessageToLatest() self.navigationItem.title = NSLocalizedString("MESSAGE_METADATA_VIEW_TITLE", @@ -161,6 +161,14 @@ class MessageDetailViewController: OWSViewController, UIScrollViewDelegate, Medi updateContent() } + lazy var thread: TSThread = { + var thread: TSThread? + self.uiDatabaseConnection.read { transaction in + thread = self.message.thread(with: transaction) + } + return thread! + }() + private func updateContent() { guard let contentView = contentView else { owsFail("\(TAG) Missing contentView") @@ -174,7 +182,6 @@ class MessageDetailViewController: OWSViewController, UIScrollViewDelegate, Medi var rows = [UIView]() let contactsManager = Environment.current().contactsManager! - let thread = message.thread // Content rows += contentRows() @@ -191,7 +198,7 @@ class MessageDetailViewController: OWSViewController, UIScrollViewDelegate, Medi // Recipient(s) if let outgoingMessage = message as? TSOutgoingMessage { - let isGroupThread = message.thread.isGroupThread() + let isGroupThread = thread.isGroupThread() let recipientStatusGroups: [MessageRecipientStatus] = [ .read, @@ -583,7 +590,7 @@ class MessageDetailViewController: OWSViewController, UIScrollViewDelegate, Medi AssertIsOnMainThread() - self.databaseConnection.read { transaction in + self.uiDatabaseConnection.read { transaction in guard let uniqueId = self.message.uniqueId else { Logger.error("\(self.TAG) Message is missing uniqueId.") return @@ -600,13 +607,13 @@ class MessageDetailViewController: OWSViewController, UIScrollViewDelegate, Medi internal func yapDatabaseModified(notification: NSNotification) { AssertIsOnMainThread() - let notifications = self.databaseConnection.beginLongLivedReadTransaction() + let notifications = self.uiDatabaseConnection.beginLongLivedReadTransaction() guard let uniqueId = self.message.uniqueId else { Logger.error("\(self.TAG) Message is missing uniqueId.") return } - guard self.databaseConnection.hasChange(forKey: uniqueId, + guard self.uiDatabaseConnection.hasChange(forKey: uniqueId, inCollection: TSInteraction.collection(), in: notifications) else { Logger.debug("\(TAG) No relevant changes.") @@ -755,7 +762,7 @@ class MessageDetailViewController: OWSViewController, UIScrollViewDelegate, Medi return } - let mediaDetailViewController = MediaDetailViewController(attachmentStream: attachmentStream, viewItem: self.viewItem) - mediaDetailViewController.present(from: self, replacing: fromView) + let mediaPageViewController = MediaPageViewController(thread: self.thread, mediaMessage: self.message, includeGallery: false) + mediaPageViewController.present(fromViewController: self, replacingView: fromView) } } diff --git a/SignalMessaging/attachments/OWSVideoPlayer.swift b/SignalMessaging/attachments/OWSVideoPlayer.swift index c5b86db31..16a41f0f9 100644 --- a/SignalMessaging/attachments/OWSVideoPlayer.swift +++ b/SignalMessaging/attachments/OWSVideoPlayer.swift @@ -53,6 +53,12 @@ public class OWSVideoPlayer: NSObject { avPlayer.play() } + public func stop() { + avPlayer.pause() + avPlayer.seek(to: kCMTimeZero) + OWSAudioSession.shared.endAudioActivity(self.audioActivity) + } + @objc(seekToTime:) public func seek(to time: CMTime) { avPlayer.seek(to: time) diff --git a/SignalMessaging/categories/Collection+OWS.swift b/SignalMessaging/categories/Collection+OWS.swift new file mode 100644 index 000000000..c769d2444 --- /dev/null +++ b/SignalMessaging/categories/Collection+OWS.swift @@ -0,0 +1,11 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +public extension Collection { + + /// Returns the element at the specified index iff it is within bounds, otherwise nil. + public subscript (safe index: Index) -> Element? { + return indices.contains(index) ? self[index] : nil + } +} diff --git a/SignalServiceKit/src/Messages/Interactions/TSInteraction.h b/SignalServiceKit/src/Messages/Interactions/TSInteraction.h index 6d9fc7d59..19f6d246d 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSInteraction.h +++ b/SignalServiceKit/src/Messages/Interactions/TSInteraction.h @@ -1,5 +1,5 @@ // -// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. // #import "TSYapDatabaseObject.h" @@ -29,7 +29,7 @@ typedef NS_ENUM(NSInteger, OWSInteractionType) { - (OWSInteractionType)interactionType; -- (TSThread *)threadWithTransaction:(YapDatabaseReadWriteTransaction *)transaction; +- (TSThread *)threadWithTransaction:(YapDatabaseReadTransaction *)transaction; /** * When an interaction is updated, it often affects the UI for it's containing thread. Touching it's thread will notify diff --git a/SignalServiceKit/src/Messages/Interactions/TSInteraction.m b/SignalServiceKit/src/Messages/Interactions/TSInteraction.m index 87b904c1c..e95088d8d 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSInteraction.m +++ b/SignalServiceKit/src/Messages/Interactions/TSInteraction.m @@ -77,7 +77,7 @@ NS_ASSUME_NONNULL_BEGIN return [TSThread fetchObjectWithUniqueID:self.uniqueThreadId]; } -- (TSThread *)threadWithTransaction:(YapDatabaseReadWriteTransaction *)transaction +- (TSThread *)threadWithTransaction:(YapDatabaseReadTransaction *)transaction { return [TSThread fetchObjectWithUniqueID:self.uniqueThreadId transaction:transaction]; } diff --git a/SignalServiceKit/src/Messages/Interactions/TSMessage.h b/SignalServiceKit/src/Messages/Interactions/TSMessage.h index e00caa19b..85fd3519f 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSMessage.h +++ b/SignalServiceKit/src/Messages/Interactions/TSMessage.h @@ -1,5 +1,5 @@ // -// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. // #import "TSInteraction.h" @@ -10,7 +10,7 @@ NS_ASSUME_NONNULL_BEGIN * Abstract message class. */ -@class TSAttachmentPointer; +@class TSAttachment; @interface TSMessage : TSInteraction @@ -50,7 +50,7 @@ NS_ASSUME_NONNULL_BEGIN - (nullable instancetype)initWithCoder:(NSCoder *)coder NS_DESIGNATED_INITIALIZER; - (BOOL)hasAttachments; - +- (nullable TSAttachment *)attachmentWithTransaction:(YapDatabaseReadTransaction *)transaction; - (NSString *)previewTextWithTransaction:(YapDatabaseReadTransaction *)transaction; - (BOOL)shouldStartExpireTimer; diff --git a/SignalServiceKit/src/Messages/Interactions/TSMessage.m b/SignalServiceKit/src/Messages/Interactions/TSMessage.m index 8b8f425ab..e50ca6c0d 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSMessage.m +++ b/SignalServiceKit/src/Messages/Interactions/TSMessage.m @@ -226,6 +226,16 @@ static const NSUInteger OWSMessageSchemaVersion = 4; return self.attachmentIds ? (self.attachmentIds.count > 0) : NO; } +- (nullable TSAttachment *)attachmentWithTransaction:(YapDatabaseReadTransaction *)transaction +{ + if (!self.hasAttachments) { + return nil; + } + + OWSAssert(self.attachmentIds.count == 1); + return [TSAttachment fetchObjectWithUniqueID:self.attachmentIds.firstObject transaction:transaction]; +} + - (NSString *)debugDescription { if ([self hasAttachments] && self.body.length > 0) { diff --git a/SignalServiceKit/src/Storage/OWSMediaGalleryFinder.h b/SignalServiceKit/src/Storage/OWSMediaGalleryFinder.h new file mode 100644 index 000000000..e046036c6 --- /dev/null +++ b/SignalServiceKit/src/Storage/OWSMediaGalleryFinder.h @@ -0,0 +1,28 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +NS_ASSUME_NONNULL_BEGIN + +@class OWSStorage; +@class TSMessage; +@class TSThread; +@class YapDatabaseReadTransaction; + +@interface OWSMediaGalleryFinder : NSObject + +// How many media items a thread has +- (NSUInteger)mediaCountForThread:(TSThread *)thread transaction:(YapDatabaseReadTransaction *)transaction NS_SWIFT_NAME(mediaCount(thread:transaction:)); + +// The ordinal position of a message within a thread's media gallery +- (NSUInteger)mediaIndexForMessage:(TSMessage *)message transaction:(YapDatabaseReadTransaction *)transaction NS_SWIFT_NAME(mediaIndex(message:transaction:)); + +- (void)enumerateMediaMessagesWithThread:(TSThread *)thread + transaction:(YapDatabaseReadTransaction *)transaction + block:(void (^)(TSMessage *))messageBlock; + ++ (void)asyncRegisterDatabaseExtensionsWithPrimaryStorage:(OWSStorage *)storage; + +@end + +NS_ASSUME_NONNULL_END diff --git a/SignalServiceKit/src/Storage/OWSMediaGalleryFinder.m b/SignalServiceKit/src/Storage/OWSMediaGalleryFinder.m new file mode 100644 index 000000000..a79574eb1 --- /dev/null +++ b/SignalServiceKit/src/Storage/OWSMediaGalleryFinder.m @@ -0,0 +1,149 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +#import "OWSMediaGalleryFinder.h" +#import "OWSStorage.h" +#import "TSAttachmentStream.h" +#import "TSMessage.h" +#import "TSThread.h" +#import +#import +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +static NSString *const OWSMediaGalleryFinderExtensionName = @"OWSMediaGalleryFinderExtensionName"; + +@implementation OWSMediaGalleryFinder + +#pragma mark - Public Finder Methods + +- (NSUInteger)mediaCountForThread:(TSThread *)thread transaction:(YapDatabaseReadTransaction *)transaction +{ + NSString *group = [self mediaGroupWithThreadId:thread.uniqueId]; + return [[self galleryExtensionWithTransaction:transaction] numberOfItemsInGroup:group]; +} + +- (NSUInteger)mediaIndexForMessage:(TSMessage *)message transaction:(YapDatabaseReadTransaction *)transaction +{ + NSString *groupId; + NSUInteger index; + + BOOL wasFound = [[self galleryExtensionWithTransaction:transaction] getGroup:&groupId + index:&index + forKey:message.uniqueId + inCollection:[TSMessage collection]]; + + OWSAssert(wasFound); + + return index; +} + +- (void)enumerateMediaMessagesWithThread:(TSThread *)thread + transaction:(YapDatabaseReadTransaction *)transaction + block:(void (^)(TSMessage *))messageBlock +{ + NSString *group = [self mediaGroupWithThreadId:thread.uniqueId]; + [[self galleryExtensionWithTransaction:transaction] + enumerateKeysAndObjectsInGroup:group + usingBlock:^(NSString *_Nonnull collection, + NSString *_Nonnull key, + id _Nonnull object, + NSUInteger index, + BOOL *_Nonnull stop) { + OWSAssert([object isKindOfClass:[TSMessage class]]); + messageBlock((TSMessage *)object); + }]; +} + +#pragma mark - Util + +- (YapDatabaseAutoViewTransaction *)galleryExtensionWithTransaction:(YapDatabaseReadTransaction *)transaction +{ + YapDatabaseAutoViewTransaction *extension = [transaction extension:OWSMediaGalleryFinderExtensionName]; + OWSAssert(extension); + + return extension; +} + ++ (NSString *)mediaGroupWithThreadId:(NSString *)threadId +{ + return [NSString stringWithFormat:@"%@-media", threadId]; +} + +- (NSString *)mediaGroupWithThreadId:(NSString *)threadId +{ + return [[self class] mediaGroupWithThreadId:threadId]; +} + +#pragma mark - Extension registration + ++ (void)asyncRegisterDatabaseExtensionsWithPrimaryStorage:(OWSStorage *)storage +{ + [storage asyncRegisterExtension:[self mediaGalleryDatabaseExtension] + withName:OWSMediaGalleryFinderExtensionName]; +} + ++ (YapDatabaseAutoView *)mediaGalleryDatabaseExtension +{ + YapDatabaseViewSorting *sorting = [YapDatabaseViewSorting withObjectBlock:^NSComparisonResult(YapDatabaseReadTransaction * _Nonnull transaction, NSString * _Nonnull group, NSString * _Nonnull collection1, NSString * _Nonnull key1, id _Nonnull object1, NSString * _Nonnull collection2, NSString * _Nonnull key2, id _Nonnull object2) { + + if (![object1 isKindOfClass:[TSMessage class]]) { + OWSFail(@"%@ Unexpected object while sorting: %@", self.logTag, [object1 class]); + return NSOrderedSame; + } + TSMessage *message1 = (TSMessage *)object1; + + if (![object2 isKindOfClass:[TSMessage class]]) { + OWSFail(@"%@ Unexpected object while sorting: %@", self.logTag, [object2 class]); + return NSOrderedSame; + } + TSMessage *message2 = (TSMessage *)object2; + + return [@(message1.timestampForSorting) compare:@(message2.timestampForSorting)]; + }]; + + YapDatabaseViewGrouping *grouping = [YapDatabaseViewGrouping withObjectBlock:^NSString * _Nullable(YapDatabaseReadTransaction * _Nonnull transaction, NSString * _Nonnull collection, NSString * _Nonnull key, id _Nonnull object) { + + if (![object isKindOfClass:[TSMessage class]]) { + return nil; + } + TSMessage *message = (TSMessage *)object; + + OWSAssert(message.attachmentIds.count <= 1); + NSString *attachmentId = message.attachmentIds.firstObject; + if (attachmentId.length == 0) { + return nil; + } + + if ([self attachmentIdShouldAppearInMediaGallery:attachmentId transaction:transaction]) { + return [self mediaGroupWithThreadId:message.uniqueThreadId]; + } + + return nil; + }]; + + YapDatabaseViewOptions *options = [YapDatabaseViewOptions new]; + options.allowedCollections = [[YapWhitelistBlacklist alloc] initWithWhitelist:[NSSet setWithObject:TSMessage.collection]]; + + return [[YapDatabaseAutoView alloc] initWithGrouping:grouping sorting:sorting versionTag:@"1" options:options]; +} + ++ (BOOL)attachmentIdShouldAppearInMediaGallery:(NSString *)attachmentId transaction:(YapDatabaseReadTransaction *)transaction +{ + TSAttachmentStream *attachment = [TSAttachmentStream fetchObjectWithUniqueID:attachmentId + transaction:transaction]; + + // Don't include nil or not yet downloaded attachments. + if (![attachment isKindOfClass:[TSAttachmentStream class]]) { + return NO; + } + + return attachment.isImage || attachment.isVideo || attachment.isAnimated; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/SignalServiceKit/src/Storage/OWSPrimaryStorage.m b/SignalServiceKit/src/Storage/OWSPrimaryStorage.m index 5689628c6..3d575e4f7 100644 --- a/SignalServiceKit/src/Storage/OWSPrimaryStorage.m +++ b/SignalServiceKit/src/Storage/OWSPrimaryStorage.m @@ -11,6 +11,7 @@ #import "OWSFailedMessagesJob.h" #import "OWSFileSystem.h" #import "OWSIncomingMessageFinder.h" +#import "OWSMediaGalleryFinder.h" #import "OWSMessageReceiver.h" #import "OWSStorage+Subclass.h" #import "TSDatabaseSecondaryIndexes.h" @@ -55,6 +56,7 @@ void runAsyncRegistrationsForStorage(OWSStorage *storage) [OWSDisappearingMessagesFinder asyncRegisterDatabaseExtensions:storage]; [OWSFailedMessagesJob asyncRegisterDatabaseExtensionsWithPrimaryStorage:storage]; [OWSFailedAttachmentDownloadsJob asyncRegisterDatabaseExtensionsWithPrimaryStorage:storage]; + [OWSMediaGalleryFinder asyncRegisterDatabaseExtensionsWithPrimaryStorage:storage]; } #pragma mark -