Add link previews to conversation message bubbles.

This commit is contained in:
Matthew Chen 2019-01-18 16:44:04 -05:00
parent ca8a4b3751
commit 3d757b492a
13 changed files with 403 additions and 110 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 state.isLoaded() else {
createLoadingContents()
guard isApproval else {
createSentContents()
return
}
guard isApproval else {
createMessageContents()
guard state.isLoaded() else {
createLoadingContents()
return
}
createApprovalContents(state: state)
}
private func createMessageContents() {
// TODO:
private func createSentContents() {
guard let state = state as? LinkPreviewSent else {
owsFailDebug("Invalid state")
return
}
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 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()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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