From c7053aa36d34f87e6f9f55765f4ae0dfcfb45b36 Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Fri, 18 Jan 2019 09:54:12 -0500 Subject: [PATCH 1/4] Add link previews to converastion view items. --- .../ColorPickerViewController.swift | 2 ++ .../ConversationView/ConversationViewItem.h | 4 ++++ .../ConversationView/ConversationViewItem.m | 19 ++++++++++++++++++- Signal/src/views/LinkPreviewView.swift | 10 +++++----- 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/Signal/src/ViewControllers/ColorPickerViewController.swift b/Signal/src/ViewControllers/ColorPickerViewController.swift index b0513e804..c72f2a14c 100644 --- a/Signal/src/ViewControllers/ColorPickerViewController.swift +++ b/Signal/src/ViewControllers/ColorPickerViewController.swift @@ -341,6 +341,8 @@ private class MockConversationViewItem: NSObject, ConversationViewItem { var hasMediaActionContent: Bool = false var mediaAlbumItems: [ConversationMediaAlbumItem]? var hasCachedLayoutState: Bool = false + var linkPreview: OWSLinkPreview? + var linkPreviewAttachment: TSAttachment? override init() { super.init() diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewItem.h b/Signal/src/ViewControllers/ConversationView/ConversationViewItem.h index 487d5a7f5..4a980afb9 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewItem.h +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewItem.h @@ -26,6 +26,7 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType); @class ConversationViewCell; @class DisplayableText; @class OWSAudioMessageView; +@class OWSLinkPreview; @class OWSQuotedReplyModel; @class OWSUnreadIndicator; @class TSAttachment; @@ -121,6 +122,9 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType); @property (nonatomic, readonly, nullable) ContactShareViewModel *contactShare; +@property (nonatomic, readonly, nullable) OWSLinkPreview *linkPreview; +@property (nonatomic, readonly, nullable) TSAttachment *linkPreviewAttachment; + @property (nonatomic, readonly, nullable) NSString *systemMessageText; // NOTE: This property is only set for incoming messages. diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m b/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m index 92687819a..539788ee5 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m @@ -97,6 +97,8 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) @property (nonatomic, nullable) TSAttachmentStream *attachmentStream; @property (nonatomic, nullable) TSAttachmentPointer *attachmentPointer; @property (nonatomic, nullable) ContactShareViewModel *contactShare; +@property (nonatomic, nullable) OWSLinkPreview *linkPreview; +@property (nonatomic, nullable) TSAttachment *linkPreviewAttachment; @property (nonatomic, nullable) NSArray *mediaAlbumItems; @property (nonatomic, nullable) NSString *systemMessageText; @property (nonatomic, nullable) TSThread *incomingMessageAuthorThread; @@ -158,10 +160,14 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) self.displayableBodyText = nil; self.attachmentStream = nil; self.attachmentPointer = nil; + self.mediaAlbumItems = nil; self.displayableQuotedText = nil; self.quotedReply = nil; + self.contactShare = nil; self.systemMessageText = nil; - self.mediaAlbumItems = nil; + self.authorConversationColorName = nil; + self.linkPreview = nil; + self.linkPreviewAttachment = nil; [self updateAuthorConversationColorNameWithTransaction:transaction]; @@ -660,6 +666,17 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) OWSAssertDebug(self.displayableBodyText); } + if (self.hasBodyText && attachment == nil && message.linkPreview) { + self.linkPreview = message.linkPreview; + if (message.linkPreview.imageAttachmentId.length > 0) { + self.linkPreviewAttachment = + [TSAttachment fetchObjectWithUniqueID:message.linkPreview.imageAttachmentId transaction:transaction]; + if (!self.linkPreviewAttachment) { + OWSFailDebug(@"Could not load link preview image attachment."); + } + } + } + if (self.messageCellType == OWSMessageCellType_Unknown) { // Messages of unknown type (including messages with missing attachments) // are rendered like empty text messages, but without any interactivity. diff --git a/Signal/src/views/LinkPreviewView.swift b/Signal/src/views/LinkPreviewView.swift index 85dfb62cb..6afa8a4ff 100644 --- a/Signal/src/views/LinkPreviewView.swift +++ b/Signal/src/views/LinkPreviewView.swift @@ -369,11 +369,11 @@ public class LinkPreviewView: UIStackView { self.alignment = .center self.autoSetDimension(.height, toSize: approvalHeight) - let label = UILabel() - label.text = NSLocalizedString("LINK_PREVIEW_LOADING", comment: "Indicates that the link preview is being loaded.") - label.textColor = Theme.secondaryColor - label.font = UIFont.ows_dynamicTypeBody - addArrangedSubview(label) + let activityIndicator = UIActivityIndicatorView(activityIndicatorStyle: .gray) + activityIndicator.startAnimating() + addArrangedSubview(activityIndicator) + let activityIndicatorSize: CGFloat = 25 + activityIndicator.autoSetDimensions(to: CGSize(width: activityIndicatorSize, height: activityIndicatorSize)) } // MARK: Events From ca8a4b3751fa5bfbcf1a8987c62e04fc7003fcfe Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Fri, 18 Jan 2019 10:23:07 -0500 Subject: [PATCH 2/4] Make LinkPreviewView reusable. --- .../Cells/OWSMessageBubbleView.h | 6 +- .../Cells/OWSMessageBubbleView.m | 30 ++++++++ .../ConversationView/Cells/OWSMessageCell.m | 5 +- .../ConversationInputToolbar.m | 3 +- .../ConversationViewController.m | 7 ++ .../MessageDetailViewController.swift | 8 ++- Signal/src/views/LinkPreviewView.swift | 70 ++++++++++++++----- 7 files changed, 105 insertions(+), 24 deletions(-) diff --git a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.h b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.h index 6da61337a..d1b3fd1ed 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.h +++ b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.h @@ -1,5 +1,5 @@ // -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. // NS_ASSUME_NONNULL_BEGIN @@ -10,6 +10,7 @@ NS_ASSUME_NONNULL_BEGIN @protocol ConversationViewItem; @class OWSContact; +@class OWSLinkPreview; @class OWSQuotedReplyModel; @class TSAttachmentPointer; @class TSAttachmentStream; @@ -21,6 +22,7 @@ typedef NS_ENUM(NSUInteger, OWSMessageGestureLocation) { OWSMessageGestureLocation_OversizeText, OWSMessageGestureLocation_Media, OWSMessageGestureLocation_QuotedReply, + OWSMessageGestureLocation_LinkPreview, }; extern const UIDataDetectorTypes kOWSAllowedDataDetectorTypes; @@ -46,6 +48,8 @@ extern const UIDataDetectorTypes kOWSAllowedDataDetectorTypes; quotedReply:(OWSQuotedReplyModel *)quotedReply failedThumbnailDownloadAttachmentPointer:(TSAttachmentPointer *)attachmentPointer; +- (void)didTapConversationItem:(id)viewItem linkPreview:(OWSLinkPreview *)linkPreview; + - (void)didTapContactShareViewItem:(id)viewItem; - (void)didTapSendMessageToContactShare:(ContactShareViewModel *)contactShare diff --git a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m index 2cb3d6a67..e4a043e5b 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m +++ b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m @@ -40,6 +40,8 @@ const UIDataDetectorTypes kOWSAllowedDataDetectorTypes @property (nonatomic, nullable) UIView *bodyMediaView; +@property (nonatomic, nullable) LinkPreviewView *linkPreviewView; + // Should lazy-load expensive view contents (images, etc.). // Should do nothing if view is already loaded. @property (nonatomic, nullable) dispatch_block_t loadCellContentBlock; @@ -1158,6 +1160,13 @@ const UIDataDetectorTypes kOWSAllowedDataDetectorTypes } } + if (self.viewItem.linkPreview) { + CGSize linkPreviewSize = [LinkPreviewView measureWithConversationViewItem:self.viewItem]; + linkPreviewSize.width = MIN(linkPreviewSize.width, self.conversationStyle.maxMessageWidth); + cellSize.width = MAX(cellSize.width, linkPreviewSize.width); + cellSize.height += linkPreviewSize.height; + } + NSValue *_Nullable bodyTextSize = [self bodyTextSize]; if (bodyTextSize) { [textViewSizes addObject:bodyTextSize]; @@ -1284,6 +1293,9 @@ const UIDataDetectorTypes kOWSAllowedDataDetectorTypes [self.contactShareButtonsView removeFromSuperview]; self.contactShareButtonsView = nil; + + [self.linkPreviewView removeFromSuperview]; + self.linkPreviewView = nil; } #pragma mark - Gestures @@ -1338,6 +1350,13 @@ const UIDataDetectorTypes kOWSAllowedDataDetectorTypes OWSFailDebug(@"Missing quoted message."); } break; + case OWSMessageGestureLocation_LinkPreview: + if (self.viewItem.linkPreview) { + [self.delegate didTapConversationItem:self.viewItem linkPreview:self.viewItem.linkPreview]; + } else { + OWSFailDebug(@"Missing link preview."); + } + break; } } @@ -1426,6 +1445,17 @@ const UIDataDetectorTypes kOWSAllowedDataDetectorTypes } } + if (self.linkPreviewView) { + // Treat this as a "quoted reply" gesture if: + // + // * There is a "quoted reply" view. + // * The gesture occured within or above the "quoted reply" view. + CGPoint location = [self convertPoint:locationInMessageBubble toView:self.linkPreviewView]; + if (CGRectContainsPoint(self.linkPreviewView.bounds, location)) { + return OWSMessageGestureLocation_LinkPreview; + } + } + if (self.bodyMediaView) { // Treat this as a "body media" gesture if: // diff --git a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageCell.m b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageCell.m index a8429c7aa..1da289a14 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageCell.m +++ b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageCell.m @@ -1,5 +1,5 @@ // -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. // #import "OWSMessageCell.h" @@ -438,7 +438,8 @@ NS_ASSUME_NONNULL_BEGIN CGPoint locationInMessageBubble = [sender locationInView:self.messageBubbleView]; switch ([self.messageBubbleView gestureLocationForLocation:locationInMessageBubble]) { case OWSMessageGestureLocation_Default: - case OWSMessageGestureLocation_OversizeText: { + case OWSMessageGestureLocation_OversizeText: + case OWSMessageGestureLocation_LinkPreview: { [self.delegate conversationCell:self shouldAllowReply:shouldAllowReply didLongpressTextViewItem:self.viewItem]; diff --git a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m index 47b198e56..576561141 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m @@ -765,7 +765,8 @@ const CGFloat kMaxTextViewHeight = 98; [self clearLinkPreviewView]; - LinkPreviewView *linkPreviewView = [[LinkPreviewView alloc] initWithState:state delegate:self]; + LinkPreviewView *linkPreviewView = [[LinkPreviewView alloc] initWithDelegate:self]; + linkPreviewView.state = state; self.linkPreviewView = linkPreviewView; // TODO: Revisit once we have a separate quoted reply view. [self.contentRows insertArrangedSubview:linkPreviewView atIndex:0]; diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index 8f711837a..1338b9851 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -2338,6 +2338,13 @@ typedef enum : NSUInteger { // TODO: Highlight the quoted message? } +- (void)didTapConversationItem:(id)viewItem linkPreview:(OWSLinkPreview *)linkPreview +{ + OWSAssertIsOnMainThread(); + + // TODO: +} + - (void)showDetailViewForViewItem:(id)conversationItem { OWSAssertIsOnMainThread(); diff --git a/Signal/src/ViewControllers/MessageDetailViewController.swift b/Signal/src/ViewControllers/MessageDetailViewController.swift index 107614773..1be08d7e2 100644 --- a/Signal/src/ViewControllers/MessageDetailViewController.swift +++ b/Signal/src/ViewControllers/MessageDetailViewController.swift @@ -1,5 +1,5 @@ // -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. // import Foundation @@ -710,6 +710,12 @@ class MessageDetailViewController: OWSViewController, MediaGalleryDataSourceDele // no - op } + func didTapConversationItem(_ viewItem: ConversationViewItem, linkPreview: OWSLinkPreview) { + // no - op + + // TODO: + } + @objc func didLongPressSent(sender: UIGestureRecognizer) { guard sender.state == .began else { return diff --git a/Signal/src/views/LinkPreviewView.swift b/Signal/src/views/LinkPreviewView.swift index 6afa8a4ff..6cfb538f7 100644 --- a/Signal/src/views/LinkPreviewView.swift +++ b/Signal/src/views/LinkPreviewView.swift @@ -197,7 +197,16 @@ public protocol LinkPreviewViewDelegate { @objc public class LinkPreviewView: UIStackView { private weak var delegate: LinkPreviewViewDelegate? - private let state: LinkPreviewState + + @objc + public var state: LinkPreviewState? { + didSet { + AssertIsOnMainThread() + assert(oldValue == nil) + + updateContents() + } + } @available(*, unavailable, message:"use other constructor instead.") required init(coder aDecoder: NSCoder) { @@ -209,30 +218,44 @@ public class LinkPreviewView: UIStackView { notImplemented() } - private let imageView = UIImageView() - private let titleLabel = UILabel() - private let domainLabel = UILabel() + private var cancelButton: UIButton? + private var layoutConstraints = [NSLayoutConstraint]() @objc - public init(state: LinkPreviewState, - delegate: LinkPreviewViewDelegate?) { - self.state = state + public init(delegate: LinkPreviewViewDelegate?) { self.delegate = delegate super.init(frame: .zero) - createContents() + self.isUserInteractionEnabled = true + self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(wasTapped))) } private var isApproval: Bool { return delegate != nil } - private func createContents() { + private func resetContents() { + for subview in subviews { + subview.removeFromSuperview() + } + self.axis = .horizontal + self.alignment = .center + self.distribution = .fill + self.spacing = 0 - self.isUserInteractionEnabled = true - self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(wasTapped))) + cancelButton = nil + NSLayoutConstraint.deactivate(layoutConstraints) + layoutConstraints = [] + } + + private func updateContents() { + resetContents() + + guard let state = state else { + return + } guard state.isLoaded() else { createLoadingContents() return @@ -241,7 +264,7 @@ public class LinkPreviewView: UIStackView { createMessageContents() return } - createApprovalContents() + createApprovalContents(state: state) } private func createMessageContents() { @@ -250,9 +273,7 @@ public class LinkPreviewView: UIStackView { private let approvalHeight: CGFloat = 76 - private var cancelButton: UIButton? - - private func createApprovalContents() { + private func createApprovalContents(state: LinkPreviewState) { self.axis = .horizontal self.alignment = .fill self.distribution = .equalSpacing @@ -260,7 +281,7 @@ public class LinkPreviewView: UIStackView { // Image - if let imageView = createImageView() { + if let imageView = createImageView(state: state) { imageView.contentMode = .scaleAspectFill imageView.autoPinToSquareAspectRatio() let imageSize = approvalHeight @@ -346,7 +367,7 @@ public class LinkPreviewView: UIStackView { strokeView.autoSetDimension(.height, toSize: CGHairlineWidth()) } - private func createImageView() -> UIImageView? { + private func createImageView(state: LinkPreviewState) -> UIImageView? { guard state.isLoaded() else { owsFailDebug("State not loaded.") return nil @@ -367,7 +388,7 @@ public class LinkPreviewView: UIStackView { private func createLoadingContents() { self.axis = .vertical self.alignment = .center - self.autoSetDimension(.height, toSize: approvalHeight) + self.layoutConstraints.append(self.autoSetDimension(.height, toSize: approvalHeight)) let activityIndicator = UIActivityIndicatorView(activityIndicatorStyle: .gray) activityIndicator.startAnimating() @@ -379,6 +400,9 @@ public class LinkPreviewView: UIStackView { // MARK: Events @objc func wasTapped(sender: UIGestureRecognizer) { + guard let state = state else { + return + } guard sender.state == .recognized else { return } @@ -392,7 +416,15 @@ public class LinkPreviewView: UIStackView { return } } - self.delegate?.linkPreviewDidTap?(urlString: self.state.urlString()) + self.delegate?.linkPreviewDidTap?(urlString: state.urlString()) + } + + // MARK: Measurement + + @objc + public class func measure(withConversationViewItem item: ConversationViewItem) -> CGSize { + // TODO: + return CGSize.zero } @objc func didTapCancel(sender: UIButton) { From 3d757b492a03352a08da51f8443b0a8a70559079 Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Fri, 18 Jan 2019 16:44:04 -0500 Subject: [PATCH 3/4] Add link previews to conversation message bubbles. --- .../Cells/OWSMessageBubbleView.m | 26 +- .../ConversationInputToolbar.m | 7 + .../ConversationViewController.m | 43 +-- .../ViewControllers/DebugUI/DebugUIMessages.m | 17 +- .../src/ViewControllers/DebugUI/DebugUIMisc.m | 4 +- Signal/src/util/Pastelog.m | 4 +- Signal/src/views/LinkPreviewView.swift | 297 ++++++++++++++++-- .../translations/en.lproj/Localizable.strings | 4 +- SignalMessaging/utils/ThreadUtil.h | 19 +- SignalMessaging/utils/ThreadUtil.m | 36 ++- .../Interactions/OWSLinkPreview.swift | 39 ++- .../src/Messages/Interactions/TSMessage.h | 2 + .../src/Messages/Interactions/TSMessage.m | 13 + 13 files changed, 402 insertions(+), 109 deletions(-) diff --git a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m index e4a043e5b..871959a4c 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m +++ b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m @@ -40,7 +40,7 @@ const UIDataDetectorTypes kOWSAllowedDataDetectorTypes @property (nonatomic, nullable) UIView *bodyMediaView; -@property (nonatomic, nullable) LinkPreviewView *linkPreviewView; +@property (nonatomic) LinkPreviewView *linkPreviewView; // Should lazy-load expensive view contents (images, etc.). // Should do nothing if view is already loaded. @@ -102,6 +102,8 @@ const UIDataDetectorTypes kOWSAllowedDataDetectorTypes self.bodyTextView.dataDetectorTypes = kOWSAllowedDataDetectorTypes; self.bodyTextView.hidden = YES; + self.linkPreviewView = [[LinkPreviewView alloc] initWithDelegate:nil]; + self.footerView = [OWSMessageFooterView new]; } @@ -360,6 +362,12 @@ const UIDataDetectorTypes kOWSAllowedDataDetectorTypes } } + if (self.viewItem.linkPreview) { + self.linkPreviewView.state = self.linkPreviewState; + [self.stackView addArrangedSubview:self.linkPreviewView]; + [self.linkPreviewView addBorderViewsWithBubbleView:self.bubbleView]; + } + // We render malformed messages as "empty text" messages, // so create a text view if there is no body media view. if (self.hasBodyText || !bodyMediaView) { @@ -660,6 +668,16 @@ const UIDataDetectorTypes kOWSAllowedDataDetectorTypes return 6.f; } +- (nullable LinkPreviewSent *)linkPreviewState +{ + if (!self.viewItem.linkPreview) { + return nil; + } + return [[LinkPreviewSent alloc] initWithLinkPreview:self.viewItem.linkPreview + imageAttachment:self.viewItem.linkPreviewAttachment + conversationStyle:self.conversationStyle]; +} + #pragma mark - Load / Unload - (void)loadContent @@ -1161,7 +1179,7 @@ const UIDataDetectorTypes kOWSAllowedDataDetectorTypes } if (self.viewItem.linkPreview) { - CGSize linkPreviewSize = [LinkPreviewView measureWithConversationViewItem:self.viewItem]; + CGSize linkPreviewSize = [self.linkPreviewView measureWithSentState:self.linkPreviewState]; linkPreviewSize.width = MIN(linkPreviewSize.width, self.conversationStyle.maxMessageWidth); cellSize.width = MAX(cellSize.width, linkPreviewSize.width); cellSize.height += linkPreviewSize.height; @@ -1295,7 +1313,7 @@ const UIDataDetectorTypes kOWSAllowedDataDetectorTypes self.contactShareButtonsView = nil; [self.linkPreviewView removeFromSuperview]; - self.linkPreviewView = nil; + self.linkPreviewView.state = nil; } #pragma mark - Gestures @@ -1445,7 +1463,7 @@ const UIDataDetectorTypes kOWSAllowedDataDetectorTypes } } - if (self.linkPreviewView) { + if (self.viewItem.linkPreview) { // Treat this as a "quoted reply" gesture if: // // * There is a "quoted reply" view. diff --git a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m index 576561141..a29d04d30 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m @@ -717,6 +717,13 @@ const CGFloat kMaxTextViewHeight = 98; return; } + // Don't include link previews for oversize text messages. + if ([body lengthOfBytesUsingEncoding:NSUTF8StringEncoding] >= kOversizeTextMessageSizeThreshold) { + self.inputLinkPreview = nil; + [self clearLinkPreviewView]; + return; + } + NSString *_Nullable previewUrl = [OWSLinkPreview previewUrlForMessageBodyText:body]; if (previewUrl.length < 1) { self.inputLinkPreview = nil; diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index 1338b9851..d4b27789c 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -3589,15 +3589,11 @@ typedef enum : NSUInteger { } } - OWSLinkPreview *_Nullable linkPreview = - [self linkPreviewForLinkPreviewDraft:self.inputToolbar.linkPreviewDraft]; - BOOL didAddToProfileWhitelist = [ThreadUtil addThreadToProfileWhitelistIfEmptyContactThread:self.thread]; TSOutgoingMessage *message = [ThreadUtil enqueueMessageWithAttachments:attachments messageBody:messageText inThread:self.thread - quotedReplyModel:self.inputToolbar.quotedReply - linkPreview:linkPreview]; + quotedReplyModel:self.inputToolbar.quotedReply]; [self messageWasSent:message]; @@ -3966,8 +3962,6 @@ typedef enum : NSUInteger { BOOL didAddToProfileWhitelist = [ThreadUtil addThreadToProfileWhitelistIfEmptyContactThread:self.thread]; __block TSOutgoingMessage *message; - OWSLinkPreview *_Nullable linkPreview = [self linkPreviewForLinkPreviewDraft:self.inputToolbar.linkPreviewDraft]; - if ([text lengthOfBytesUsingEncoding:NSUTF8StringEncoding] >= kOversizeTextMessageSizeThreshold) { DataSource *_Nullable dataSource = [DataSourceValue dataSourceWithOversizeText:text]; SignalAttachment *attachment = @@ -3977,14 +3971,13 @@ typedef enum : NSUInteger { // before the attachment is downloaded) message = [ThreadUtil enqueueMessageWithAttachment:attachment inThread:self.thread - quotedReplyModel:self.inputToolbar.quotedReply - linkPreview:linkPreview]; + quotedReplyModel:self.inputToolbar.quotedReply]; } else { [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *_Nonnull transaction) { message = [ThreadUtil enqueueMessageWithText:text inThread:self.thread quotedReplyModel:self.inputToolbar.quotedReply - linkPreview:linkPreview + linkPreviewDraft:self.inputToolbar.linkPreviewDraft transaction:transaction]; }]; } @@ -4013,36 +4006,6 @@ typedef enum : NSUInteger { } } -- (nullable OWSLinkPreview *)linkPreviewForLinkPreviewDraft:(nullable OWSLinkPreviewDraft *)linkPreviewDraft -{ - if (!linkPreviewDraft) { - return nil; - } - __block OWSLinkPreview *_Nullable linkPreview; - [self.editingDatabaseConnection readWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - linkPreview = [self linkPreviewForLinkPreviewDraft:linkPreviewDraft transaction:transaction]; - }]; - return linkPreview; -} - -- (nullable OWSLinkPreview *)linkPreviewForLinkPreviewDraft:(nullable OWSLinkPreviewDraft *)linkPreviewDraft - transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - OWSAssertDebug(transaction); - - if (!linkPreviewDraft) { - return nil; - } - NSError *linkPreviewError; - OWSLinkPreview *_Nullable linkPreview = [OWSLinkPreview buildValidatedLinkPreviewFromInfo:linkPreviewDraft - transaction:transaction - error:&linkPreviewError]; - if (linkPreviewError && ![OWSLinkPreview isNoPreviewError:linkPreviewError]) { - OWSLogError(@"linkPreviewError: %@", linkPreviewError); - } - return linkPreview; -} - - (void)voiceMemoGestureDidStart { OWSAssertIsOnMainThread(); diff --git a/Signal/src/ViewControllers/DebugUI/DebugUIMessages.m b/Signal/src/ViewControllers/DebugUI/DebugUIMessages.m index 6ddb77042..8a69619e1 100644 --- a/Signal/src/ViewControllers/DebugUI/DebugUIMessages.m +++ b/Signal/src/ViewControllers/DebugUI/DebugUIMessages.m @@ -362,7 +362,7 @@ NS_ASSUME_NONNULL_BEGIN message = [ThreadUtil enqueueMessageWithText:text inThread:thread quotedReplyModel:nil - linkPreview:nil + linkPreviewDraft:nil transaction:transaction]; }]; OWSLogError(@"sendTextMessageInThread timestamp: %llu.", message.timestamp); @@ -425,7 +425,7 @@ NS_ASSUME_NONNULL_BEGIN [DDLog flushLog]; } OWSAssertDebug(![attachment hasError]); - [ThreadUtil enqueueMessageWithAttachment:attachment inThread:thread quotedReplyModel:nil linkPreview:nil]; + [ThreadUtil enqueueMessageWithAttachment:attachment inThread:thread quotedReplyModel:nil]; success(); } @@ -1741,7 +1741,7 @@ NS_ASSUME_NONNULL_BEGIN OWSAssertDebug(thread); SignalAttachment *attachment = [self signalAttachmentForFilePath:filePath]; - [ThreadUtil enqueueMessageWithAttachment:attachment inThread:thread quotedReplyModel:nil linkPreview:nil]; + [ThreadUtil enqueueMessageWithAttachment:attachment inThread:thread quotedReplyModel:nil]; success(); } @@ -3346,7 +3346,7 @@ typedef OWSContact * (^OWSContactBlock)(YapDatabaseReadWriteTransaction *transac DataSource *_Nullable dataSource = [DataSourceValue dataSourceWithOversizeText:message]; SignalAttachment *attachment = [SignalAttachment attachmentWithDataSource:dataSource dataUTI:kOversizeTextAttachmentUTI]; - [ThreadUtil enqueueMessageWithAttachment:attachment inThread:thread quotedReplyModel:nil linkPreview:nil]; + [ThreadUtil enqueueMessageWithAttachment:attachment inThread:thread quotedReplyModel:nil]; } + (NSData *)createRandomNSDataOfSize:(size_t)size @@ -3379,7 +3379,7 @@ typedef OWSContact * (^OWSContactBlock)(YapDatabaseReadWriteTransaction *transac // style them indistinguishably from a separate text message. attachment.captionText = [self randomCaptionText]; } - [ThreadUtil enqueueMessageWithAttachment:attachment inThread:thread quotedReplyModel:nil linkPreview:nil]; + [ThreadUtil enqueueMessageWithAttachment:attachment inThread:thread quotedReplyModel:nil]; } + (SSKProtoEnvelope *)createEnvelopeForThread:(TSThread *)thread @@ -3888,7 +3888,7 @@ typedef OWSContact * (^OWSContactBlock)(YapDatabaseReadWriteTransaction *transac [ThreadUtil enqueueMessageWithText:[@(counter) description] inThread:thread quotedReplyModel:nil - linkPreview:nil + linkPreviewDraft:nil transaction:transaction]; }]; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)1.f * NSEC_PER_SEC), dispatch_get_main_queue(), ^{ @@ -4445,7 +4445,7 @@ typedef OWSContact * (^OWSContactBlock)(YapDatabaseReadWriteTransaction *transac [DDLog flushLog]; } OWSAssertDebug(![attachment hasError]); - [ThreadUtil enqueueMessageWithAttachment:attachment inThread:thread quotedReplyModel:nil linkPreview:nil]; + [ThreadUtil enqueueMessageWithAttachment:attachment inThread:thread quotedReplyModel:nil]; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{ sendUnsafeFile(); @@ -4763,8 +4763,7 @@ typedef OWSContact * (^OWSContactBlock)(YapDatabaseReadWriteTransaction *transac TSOutgoingMessage *message = [ThreadUtil enqueueMessageWithAttachments:attachments messageBody:messageBody inThread:thread - quotedReplyModel:nil - linkPreview:nil]; + quotedReplyModel:nil]; OWSLogError(@"timestamp: %llu.", message.timestamp); }]; } diff --git a/Signal/src/ViewControllers/DebugUI/DebugUIMisc.m b/Signal/src/ViewControllers/DebugUI/DebugUIMisc.m index 2e5cd0657..844a074c8 100644 --- a/Signal/src/ViewControllers/DebugUI/DebugUIMisc.m +++ b/Signal/src/ViewControllers/DebugUI/DebugUIMisc.m @@ -257,7 +257,7 @@ NS_ASSUME_NONNULL_BEGIN OWSFailDebug(@"attachment[%@]: %@", [attachment sourceFilename], [attachment errorName]); return; } - [ThreadUtil enqueueMessageWithAttachment:attachment inThread:thread quotedReplyModel:nil linkPreview:nil]; + [ThreadUtil enqueueMessageWithAttachment:attachment inThread:thread quotedReplyModel:nil]; } + (void)sendUnencryptedDatabase:(TSThread *)thread @@ -279,7 +279,7 @@ NS_ASSUME_NONNULL_BEGIN OWSFailDebug(@"attachment[%@]: %@", [attachment sourceFilename], [attachment errorName]); return; } - [ThreadUtil enqueueMessageWithAttachment:attachment inThread:thread quotedReplyModel:nil linkPreview:nil]; + [ThreadUtil enqueueMessageWithAttachment:attachment inThread:thread quotedReplyModel:nil]; } #ifdef DEBUG diff --git a/Signal/src/util/Pastelog.m b/Signal/src/util/Pastelog.m index f737cf06b..c1b1809c6 100644 --- a/Signal/src/util/Pastelog.m +++ b/Signal/src/util/Pastelog.m @@ -591,7 +591,7 @@ typedef void (^DebugLogUploadFailure)(DebugLogUploader *uploader, NSError *error [ThreadUtil enqueueMessageWithText:url.absoluteString inThread:thread quotedReplyModel:nil - linkPreview:nil + linkPreviewDraft:nil transaction:transaction]; }]; }); @@ -616,7 +616,7 @@ typedef void (^DebugLogUploadFailure)(DebugLogUploader *uploader, NSError *error [ThreadUtil enqueueMessageWithText:url.absoluteString inThread:thread quotedReplyModel:nil - linkPreview:nil + linkPreviewDraft:nil transaction:transaction]; }]; } else { diff --git a/Signal/src/views/LinkPreviewView.swift b/Signal/src/views/LinkPreviewView.swift index 6cfb538f7..953eacca6 100644 --- a/Signal/src/views/LinkPreviewView.swift +++ b/Signal/src/views/LinkPreviewView.swift @@ -83,7 +83,11 @@ public class LinkPreviewDraft: NSObject, LinkPreviewState { } public func title() -> String? { - return linkPreviewDraft.title + guard let value = linkPreviewDraft.title, + value.count > 0 else { + return nil + } + return value } public func imageState() -> LinkPreviewImageState { @@ -114,11 +118,23 @@ public class LinkPreviewSent: NSObject, LinkPreviewState { private let linkPreview: OWSLinkPreview private let imageAttachment: TSAttachment? + @objc public let conversationStyle: ConversationStyle + + @objc + public var imageSize: CGSize { + guard let attachmentStream = imageAttachment as? TSAttachmentStream else { + return CGSize.zero + } + return attachmentStream.imageSize() + } + @objc public required init(linkPreview: OWSLinkPreview, - imageAttachment: TSAttachment?) { + imageAttachment: TSAttachment?, + conversationStyle: ConversationStyle) { self.linkPreview = linkPreview self.imageAttachment = imageAttachment + self.conversationStyle = conversationStyle } public func isLoaded() -> Bool { @@ -142,7 +158,11 @@ public class LinkPreviewSent: NSObject, LinkPreviewState { } public func title() -> String? { - return linkPreview.title + guard let value = linkPreview.title, + value.count > 0 else { + return nil + } + return value } public func imageState() -> LinkPreviewImageState { @@ -188,8 +208,7 @@ public class LinkPreviewSent: NSObject, LinkPreviewState { @objc public protocol LinkPreviewViewDelegate { func linkPreviewCanCancel() -> Bool - @objc optional func linkPreviewDidCancel() - @objc optional func linkPreviewDidTap(urlString: String?) + func linkPreviewDidCancel() } // MARK: - @@ -202,7 +221,7 @@ public class LinkPreviewView: UIStackView { public var state: LinkPreviewState? { didSet { AssertIsOnMainThread() - assert(oldValue == nil) + assert(state == nil || oldValue == nil) updateContents() } @@ -219,6 +238,8 @@ public class LinkPreviewView: UIStackView { } private var cancelButton: UIButton? + private weak var heroImageView: UIView? + private weak var sentBodyView: UIView? private var layoutConstraints = [NSLayoutConstraint]() @objc @@ -227,8 +248,11 @@ public class LinkPreviewView: UIStackView { super.init(frame: .zero) - self.isUserInteractionEnabled = true - self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(wasTapped))) + if let delegate = delegate, + delegate.linkPreviewCanCancel() { + self.isUserInteractionEnabled = true + self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(wasTapped))) + } } private var isApproval: Bool { @@ -243,8 +267,12 @@ public class LinkPreviewView: UIStackView { self.alignment = .center self.distribution = .fill self.spacing = 0 + self.isLayoutMarginsRelativeArrangement = false + self.layoutMargins = .zero cancelButton = nil + heroImageView = nil + sentBodyView = nil NSLayoutConstraint.deactivate(layoutConstraints) layoutConstraints = [] @@ -256,19 +284,156 @@ public class LinkPreviewView: UIStackView { guard let state = state else { return } + + guard isApproval else { + createSentContents() + return + } guard state.isLoaded() else { createLoadingContents() return } - guard isApproval else { - createMessageContents() + createApprovalContents(state: state) + } + + private func createSentContents() { + guard let state = state as? LinkPreviewSent else { + owsFailDebug("Invalid state") return } - createApprovalContents(state: state) + + self.addBackgroundView(withBackgroundColor: Theme.backgroundColor) + + if let imageView = createImageView(state: state) { + if sentIsHero(state: state) { + createHeroSentContents(state: state, + imageView: imageView) + } else { + createNonHeroSentContents(state: state, + imageView: imageView) + } + } else { + createNonHeroSentContents(state: state, + imageView: nil) + } + } + + private func sentHeroImageSize(state: LinkPreviewSent) -> CGSize { + let maxMessageWidth = state.conversationStyle.maxMessageWidth + let imageSize = state.imageSize + let minImageHeight: CGFloat = maxMessageWidth * 0.5 + let maxImageHeight: CGFloat = maxMessageWidth + let rawImageHeight = maxMessageWidth * imageSize.height / imageSize.width + let imageHeight: CGFloat = min(maxImageHeight, max(minImageHeight, rawImageHeight)) + return CGSizeCeil(CGSize(width: maxMessageWidth, height: imageHeight)) + } + + private func createHeroSentContents(state: LinkPreviewSent, + imageView: UIImageView) { + self.layoutMargins = .zero + self.axis = .vertical + self.alignment = .fill + + let heroImageSize = sentHeroImageSize(state: state) + imageView.autoSetDimensions(to: heroImageSize) + imageView.contentMode = .scaleAspectFill + imageView.setContentHuggingHigh() + imageView.setCompressionResistanceHigh() + imageView.clipsToBounds = true + // TODO: Cropping, stroke. + addArrangedSubview(imageView) + + let textStack = createSentTextStack(state: state) + textStack.isLayoutMarginsRelativeArrangement = true + textStack.layoutMargins = UIEdgeInsets(top: sentHeroVMargin, left: sentHeroHMargin, bottom: sentHeroVMargin, right: sentHeroHMargin) + addArrangedSubview(textStack) + + heroImageView = imageView + sentBodyView = textStack + } + + private func createNonHeroSentContents(state: LinkPreviewSent, + imageView: UIImageView?) { + self.layoutMargins = .zero + self.axis = .horizontal + self.isLayoutMarginsRelativeArrangement = true + self.layoutMargins = UIEdgeInsets(top: sentNonHeroVMargin, left: sentNonHeroHMargin, bottom: sentNonHeroVMargin, right: sentNonHeroHMargin) + self.spacing = sentNonHeroHSpacing + + if let imageView = imageView { + imageView.autoSetDimensions(to: CGSize(width: sentNonHeroImageSize, height: sentNonHeroImageSize)) + imageView.contentMode = .scaleAspectFill + imageView.setContentHuggingHigh() + imageView.setCompressionResistanceHigh() + imageView.clipsToBounds = true + // TODO: Cropping, stroke. + addArrangedSubview(imageView) + } + + let textStack = createSentTextStack(state: state) + addArrangedSubview(textStack) + + sentBodyView = self + } + + private func createSentTextStack(state: LinkPreviewSent) -> UIStackView { + let textStack = UIStackView() + textStack.axis = .vertical + textStack.spacing = sentVSpacing + + if let titleLabel = sentTitleLabel(state: state) { + textStack.addArrangedSubview(titleLabel) + } + let domainLabel = sentDomainLabel(state: state) + textStack.addArrangedSubview(domainLabel) + + return textStack + } + + private let sentMinimumHeroSize: CGFloat = 200 + + private let sentTitleFontSizePoints: CGFloat = 17 + private let sentDomainFontSizePoints: CGFloat = 12 + private let sentVSpacing: CGFloat = 4 + + // The "sent message" mode has two submodes: "hero" and "non-hero". + private let sentNonHeroHMargin: CGFloat = 6 + private let sentNonHeroVMargin: CGFloat = 6 + private let sentNonHeroImageSize: CGFloat = 72 + private let sentNonHeroHSpacing: CGFloat = 8 + + private let sentHeroHMargin: CGFloat = 12 + private let sentHeroVMargin: CGFloat = 7 + + private func sentIsHero(state: LinkPreviewSent) -> Bool { + let imageSize = state.imageSize + return imageSize.width >= sentMinimumHeroSize && imageSize.height >= sentMinimumHeroSize } - private func createMessageContents() { - // TODO: + private func sentTitleLabel(state: LinkPreviewState) -> UILabel? { + guard let text = state.title() else { + return nil + } + let label = UILabel() + label.text = text + label.font = UIFont.systemFont(ofSize: sentTitleFontSizePoints).ows_mediumWeight() + label.textColor = Theme.primaryColor + label.numberOfLines = 2 + label.lineBreakMode = .byWordWrapping + return label + } + + private func sentDomainLabel(state: LinkPreviewState) -> UILabel { + let label = UILabel() + if let displayDomain = state.displayDomain(), + displayDomain.count > 0 { + label.text = displayDomain.uppercased() + } else { + label.text = NSLocalizedString("LINK_PREVIEW_UNKNOWN_DOMAIN", comment: "Label for link previews with an unknown host.").uppercased() + } + label.font = UIFont.systemFont(ofSize: sentDomainFontSizePoints) + label.textColor = Theme.secondaryColor + return label } private let approvalHeight: CGFloat = 76 @@ -276,9 +441,13 @@ public class LinkPreviewView: UIStackView { private func createApprovalContents(state: LinkPreviewState) { self.axis = .horizontal self.alignment = .fill - self.distribution = .equalSpacing + self.distribution = .fill self.spacing = 8 + NSLayoutConstraint.autoSetPriority(UILayoutPriority.defaultHigh) { + self.layoutConstraints.append(self.autoSetDimension(.height, toSize: approvalHeight)) + } + // Image if let imageView = createImageView(state: state) { @@ -388,7 +557,10 @@ public class LinkPreviewView: UIStackView { private func createLoadingContents() { self.axis = .vertical self.alignment = .center - self.layoutConstraints.append(self.autoSetDimension(.height, toSize: approvalHeight)) + + NSLayoutConstraint.autoSetPriority(UILayoutPriority.defaultHigh) { + self.layoutConstraints.append(self.autoSetDimension(.height, toSize: approvalHeight)) + } let activityIndicator = UIActivityIndicatorView(activityIndicatorStyle: .gray) activityIndicator.startAnimating() @@ -400,9 +572,6 @@ public class LinkPreviewView: UIStackView { // MARK: Events @objc func wasTapped(sender: UIGestureRecognizer) { - guard let state = state else { - return - } guard sender.state == .recognized else { return } @@ -412,22 +581,102 @@ public class LinkPreviewView: UIStackView { let hotAreaInset: CGFloat = -20 let cancelButtonHotArea = cancelButton.bounds.insetBy(dx: hotAreaInset, dy: hotAreaInset) if cancelButtonHotArea.contains(cancelLocation) { - self.delegate?.linkPreviewDidCancel?() + self.delegate?.linkPreviewDidCancel() return } } - self.delegate?.linkPreviewDidTap?(urlString: state.urlString()) } // MARK: Measurement @objc - public class func measure(withConversationViewItem item: ConversationViewItem) -> CGSize { - // TODO: - return CGSize.zero + public func measure(withSentState state: LinkPreviewSent) -> CGSize { + switch state.imageState() { + case .loaded: + if sentIsHero(state: state) { + return measureSentHero(state: state) + } else { + return measureSentNonHero(state: state, hasImage: true) + } + default: + return measureSentNonHero(state: state, hasImage: false) + } + } + + private func measureSentHero(state: LinkPreviewSent) -> CGSize { + let maxMessageWidth = state.conversationStyle.maxMessageWidth + var messageHeight: CGFloat = 0 + + let heroImageSize = sentHeroImageSize(state: state) + messageHeight += heroImageSize.height + + let textStackSize = sentTextStackSize(state: state, maxWidth: maxMessageWidth - 2 * sentHeroHMargin) + messageHeight += textStackSize.height + 2 * sentHeroVMargin + + return CGSizeCeil(CGSize(width: maxMessageWidth, height: messageHeight)) + } + + private func measureSentNonHero(state: LinkPreviewSent, hasImage: Bool) -> CGSize { + let maxMessageWidth = state.conversationStyle.maxMessageWidth + + var maxTextWidth = maxMessageWidth - 2 * sentNonHeroHMargin + if hasImage { + maxTextWidth -= (sentNonHeroImageSize + sentNonHeroHSpacing) + } + let textStackSize = sentTextStackSize(state: state, maxWidth: maxTextWidth) + + var result = textStackSize + + if hasImage { + result.width += sentNonHeroImageSize + sentNonHeroHSpacing + result.height += max(result.height, sentNonHeroImageSize) + } + + result.width += 2 * sentNonHeroHMargin + result.height += 2 * sentNonHeroVMargin + + return CGSizeCeil(result) + } + + private func sentTextStackSize(state: LinkPreviewSent, maxWidth: CGFloat) -> CGSize { + let domainLabel = sentDomainLabel(state: state) + let domainLabelSize = CGSizeCeil(domainLabel.sizeThatFits(CGSize(width: maxWidth, height: CGFloat.greatestFiniteMagnitude))) + + var result = domainLabelSize + + if let titleLabel = sentTitleLabel(state: state) { + let titleLabelSize = CGSizeCeil(titleLabel.sizeThatFits(CGSize(width: maxWidth, height: CGFloat.greatestFiniteMagnitude))) + result.width = max(result.width, titleLabelSize.width) + result.height += titleLabelSize.height + sentVSpacing + } + + return result + } + + @objc + public func addBorderViews(bubbleView: OWSBubbleView) { + if let heroImageView = self.heroImageView { + let borderView = OWSBubbleShapeView(draw: ()) + borderView.strokeColor = Theme.primaryColor + borderView.strokeThickness = CGHairlineWidth() + heroImageView.addSubview(borderView) + bubbleView.addPartnerView(borderView) + borderView.ows_autoPinToSuperviewEdges() + } + if let sentBodyView = self.sentBodyView { + let borderView = OWSBubbleShapeView(draw: ()) + let borderColor = UIColor(rgbHex: Theme.isDarkThemeEnabled ? 0x0F1012 : 0xD5D6D6) + borderView.strokeColor = borderColor + borderView.strokeThickness = CGHairlineWidth() + sentBodyView.addSubview(borderView) + bubbleView.addPartnerView(borderView) + borderView.ows_autoPinToSuperviewEdges() + } else { + owsFailDebug("Missing sentBodyView") + } } @objc func didTapCancel(sender: UIButton) { - self.delegate?.linkPreviewDidCancel?() + self.delegate?.linkPreviewDidCancel() } } diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 0d7ed36a9..b2e096618 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -1185,8 +1185,8 @@ /* Navigation title when scanning QR code to add new device. */ "LINK_NEW_DEVICE_TITLE" = "Link New Device"; -/* Indicates that the link preview is being loaded. */ -"LINK_PREVIEW_LOADING" = "Loading…"; +/* Label for link previews with an unknown host. */ +"LINK_PREVIEW_UNKNOWN_DOMAIN" = "Link Preview"; /* Menu item and navbar title for the device manager */ "LINKED_DEVICES_TITLE" = "Linked Devices"; diff --git a/SignalMessaging/utils/ThreadUtil.h b/SignalMessaging/utils/ThreadUtil.h index 64a0052e6..47e9e3763 100644 --- a/SignalMessaging/utils/ThreadUtil.h +++ b/SignalMessaging/utils/ThreadUtil.h @@ -5,17 +5,21 @@ NS_ASSUME_NONNULL_BEGIN @class OWSBlockingManager; +@class OWSContact; @class OWSContactsManager; -@class OWSLinkPreview; +@class OWSLinkPreviewDraft; @class OWSMessageSender; +@class OWSQuotedReplyModel; @class OWSUnreadIndicator; @class SignalAttachment; @class TSContactThread; @class TSGroupThread; @class TSInteraction; +@class TSOutgoingMessage; @class TSThread; @class YapDatabaseConnection; @class YapDatabaseReadTransaction; +@class YapDatabaseReadWriteTransaction; @interface ThreadDynamicInteractions : NSObject @@ -36,11 +40,6 @@ NS_ASSUME_NONNULL_BEGIN #pragma mark - -@class OWSContact; -@class OWSQuotedReplyModel; -@class TSOutgoingMessage; -@class YapDatabaseReadWriteTransaction; - @interface ThreadUtil : NSObject #pragma mark - Durable Message Enqueue @@ -48,19 +47,17 @@ NS_ASSUME_NONNULL_BEGIN + (TSOutgoingMessage *)enqueueMessageWithText:(NSString *)text inThread:(TSThread *)thread quotedReplyModel:(nullable OWSQuotedReplyModel *)quotedReplyModel - linkPreview:(nullable OWSLinkPreview *)linkPreview + linkPreviewDraft:(nullable nullable OWSLinkPreviewDraft *)linkPreviewDraft transaction:(YapDatabaseReadTransaction *)transaction; + (TSOutgoingMessage *)enqueueMessageWithAttachment:(SignalAttachment *)attachment inThread:(TSThread *)thread - quotedReplyModel:(nullable OWSQuotedReplyModel *)quotedReplyModel - linkPreview:(nullable OWSLinkPreview *)linkPreview; + quotedReplyModel:(nullable OWSQuotedReplyModel *)quotedReplyModel; + (TSOutgoingMessage *)enqueueMessageWithAttachments:(NSArray *)attachments messageBody:(nullable NSString *)messageBody inThread:(TSThread *)thread - quotedReplyModel:(nullable OWSQuotedReplyModel *)quotedReplyModel - linkPreview:(nullable OWSLinkPreview *)linkPreview; + quotedReplyModel:(nullable OWSQuotedReplyModel *)quotedReplyModel; + (TSOutgoingMessage *)enqueueMessageWithContactShare:(OWSContact *)contactShare inThread:(TSThread *)thread; + (void)enqueueLeaveGroupMessageInThread:(TSGroupThread *)thread; diff --git a/SignalMessaging/utils/ThreadUtil.m b/SignalMessaging/utils/ThreadUtil.m index 16d6bd493..2d5b182f3 100644 --- a/SignalMessaging/utils/ThreadUtil.m +++ b/SignalMessaging/utils/ThreadUtil.m @@ -68,7 +68,7 @@ NS_ASSUME_NONNULL_BEGIN + (TSOutgoingMessage *)enqueueMessageWithText:(NSString *)text inThread:(TSThread *)thread quotedReplyModel:(nullable OWSQuotedReplyModel *)quotedReplyModel - linkPreview:(nullable OWSLinkPreview *)linkPreview + linkPreviewDraft:(nullable nullable OWSLinkPreviewDraft *)linkPreviewDraft transaction:(YapDatabaseReadTransaction *)transaction { OWSDisappearingMessagesConfiguration *configuration = @@ -82,12 +82,19 @@ NS_ASSUME_NONNULL_BEGIN attachmentId:nil expiresInSeconds:expiresInSeconds quotedMessage:[quotedReplyModel buildQuotedMessageForSending] - linkPreview:linkPreview]; + linkPreview:nil]; [BenchManager benchAsyncWithTitle:@"Saving outgoing message" block:^(void (^benchmarkCompletion)(void)) { // To avoid blocking the send flow, we dispatch an async write from within this read transaction [self.dbConnection asyncReadWriteWithBlock:^(YapDatabaseReadWriteTransaction * _Nonnull writeTransaction) { [message saveWithTransaction:writeTransaction]; + + OWSLinkPreview *_Nullable linkPreview = + [self linkPreviewForLinkPreviewDraft:linkPreviewDraft transaction:writeTransaction]; + if (linkPreview) { + [message updateWithLinkPreview:linkPreview transaction:writeTransaction]; + } + [self.messageSenderJobQueue addMessage:message transaction:writeTransaction]; } completionBlock:benchmarkCompletion]; @@ -96,25 +103,40 @@ NS_ASSUME_NONNULL_BEGIN return message; } ++ (nullable OWSLinkPreview *)linkPreviewForLinkPreviewDraft:(nullable OWSLinkPreviewDraft *)linkPreviewDraft + transaction:(YapDatabaseReadWriteTransaction *)transaction +{ + OWSAssertDebug(transaction); + + if (!linkPreviewDraft) { + return nil; + } + NSError *linkPreviewError; + OWSLinkPreview *_Nullable linkPreview = [OWSLinkPreview buildValidatedLinkPreviewFromInfo:linkPreviewDraft + transaction:transaction + error:&linkPreviewError]; + if (linkPreviewError && ![OWSLinkPreview isNoPreviewError:linkPreviewError]) { + OWSLogError(@"linkPreviewError: %@", linkPreviewError); + } + return linkPreview; +} + + (TSOutgoingMessage *)enqueueMessageWithAttachment:(SignalAttachment *)attachment inThread:(TSThread *)thread quotedReplyModel:(nullable OWSQuotedReplyModel *)quotedReplyModel - linkPreview:(nullable OWSLinkPreview *)linkPreview { return [self enqueueMessageWithAttachments:@[ attachment, ] messageBody:attachment.captionText inThread:thread - quotedReplyModel:quotedReplyModel - linkPreview:linkPreview]; + quotedReplyModel:quotedReplyModel]; } + (TSOutgoingMessage *)enqueueMessageWithAttachments:(NSArray *)attachments messageBody:(nullable NSString *)messageBody inThread:(TSThread *)thread quotedReplyModel:(nullable OWSQuotedReplyModel *)quotedReplyModel - linkPreview:(nullable OWSLinkPreview *)linkPreview { OWSAssertIsOnMainThread(); OWSAssertDebug(attachments.count > 0); @@ -140,7 +162,7 @@ NS_ASSUME_NONNULL_BEGIN groupMetaMessage:TSGroupMetaMessageUnspecified quotedMessage:[quotedReplyModel buildQuotedMessageForSending] contactShare:nil - linkPreview:linkPreview]; + linkPreview:nil]; NSMutableArray *attachmentInfos = [NSMutableArray new]; for (SignalAttachment *attachment in attachments) { diff --git a/SignalServiceKit/src/Messages/Interactions/OWSLinkPreview.swift b/SignalServiceKit/src/Messages/Interactions/OWSLinkPreview.swift index 7c58c17cd..b72abb478 100644 --- a/SignalServiceKit/src/Messages/Interactions/OWSLinkPreview.swift +++ b/SignalServiceKit/src/Messages/Interactions/OWSLinkPreview.swift @@ -140,7 +140,7 @@ public class OWSLinkPreview: MTLModel { } var title: String? - if let rawTitle = previewProto.title?.trimmingCharacters(in: .whitespacesAndNewlines) { + if let rawTitle = previewProto.title { let normalizedTitle = OWSLinkPreview.normalizeTitle(title: rawTitle) if normalizedTitle.count > 0 { title = normalizedTitle @@ -263,7 +263,7 @@ public class OWSLinkPreview: MTLModel { let endIndex = result.index(result.startIndex, offsetBy: maxCharacterCount) result = String(result[...endIndex]) } - return result + return result.filterStringForDisplay() } // MARK: - Domain Whitelist @@ -280,7 +280,8 @@ public class OWSLinkPreview: MTLModel { // TODO: Finalize private static let mediaDomainWhitelist = [ "ytimg.com", - "cdninstagram.com" + "cdninstagram.com", + "redd.it" ] private static let protocolWhitelist = [ @@ -541,16 +542,21 @@ public class OWSLinkPreview: MTLModel { } var title: String? - if let rawTitle = NSRegularExpression.parseFirstMatch(pattern: "", text: linkText) { - let normalizedTitle = OWSLinkPreview.normalizeTitle(title: rawTitle) - if normalizedTitle.count > 0 { - title = normalizedTitle + if let rawTitle = NSRegularExpression.parseFirstMatch(pattern: "", text: linkText) { + if let decodedTitle = decodeHTMLEntities(inString: rawTitle) { + let normalizedTitle = OWSLinkPreview.normalizeTitle(title: decodedTitle) + if normalizedTitle.count > 0 { + title = normalizedTitle + } } } Logger.verbose("title: \(String(describing: title))") - guard let imageUrlString = NSRegularExpression.parseFirstMatch(pattern: "", text: linkText) else { + guard let rawImageUrlString = NSRegularExpression.parseFirstMatch(pattern: "", text: linkText) else { + return completion(OWSLinkPreviewDraft(urlString: linkUrlString, title: title)) + } + guard let imageUrlString = decodeHTMLEntities(inString: rawImageUrlString)?.ows_stripped() else { return completion(OWSLinkPreviewDraft(urlString: linkUrlString, title: title)) } Logger.verbose("imageUrlString: \(imageUrlString)") @@ -601,4 +607,21 @@ public class OWSLinkPreview: MTLModel { completion(linkPreviewDraft) }) } + + private class func decodeHTMLEntities(inString value: String) -> String? { + guard let data = value.data(using: .utf8) else { + return nil + } + + let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [ + NSAttributedString.DocumentReadingOptionKey.documentType: NSAttributedString.DocumentType.html, + NSAttributedString.DocumentReadingOptionKey.characterEncoding: String.Encoding.utf8.rawValue + ] + + guard let attributedString = try? NSAttributedString(data: data, options: options, documentAttributes: nil) else { + return nil + } + + return attributedString.string + } } diff --git a/SignalServiceKit/src/Messages/Interactions/TSMessage.h b/SignalServiceKit/src/Messages/Interactions/TSMessage.h index cf353f3ca..55297e154 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSMessage.h +++ b/SignalServiceKit/src/Messages/Interactions/TSMessage.h @@ -61,6 +61,8 @@ NS_ASSUME_NONNULL_BEGIN - (void)updateWithExpireStartedAt:(uint64_t)expireStartedAt transaction:(YapDatabaseReadWriteTransaction *)transaction; +- (void)updateWithLinkPreview:(OWSLinkPreview *)linkPreview transaction:(YapDatabaseReadWriteTransaction *)transaction; + @end NS_ASSUME_NONNULL_END diff --git a/SignalServiceKit/src/Messages/Interactions/TSMessage.m b/SignalServiceKit/src/Messages/Interactions/TSMessage.m index a1a6979b2..9865e83b2 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSMessage.m +++ b/SignalServiceKit/src/Messages/Interactions/TSMessage.m @@ -48,6 +48,8 @@ static const NSUInteger OWSMessageSchemaVersion = 4; */ @property (nonatomic, readonly) NSUInteger schemaVersion; +@property (nonatomic, nullable) OWSLinkPreview *linkPreview; + @end #pragma mark - @@ -419,6 +421,17 @@ static const NSUInteger OWSMessageSchemaVersion = 4; }]; } +- (void)updateWithLinkPreview:(OWSLinkPreview *)linkPreview transaction:(YapDatabaseReadWriteTransaction *)transaction +{ + OWSAssertDebug(linkPreview); + OWSAssertDebug(transaction); + + [self applyChangeToSelfAndLatestCopy:transaction + changeBlock:^(TSMessage *message) { + [message setLinkPreview:linkPreview]; + }]; +} + @end NS_ASSUME_NONNULL_END From a51182321c8f14545831381505544b69b06476dc Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Wed, 23 Jan 2019 09:56:46 -0500 Subject: [PATCH 4/4] Respond to CR. --- .../ConversationView/Cells/OWSMessageBubbleView.m | 4 ---- .../MessageDetailViewController.swift | 12 +++++++++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m index 871959a4c..a44276ab7 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m +++ b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m @@ -1464,10 +1464,6 @@ const UIDataDetectorTypes kOWSAllowedDataDetectorTypes } if (self.viewItem.linkPreview) { - // Treat this as a "quoted reply" gesture if: - // - // * There is a "quoted reply" view. - // * The gesture occured within or above the "quoted reply" view. CGPoint location = [self convertPoint:locationInMessageBubble toView:self.linkPreviewView]; if (CGRectContainsPoint(self.linkPreviewView.bounds, location)) { return OWSMessageGestureLocation_LinkPreview; diff --git a/Signal/src/ViewControllers/MessageDetailViewController.swift b/Signal/src/ViewControllers/MessageDetailViewController.swift index 1be08d7e2..251269d44 100644 --- a/Signal/src/ViewControllers/MessageDetailViewController.swift +++ b/Signal/src/ViewControllers/MessageDetailViewController.swift @@ -711,9 +711,15 @@ class MessageDetailViewController: OWSViewController, MediaGalleryDataSourceDele } func didTapConversationItem(_ viewItem: ConversationViewItem, linkPreview: OWSLinkPreview) { - // no - op - - // TODO: + guard let urlString = linkPreview.urlString else { + owsFailDebug("Missing url.") + return + } + guard let url = URL(string: urlString) else { + owsFailDebug("Invalid url: \(urlString).") + return + } + UIApplication.shared.openURL(url) } @objc func didLongPressSent(sender: UIGestureRecognizer) {