Merge branch 'charlesmchen/linkPreviewsSentView'

pull/1/head
Matthew Chen 6 years ago
commit 00f0d44903

@ -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()

@ -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<ConversationViewItem>)viewItem linkPreview:(OWSLinkPreview *)linkPreview;
- (void)didTapContactShareViewItem:(id<ConversationViewItem>)viewItem;
- (void)didTapSendMessageToContactShare:(ContactShareViewModel *)contactShare

@ -40,6 +40,8 @@ const UIDataDetectorTypes kOWSAllowedDataDetectorTypes
@property (nonatomic, nullable) UIView *bodyMediaView;
@property (nonatomic) 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;
@ -100,6 +102,8 @@ const UIDataDetectorTypes kOWSAllowedDataDetectorTypes
self.bodyTextView.dataDetectorTypes = kOWSAllowedDataDetectorTypes;
self.bodyTextView.hidden = YES;
self.linkPreviewView = [[LinkPreviewView alloc] initWithDelegate:nil];
self.footerView = [OWSMessageFooterView new];
}
@ -358,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) {
@ -658,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
@ -1158,6 +1178,13 @@ const UIDataDetectorTypes kOWSAllowedDataDetectorTypes
}
}
if (self.viewItem.linkPreview) {
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;
}
NSValue *_Nullable bodyTextSize = [self bodyTextSize];
if (bodyTextSize) {
[textViewSizes addObject:bodyTextSize];
@ -1284,6 +1311,9 @@ const UIDataDetectorTypes kOWSAllowedDataDetectorTypes
[self.contactShareButtonsView removeFromSuperview];
self.contactShareButtonsView = nil;
[self.linkPreviewView removeFromSuperview];
self.linkPreviewView.state = nil;
}
#pragma mark - Gestures
@ -1338,6 +1368,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 +1463,13 @@ const UIDataDetectorTypes kOWSAllowedDataDetectorTypes
}
}
if (self.viewItem.linkPreview) {
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:
//

@ -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];

@ -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;
@ -765,7 +772,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];

@ -2338,6 +2338,13 @@ typedef enum : NSUInteger {
// TODO: Highlight the quoted message?
}
- (void)didTapConversationItem:(id<ConversationViewItem>)viewItem linkPreview:(OWSLinkPreview *)linkPreview
{
OWSAssertIsOnMainThread();
// TODO:
}
- (void)showDetailViewForViewItem:(id<ConversationViewItem>)conversationItem
{
OWSAssertIsOnMainThread();
@ -3582,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];
@ -3959,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 =
@ -3970,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];
}];
}
@ -4006,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();

@ -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.

@ -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<ConversationMediaAlbumItem *> *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.

@ -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);
}];
}

@ -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

@ -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,18 @@ class MessageDetailViewController: OWSViewController, MediaGalleryDataSourceDele
// no - op
}
func didTapConversationItem(_ viewItem: ConversationViewItem, linkPreview: OWSLinkPreview) {
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) {
guard sender.state == .began else {
return

@ -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 {

@ -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: -
@ -197,7 +216,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(state == nil || oldValue == nil)
updateContents()
}
}
@available(*, unavailable, message:"use other constructor instead.")
required init(coder aDecoder: NSCoder) {
@ -209,58 +237,220 @@ public class LinkPreviewView: UIStackView {
notImplemented()
}
private let imageView = UIImageView()
private let titleLabel = UILabel()
private let domainLabel = UILabel()
private var cancelButton: UIButton?
private weak var heroImageView: UIView?
private weak var sentBodyView: UIView?
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()
if let delegate = delegate,
delegate.linkPreviewCanCancel() {
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.isLayoutMarginsRelativeArrangement = false
self.layoutMargins = .zero
cancelButton = nil
heroImageView = nil
sentBodyView = nil
NSLayoutConstraint.deactivate(layoutConstraints)
layoutConstraints = []
}
private func updateContents() {
resetContents()
self.isUserInteractionEnabled = true
self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(wasTapped)))
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()
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 createMessageContents() {
// TODO:
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 let approvalHeight: CGFloat = 76
private func createHeroSentContents(state: LinkPreviewSent,
imageView: UIImageView) {
self.layoutMargins = .zero
self.axis = .vertical
self.alignment = .fill
private var cancelButton: UIButton?
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 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 createApprovalContents() {
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
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() {
if let imageView = createImageView(state: state) {
imageView.contentMode = .scaleAspectFill
imageView.autoPinToSquareAspectRatio()
let imageSize = approvalHeight
@ -346,7 +536,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,13 +557,16 @@ public class LinkPreviewView: UIStackView {
private func createLoadingContents() {
self.axis = .vertical
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)
NSLayoutConstraint.autoSetPriority(UILayoutPriority.defaultHigh) {
self.layoutConstraints.append(self.autoSetDimension(.height, toSize: approvalHeight))
}
let activityIndicator = UIActivityIndicatorView(activityIndicatorStyle: .gray)
activityIndicator.startAnimating()
addArrangedSubview(activityIndicator)
let activityIndicatorSize: CGFloat = 25
activityIndicator.autoSetDimensions(to: CGSize(width: activityIndicatorSize, height: activityIndicatorSize))
}
// MARK: Events
@ -388,14 +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: self.state.urlString())
}
// MARK: Measurement
@objc
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()
}
}

@ -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";

@ -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<SignalAttachment *> *)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;

@ -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<SignalAttachment *> *)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<OWSOutgoingAttachmentInfo *> *attachmentInfos = [NSMutableArray new];
for (SignalAttachment *attachment in attachments) {

@ -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: "<meta property=\"og:title\" content=\"([^\"]+)\">", text: linkText) {
let normalizedTitle = OWSLinkPreview.normalizeTitle(title: rawTitle)
if normalizedTitle.count > 0 {
title = normalizedTitle
if let rawTitle = NSRegularExpression.parseFirstMatch(pattern: "<meta\\s+property=\"og:title\"\\s+content=\"([^\"]+)\"\\s*/?>", 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: "<meta property=\"og:image\" content=\"([^\"]+)\">", text: linkText) else {
guard let rawImageUrlString = NSRegularExpression.parseFirstMatch(pattern: "<meta\\s+property=\"og:image\"\\s+content=\"([^\"]+)\"\\s*/?>", 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
}
}

@ -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

@ -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

Loading…
Cancel
Save