Merge branch 'mkirk/message-actions'

pull/1/head
Michael Kirk 7 years ago
commit 34be31b163

@ -419,6 +419,8 @@
4C20B2B720CA0034001BAC90 /* ThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4542DF51208B82E9007B4E76 /* ThreadViewModel.swift */; };
4C20B2B920CA10DE001BAC90 /* ConversationSearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C20B2B820CA10DE001BAC90 /* ConversationSearchViewController.swift */; };
4C4AEC4520EC343B0020E72B /* DismissableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4AEC4420EC343B0020E72B /* DismissableTextField.swift */; };
4CB5F26720F6E1E2004D1B42 /* MenuActionsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF4C0920F55BBA005DA313 /* MenuActionsViewController.swift */; };
4CB5F26920F7D060004D1B42 /* MessageActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB5F26820F7D060004D1B42 /* MessageActions.swift */; };
4CC0B59C20EC5F2E00CF6EE0 /* ConversationConfigurationSyncOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CC0B59B20EC5F2E00CF6EE0 /* ConversationConfigurationSyncOperation.swift */; };
70377AAB1918450100CAF501 /* MobileCoreServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 70377AAA1918450100CAF501 /* MobileCoreServices.framework */; };
768A1A2B17FC9CD300E00ED8 /* libz.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = 768A1A2A17FC9CD300E00ED8 /* libz.dylib */; };
@ -1082,7 +1084,9 @@
4C13C9F520E57BA30089A98B /* ColorPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPickerViewController.swift; sourceTree = "<group>"; };
4C20B2B820CA10DE001BAC90 /* ConversationSearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationSearchViewController.swift; sourceTree = "<group>"; };
4C4AEC4420EC343B0020E72B /* DismissableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DismissableTextField.swift; sourceTree = "<group>"; };
4CB5F26820F7D060004D1B42 /* MessageActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageActions.swift; sourceTree = "<group>"; };
4CC0B59B20EC5F2E00CF6EE0 /* ConversationConfigurationSyncOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationConfigurationSyncOperation.swift; sourceTree = "<group>"; };
4CFF4C0920F55BBA005DA313 /* MenuActionsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuActionsViewController.swift; sourceTree = "<group>"; };
69349DE607F5BA6036C9AC60 /* Pods-SignalShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalShareExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SignalShareExtension/Pods-SignalShareExtension.debug.xcconfig"; sourceTree = "<group>"; };
70377AAA1918450100CAF501 /* MobileCoreServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MobileCoreServices.framework; path = System/Library/Frameworks/MobileCoreServices.framework; sourceTree = SDKROOT; };
748A5CAEDD7C919FC64C6807 /* Pods_SignalTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SignalTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@ -1689,6 +1693,7 @@
452EC6DE205E9E30000E787C /* MediaGalleryViewController.swift */,
45F32C1D205718B000A300D5 /* MediaPageViewController.swift */,
454A84032059C787008B8C75 /* MediaTileViewController.swift */,
4CFF4C0920F55BBA005DA313 /* MenuActionsViewController.swift */,
34CA1C261F7156F300E51C51 /* MessageDetailViewController.swift */,
34B3F84F1E8DF1700035BE1A /* NewContactThreadViewController.h */,
34B3F8501E8DF1700035BE1A /* NewContactThreadViewController.m */,
@ -1848,7 +1853,6 @@
450DF2061E0DD28D003D14BE /* UserInterface */ = {
isa = PBXGroup;
children = (
4541B719209D2D860008608F /* ViewModels */,
450DF2071E0DD29E003D14BE /* Notifications */,
34FD936E1E3BD43A00109093 /* OWSAnyTouchGestureRecognizer.h */,
34FD936F1E3BD43A00109093 /* OWSAnyTouchGestureRecognizer.m */,
@ -1931,13 +1935,6 @@
path = SignalMessaging;
sourceTree = "<group>";
};
4541B719209D2D860008608F /* ViewModels */ = {
isa = PBXGroup;
children = (
);
path = ViewModels;
sourceTree = "<group>";
};
4541B71C209D3B4F0008608F /* ViewModels */ = {
isa = PBXGroup;
children = (
@ -1994,6 +1991,7 @@
45DF5DF11DDB843F00C936C7 /* CompareSafetyNumbersActivity.swift */,
458E38351D668EBF0094BD24 /* OWSDeviceProvisioningURLParser.h */,
458E38361D668EBF0094BD24 /* OWSDeviceProvisioningURLParser.m */,
4CB5F26820F7D060004D1B42 /* MessageActions.swift */,
);
path = Models;
sourceTree = "<group>";
@ -3269,6 +3267,7 @@
340FC8CD20518C77007AEB0F /* OWSBackupJob.m in Sources */,
34D1F0AE1F867BFC0066283D /* OWSMessageCell.m in Sources */,
4C4AEC4520EC343B0020E72B /* DismissableTextField.swift in Sources */,
4CB5F26720F6E1E2004D1B42 /* MenuActionsViewController.swift in Sources */,
451686AB1F520CDA00AC3D4B /* MultiDeviceProfileKeyUpdateJob.swift in Sources */,
45D231771DC7E8F10034FA89 /* SessionResetJob.swift in Sources */,
340FC8A9204DAC8D007AEB0F /* NotificationSettingsOptionsViewController.m in Sources */,
@ -3354,6 +3353,7 @@
76EB054018170B33006006FC /* AppDelegate.m in Sources */,
34D1F0831F8678AA0066283D /* ConversationInputTextView.m in Sources */,
340FC8B6204DAC8D007AEB0F /* OWSQRCodeScanningViewController.m in Sources */,
4CB5F26920F7D060004D1B42 /* MessageActions.swift in Sources */,
340FC8B5204DAC8D007AEB0F /* AboutTableViewController.m in Sources */,
34BECE2B1F74C12700D7438D /* DebugUIStress.m in Sources */,
340FC8B9204DAC8D007AEB0F /* UpdateGroupViewController.m in Sources */,

@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "Copy-24@1x.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "Copy-24@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "Copy-24@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 664 B

@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "download-24@1x.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "download-24@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "download-24@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 B

@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "info-24@1x.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "info-24@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "info-24@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 966 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "reply-24@1x.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "reply-24@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "reply-24@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 807 B

@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "trash-24@1x.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "trash-24@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "trash-24@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 B

@ -0,0 +1,131 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
import Foundation
@objc
protocol MessageActionsDelegate: class {
func messageActionsShowDetailsForItem(_ conversationViewItem: ConversationViewItem)
func messageActionsReplyToItem(_ conversationViewItem: ConversationViewItem)
}
struct MessageActionBuilder {
static func reply(conversationViewItem: ConversationViewItem, delegate: MessageActionsDelegate) -> MenuAction {
return MenuAction(image: #imageLiteral(resourceName: "ic_reply"),
title: NSLocalizedString("MESSAGE_ACTION_REPLY", comment: "Action sheet button title"),
subtitle: nil,
block: { [weak delegate] (_) in
delegate?.messageActionsReplyToItem(conversationViewItem)
})
}
static func copyText(conversationViewItem: ConversationViewItem, delegate: MessageActionsDelegate) -> MenuAction {
return MenuAction(image: #imageLiteral(resourceName: "ic_copy"),
title: NSLocalizedString("MESSAGE_ACTION_COPY_TEXT", comment: "Action sheet button title"),
subtitle: nil,
block: { (_) in
conversationViewItem.copyTextAction()
})
}
static func showDetails(conversationViewItem: ConversationViewItem, delegate: MessageActionsDelegate) -> MenuAction {
return MenuAction(image: #imageLiteral(resourceName: "ic_info"),
title: NSLocalizedString("MESSAGE_ACTION_DETAILS", comment: "Action sheet button title"),
subtitle: nil,
block: { [weak delegate] (_) in
delegate?.messageActionsShowDetailsForItem(conversationViewItem)
})
}
static func deleteMessage(conversationViewItem: ConversationViewItem, delegate: MessageActionsDelegate) -> MenuAction {
return MenuAction(image: #imageLiteral(resourceName: "ic_trash"),
title: NSLocalizedString("MESSAGE_ACTION_DELETE_MESSAGE", comment: "Action sheet button title"),
subtitle: NSLocalizedString("MESSAGE_ACTION_DELETE_MESSAGE_SUBTITLE", comment: "Action sheet button subtitle"),
block: { (_) in
conversationViewItem.deleteAction()
})
}
static func copyMedia(conversationViewItem: ConversationViewItem, delegate: MessageActionsDelegate) -> MenuAction {
return MenuAction(image: #imageLiteral(resourceName: "ic_copy"),
title: NSLocalizedString("MESSAGE_ACTION_COPY_MEDIA", comment: "Action sheet button title"),
subtitle: nil,
block: { (_) in
conversationViewItem.copyMediaAction()
})
}
static func saveMedia(conversationViewItem: ConversationViewItem, delegate: MessageActionsDelegate) -> MenuAction {
return MenuAction(image: #imageLiteral(resourceName: "ic_download"),
title: NSLocalizedString("MESSAGE_ACTION_SAVE_MEDIA", comment: "Action sheet button title"),
subtitle: nil,
block: { (_) in
conversationViewItem.saveMediaAction()
})
}
}
extension ConversationViewItem {
@objc
func textActions(delegate: MessageActionsDelegate) -> [MenuAction] {
var actions: [MenuAction] = []
let replyAction = MessageActionBuilder.reply(conversationViewItem: self, delegate: delegate)
actions.append(replyAction)
if self.hasBodyTextActionContent {
let copyTextAction = MessageActionBuilder.copyText(conversationViewItem: self, delegate: delegate)
actions.append(copyTextAction)
}
let deleteAction = MessageActionBuilder.deleteMessage(conversationViewItem: self, delegate: delegate)
actions.append(deleteAction)
let showDetailsAction = MessageActionBuilder.showDetails(conversationViewItem: self, delegate: delegate)
actions.append(showDetailsAction)
return actions
}
@objc
func mediaActions(delegate: MessageActionsDelegate) -> [MenuAction] {
var actions: [MenuAction] = []
let replyAction = MessageActionBuilder.reply(conversationViewItem: self, delegate: delegate)
actions.append(replyAction)
if self.hasMediaActionContent {
let copyMediaAction = MessageActionBuilder.copyMedia(conversationViewItem: self, delegate: delegate)
actions.append(copyMediaAction)
let saveMediaAction = MessageActionBuilder.saveMedia(conversationViewItem: self, delegate: delegate)
actions.append(saveMediaAction)
}
let deleteAction = MessageActionBuilder.deleteMessage(conversationViewItem: self, delegate: delegate)
actions.append(deleteAction)
let showDetailsAction = MessageActionBuilder.showDetails(conversationViewItem: self, delegate: delegate)
actions.append(showDetailsAction)
return actions
}
@objc
func quotedMessageActions(delegate: MessageActionsDelegate) -> [MenuAction] {
let replyAction = MessageActionBuilder.reply(conversationViewItem: self, delegate: delegate)
let deleteAction = MessageActionBuilder.deleteMessage(conversationViewItem: self, delegate: delegate)
let showDetailsAction = MessageActionBuilder.showDetails(conversationViewItem: self, delegate: delegate)
return [replyAction, deleteAction, showDetailsAction]
}
@objc
func infoMessageActions(delegate: MessageActionsDelegate) -> [MenuAction] {
let deleteAction = MessageActionBuilder.deleteMessage(conversationViewItem: self, delegate: delegate)
return [deleteAction]
}
}

@ -22,12 +22,15 @@ NS_ASSUME_NONNULL_BEGIN
@protocol ConversationViewCellDelegate <NSObject>
- (void)conversationCell:(ConversationViewCell *)cell didLongpressTextViewItem:(ConversationViewItem *)viewItem;
- (void)conversationCell:(ConversationViewCell *)cell didLongpressMediaViewItem:(ConversationViewItem *)viewItem;
- (void)conversationCell:(ConversationViewCell *)cell didLongpressQuoteViewItem:(ConversationViewItem *)viewItem;
- (void)conversationCell:(ConversationViewCell *)cell
didLongpressSystemMessageViewItem:(ConversationViewItem *)viewItem;
- (void)didPanWithGestureRecognizer:(UIPanGestureRecognizer *)gestureRecognizer
viewItem:(ConversationViewItem *)conversationItem;
- (void)showMetadataViewForViewItem:(ConversationViewItem *)conversationItem;
- (void)conversationCell:(ConversationViewCell *)cell didTapReplyForViewItem:(ConversationViewItem *)conversationItem;
#pragma mark - System Cell
- (void)tappedNonBlockingIdentityChangeForRecipientId:(nullable NSString *)signalId;

@ -377,8 +377,6 @@ NS_ASSUME_NONNULL_BEGIN
[self.sendFailureBadgeView removeFromSuperview];
self.sendFailureBadgeView = nil;
[self hideMenuControllerIfNecessary];
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
@ -394,10 +392,6 @@ NS_ASSUME_NONNULL_BEGIN
}
[self ensureMediaLoadState];
if (!isCellVisible) {
[self hideMenuControllerIfNecessary];
}
}
#pragma mark - Gesture recognizers
@ -448,18 +442,15 @@ NS_ASSUME_NONNULL_BEGIN
switch ([self.messageBubbleView gestureLocationForLocation:locationInMessageBubble]) {
case OWSMessageGestureLocation_Default:
case OWSMessageGestureLocation_OversizeText: {
CGPoint location = [sender locationInView:self];
[self showTextMenuController:location];
[self.delegate conversationCell:self didLongpressTextViewItem:self.viewItem];
break;
}
case OWSMessageGestureLocation_Media: {
CGPoint location = [sender locationInView:self];
[self showMediaMenuController:location];
[self.delegate conversationCell:self didLongpressMediaViewItem:self.viewItem];
break;
}
case OWSMessageGestureLocation_QuotedReply: {
CGPoint location = [sender locationInView:self];
[self showDefaultMenuController:location];
[self.delegate conversationCell:self didLongpressQuoteViewItem:self.viewItem];
break;
}
}
@ -472,136 +463,6 @@ NS_ASSUME_NONNULL_BEGIN
[self.delegate didPanWithGestureRecognizer:panRecognizer viewItem:self.viewItem];
}
#pragma mark - UIMenuController
- (void)showTextMenuController:(CGPoint)fromLocation
{
[self showMenuController:fromLocation menuItems:self.viewItem.textMenuControllerItems];
}
- (void)showMediaMenuController:(CGPoint)fromLocation
{
[self showMenuController:fromLocation menuItems:self.viewItem.mediaMenuControllerItems];
}
- (void)showDefaultMenuController:(CGPoint)fromLocation
{
[self showMenuController:fromLocation menuItems:self.viewItem.defaultMenuControllerItems];
}
- (void)showMenuController:(CGPoint)fromLocation menuItems:(NSArray *)menuItems
{
if (menuItems.count < 1) {
OWSFail(@"%@ No menu items to present.", self.logTag);
return;
}
// We don't want taps on messages to hide the keyboard,
// so we only let messages become first responder
// while they are trying to present the menu controller.
self.isPresentingMenuController = YES;
[self becomeFirstResponder];
if ([UIMenuController sharedMenuController].isMenuVisible) {
[[UIMenuController sharedMenuController] setMenuVisible:NO animated:NO];
}
// We use custom action selectors so that we can control
// the ordering of the actions in the menu.
[UIMenuController sharedMenuController].menuItems = menuItems;
CGRect targetRect = CGRectMake(fromLocation.x, fromLocation.y, 1, 1);
[[UIMenuController sharedMenuController] setTargetRect:targetRect inView:self];
[[UIMenuController sharedMenuController] setMenuVisible:YES animated:YES];
}
- (BOOL)canPerformAction:(SEL)action withSender:(nullable id)sender
{
return [self.viewItem canPerformAction:action];
}
- (void)copyTextAction:(nullable id)sender
{
[self.viewItem copyTextAction];
}
- (void)copyMediaAction:(nullable id)sender
{
[self.viewItem copyMediaAction];
}
- (void)shareTextAction:(nullable id)sender
{
[self.viewItem shareTextAction];
}
- (void)shareMediaAction:(nullable id)sender
{
[self.viewItem shareMediaAction];
}
- (void)saveMediaAction:(nullable id)sender
{
[self.viewItem saveMediaAction];
}
- (void)deleteAction:(nullable id)sender
{
[self.viewItem deleteAction];
}
- (void)metadataAction:(nullable id)sender
{
OWSAssert([self.viewItem.interaction isKindOfClass:[TSMessage class]]);
[self.delegate showMetadataViewForViewItem:self.viewItem];
}
- (void)replyAction:(nullable id)sender
{
OWSAssert([self.viewItem.interaction isKindOfClass:[TSMessage class]]);
[self.delegate conversationCell:self didTapReplyForViewItem:self.viewItem];
}
- (BOOL)canBecomeFirstResponder
{
return self.isPresentingMenuController;
}
- (void)didHideMenuController:(NSNotification *)notification
{
self.isPresentingMenuController = NO;
}
- (void)setIsPresentingMenuController:(BOOL)isPresentingMenuController
{
if (_isPresentingMenuController == isPresentingMenuController) {
return;
}
_isPresentingMenuController = isPresentingMenuController;
if (isPresentingMenuController) {
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(didHideMenuController:)
name:UIMenuControllerDidHideMenuNotification
object:nil];
} else {
[[NSNotificationCenter defaultCenter] removeObserver:self
name:UIMenuControllerDidHideMenuNotification
object:nil];
}
}
- (void)hideMenuControllerIfNecessary
{
if (self.isPresentingMenuController) {
[[UIMenuController sharedMenuController] setMenuVisible:NO animated:NO];
}
self.isPresentingMenuController = NO;
}
@end
NS_ASSUME_NONNULL_END

@ -75,6 +75,7 @@ typedef void (^SystemMessageActionBlock)(void);
self.layoutMargins = UIEdgeInsetsZero;
self.contentView.layoutMargins = UIEdgeInsetsZero;
self.contentView.backgroundColor = UIColor.whiteColor;
self.iconView = [UIImageView new];
[self.iconView autoSetDimension:ALDimensionWidth toSize:self.iconSize];
@ -380,48 +381,6 @@ typedef void (^SystemMessageActionBlock)(void);
return result;
}
#pragma mark - UIMenuController
- (void)showMenuController
{
OWSAssertIsOnMainThread();
DDLogDebug(@"%@ long pressed system message cell: %@", self.logTag, self.viewItem.interaction.debugDescription);
[self becomeFirstResponder];
if ([UIMenuController sharedMenuController].isMenuVisible) {
[[UIMenuController sharedMenuController] setMenuVisible:NO animated:NO];
}
UIMenuController *menuController = [UIMenuController sharedMenuController];
menuController.menuItems = @[];
UIView *fromView = self.titleLabel;
CGRect targetRect = [fromView.superview convertRect:fromView.frame toView:self];
[menuController setTargetRect:targetRect inView:self];
[menuController setMenuVisible:YES animated:YES];
}
- (BOOL)canPerformAction:(SEL)action withSender:(nullable id)sender
{
return action == @selector(delete:);
}
- (void)delete:(nullable id)sender
{
DDLogInfo(@"%@ chose delete", self.logTag);
TSInteraction *interaction = self.viewItem.interaction;
OWSAssert(interaction);
[interaction remove];
}
- (BOOL)canBecomeFirstResponder
{
return YES;
}
#pragma mark - Actions
- (nullable SystemMessageAction *)actionForInteraction:(TSInteraction *)interaction
@ -575,7 +534,7 @@ typedef void (^SystemMessageActionBlock)(void);
OWSAssert(interaction);
if (longPress.state == UIGestureRecognizerStateBegan) {
[self showMenuController];
[self.delegate conversationCell:self didLongpressSystemMessageViewItem:self.viewItem];
}
}

@ -172,13 +172,6 @@ NS_ASSUME_NONNULL_BEGIN
#pragma mark - UITextViewDelegate
- (void)textViewDidBeginEditing:(UITextView *)textView
{
// TODO: Is this necessary?
[textView becomeFirstResponder];
}
- (void)textViewDidChange:(UITextView *)textView
{
OWSAssert(self.textViewToolbarDelegate);
@ -188,11 +181,6 @@ NS_ASSUME_NONNULL_BEGIN
[self.textViewToolbarDelegate textViewDidChange:self];
}
- (void)textViewDidEndEditing:(UITextView *)textView
{
[textView resignFirstResponder];
}
#pragma mark - Key Commands
- (nullable NSArray<UIKeyCommand *> *)keyCommands

@ -131,6 +131,8 @@ typedef enum : NSUInteger {
ConversationViewLayoutDelegate,
ConversationViewCellDelegate,
ConversationInputTextViewDelegate,
MessageActionsDelegate,
MenuActionsViewControllerDelegate,
OWSMessageBubbleViewDelegate,
UICollectionViewDelegate,
UICollectionViewDataSource,
@ -1204,6 +1206,8 @@ typedef enum : NSUInteger {
[super viewWillDisappear:animated];
self.isViewCompletelyAppeared = NO;
[[OWSWindowManager sharedManager] hideMenuActionsWindow];
}
- (void)viewDidDisappear:(BOOL)animated
@ -1978,8 +1982,121 @@ typedef enum : NSUInteger {
[self presentViewController:alertController animated:YES completion:nil];
}
#pragma mark - MessageActionsDelegate
- (void)messageActionsShowDetailsForItem:(ConversationViewItem *)conversationViewItem
{
[self showDetailViewForViewItem:conversationViewItem];
}
- (void)messageActionsReplyToItem:(ConversationViewItem *)conversationViewItem
{
[self populateReplyForViewItem:conversationViewItem];
}
#pragma mark - MenuActionsViewControllerDelegate
- (void)menuActionsDidHide:(MenuActionsViewController *)menuActionsViewController
{
[[OWSWindowManager sharedManager] hideMenuActionsWindow];
[self updateShouldObserveDBModifications];
}
- (void)menuActions:(MenuActionsViewController *)menuActionsViewController
isPresentingWithVerticalFocusChange:(CGFloat)verticalChange
{
UIEdgeInsets oldInset = self.collectionView.contentInset;
CGPoint oldOffset = self.collectionView.contentOffset;
UIEdgeInsets newInset = oldInset;
CGPoint newOffset = oldOffset;
// In case the message is at the very top or bottom edge of the conversation we have to have these additional
// insets to be sure we can sufficiently scroll the contentOffset.
newInset.top += verticalChange;
newInset.bottom -= verticalChange;
newOffset.y -= verticalChange;
DDLogDebug(@"%@ in %s verticalChange: %f, insets: %@ -> %@",
self.logTag,
__PRETTY_FUNCTION__,
verticalChange,
NSStringFromUIEdgeInsets(oldInset),
NSStringFromUIEdgeInsets(newInset));
// Because we're in the context of the frame-changing animation, these adjustments should happen
// in lockstep with the messageActions frame change.
self.collectionView.contentOffset = newOffset;
self.collectionView.contentInset = newInset;
}
- (void)menuActions:(MenuActionsViewController *)menuActionsViewController
isDismissingWithVerticalFocusChange:(CGFloat)verticalChange
{
UIEdgeInsets oldInset = self.collectionView.contentInset;
CGPoint oldOffset = self.collectionView.contentOffset;
UIEdgeInsets newInset = oldInset;
CGPoint newOffset = oldOffset;
// In case the message is at the very top or bottom edge of the conversation we have to have these additional
// insets to be sure we can sufficiently scroll the contentOffset.
newInset.top -= verticalChange;
newInset.bottom += verticalChange;
newOffset.y += verticalChange;
DDLogDebug(@"%@ in %s verticalChange: %f, insets: %@ -> %@",
self.logTag,
__PRETTY_FUNCTION__,
verticalChange,
NSStringFromUIEdgeInsets(oldInset),
NSStringFromUIEdgeInsets(newInset));
// Because we're in the context of the frame-changing animation, these adjustments should happen
// in lockstep with the messageActions frame change.
self.collectionView.contentOffset = newOffset;
self.collectionView.contentInset = newInset;
}
#pragma mark - ConversationViewCellDelegate
- (void)conversationCell:(ConversationViewCell *)cell didLongpressMediaViewItem:(ConversationViewItem *)viewItem
{
NSArray<MenuAction *> *messageActions = [viewItem mediaActionsWithDelegate:self];
[self presentMessageActions:messageActions withFocusedCell:cell];
}
- (void)conversationCell:(ConversationViewCell *)cell didLongpressTextViewItem:(ConversationViewItem *)viewItem
{
NSArray<MenuAction *> *messageActions = [viewItem textActionsWithDelegate:self];
[self presentMessageActions:messageActions withFocusedCell:cell];
}
- (void)conversationCell:(ConversationViewCell *)cell didLongpressQuoteViewItem:(ConversationViewItem *)viewItem
{
NSArray<MenuAction *> *messageActions = [viewItem quotedMessageActionsWithDelegate:self];
[self presentMessageActions:messageActions withFocusedCell:cell];
}
- (void)conversationCell:(ConversationViewCell *)cell didLongpressSystemMessageViewItem:(ConversationViewItem *)viewItem
{
NSArray<MenuAction *> *messageActions = [viewItem infoMessageActionsWithDelegate:self];
[self presentMessageActions:messageActions withFocusedCell:cell];
}
- (void)presentMessageActions:(NSArray<MenuAction *> *)messageActions withFocusedCell:(ConversationViewCell *)cell
{
MenuActionsViewController *menuActionsViewController =
[[MenuActionsViewController alloc] initWithFocusedView:cell actions:messageActions];
menuActionsViewController.delegate = self;
[[OWSWindowManager sharedManager] showMenuActionsWindow:menuActionsViewController];
[self updateShouldObserveDBModifications];
}
- (NSAttributedString *)attributedContactOrProfileNameForPhoneIdentifier:(NSString *)recipientId
{
OWSAssertIsOnMainThread();
@ -2401,7 +2518,7 @@ typedef enum : NSUInteger {
return @(groupIndex);
}
- (void)showMetadataViewForViewItem:(ConversationViewItem *)conversationItem
- (void)showDetailViewForViewItem:(ConversationViewItem *)conversationItem
{
OWSAssertIsOnMainThread();
OWSAssert(conversationItem);
@ -2416,7 +2533,7 @@ typedef enum : NSUInteger {
[self.navigationController pushViewController:view animated:YES];
}
- (void)conversationCell:(ConversationViewCell *)cell didTapReplyForViewItem:(ConversationViewItem *)conversationItem
- (void)populateReplyForViewItem:(ConversationViewItem *)conversationItem
{
DDLogDebug(@"%@ user did tap reply", self.logTag);
@ -4425,8 +4542,22 @@ typedef enum : NSUInteger {
- (void)updateShouldObserveDBModifications
{
BOOL isAppForegroundAndActive = CurrentAppContext().isAppForegroundAndActive;
self.shouldObserveDBModifications = self.isViewVisible && isAppForegroundAndActive;
if (!CurrentAppContext().isAppForegroundAndActive) {
self.shouldObserveDBModifications = NO;
return;
}
if (!self.isViewVisible) {
self.shouldObserveDBModifications = NO;
return;
}
if (OWSWindowManager.sharedManager.isPresentingMenuActions) {
self.shouldObserveDBModifications = NO;
return;
}
self.shouldObserveDBModifications = YES;
}
- (void)setShouldObserveDBModifications:(BOOL)shouldObserveDBModifications
@ -5002,7 +5133,7 @@ typedef enum : NSUInteger {
OWSAssert(self.navigationController.delegate == nil);
self.navigationController.delegate = self;
[self showMetadataViewForViewItem:conversationItem];
[self showDetailViewForViewItem:conversationItem];
} else {
OWSFail(@"%@ Can't show message metadata for message of type: %@", self.logTag, [interaction class]);
}

@ -109,13 +109,11 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType);
@property (nonatomic, readonly, nullable) ContactShareViewModel *contactShare;
#pragma mark - UIMenuController
#pragma mark - MessageActions
- (NSArray<UIMenuItem *> *)textMenuControllerItems;
- (NSArray<UIMenuItem *> *)mediaMenuControllerItems;
- (NSArray<UIMenuItem *> *)defaultMenuControllerItems;
@property (nonatomic, readonly) BOOL hasBodyTextActionContent;
@property (nonatomic, readonly) BOOL hasMediaActionContent;
- (BOOL)canPerformAction:(SEL)action;
- (void)copyMediaAction;
- (void)copyTextAction;
- (void)shareMediaAction;
@ -123,9 +121,6 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType);
- (void)saveMediaAction;
- (void)deleteAction;
- (SEL)replyActionSelector;
- (SEL)metadataActionSelector;
@end
NS_ASSUME_NONNULL_END

@ -598,150 +598,6 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType)
return _displayableQuotedText;
}
#pragma mark - UIMenuController
- (NSArray<UIMenuItem *> *)textMenuControllerItems
{
return @[
[[UIMenuItem alloc] initWithTitle:NSLocalizedString(@"EDIT_ITEM_MESSAGE_METADATA_ACTION",
@"Short name for edit menu item to show message metadata.")
action:self.metadataActionSelector],
[[UIMenuItem alloc] initWithTitle:NSLocalizedString(@"EDIT_ITEM_COPY_ACTION",
@"Short name for edit menu item to copy contents of media message.")
action:self.copyTextActionSelector],
[[UIMenuItem alloc] initWithTitle:NSLocalizedString(@"REPLY_ITEM_ACTION",
@"Short name for edit menu item to reply to a message.")
action:self.replyActionSelector],
[[UIMenuItem alloc] initWithTitle:NSLocalizedString(@"EDIT_ITEM_DELETE_ACTION",
@"Short name for edit menu item to delete contents of media message.")
action:self.deleteActionSelector]
];
}
- (NSArray<UIMenuItem *> *)mediaMenuControllerItems
{
return @[
[[UIMenuItem alloc] initWithTitle:NSLocalizedString(@"EDIT_ITEM_MESSAGE_METADATA_ACTION",
@"Short name for edit menu item to show message metadata.")
action:self.metadataActionSelector],
[[UIMenuItem alloc] initWithTitle:NSLocalizedString(@"EDIT_ITEM_COPY_ACTION",
@"Short name for edit menu item to copy contents of media message.")
action:self.copyMediaActionSelector],
[[UIMenuItem alloc] initWithTitle:NSLocalizedString(@"REPLY_ITEM_ACTION",
@"Short name for edit menu item to reply to a message.")
action:self.replyActionSelector],
[[UIMenuItem alloc] initWithTitle:NSLocalizedString(@"EDIT_ITEM_DELETE_ACTION",
@"Short name for edit menu item to delete contents of media message.")
action:self.deleteActionSelector],
[[UIMenuItem alloc] initWithTitle:NSLocalizedString(@"EDIT_ITEM_SAVE_ACTION",
@"Short name for edit menu item to save contents of media message.")
action:self.saveMediaActionSelector],
];
}
- (NSArray<UIMenuItem *> *)defaultMenuControllerItems
{
return @[
[[UIMenuItem alloc] initWithTitle:NSLocalizedString(@"EDIT_ITEM_MESSAGE_METADATA_ACTION",
@"Short name for edit menu item to show message metadata.")
action:self.metadataActionSelector],
[[UIMenuItem alloc] initWithTitle:NSLocalizedString(@"REPLY_ITEM_ACTION",
@"Short name for edit menu item to reply to a message.")
action:self.replyActionSelector],
[[UIMenuItem alloc] initWithTitle:NSLocalizedString(@"EDIT_ITEM_DELETE_ACTION",
@"Short name for edit menu item to delete contents of media message.")
action:self.deleteActionSelector],
];
}
- (SEL)copyTextActionSelector
{
return NSSelectorFromString(@"copyTextAction:");
}
- (SEL)copyMediaActionSelector
{
return NSSelectorFromString(@"copyMediaAction:");
}
- (SEL)saveMediaActionSelector
{
return NSSelectorFromString(@"saveMediaAction:");
}
- (SEL)shareTextActionSelector
{
return NSSelectorFromString(@"shareTextAction:");
}
- (SEL)shareMediaActionSelector
{
return NSSelectorFromString(@"shareMediaAction:");
}
- (SEL)deleteActionSelector
{
return NSSelectorFromString(@"deleteAction:");
}
- (SEL)replyActionSelector
{
return NSSelectorFromString(@"replyAction:");
}
- (SEL)metadataActionSelector
{
return NSSelectorFromString(@"metadataAction:");
}
// We only use custom actions in UIMenuController.
- (BOOL)canPerformAction:(SEL)action
{
if (action == self.copyTextActionSelector) {
return [self hasBodyTextActionContent];
} else if (action == self.copyMediaActionSelector) {
return [self hasMediaActionContent];
} else if (action == self.saveMediaActionSelector) {
return [self canSaveMedia];
} else if (action == self.shareTextActionSelector) {
return [self hasBodyTextActionContent];
} else if (action == self.shareMediaActionSelector) {
return [self hasMediaActionContent];
} else if (action == self.deleteActionSelector) {
return YES;
} else if (action == self.metadataActionSelector) {
return YES;
} else if (action == self.replyActionSelector) {
if ([self.interaction isKindOfClass:[TSOutgoingMessage class]]) {
TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)self.interaction;
if (outgoingMessage.messageState == TSOutgoingMessageStateFailed
|| outgoingMessage.messageState == TSOutgoingMessageStateSending) {
// Don't let users reply to messages which aren't yet delivered to the service.
return NO;
}
} else if ([self.interaction isKindOfClass:[TSIncomingMessage class]]) {
TSIncomingMessage *incomingMessage = (TSIncomingMessage *)self.interaction;
if (incomingMessage.hasAttachments) {
NSString *attachmentId = incomingMessage.attachmentIds.firstObject;
__block TSAttachment *_Nullable attachment = nil;
[[OWSPrimaryStorage.sharedManager newDatabaseConnection]
readWithBlock:^(YapDatabaseReadTransaction *transaction) {
attachment = [TSAttachment fetchObjectWithUniqueID:attachmentId transaction:transaction];
}];
if (![attachment isKindOfClass:[TSAttachmentStream class]]) {
// Don't let users reply to attachments which aren't yet downloaded
// (or otherwise missing on disk).
return NO;
}
}
}
return YES;
} else {
return NO;
}
}
// TODO: Update for quoted text.
- (void)copyTextAction
{
switch (self.messageCellType) {
@ -807,7 +663,6 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType)
}
}
// TODO: Update for quoted text.
- (void)shareTextAction
{
switch (self.messageCellType) {

@ -22,29 +22,6 @@
NS_ASSUME_NONNULL_BEGIN
// In order to use UIMenuController, the view from which it is
// presented must have certain custom behaviors.
@interface AttachmentMenuView : UIView
@end
#pragma mark -
@implementation AttachmentMenuView
- (BOOL)canBecomeFirstResponder
{
return YES;
}
// We only use custom actions in UIMenuController.
- (BOOL)canPerformAction:(SEL)action withSender:(nullable id)sender
{
return NO;
}
@end
#pragma mark -
@interface MediaDetailViewController () <UIScrollViewDelegate,
@ -132,16 +109,12 @@ NS_ASSUME_NONNULL_BEGIN
return self.attachmentStream.isVideo;
}
- (void)loadView
{
self.view = [AttachmentMenuView new];
self.view.backgroundColor = [UIColor clearColor];
}
- (void)viewDidLoad
{
[super viewDidLoad];
self.view.backgroundColor = [UIColor clearColor];
[self createContents];
}
@ -151,15 +124,6 @@ NS_ASSUME_NONNULL_BEGIN
[self resetMediaFrame];
}
- (void)viewWillDisappear:(BOOL)animated
{
[super viewWillDisappear:animated];
if ([UIMenuController sharedMenuController].isMenuVisible) {
[[UIMenuController sharedMenuController] setMenuVisible:NO animated:NO];
}
}
- (void)viewDidLayoutSubviews
{
[super viewDidLayoutSubviews];
@ -333,11 +297,6 @@ NS_ASSUME_NONNULL_BEGIN
[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(didSingleTapImage:)];
[singleTap requireGestureRecognizerToFail:doubleTap];
[view addGestureRecognizer:singleTap];
UILongPressGestureRecognizer *longPress =
[[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(longPressGesture:)];
longPress.delegate = self;
[view addGestureRecognizer:longPress];
}
#pragma mark - Gesture Recognizers
@ -372,29 +331,6 @@ NS_ASSUME_NONNULL_BEGIN
}
}
- (void)longPressGesture:(UIGestureRecognizer *)sender
{
// We "eagerly" respond when the long press begins, not when it ends.
if (sender.state == UIGestureRecognizerStateBegan) {
if (!self.viewItem) {
return;
}
[self.view becomeFirstResponder];
if ([UIMenuController sharedMenuController].isMenuVisible) {
[[UIMenuController sharedMenuController] setMenuVisible:NO animated:NO];
}
NSArray *menuItems = self.viewItem.mediaMenuControllerItems;
[UIMenuController sharedMenuController].menuItems = menuItems;
CGPoint location = [sender locationInView:self.view];
CGRect targetRect = CGRectMake(location.x, location.y, 1, 1);
[[UIMenuController sharedMenuController] setTargetRect:targetRect inView:self.view];
[[UIMenuController sharedMenuController] setMenuVisible:YES animated:YES];
}
}
- (void)didPressShare:(id)sender
{
DDLogInfo(@"%@: didPressShare", self.logTag);
@ -417,67 +353,6 @@ NS_ASSUME_NONNULL_BEGIN
[self.delegate mediaDetailViewController:self requestDeleteConversationViewItem:self.viewItem];
}
- (BOOL)canPerformAction:(SEL)action withSender:(nullable id)sender
{
if (self.viewItem == nil) {
return NO;
}
// Already in detail view, so no link to "info"
if (action == self.viewItem.metadataActionSelector) {
return NO;
}
// Reply is not supported from MediaDetailView.
// TODO implement a "scroll to message" action which would
// let users scroll back to the media message in their message history.
if (action == self.viewItem.replyActionSelector) {
return NO;
}
return [self.viewItem canPerformAction:action];
}
- (void)copyMediaAction:(nullable id)sender
{
if (!self.viewItem) {
OWSFail(@"copy should only be available when a viewItem is present");
return;
}
[self.viewItem copyMediaAction];
}
- (void)shareMediaAction:(nullable id)sender
{
if (!self.viewItem) {
OWSFail(@"share should only be available when a viewItem is present");
return;
}
[self didPressShare:sender];
}
- (void)saveMediaAction:(nullable id)sender
{
if (!self.viewItem) {
OWSFail(@"save should only be available when a viewItem is present");
return;
}
[self.viewItem saveMediaAction];
}
- (void)deleteAction:(nullable id)sender
{
if (!self.viewItem) {
OWSFail(@"delete should only be available when a viewItem is present");
return;
}
[self didPressDelete:sender];
}
- (void)didPressPlayBarButton:(id)sender
{
OWSAssert(self.isVideo);

@ -0,0 +1,402 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
import Foundation
@objc
public class MenuAction: NSObject {
let block: (MenuAction) -> Void
let image: UIImage
let title: String
let subtitle: String?
public init(image: UIImage, title: String, subtitle: String?, block: @escaping (MenuAction) -> Void) {
self.image = image
self.title = title
self.subtitle = subtitle
self.block = block
}
}
@objc
protocol MenuActionsViewControllerDelegate: class {
func menuActionsDidHide(_ menuActionsViewController: MenuActionsViewController)
func menuActions(_ menuActionsViewController: MenuActionsViewController, isPresentingWithVerticalFocusChange: CGFloat)
func menuActions(_ menuActionsViewController: MenuActionsViewController, isDismissingWithVerticalFocusChange: CGFloat)
}
@objc
class MenuActionsViewController: UIViewController, MenuActionSheetDelegate {
@objc
weak var delegate: MenuActionsViewControllerDelegate?
private let focusedView: UIView
private let actionSheetView: MenuActionSheetView
deinit {
Logger.verbose("\(logTag) in \(#function)")
assert(didInformDelegateOfDismissalAnimation)
assert(didInformDelegateThatDisappearenceCompleted)
}
@objc
required init(focusedView: UIView, actions: [MenuAction]) {
self.focusedView = focusedView
self.actionSheetView = MenuActionSheetView(actions: actions)
super.init(nibName: nil, bundle: nil)
actionSheetView.delegate = self
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: View LifeCycle
var actionSheetViewVerticalConstraint: NSLayoutConstraint?
override func loadView() {
self.view = UIView()
view.addSubview(actionSheetView)
actionSheetView.autoPinWidthToSuperview()
actionSheetView.setContentHuggingVerticalHigh()
actionSheetView.setCompressionResistanceHigh()
self.actionSheetViewVerticalConstraint = actionSheetView.autoPinEdge(.top, to: .bottom, of: self.view)
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapBackground))
self.view.addGestureRecognizer(tapGesture)
let swipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didSwipeBackground))
swipeGesture.direction = .down
self.view.addGestureRecognizer(swipeGesture)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(true)
self.animatePresentation()
}
override func viewDidDisappear(_ animated: Bool) {
Logger.debug("\(logTag) in \(#function)")
super.viewDidDisappear(animated)
// When the user has manually dismissed the menu, we do a nice animation
// but if the view otherwise disappears (e.g. due to resigning active),
// we still want to give the delegate the information it needs to restore it's UI.
ensureDelegateIsInformedOfDismissalAnimation()
ensureDelegateIsInformedThatDisappearenceCompleted()
}
// MARK: Present / Dismiss animations
var presentationFocusOffset: CGFloat?
var snapshotView: UIView?
private func addSnapshotFocusedView() -> UIView? {
guard let snapshotView = self.focusedView.snapshotView(afterScreenUpdates: false) else {
owsFail("\(self.logTag) in \(#function) snapshotView was unexpectedly nil")
return nil
}
view.addSubview(snapshotView)
guard let focusedViewSuperview = focusedView.superview else {
owsFail("\(self.logTag) in \(#function) focusedViewSuperview was unexpectedly nil")
return nil
}
let convertedFrame = view.convert(focusedView.frame, from: focusedViewSuperview)
snapshotView.frame = convertedFrame
return snapshotView
}
private func animatePresentation() {
guard let actionSheetViewVerticalConstraint = self.actionSheetViewVerticalConstraint else {
owsFail("\(self.logTag) in \(#function) actionSheetViewVerticalConstraint was unexpectedly nil")
return
}
guard let focusedViewSuperview = focusedView.superview else {
owsFail("\(self.logTag) in \(#function) focusedViewSuperview was unexpectedly nil")
return
}
// darken background
guard let snapshotView = addSnapshotFocusedView() else {
owsFail("\(self.logTag) in \(#function) snapshotView was unexpectedly nil")
return
}
self.snapshotView = snapshotView
snapshotView.superview?.layoutIfNeeded()
let backgroundDuration: TimeInterval = 0.1
UIView.animate(withDuration: backgroundDuration) {
self.view.backgroundColor = UIColor.black.withAlphaComponent(0.4)
}
self.actionSheetView.superview?.layoutIfNeeded()
let oldFocusFrame = self.view.convert(focusedView.frame, from: focusedViewSuperview)
NSLayoutConstraint.deactivate([actionSheetViewVerticalConstraint])
self.actionSheetViewVerticalConstraint = self.actionSheetView.autoPinEdge(toSuperviewEdge: .bottom)
UIView.animate(withDuration: 0.3,
delay: backgroundDuration,
options: .curveEaseOut,
animations: {
self.actionSheetView.superview?.layoutIfNeeded()
let newSheetFrame = self.actionSheetView.frame
var newFocusFrame = oldFocusFrame
// Position focused item just over the action sheet.
let padding: CGFloat = 10
let overlap: CGFloat = (oldFocusFrame.maxY + padding) - newSheetFrame.minY
newFocusFrame.origin.y = oldFocusFrame.origin.y - overlap
snapshotView.frame = newFocusFrame
let offset = -overlap
self.presentationFocusOffset = offset
self.delegate?.menuActions(self, isPresentingWithVerticalFocusChange: offset)
},
completion: nil)
}
private func animateDismiss(action: MenuAction?) {
guard let actionSheetViewVerticalConstraint = self.actionSheetViewVerticalConstraint else {
owsFail("\(self.logTag) in \(#function) actionSheetVerticalConstraint was unexpectedly nil")
self.delegate?.menuActionsDidHide(self)
return
}
guard let snapshotView = self.snapshotView else {
owsFail("\(self.logTag) in \(#function) snapshotView was unexpectedly nil")
self.delegate?.menuActionsDidHide(self)
return
}
guard let presentationFocusOffset = self.presentationFocusOffset else {
owsFail("\(self.logTag) in \(#function) presentationFocusOffset was unexpectedly nil")
self.delegate?.menuActionsDidHide(self)
return
}
self.actionSheetView.superview?.layoutIfNeeded()
NSLayoutConstraint.deactivate([actionSheetViewVerticalConstraint])
let dismissDuration: TimeInterval = 0.2
self.actionSheetViewVerticalConstraint = self.actionSheetView.autoPinEdge(.top, to: .bottom, of: self.view)
UIView.animate(withDuration: dismissDuration,
delay: 0,
options: .curveEaseOut,
animations: {
self.view.backgroundColor = UIColor.clear
self.actionSheetView.superview?.layoutIfNeeded()
snapshotView.frame.origin.y -= presentationFocusOffset
// this helps when focused view is above navbars, etc.
snapshotView.alpha = 0
self.ensureDelegateIsInformedOfDismissalAnimation()
},
completion: { _ in
self.view.isHidden = true
self.ensureDelegateIsInformedThatDisappearenceCompleted()
if let action = action {
action.block(action)
}
})
}
var didInformDelegateThatDisappearenceCompleted = false
func ensureDelegateIsInformedThatDisappearenceCompleted() {
guard !didInformDelegateThatDisappearenceCompleted else {
Logger.debug("\(logTag) in \(#function) ignoring redundant 'disappeared' notification")
return
}
didInformDelegateThatDisappearenceCompleted = true
self.delegate?.menuActionsDidHide(self)
}
var didInformDelegateOfDismissalAnimation = false
func ensureDelegateIsInformedOfDismissalAnimation() {
guard !didInformDelegateOfDismissalAnimation else {
Logger.debug("\(logTag) in \(#function) ignoring redundant 'dismissal' notification")
return
}
didInformDelegateOfDismissalAnimation = true
guard let presentationFocusOffset = self.presentationFocusOffset else {
owsFail("\(self.logTag) in \(#function) presentationFocusOffset was unexpectedly nil")
self.delegate?.menuActionsDidHide(self)
return
}
self.delegate?.menuActions(self, isDismissingWithVerticalFocusChange: presentationFocusOffset)
}
// MARK: Actions
@objc
func didTapBackground() {
animateDismiss(action: nil)
}
@objc
func didSwipeBackground(gesture: UISwipeGestureRecognizer) {
animateDismiss(action: nil)
}
// MARK: MenuActionSheetDelegate
func actionSheet(_ actionSheet: MenuActionSheetView, didSelectAction action: MenuAction) {
animateDismiss(action: action)
}
}
protocol MenuActionSheetDelegate: class {
func actionSheet(_ actionSheet: MenuActionSheetView, didSelectAction action: MenuAction)
}
class MenuActionSheetView: UIView, MenuActionViewDelegate {
private let actionStackView: UIStackView
private var actions: [MenuAction]
weak var delegate: MenuActionSheetDelegate?
override var bounds: CGRect {
didSet {
updateMask()
}
}
convenience init(actions: [MenuAction]) {
self.init(frame: CGRect.zero)
actions.forEach { self.addAction($0) }
}
override init(frame: CGRect) {
actionStackView = UIStackView()
actionStackView.axis = .vertical
actionStackView.spacing = CGHairlineWidth()
actions = []
super.init(frame: frame)
backgroundColor = UIColor.ows_light10
addSubview(actionStackView)
actionStackView.autoPinToSuperviewEdges()
self.clipsToBounds = true
// Prevent panning from percolating to the superview, which would
// cause us to dismiss
let panGestureSink = UIPanGestureRecognizer(target: nil, action: nil)
self.addGestureRecognizer(panGestureSink)
}
required init?(coder aDecoder: NSCoder) {
fatalError("not implemented")
}
public func addAction(_ action: MenuAction) {
let actionView = MenuActionView(action: action)
actionView.delegate = self
actions.append(action)
self.actionStackView.addArrangedSubview(actionView)
}
// MARK: MenuActionViewDelegate
func actionView(_ actionView: MenuActionView, didSelectAction action: MenuAction) {
self.delegate?.actionSheet(self, didSelectAction: action)
}
// MARK:
private func updateMask() {
let cornerRadius: CGFloat = 16
let path: UIBezierPath = UIBezierPath(roundedRect: bounds, byRoundingCorners: [.topLeft, .topRight], cornerRadii: CGSize(width: cornerRadius, height: cornerRadius))
let mask = CAShapeLayer()
mask.path = path.cgPath
self.layer.mask = mask
}
}
protocol MenuActionViewDelegate: class {
func actionView(_ actionView: MenuActionView, didSelectAction action: MenuAction)
}
class MenuActionView: UIButton {
public weak var delegate: MenuActionViewDelegate?
private let action: MenuAction
required init(action: MenuAction) {
self.action = action
super.init(frame: CGRect.zero)
isUserInteractionEnabled = true
backgroundColor = .white
let imageView = UIImageView(image: action.image)
let imageWidth: CGFloat = 24
imageView.autoSetDimensions(to: CGSize(width: imageWidth, height: imageWidth))
imageView.isUserInteractionEnabled = false
let titleLabel = UILabel()
titleLabel.font = UIFont.ows_dynamicTypeBody
titleLabel.textColor = UIColor.ows_light90
titleLabel.text = action.title
titleLabel.isUserInteractionEnabled = false
let subtitleLabel = UILabel()
subtitleLabel.font = UIFont.ows_dynamicTypeSubheadline
subtitleLabel.textColor = UIColor.ows_light60
subtitleLabel.text = action.subtitle
subtitleLabel.isUserInteractionEnabled = false
let textColumn = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel])
textColumn.axis = .vertical
textColumn.alignment = .leading
textColumn.isUserInteractionEnabled = false
let contentRow = UIStackView(arrangedSubviews: [imageView, textColumn])
contentRow.axis = .horizontal
contentRow.alignment = .center
contentRow.spacing = 12
contentRow.isLayoutMarginsRelativeArrangement = true
contentRow.layoutMargins = UIEdgeInsets(top: 7, left: 16, bottom: 7, right: 16)
contentRow.isUserInteractionEnabled = false
self.addSubview(contentRow)
contentRow.autoPinToSuperviewMargins()
contentRow.autoSetDimension(.height, toSize: 56, relation: .greaterThanOrEqual)
self.addTarget(self, action: #selector(didPress(sender:)), for: .touchUpInside)
}
override var isHighlighted: Bool {
didSet {
self.backgroundColor = isHighlighted ? UIColor.ows_light10 : UIColor.white
}
}
@objc
func didPress(sender: Any) {
Logger.debug("\(logTag) in \(#function)")
self.delegate?.actionView(self, didSelectAction: action)
}
required init?(coder aDecoder: NSCoder) {
fatalError("not implemented")
}
}

@ -1161,6 +1161,27 @@
/* Section header in media gallery collection view */
"MEDIA_GALLERY_THIS_MONTH_HEADER" = "This Month";
/* Action sheet button title */
"MESSAGE_ACTION_COPY_MEDIA" = "Copy Media";
/* Action sheet button title */
"MESSAGE_ACTION_COPY_TEXT" = "Copy Message Text";
/* Action sheet button title */
"MESSAGE_ACTION_DELETE_MESSAGE" = "Delete this Message";
/* Action sheet button subtitle */
"MESSAGE_ACTION_DELETE_MESSAGE_SUBTITLE" = "It will be deleted on this device only";
/* Action sheet button title */
"MESSAGE_ACTION_DETAILS" = "More Info";
/* Action sheet button title */
"MESSAGE_ACTION_REPLY" = "Reply to this Message";
/* Action sheet button title */
"MESSAGE_ACTION_SAVE_MEDIA" = "Save Media";
/* Title for the 'message approval' dialog. */
"MESSAGE_APPROVAL_DIALOG_TITLE" = "Message";

@ -27,6 +27,13 @@ extern const UIWindowLevel UIWindowLevel_Background;
- (void)setIsScreenBlockActive:(BOOL)isScreenBlockActive;
#pragma mark - Message Actions
@property (nonatomic, readonly) BOOL isPresentingMenuActions;
- (void)showMenuActionsWindow:(UIViewController *)menuActionsViewController;
- (void)hideMenuActionsWindow;
#pragma mark - Calls
@property (nonatomic, readonly) BOOL shouldShowCallView;

@ -12,7 +12,6 @@ NS_ASSUME_NONNULL_BEGIN
NSString *const OWSWindowManagerCallDidChangeNotification = @"OWSWindowManagerCallDidChangeNotification";
const CGFloat OWSWindowManagerCallScreenHeight(void)
{
if ([UIDevice currentDevice].isIPhoneX) {
@ -43,13 +42,40 @@ const UIWindowLevel UIWindowLevel_CallView(void)
return UIWindowLevelNormal + 1.f;
}
// In front of everything, including the status bar.
// In front of the status bar and CallView
const UIWindowLevel UIWindowLevel_ScreenBlocking(void);
const UIWindowLevel UIWindowLevel_ScreenBlocking(void)
{
return UIWindowLevelStatusBar + 2.f;
}
// In front of everything
const UIWindowLevel UIWindowLevel_MessageActions(void);
const UIWindowLevel UIWindowLevel_MessageActions(void)
{
// Note: To cover the keyboard, this is higher than the ScreenBlocking level,
// but this window is hidden when screen protection is shown.
return CGFLOAT_MAX;
}
@interface MessageActionsWindow : UIWindow
@end
@implementation MessageActionsWindow
- (UIWindowLevel)windowLevel
{
// As of iOS11, setWindowLevel clamps the value below
// the height of the keyboard window.
// Because we want to display above the keyboard, we hardcode
// the `windowLevel` getter.
return UIWindowLevel_MessageActions();
}
@end
@implementation OWSWindowRootViewController
- (BOOL)canBecomeFirstResponder
@ -74,6 +100,10 @@ const UIWindowLevel UIWindowLevel_ScreenBlocking(void)
@property (nonatomic) UIWindow *callViewWindow;
@property (nonatomic) UINavigationController *callNavigationController;
// UIWindowLevel_MessageActions
@property (nonatomic) UIWindow *menuActionsWindow;
@property (nonatomic, nullable) UIViewController *menuActionsViewController;
// UIWindowLevel_Background if inactive,
// UIWindowLevel_ScreenBlocking() if active.
@property (nonatomic) UIWindow *screenBlockingWindow;
@ -127,12 +157,18 @@ const UIWindowLevel UIWindowLevel_ScreenBlocking(void)
self.returnToCallWindow = [self createReturnToCallWindow:rootWindow];
self.callViewWindow = [self createCallViewWindow:rootWindow];
self.menuActionsWindow = [self createMenuActionsWindowWithRoowWindow:rootWindow];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(didChangeStatusBarFrame:)
name:UIApplicationDidChangeStatusBarFrameNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(applicationWillResignActive:)
name:OWSApplicationWillResignActiveNotification
object:nil];
[self ensureWindowState];
}
@ -147,6 +183,11 @@ const UIWindowLevel UIWindowLevel_ScreenBlocking(void)
self.returnToCallWindow.frame = newFrame;
}
- (void)applicationWillResignActive:(NSNotification *)notification
{
[self hideMenuActionsWindow];
}
- (UIWindow *)createReturnToCallWindow:(UIWindow *)rootWindow
{
OWSAssertIsOnMainThread();
@ -169,6 +210,27 @@ const UIWindowLevel UIWindowLevel_ScreenBlocking(void)
return window;
}
- (UIWindow *)createMenuActionsWindowWithRoowWindow:(UIWindow *)rootWindow
{
UIWindow *window;
if (@available(iOS 11, *)) {
// On iOS11, setting the windowLevel is insufficient, so we override
// the `windowLevel` getter.
window = [[MessageActionsWindow alloc] initWithFrame:rootWindow.bounds];
} else {
// On iOS9, 10 overriding the `windowLevel` getter does not cause the
// window to be displayed above the keyboard, but setting the window
// level works.
window = [[UIWindow alloc] initWithFrame:rootWindow.bounds];
window.windowLevel = UIWindowLevel_MessageActions();
}
window.hidden = YES;
window.backgroundColor = UIColor.clearColor;
return window;
}
- (UIWindow *)createCallViewWindow:(UIWindow *)rootWindow
{
OWSAssertIsOnMainThread();
@ -208,6 +270,31 @@ const UIWindowLevel UIWindowLevel_ScreenBlocking(void)
[self ensureWindowState];
}
#pragma mark - Message Actions
- (BOOL)isPresentingMenuActions
{
return self.menuActionsViewController != nil;
}
- (void)showMenuActionsWindow:(UIViewController *)menuActionsViewController
{
OWSAssert(self.menuActionsViewController == nil);
self.menuActionsViewController = menuActionsViewController;
self.menuActionsWindow.rootViewController = menuActionsViewController;
[self ensureWindowState];
}
- (void)hideMenuActionsWindow
{
self.menuActionsWindow.rootViewController = nil;
self.menuActionsViewController = nil;
[self ensureWindowState];
}
#pragma mark - Calls
- (void)setCallViewController:(nullable UIViewController *)callViewController
@ -309,6 +396,7 @@ const UIWindowLevel UIWindowLevel_ScreenBlocking(void)
[self ensureRootWindowHidden];
[self ensureReturnToCallWindowHidden];
[self ensureCallViewWindowHidden];
[self ensureMessageActionsWindowHidden];
[self ensureScreenBlockWindowShown];
} else if (self.callViewController && self.shouldShowCallView) {
// Show Call View.
@ -316,6 +404,7 @@ const UIWindowLevel UIWindowLevel_ScreenBlocking(void)
[self ensureRootWindowHidden];
[self ensureReturnToCallWindowHidden];
[self ensureCallViewWindowShown];
[self ensureMessageActionsWindowHidden];
[self ensureScreenBlockWindowHidden];
} else if (self.callViewController) {
// Show Root Window + "Return to Call".
@ -323,13 +412,26 @@ const UIWindowLevel UIWindowLevel_ScreenBlocking(void)
[self ensureRootWindowShown];
[self ensureReturnToCallWindowShown];
[self ensureCallViewWindowHidden];
[self ensureMessageActionsWindowHidden];
[self ensureScreenBlockWindowHidden];
} else if (self.menuActionsViewController) {
// Show Message Actions
[self ensureRootWindowShown];
[self ensureReturnToCallWindowHidden];
[self ensureCallViewWindowHidden];
[self ensureMessageActionsWindowShown];
[self ensureScreenBlockWindowHidden];
// Don't hide rootWindow so as not to dismiss keyboard.
OWSAssert(!self.rootWindow.isHidden);
} else {
// Show Root Window
[self ensureRootWindowShown];
[self ensureReturnToCallWindowHidden];
[self ensureCallViewWindowHidden];
[self ensureMessageActionsWindowHidden];
[self ensureScreenBlockWindowHidden];
}
}
@ -407,6 +509,29 @@ const UIWindowLevel UIWindowLevel_ScreenBlocking(void)
self.callViewWindow.hidden = YES;
}
- (void)ensureMessageActionsWindowShown
{
OWSAssertIsOnMainThread();
if (self.menuActionsWindow.hidden) {
DDLogInfo(@"%@ showing message actions window.", self.logTag);
}
// Do not make key, we want the keyboard to stay popped.
self.menuActionsWindow.hidden = NO;
}
- (void)ensureMessageActionsWindowHidden
{
OWSAssertIsOnMainThread();
if (!self.menuActionsWindow.hidden) {
DDLogInfo(@"%@ hiding message actions window.", self.logTag);
}
self.menuActionsWindow.hidden = YES;
}
- (void)ensureScreenBlockWindowShown
{
OWSAssertIsOnMainThread();

Loading…
Cancel
Save