mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
Media page view controller
First pass at a swipe-through media view for conversations. Future work could include - title label per item - sender name - date/time - photo rail - include caption // FREEBIE
This commit is contained in:
parent
88e1386720
commit
4ac9a1019b
17 changed files with 1032 additions and 423 deletions
2
Pods
2
Pods
|
@ -1 +1 @@
|
||||||
Subproject commit 93e79025cf285042cb397f3f4d1e0d52c68b9ecc
|
Subproject commit 594b44bf169e0ee2a690507ad09ff396888e81f9
|
|
@ -287,6 +287,7 @@
|
||||||
454A965B1FD601BF008D2A0E /* MediaMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34CA1C281F7164F700E51C51 /* MediaMessageView.swift */; };
|
454A965B1FD601BF008D2A0E /* MediaMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34CA1C281F7164F700E51C51 /* MediaMessageView.swift */; };
|
||||||
454A965F1FD60EA3008D2A0E /* OWSFlatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 454A965E1FD60EA2008D2A0E /* OWSFlatButton.swift */; };
|
454A965F1FD60EA3008D2A0E /* OWSFlatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 454A965E1FD60EA2008D2A0E /* OWSFlatButton.swift */; };
|
||||||
454EBAB41F2BE14C00ACE0BB /* OWSAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D99C911F2937CC00D284D6 /* OWSAnalytics.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 */; };
|
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, ); }; };
|
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, ); }; };
|
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 = "<group>"; };
|
45464DBB1DFA041F001D3FD6 /* DataChannelMessage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataChannelMessage.swift; sourceTree = "<group>"; };
|
||||||
454A965E1FD60EA2008D2A0E /* OWSFlatButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = OWSFlatButton.swift; path = SignalMessaging/Views/OWSFlatButton.swift; sourceTree = SOURCE_ROOT; };
|
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 = "<group>"; };
|
454B35071D08EED80026D658 /* mk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = mk; path = translations/mk.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
4551DB59205C562300C8AE75 /* Collection+OWS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+OWS.swift"; sourceTree = "<group>"; };
|
||||||
4556FA671F54AA9500AF40DD /* DebugUIProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DebugUIProfile.swift; sourceTree = "<group>"; };
|
4556FA671F54AA9500AF40DD /* DebugUIProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DebugUIProfile.swift; sourceTree = "<group>"; };
|
||||||
455A16DB1F1FEA0000F86704 /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; };
|
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; };
|
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 */,
|
34480B601FD0A98800BC14EF /* UIView+OWS.m */,
|
||||||
346129D41FD20ADC00532771 /* UIViewController+OWS.h */,
|
346129D41FD20ADC00532771 /* UIViewController+OWS.h */,
|
||||||
346129D31FD20ADB00532771 /* UIViewController+OWS.m */,
|
346129D31FD20ADB00532771 /* UIViewController+OWS.m */,
|
||||||
|
4551DB59205C562300C8AE75 /* Collection+OWS.swift */,
|
||||||
);
|
);
|
||||||
path = categories;
|
path = categories;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -3065,6 +3068,7 @@
|
||||||
451F8A381FD7117E005CB9DA /* OWSViewController.m in Sources */,
|
451F8A381FD7117E005CB9DA /* OWSViewController.m in Sources */,
|
||||||
346129721FD1D74C00532771 /* SignalKeyingStorage.m in Sources */,
|
346129721FD1D74C00532771 /* SignalKeyingStorage.m in Sources */,
|
||||||
34480B561FD0A7A400BC14EF /* DebugLogger.m in Sources */,
|
34480B561FD0A7A400BC14EF /* DebugLogger.m in Sources */,
|
||||||
|
4551DB5A205C562300C8AE75 /* Collection+OWS.swift in Sources */,
|
||||||
3461293C1FD1D46A00532771 /* OWSMath.m in Sources */,
|
3461293C1FD1D46A00532771 /* OWSMath.m in Sources */,
|
||||||
451F8A391FD711D6005CB9DA /* ContactsViewHelper.m in Sources */,
|
451F8A391FD711D6005CB9DA /* ContactsViewHelper.m in Sources */,
|
||||||
346129AF1FD1F5D900532771 /* SystemContactsFetcher.swift in Sources */,
|
346129AF1FD1F5D900532771 /* SystemContactsFetcher.swift in Sources */,
|
||||||
|
|
|
@ -21,6 +21,7 @@
|
||||||
#import "OWSBezierPathView.h"
|
#import "OWSBezierPathView.h"
|
||||||
#import "OWSCallNotificationsAdaptee.h"
|
#import "OWSCallNotificationsAdaptee.h"
|
||||||
#import "OWSDatabaseMigration.h"
|
#import "OWSDatabaseMigration.h"
|
||||||
|
#import "OWSMessageCell.h"
|
||||||
#import "OWSNavigationController.h"
|
#import "OWSNavigationController.h"
|
||||||
#import "OWSProgressView.h"
|
#import "OWSProgressView.h"
|
||||||
#import "OWSWebRTCDataProtos.pb.h"
|
#import "OWSWebRTCDataProtos.pb.h"
|
||||||
|
@ -90,6 +91,7 @@
|
||||||
#import <SignalServiceKit/OWSError.h>
|
#import <SignalServiceKit/OWSError.h>
|
||||||
#import <SignalServiceKit/OWSFileSystem.h>
|
#import <SignalServiceKit/OWSFileSystem.h>
|
||||||
#import <SignalServiceKit/OWSIdentityManager.h>
|
#import <SignalServiceKit/OWSIdentityManager.h>
|
||||||
|
#import <SignalServiceKit/OWSMediaGalleryFinder.h>
|
||||||
#import <SignalServiceKit/OWSMessageManager.h>
|
#import <SignalServiceKit/OWSMessageManager.h>
|
||||||
#import <SignalServiceKit/OWSMessageReceiver.h>
|
#import <SignalServiceKit/OWSMessageReceiver.h>
|
||||||
#import <SignalServiceKit/OWSMessageSender.h>
|
#import <SignalServiceKit/OWSMessageSender.h>
|
||||||
|
|
|
@ -18,7 +18,6 @@
|
||||||
#import "DateUtil.h"
|
#import "DateUtil.h"
|
||||||
#import "DebugUITableViewController.h"
|
#import "DebugUITableViewController.h"
|
||||||
#import "FingerprintViewController.h"
|
#import "FingerprintViewController.h"
|
||||||
#import "MediaDetailViewController.h"
|
|
||||||
#import "NSAttributedString+OWS.h"
|
#import "NSAttributedString+OWS.h"
|
||||||
#import "NewGroupViewController.h"
|
#import "NewGroupViewController.h"
|
||||||
#import "OWSAudioPlayer.h"
|
#import "OWSAudioPlayer.h"
|
||||||
|
@ -2028,8 +2027,15 @@ typedef enum : NSUInteger {
|
||||||
|
|
||||||
[self dismissKeyBoard];
|
[self dismissKeyBoard];
|
||||||
|
|
||||||
MediaDetailViewController *vc = [[MediaDetailViewController alloc] initWithAttachmentStream:attachmentStream
|
if (![viewItem.interaction isKindOfClass:[TSMessage class]]) {
|
||||||
viewItem:viewItem];
|
OWSFail(@"Unexpected viewItem.interaction");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TSMessage *mediaMessage = (TSMessage *)viewItem.interaction;
|
||||||
|
|
||||||
|
MediaPageViewController *vc =
|
||||||
|
[[MediaPageViewController alloc] initWithThread:self.thread mediaMessage:mediaMessage];
|
||||||
|
|
||||||
[vc presentFromViewController:self replacingView:imageView];
|
[vc presentFromViewController:self replacingView:imageView];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2042,8 +2048,15 @@ typedef enum : NSUInteger {
|
||||||
OWSAssert(attachmentStream);
|
OWSAssert(attachmentStream);
|
||||||
|
|
||||||
[self dismissKeyBoard];
|
[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];
|
[vc presentFromViewController:self replacingView:imageView];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,18 +7,42 @@
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
@class ConversationViewItem;
|
@class ConversationViewItem;
|
||||||
|
@class MediaDetailViewController;
|
||||||
@class SignalAttachment;
|
@class SignalAttachment;
|
||||||
@class TSAttachmentStream;
|
@class TSAttachmentStream;
|
||||||
|
|
||||||
|
@protocol MediaDetailViewControllerDelegate <NSObject>
|
||||||
|
|
||||||
|
- (void)dismissSelfAnimated:(BOOL)isAnimated completion:(void (^_Nullable)(void))completionBlock;
|
||||||
|
- (void)mediaDetailViewController:(MediaDetailViewController *)mediaDetailViewController
|
||||||
|
isPlayingVideo:(BOOL)isPlayingVideo;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
@interface MediaDetailViewController : OWSViewController
|
@interface MediaDetailViewController : OWSViewController
|
||||||
|
|
||||||
|
@property (nonatomic, weak) id<MediaDetailViewControllerDelegate> delegate;
|
||||||
|
|
||||||
// If viewItem is non-null, long press will show a menu controller.
|
// If viewItem is non-null, long press will show a menu controller.
|
||||||
- (instancetype)initWithAttachmentStream:(TSAttachmentStream *)attachmentStream
|
- (instancetype)initWithAttachmentStream:(TSAttachmentStream *)attachmentStream
|
||||||
viewItem:(ConversationViewItem *_Nullable)viewItem;
|
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
|
@end
|
||||||
|
|
||||||
|
|
|
@ -62,12 +62,8 @@ NS_ASSUME_NONNULL_BEGIN
|
||||||
@property (nonatomic) NSData *fileData;
|
@property (nonatomic) NSData *fileData;
|
||||||
|
|
||||||
@property (nonatomic, nullable) TSAttachmentStream *attachmentStream;
|
@property (nonatomic, nullable) TSAttachmentStream *attachmentStream;
|
||||||
@property (nonatomic, nullable) SignalAttachment *attachment;
|
|
||||||
@property (nonatomic, nullable) ConversationViewItem *viewItem;
|
@property (nonatomic, nullable) ConversationViewItem *viewItem;
|
||||||
|
|
||||||
@property (nonatomic) UIToolbar *footerBar;
|
|
||||||
@property (nonatomic) BOOL areToolbarsHidden;
|
|
||||||
|
|
||||||
@property (nonatomic, nullable) OWSVideoPlayer *videoPlayer;
|
@property (nonatomic, nullable) OWSVideoPlayer *videoPlayer;
|
||||||
@property (nonatomic, nullable) UIButton *playVideoButton;
|
@property (nonatomic, nullable) UIButton *playVideoButton;
|
||||||
@property (nonatomic, nullable) PlayerProgressBar *videoProgressBar;
|
@property (nonatomic, nullable) PlayerProgressBar *videoProgressBar;
|
||||||
|
@ -98,27 +94,9 @@ NS_ASSUME_NONNULL_BEGIN
|
||||||
return self;
|
return self;
|
||||||
}
|
}
|
||||||
|
|
||||||
- (instancetype)initWithAttachment:(SignalAttachment *)attachment
|
|
||||||
{
|
|
||||||
self = [super initWithNibName:nil bundle:nil];
|
|
||||||
if (!self) {
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.attachment = attachment;
|
|
||||||
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSURL *_Nullable)attachmentUrl
|
- (NSURL *_Nullable)attachmentUrl
|
||||||
{
|
{
|
||||||
if (self.attachmentStream) {
|
|
||||||
return self.attachmentStream.mediaURL;
|
return self.attachmentStream.mediaURL;
|
||||||
} else if (self.attachment) {
|
|
||||||
return self.attachment.dataUrl;
|
|
||||||
} else {
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- (NSData *)fileData
|
- (NSData *)fileData
|
||||||
|
@ -134,39 +112,17 @@ NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
- (UIImage *)image
|
- (UIImage *)image
|
||||||
{
|
{
|
||||||
if (self.attachmentStream) {
|
|
||||||
return self.attachmentStream.image;
|
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
|
- (BOOL)isAnimated
|
||||||
{
|
{
|
||||||
if (self.attachmentStream) {
|
|
||||||
return self.attachmentStream.isAnimated;
|
return self.attachmentStream.isAnimated;
|
||||||
} else if (self.attachment) {
|
|
||||||
return self.attachment.isAnimatedImage;
|
|
||||||
} else {
|
|
||||||
return NO;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- (BOOL)isVideo
|
- (BOOL)isVideo
|
||||||
{
|
{
|
||||||
if (self.attachmentStream) {
|
|
||||||
return self.attachmentStream.isVideo;
|
return self.attachmentStream.isVideo;
|
||||||
} else if (self.attachment) {
|
|
||||||
return self.attachment.isVideo;
|
|
||||||
} else {
|
|
||||||
return NO;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)loadView
|
- (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 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.
|
// The alternative would be that content would shift when the navbars hide.
|
||||||
self.extendedLayoutIncludesOpaqueBars = YES;
|
self.extendedLayoutIncludesOpaqueBars = YES;
|
||||||
|
}
|
||||||
|
|
||||||
// FIXME better title.
|
- (void)viewWillAppear:(BOOL)animated
|
||||||
self.title = @"Attachment";
|
{
|
||||||
|
[super viewWillAppear:animated];
|
||||||
self.navigationItem.leftBarButtonItem =
|
[self resetMediaFrame];
|
||||||
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemStop
|
|
||||||
target:self
|
|
||||||
action:@selector(didTapDismissButton:)];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)viewWillDisappear:(BOOL)animated
|
- (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
|
#pragma mark - Initializers
|
||||||
|
|
||||||
- (void)createContents
|
- (void)createContents
|
||||||
{
|
{
|
||||||
CGFloat kFooterHeight = 44;
|
|
||||||
|
|
||||||
UIScrollView *scrollView = [UIScrollView new];
|
UIScrollView *scrollView = [UIScrollView new];
|
||||||
[self.view addSubview:scrollView];
|
[self.view addSubview:scrollView];
|
||||||
self.scrollView = scrollView;
|
self.scrollView = scrollView;
|
||||||
|
@ -295,24 +254,16 @@ NS_ASSUME_NONNULL_BEGIN
|
||||||
self.mediaView.layer.minificationFilter = kCAFilterTrilinear;
|
self.mediaView.layer.minificationFilter = kCAFilterTrilinear;
|
||||||
self.mediaView.layer.magnificationFilter = 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) {
|
if (self.isVideo) {
|
||||||
PlayerProgressBar *videoProgressBar = [PlayerProgressBar new];
|
PlayerProgressBar *videoProgressBar = [PlayerProgressBar new];
|
||||||
videoProgressBar.delegate = self;
|
videoProgressBar.delegate = self;
|
||||||
videoProgressBar.player = self.videoPlayer.avPlayer;
|
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.videoProgressBar = videoProgressBar;
|
||||||
[self.view addSubview:videoProgressBar];
|
[self.view addSubview:videoProgressBar];
|
||||||
[videoProgressBar autoPinWidthToSuperview];
|
[videoProgressBar autoPinWidthToSuperview];
|
||||||
|
@ -335,98 +286,6 @@ NS_ASSUME_NONNULL_BEGIN
|
||||||
[playVideoButton autoSetDimensionsToSize:CGSizeMake(playVideoButtonWidth, playVideoButtonWidth)];
|
[playVideoButton autoSetDimensionsToSize:CGSizeMake(playVideoButtonWidth, playVideoButtonWidth)];
|
||||||
[playVideoButton autoCenterInSuperview];
|
[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<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) {
|
|
||||||
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<NSLayoutConstraint *> *presentationViewConstraints = [NSMutableArray new];
|
|
||||||
self.presentationViewConstraints = presentationViewConstraints;
|
|
||||||
|
|
||||||
[presentationViewConstraints
|
|
||||||
addObjectsFromArray:[self.presentationView autoSetDimensionsToSize:convertedRect.size]];
|
|
||||||
[presentationViewConstraints addObjectsFromArray:@[
|
|
||||||
[self.presentationView autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:convertedRect.origin.y],
|
|
||||||
[self.presentationView autoPinEdgeToSuperviewEdge:ALEdgeLeft withInset:convertedRect.origin.x]
|
|
||||||
]];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)applyFinalMediaViewConstraints
|
|
||||||
{
|
|
||||||
if (self.presentationViewConstraints.count > 0) {
|
|
||||||
[NSLayoutConstraint deactivateConstraints:self.presentationViewConstraints];
|
|
||||||
}
|
|
||||||
|
|
||||||
NSMutableArray<NSLayoutConstraint *> *presentationViewConstraints = [NSMutableArray new];
|
|
||||||
self.presentationViewConstraints = presentationViewConstraints;
|
|
||||||
|
|
||||||
[presentationViewConstraints addObjectsFromArray:@[
|
|
||||||
[self.presentationView autoPinEdgeToSuperviewEdge:ALEdgeLeading],
|
|
||||||
[self.presentationView autoPinEdgeToSuperviewEdge:ALEdgeTop],
|
|
||||||
[self.presentationView autoPinEdgeToSuperviewEdge:ALEdgeTrailing],
|
|
||||||
[self.presentationView autoPinEdgeToSuperviewEdge:ALEdgeBottom]
|
|
||||||
]];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- (UIView *)buildVideoPlayerView
|
- (UIView *)buildVideoPlayerView
|
||||||
|
@ -452,28 +311,9 @@ NS_ASSUME_NONNULL_BEGIN
|
||||||
return playerView;
|
return playerView;
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)setAreToolbarsHidden:(BOOL)areToolbarsHidden
|
- (void)setShouldHideToolbars:(BOOL)shouldHideToolbars
|
||||||
{
|
{
|
||||||
if (_areToolbarsHidden == areToolbarsHidden) {
|
self.videoProgressBar.hidden = shouldHideToolbars;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_areToolbarsHidden = areToolbarsHidden;
|
|
||||||
|
|
||||||
// Hiding the status bar affects the positioing of the navbar. We don't want to show that in an animation, it's
|
|
||||||
// better to just have everythign "flit" in/out.
|
|
||||||
[[UIApplication sharedApplication] setStatusBarHidden:areToolbarsHidden withAnimation:UIStatusBarAnimationNone];
|
|
||||||
[self.navigationController setNavigationBarHidden:areToolbarsHidden animated:NO];
|
|
||||||
self.videoProgressBar.hidden = areToolbarsHidden;
|
|
||||||
|
|
||||||
// We don't animate the background color change because the old color shows through momentarily
|
|
||||||
// behind where the status bar "used to be".
|
|
||||||
self.view.backgroundColor = areToolbarsHidden ? UIColor.blackColor : UIColor.whiteColor;
|
|
||||||
|
|
||||||
[UIView animateWithDuration:0.1
|
|
||||||
animations:^(void) {
|
|
||||||
self.footerBar.alpha = areToolbarsHidden ? 0 : 1;
|
|
||||||
}];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)initializeGestureRecognizers
|
- (void)initializeGestureRecognizers
|
||||||
|
@ -483,28 +323,6 @@ NS_ASSUME_NONNULL_BEGIN
|
||||||
doubleTap.numberOfTapsRequired = 2;
|
doubleTap.numberOfTapsRequired = 2;
|
||||||
[self.view addGestureRecognizer:doubleTap];
|
[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 *longPress =
|
||||||
[[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(longPressGesture:)];
|
[[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(longPressGesture:)];
|
||||||
longPress.delegate = self;
|
longPress.delegate = self;
|
||||||
|
@ -513,17 +331,6 @@ NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
#pragma mark - Gesture Recognizers
|
#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
|
- (void)didDoubleTapImage:(UITapGestureRecognizer *)gesture
|
||||||
{
|
{
|
||||||
DDLogVerbose(@"%@ did double tap image.", self.logTag);
|
DDLogVerbose(@"%@ did double tap image.", self.logTag);
|
||||||
|
@ -545,23 +352,10 @@ NS_ASSUME_NONNULL_BEGIN
|
||||||
[self.scrollView zoomToRect:translatedRect animated:YES];
|
[self.scrollView zoomToRect:translatedRect animated:YES];
|
||||||
} else {
|
} else {
|
||||||
// If already zoomed in at all, zoom out all the way.
|
// 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
|
- (void)longPressGesture:(UIGestureRecognizer *)sender
|
||||||
{
|
{
|
||||||
// We "eagerly" respond when the long press begins, not when it ends.
|
// We "eagerly" respond when the long press begins, not when it ends.
|
||||||
|
@ -618,20 +412,20 @@ NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
if ([navController.topViewController
|
if ([navController.topViewController
|
||||||
isKindOfClass:[ConversationViewController class]]) {
|
isKindOfClass:[ConversationViewController class]]) {
|
||||||
[self dismissSelfAnimated:YES
|
[self.delegate dismissSelfAnimated:YES
|
||||||
completion:^{
|
completion:^{
|
||||||
[self.viewItem deleteAction];
|
[self.viewItem deleteAction];
|
||||||
}];
|
}];
|
||||||
} else if ([navController.topViewController
|
} else if ([navController.topViewController
|
||||||
isKindOfClass:[MessageDetailViewController class]]) {
|
isKindOfClass:[MessageDetailViewController class]]) {
|
||||||
[self dismissSelfAnimated:NO
|
[self.delegate dismissSelfAnimated:YES
|
||||||
completion:^{
|
completion:^{
|
||||||
[self.viewItem deleteAction];
|
[self.viewItem deleteAction];
|
||||||
}];
|
}];
|
||||||
[navController popViewControllerAnimated:YES];
|
[navController popViewControllerAnimated:YES];
|
||||||
} else {
|
} else {
|
||||||
OWSFail(@"Unexpected presentation context.");
|
OWSFail(@"Unexpected presentation context.");
|
||||||
[self dismissSelfAnimated:YES
|
[self.delegate dismissSelfAnimated:YES
|
||||||
completion:^{
|
completion:^{
|
||||||
[self.viewItem deleteAction];
|
[self.viewItem deleteAction];
|
||||||
}];
|
}];
|
||||||
|
@ -710,156 +504,6 @@ NS_ASSUME_NONNULL_BEGIN
|
||||||
[self pauseVideo];
|
[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
|
#pragma mark - UIScrollViewDelegate
|
||||||
|
|
||||||
- (nullable UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView
|
- (nullable UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView
|
||||||
|
@ -889,17 +533,28 @@ NS_ASSUME_NONNULL_BEGIN
|
||||||
[self.view layoutIfNeeded];
|
[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
|
#pragma mark - Video Playback
|
||||||
|
|
||||||
- (void)playVideo
|
- (void)playVideo
|
||||||
{
|
{
|
||||||
OWSAssert(self.videoPlayer);
|
OWSAssert(self.videoPlayer);
|
||||||
|
|
||||||
[self updateFooterBarButtonItemsWithIsPlayingVideo:YES];
|
|
||||||
self.playVideoButton.hidden = YES;
|
self.playVideoButton.hidden = YES;
|
||||||
self.areToolbarsHidden = YES;
|
|
||||||
|
|
||||||
[self.videoPlayer play];
|
[self.videoPlayer play];
|
||||||
|
|
||||||
|
[self.delegate mediaDetailViewController:self isPlayingVideo:YES];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)pauseVideo
|
- (void)pauseVideo
|
||||||
|
@ -907,8 +562,21 @@ NS_ASSUME_NONNULL_BEGIN
|
||||||
OWSAssert(self.isVideo);
|
OWSAssert(self.isVideo);
|
||||||
OWSAssert(self.videoPlayer);
|
OWSAssert(self.videoPlayer);
|
||||||
|
|
||||||
[self updateFooterBarButtonItemsWithIsPlayingVideo:NO];
|
|
||||||
[self.videoPlayer pause];
|
[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
|
#pragma mark - OWSVideoPlayer
|
||||||
|
@ -919,10 +587,7 @@ NS_ASSUME_NONNULL_BEGIN
|
||||||
OWSAssert(self.videoPlayer);
|
OWSAssert(self.videoPlayer);
|
||||||
DDLogVerbose(@"%@ %s", self.logTag, __PRETTY_FUNCTION__);
|
DDLogVerbose(@"%@ %s", self.logTag, __PRETTY_FUNCTION__);
|
||||||
|
|
||||||
self.areToolbarsHidden = NO;
|
[self stopVideo];
|
||||||
self.playVideoButton.hidden = NO;
|
|
||||||
|
|
||||||
[self updateFooterBarButtonItemsWithIsPlayingVideo:NO];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma mark - PlayerProgressBarDelegate
|
#pragma mark - PlayerProgressBarDelegate
|
||||||
|
|
688
Signal/src/ViewControllers/MediaPageViewController.swift
Normal file
688
Signal/src/ViewControllers/MediaPageViewController.swift
Normal file
|
@ -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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -21,7 +21,7 @@ class MessageDetailViewController: OWSViewController, UIScrollViewDelegate, Medi
|
||||||
|
|
||||||
let contactsManager: OWSContactsManager
|
let contactsManager: OWSContactsManager
|
||||||
|
|
||||||
let databaseConnection: YapDatabaseConnection
|
let uiDatabaseConnection: YapDatabaseConnection
|
||||||
|
|
||||||
let bubbleFactory = OWSMessagesBubbleImageFactory()
|
let bubbleFactory = OWSMessagesBubbleImageFactory()
|
||||||
var bubbleView: UIView?
|
var bubbleView: UIView?
|
||||||
|
@ -60,7 +60,7 @@ class MessageDetailViewController: OWSViewController, UIScrollViewDelegate, Medi
|
||||||
self.viewItem = viewItem
|
self.viewItem = viewItem
|
||||||
self.message = message
|
self.message = message
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
self.databaseConnection = OWSPrimaryStorage.shared().newDatabaseConnection()
|
self.uiDatabaseConnection = OWSPrimaryStorage.shared().newDatabaseConnection()
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
@ -70,7 +70,7 @@ class MessageDetailViewController: OWSViewController, UIScrollViewDelegate, Medi
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
self.databaseConnection.beginLongLivedReadTransaction()
|
self.uiDatabaseConnection.beginLongLivedReadTransaction()
|
||||||
updateDBConnectionAndMessageToLatest()
|
updateDBConnectionAndMessageToLatest()
|
||||||
|
|
||||||
self.navigationItem.title = NSLocalizedString("MESSAGE_METADATA_VIEW_TITLE",
|
self.navigationItem.title = NSLocalizedString("MESSAGE_METADATA_VIEW_TITLE",
|
||||||
|
@ -161,6 +161,14 @@ class MessageDetailViewController: OWSViewController, UIScrollViewDelegate, Medi
|
||||||
updateContent()
|
updateContent()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lazy var thread: TSThread = {
|
||||||
|
var thread: TSThread?
|
||||||
|
self.uiDatabaseConnection.read { transaction in
|
||||||
|
thread = self.message.thread(with: transaction)
|
||||||
|
}
|
||||||
|
return thread!
|
||||||
|
}()
|
||||||
|
|
||||||
private func updateContent() {
|
private func updateContent() {
|
||||||
guard let contentView = contentView else {
|
guard let contentView = contentView else {
|
||||||
owsFail("\(TAG) Missing contentView")
|
owsFail("\(TAG) Missing contentView")
|
||||||
|
@ -174,7 +182,6 @@ class MessageDetailViewController: OWSViewController, UIScrollViewDelegate, Medi
|
||||||
|
|
||||||
var rows = [UIView]()
|
var rows = [UIView]()
|
||||||
let contactsManager = Environment.current().contactsManager!
|
let contactsManager = Environment.current().contactsManager!
|
||||||
let thread = message.thread
|
|
||||||
|
|
||||||
// Content
|
// Content
|
||||||
rows += contentRows()
|
rows += contentRows()
|
||||||
|
@ -191,7 +198,7 @@ class MessageDetailViewController: OWSViewController, UIScrollViewDelegate, Medi
|
||||||
// Recipient(s)
|
// Recipient(s)
|
||||||
if let outgoingMessage = message as? TSOutgoingMessage {
|
if let outgoingMessage = message as? TSOutgoingMessage {
|
||||||
|
|
||||||
let isGroupThread = message.thread.isGroupThread()
|
let isGroupThread = thread.isGroupThread()
|
||||||
|
|
||||||
let recipientStatusGroups: [MessageRecipientStatus] = [
|
let recipientStatusGroups: [MessageRecipientStatus] = [
|
||||||
.read,
|
.read,
|
||||||
|
@ -583,7 +590,7 @@ class MessageDetailViewController: OWSViewController, UIScrollViewDelegate, Medi
|
||||||
|
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
|
|
||||||
self.databaseConnection.read { transaction in
|
self.uiDatabaseConnection.read { transaction in
|
||||||
guard let uniqueId = self.message.uniqueId else {
|
guard let uniqueId = self.message.uniqueId else {
|
||||||
Logger.error("\(self.TAG) Message is missing uniqueId.")
|
Logger.error("\(self.TAG) Message is missing uniqueId.")
|
||||||
return
|
return
|
||||||
|
@ -600,13 +607,13 @@ class MessageDetailViewController: OWSViewController, UIScrollViewDelegate, Medi
|
||||||
internal func yapDatabaseModified(notification: NSNotification) {
|
internal func yapDatabaseModified(notification: NSNotification) {
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
|
|
||||||
let notifications = self.databaseConnection.beginLongLivedReadTransaction()
|
let notifications = self.uiDatabaseConnection.beginLongLivedReadTransaction()
|
||||||
|
|
||||||
guard let uniqueId = self.message.uniqueId else {
|
guard let uniqueId = self.message.uniqueId else {
|
||||||
Logger.error("\(self.TAG) Message is missing uniqueId.")
|
Logger.error("\(self.TAG) Message is missing uniqueId.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard self.databaseConnection.hasChange(forKey: uniqueId,
|
guard self.uiDatabaseConnection.hasChange(forKey: uniqueId,
|
||||||
inCollection: TSInteraction.collection(),
|
inCollection: TSInteraction.collection(),
|
||||||
in: notifications) else {
|
in: notifications) else {
|
||||||
Logger.debug("\(TAG) No relevant changes.")
|
Logger.debug("\(TAG) No relevant changes.")
|
||||||
|
@ -755,7 +762,7 @@ class MessageDetailViewController: OWSViewController, UIScrollViewDelegate, Medi
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let mediaDetailViewController = MediaDetailViewController(attachmentStream: attachmentStream, viewItem: self.viewItem)
|
let mediaPageViewController = MediaPageViewController(thread: self.thread, mediaMessage: self.message, includeGallery: false)
|
||||||
mediaDetailViewController.present(from: self, replacing: fromView)
|
mediaPageViewController.present(fromViewController: self, replacingView: fromView)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -53,6 +53,12 @@ public class OWSVideoPlayer: NSObject {
|
||||||
avPlayer.play()
|
avPlayer.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func stop() {
|
||||||
|
avPlayer.pause()
|
||||||
|
avPlayer.seek(to: kCMTimeZero)
|
||||||
|
OWSAudioSession.shared.endAudioActivity(self.audioActivity)
|
||||||
|
}
|
||||||
|
|
||||||
@objc(seekToTime:)
|
@objc(seekToTime:)
|
||||||
public func seek(to time: CMTime) {
|
public func seek(to time: CMTime) {
|
||||||
avPlayer.seek(to: time)
|
avPlayer.seek(to: time)
|
||||||
|
|
11
SignalMessaging/categories/Collection+OWS.swift
Normal file
11
SignalMessaging/categories/Collection+OWS.swift
Normal file
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
#import "TSYapDatabaseObject.h"
|
||||||
|
@ -29,7 +29,7 @@ typedef NS_ENUM(NSInteger, OWSInteractionType) {
|
||||||
|
|
||||||
- (OWSInteractionType)interactionType;
|
- (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
|
* When an interaction is updated, it often affects the UI for it's containing thread. Touching it's thread will notify
|
||||||
|
|
|
@ -77,7 +77,7 @@ NS_ASSUME_NONNULL_BEGIN
|
||||||
return [TSThread fetchObjectWithUniqueID:self.uniqueThreadId];
|
return [TSThread fetchObjectWithUniqueID:self.uniqueThreadId];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (TSThread *)threadWithTransaction:(YapDatabaseReadWriteTransaction *)transaction
|
- (TSThread *)threadWithTransaction:(YapDatabaseReadTransaction *)transaction
|
||||||
{
|
{
|
||||||
return [TSThread fetchObjectWithUniqueID:self.uniqueThreadId transaction:transaction];
|
return [TSThread fetchObjectWithUniqueID:self.uniqueThreadId transaction:transaction];
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
#import "TSInteraction.h"
|
||||||
|
@ -10,7 +10,7 @@ NS_ASSUME_NONNULL_BEGIN
|
||||||
* Abstract message class.
|
* Abstract message class.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@class TSAttachmentPointer;
|
@class TSAttachment;
|
||||||
|
|
||||||
@interface TSMessage : TSInteraction
|
@interface TSMessage : TSInteraction
|
||||||
|
|
||||||
|
@ -50,7 +50,7 @@ NS_ASSUME_NONNULL_BEGIN
|
||||||
- (nullable instancetype)initWithCoder:(NSCoder *)coder NS_DESIGNATED_INITIALIZER;
|
- (nullable instancetype)initWithCoder:(NSCoder *)coder NS_DESIGNATED_INITIALIZER;
|
||||||
|
|
||||||
- (BOOL)hasAttachments;
|
- (BOOL)hasAttachments;
|
||||||
|
- (nullable TSAttachment *)attachmentWithTransaction:(YapDatabaseReadTransaction *)transaction;
|
||||||
- (NSString *)previewTextWithTransaction:(YapDatabaseReadTransaction *)transaction;
|
- (NSString *)previewTextWithTransaction:(YapDatabaseReadTransaction *)transaction;
|
||||||
|
|
||||||
- (BOOL)shouldStartExpireTimer;
|
- (BOOL)shouldStartExpireTimer;
|
||||||
|
|
|
@ -226,6 +226,16 @@ static const NSUInteger OWSMessageSchemaVersion = 4;
|
||||||
return self.attachmentIds ? (self.attachmentIds.count > 0) : NO;
|
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
|
- (NSString *)debugDescription
|
||||||
{
|
{
|
||||||
if ([self hasAttachments] && self.body.length > 0) {
|
if ([self hasAttachments] && self.body.length > 0) {
|
||||||
|
|
28
SignalServiceKit/src/Storage/OWSMediaGalleryFinder.h
Normal file
28
SignalServiceKit/src/Storage/OWSMediaGalleryFinder.h
Normal file
|
@ -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
|
149
SignalServiceKit/src/Storage/OWSMediaGalleryFinder.m
Normal file
149
SignalServiceKit/src/Storage/OWSMediaGalleryFinder.m
Normal file
|
@ -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 <YapDatabase/YapDatabaseAutoView.h>
|
||||||
|
#import <YapDatabase/YapDatabaseTransaction.h>
|
||||||
|
#import <YapDatabase/YapDatabaseViewTypes.h>
|
||||||
|
#import <YapDatabase/YapWhitelistBlacklist.h>
|
||||||
|
|
||||||
|
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
|
|
@ -11,6 +11,7 @@
|
||||||
#import "OWSFailedMessagesJob.h"
|
#import "OWSFailedMessagesJob.h"
|
||||||
#import "OWSFileSystem.h"
|
#import "OWSFileSystem.h"
|
||||||
#import "OWSIncomingMessageFinder.h"
|
#import "OWSIncomingMessageFinder.h"
|
||||||
|
#import "OWSMediaGalleryFinder.h"
|
||||||
#import "OWSMessageReceiver.h"
|
#import "OWSMessageReceiver.h"
|
||||||
#import "OWSStorage+Subclass.h"
|
#import "OWSStorage+Subclass.h"
|
||||||
#import "TSDatabaseSecondaryIndexes.h"
|
#import "TSDatabaseSecondaryIndexes.h"
|
||||||
|
@ -55,6 +56,7 @@ void runAsyncRegistrationsForStorage(OWSStorage *storage)
|
||||||
[OWSDisappearingMessagesFinder asyncRegisterDatabaseExtensions:storage];
|
[OWSDisappearingMessagesFinder asyncRegisterDatabaseExtensions:storage];
|
||||||
[OWSFailedMessagesJob asyncRegisterDatabaseExtensionsWithPrimaryStorage:storage];
|
[OWSFailedMessagesJob asyncRegisterDatabaseExtensionsWithPrimaryStorage:storage];
|
||||||
[OWSFailedAttachmentDownloadsJob asyncRegisterDatabaseExtensionsWithPrimaryStorage:storage];
|
[OWSFailedAttachmentDownloadsJob asyncRegisterDatabaseExtensionsWithPrimaryStorage:storage];
|
||||||
|
[OWSMediaGalleryFinder asyncRegisterDatabaseExtensionsWithPrimaryStorage:storage];
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma mark -
|
#pragma mark -
|
||||||
|
|
Loading…
Reference in a new issue