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