diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 1826603bf..6766d2118 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -36,6 +36,7 @@ 340FC8BC204DAC8D007AEB0F /* FingerprintViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC8A2204DAC8D007AEB0F /* FingerprintViewController.m */; }; 340FC8BD204DAC8D007AEB0F /* ShowGroupMembersViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC8A6204DAC8D007AEB0F /* ShowGroupMembersViewController.m */; }; 340FC8C5204DE223007AEB0F /* DebugUIBackup.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC8C4204DE223007AEB0F /* DebugUIBackup.m */; }; + 34129B8621EF877A005457A8 /* LinkPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34129B8521EF8779005457A8 /* LinkPreviewView.swift */; }; 341341EF2187467A00192D59 /* ConversationViewModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 341341EE2187467900192D59 /* ConversationViewModel.m */; }; 341F2C0F1F2B8AE700D07D6B /* DebugUIMisc.m in Sources */ = {isa = PBXBuildFile; fileRef = 341F2C0E1F2B8AE700D07D6B /* DebugUIMisc.m */; }; 3421981C21061D2E00C57195 /* ByteParserTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3421981B21061D2E00C57195 /* ByteParserTest.swift */; }; @@ -671,6 +672,7 @@ 340FC8A6204DAC8D007AEB0F /* ShowGroupMembersViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ShowGroupMembersViewController.m; sourceTree = ""; }; 340FC8C3204DE223007AEB0F /* DebugUIBackup.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DebugUIBackup.h; sourceTree = ""; }; 340FC8C4204DE223007AEB0F /* DebugUIBackup.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DebugUIBackup.m; sourceTree = ""; }; + 34129B8521EF8779005457A8 /* LinkPreviewView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LinkPreviewView.swift; sourceTree = ""; }; 341341ED2187467900192D59 /* ConversationViewModel.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ConversationViewModel.h; sourceTree = ""; }; 341341EE2187467900192D59 /* ConversationViewModel.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ConversationViewModel.m; sourceTree = ""; }; 341458471FBE11C4005ABCF9 /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fa; path = translations/fa.lproj/Localizable.strings; sourceTree = ""; }; @@ -2330,10 +2332,12 @@ 452EA09D1EA7ABE00078744B /* AttachmentPointerView.swift */, 34E3E5671EC4B19400495BAC /* AudioProgressView.swift */, 4C2F454E214C00E1004871FF /* AvatarTableViewCell.swift */, + 4CA46F4B219CCC630038ABDE /* CaptionView.swift */, 451764291DE939FD00EDB8B9 /* ContactCell.swift */, 4523149F1F7E9E18003A428C /* DirectionalPanGestureRecognizer.swift */, 4C4AEC4420EC343B0020E72B /* DismissableTextField.swift */, 45A663C41F92EC760027B59E /* GroupTableViewCell.swift */, + 34129B8521EF8779005457A8 /* LinkPreviewView.swift */, 45E5A6981F61E6DD001E4A8A /* MarqueeLabel.swift */, 34386A53207D271C009F5D9C /* NeverClearView.swift */, 34F308A01ECB469700BB7697 /* OWSBezierPathView.h */, @@ -2342,6 +2346,7 @@ 459311FB1D75C948008DD4F0 /* OWSDeviceTableViewCell.m */, 34330AA11E79686200DF2FB9 /* OWSProgressView.h */, 34330AA21E79686200DF2FB9 /* OWSProgressView.m */, + 4C1885D1218F8E1C00B67051 /* PhotoGridViewCell.swift */, 45D308AB2049A439000189E4 /* PinEntryView.h */, 45D308AC2049A439000189E4 /* PinEntryView.m */, 457F671A20746193000EABCD /* QuotedReplyPreview.swift */, @@ -2350,8 +2355,6 @@ 450D19121F85236600970622 /* RemoteVideoView.m */, 4CA5F792211E1F06008C2708 /* Toast.swift */, 34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */, - 4C1885D1218F8E1C00B67051 /* PhotoGridViewCell.swift */, - 4CA46F4B219CCC630038ABDE /* CaptionView.swift */, ); name = Views; path = views; @@ -3460,6 +3463,7 @@ 4C20B2B920CA10DE001BAC90 /* ConversationSearchViewController.swift in Sources */, 450D19131F85236600970622 /* RemoteVideoView.m in Sources */, B6B9ECFC198B31BA00C620D3 /* PushManager.m in Sources */, + 34129B8621EF877A005457A8 /* LinkPreviewView.swift in Sources */, 34386A54207D271D009F5D9C /* NeverClearView.swift in Sources */, 45DF5DF21DDB843F00C936C7 /* CompareSafetyNumbersActivity.swift in Sources */, 451166C01FD86B98000739BA /* AccountManager.swift in Sources */, diff --git a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.h b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.h index eaed1c481..adebf8e83 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.h +++ b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.h @@ -5,6 +5,7 @@ NS_ASSUME_NONNULL_BEGIN @class ConversationStyle; +@class OWSLinkPreviewDraft; @class OWSQuotedReplyModel; @class SignalAttachment; @@ -68,6 +69,8 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, nullable) OWSQuotedReplyModel *quotedReply; +@property (nonatomic, nullable, readonly) OWSLinkPreviewDraft *linkPreviewDraft; + @end NS_ASSUME_NONNULL_END diff --git a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m index 7420c9a67..47b198e56 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m @@ -26,7 +26,24 @@ const CGFloat kMaxTextViewHeight = 98; #pragma mark - -@interface ConversationInputToolbar () +@interface InputLinkPreview : NSObject + +@property (nonatomic) NSString *previewUrl; +@property (nonatomic, nullable) OWSLinkPreviewDraft *linkPreviewDraft; + +@end + +#pragma mark - + +@implementation InputLinkPreview + +@end + +#pragma mark - + +@interface ConversationInputToolbar () @property (nonatomic, readonly) ConversationStyle *conversationStyle; @@ -55,12 +72,12 @@ const CGFloat kMaxTextViewHeight = 98; @property (nonatomic) CGPoint voiceMemoGestureStartLocation; @property (nonatomic, nullable) NSArray *layoutContraints; @property (nonatomic) UIEdgeInsets receivedSafeAreaInsets; +@property (nonatomic, nullable) InputLinkPreview *inputLinkPreview; +@property (nonatomic, nullable) LinkPreviewView *linkPreviewView; +@property (nonatomic) BOOL wasLinkPreviewCancelled; @end -#pragma mark - - - #pragma mark - @implementation ConversationInputToolbar @@ -210,6 +227,7 @@ const CGFloat kMaxTextViewHeight = 98; [self ensureShouldShowVoiceMemoButtonAnimated:isAnimated doLayout:YES]; [self ensureTextViewHeight]; + [self updateInputLinkPreview]; } - (void)ensureTextViewHeight @@ -221,6 +239,7 @@ const CGFloat kMaxTextViewHeight = 98; { [self setMessageText:nil animated:isAnimated]; [self.inputTextView.undoManager removeAllActions]; + self.wasLinkPreviewCancelled = NO; } - (void)toggleDefaultKeyboard @@ -646,6 +665,7 @@ const CGFloat kMaxTextViewHeight = 98; OWSAssertDebug(self.inputToolbarDelegate); [self ensureShouldShowVoiceMemoButtonAnimated:YES doLayout:YES]; [self updateHeightWithTextView:textView]; + [self updateInputLinkPreview]; } - (void)updateHeightWithTextView:(UITextView *)textView @@ -676,6 +696,122 @@ const CGFloat kMaxTextViewHeight = 98; self.quotedReply = nil; } +#pragma mark - Link Preview + +- (void)updateInputLinkPreview +{ + OWSAssertIsOnMainThread(); + + if (self.wasLinkPreviewCancelled) { + self.inputLinkPreview = nil; + [self clearLinkPreviewView]; + return; + } + + NSString *body = + [[self messageText] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (body.length < 1) { + self.inputLinkPreview = nil; + [self clearLinkPreviewView]; + self.wasLinkPreviewCancelled = NO; + return; + } + + NSString *_Nullable previewUrl = [OWSLinkPreview previewUrlForMessageBodyText:body]; + if (previewUrl.length < 1) { + self.inputLinkPreview = nil; + [self clearLinkPreviewView]; + return; + } + + if (self.inputLinkPreview && [self.inputLinkPreview.previewUrl isEqualToString:previewUrl]) { + // No need to update. + return; + } + + InputLinkPreview *inputLinkPreview = [InputLinkPreview new]; + self.inputLinkPreview = inputLinkPreview; + self.inputLinkPreview.previewUrl = previewUrl; + + [self ensureLinkPreviewViewWithState:[LinkPreviewLoading new]]; + + __weak ConversationInputToolbar *weakSelf = self; + [OWSLinkPreview tryToBuildPreviewInfoWithPreviewUrl:previewUrl + callbackQueue:dispatch_get_main_queue() + completion:^(OWSLinkPreviewDraft *_Nullable linkPreviewDraft) { + ConversationInputToolbar *_Nullable strongSelf = weakSelf; + if (!strongSelf) { + return; + } + if (strongSelf.inputLinkPreview != inputLinkPreview) { + // Obsolete callback. + return; + } + if (!linkPreviewDraft) { + // The link preview could not be loaded. + [strongSelf clearLinkPreviewView]; + return; + } + inputLinkPreview.linkPreviewDraft = linkPreviewDraft; + LinkPreviewDraft *viewState = [[LinkPreviewDraft alloc] + initWithLinkPreviewDraft:linkPreviewDraft]; + [strongSelf ensureLinkPreviewViewWithState:viewState]; + }]; +} + +- (void)ensureLinkPreviewViewWithState:(id)state +{ + OWSAssertIsOnMainThread(); + + [self clearLinkPreviewView]; + + LinkPreviewView *linkPreviewView = [[LinkPreviewView alloc] initWithState:state delegate:self]; + self.linkPreviewView = linkPreviewView; + // TODO: Revisit once we have a separate quoted reply view. + [self.contentRows insertArrangedSubview:linkPreviewView atIndex:0]; +} + +- (void)clearLinkPreviewView +{ + OWSAssertIsOnMainThread(); + + // Clear old link preview state. + [self.linkPreviewView removeFromSuperview]; + self.linkPreviewView = nil; +} + +- (nullable OWSLinkPreviewDraft *)linkPreviewDraft +{ + OWSAssertIsOnMainThread(); + + if (!self.inputLinkPreview) { + return nil; + } + if (self.wasLinkPreviewCancelled) { + return nil; + } + return self.inputLinkPreview.linkPreviewDraft; +} + +#pragma mark - LinkPreviewViewDelegate + +- (BOOL)linkPreviewCanCancel +{ + OWSAssertIsOnMainThread(); + + return YES; +} + +- (void)linkPreviewDidCancel +{ + OWSAssertIsOnMainThread(); + + self.wasLinkPreviewCancelled = YES; + + self.self.inputLinkPreview = nil; + [self clearLinkPreviewView]; +} + @end NS_ASSUME_NONNULL_END diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index dd392fbca..cf3c91768 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -3579,12 +3579,15 @@ 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:nil]; + linkPreview:linkPreview]; [self messageWasSent:message]; @@ -3916,6 +3919,7 @@ typedef enum : NSUInteger { - (void)tryToSendTextMessage:(NSString *)text updateKeyboardState:(BOOL)updateKeyboardState { + OWSAssertIsOnMainThread(); __weak ConversationViewController *weakSelf = self; if ([self isBlockedConversation]) { @@ -3952,6 +3956,8 @@ 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 = @@ -3962,13 +3968,13 @@ typedef enum : NSUInteger { message = [ThreadUtil enqueueMessageWithAttachment:attachment inThread:self.thread quotedReplyModel:self.inputToolbar.quotedReply - linkPreview:nil]; + linkPreview:linkPreview]; } else { [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *_Nonnull transaction) { message = [ThreadUtil enqueueMessageWithText:text inThread:self.thread quotedReplyModel:self.inputToolbar.quotedReply - linkPreview:nil + linkPreview:linkPreview transaction:transaction]; }]; } @@ -3997,6 +4003,36 @@ 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/views/LinkPreviewView.swift b/Signal/src/views/LinkPreviewView.swift new file mode 100644 index 000000000..f1d342b5e --- /dev/null +++ b/Signal/src/views/LinkPreviewView.swift @@ -0,0 +1,417 @@ +// +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. +// + +@objc +public enum LinkPreviewImageState: Int { + case none + case loading + case loaded + case invalid +} + +// MARK: - + +@objc +public protocol LinkPreviewState { + func isLoaded() -> Bool + func urlString() -> String? + func displayDomain() -> String? + func title() -> String? + func imageState() -> LinkPreviewImageState + func image() -> UIImage? +} + +// MARK: - + +@objc +public class LinkPreviewLoading: NSObject, LinkPreviewState { + + override init() { + } + + public func isLoaded() -> Bool { + return false + } + + public func urlString() -> String? { + return nil + } + + public func displayDomain() -> String? { + return nil + } + + public func title() -> String? { + return nil + } + + public func imageState() -> LinkPreviewImageState { + return .none + } + + public func image() -> UIImage? { + return nil + } +} + +// MARK: - + +@objc +public class LinkPreviewDraft: NSObject, LinkPreviewState { + private let linkPreviewDraft: OWSLinkPreviewDraft + + @objc + public required init(linkPreviewDraft: OWSLinkPreviewDraft) { + self.linkPreviewDraft = linkPreviewDraft + } + + public func isLoaded() -> Bool { + return true + } + + public func urlString() -> String? { + return linkPreviewDraft.urlString + } + + public func displayDomain() -> String? { + guard let displayDomain = linkPreviewDraft.displayDomain() else { + owsFailDebug("Missing display domain") + return nil + } + return displayDomain + } + + public func title() -> String? { + return linkPreviewDraft.title + } + + public func imageState() -> LinkPreviewImageState { + if linkPreviewDraft.imageFilePath != nil { + return .loaded + } else { + return .none + } + } + + public func image() -> UIImage? { + assert(imageState() == .loaded) + + guard let imageFilepath = linkPreviewDraft.imageFilePath else { + return nil + } + guard let image = UIImage(contentsOfFile: imageFilepath) else { + owsFail("Could not load image: \(imageFilepath)") + } + return image + } +} + +// MARK: - + +@objc +public class LinkPreviewSent: NSObject, LinkPreviewState { + private let linkPreview: OWSLinkPreview + private let imageAttachment: TSAttachment? + + @objc + public required init(linkPreview: OWSLinkPreview, + imageAttachment: TSAttachment?) { + self.linkPreview = linkPreview + self.imageAttachment = imageAttachment + } + + public func isLoaded() -> Bool { + return true + } + + public func urlString() -> String? { + guard let urlString = linkPreview.urlString else { + owsFailDebug("Missing url") + return nil + } + return urlString + } + + public func displayDomain() -> String? { + guard let displayDomain = linkPreview.displayDomain() else { + owsFailDebug("Missing display domain") + return nil + } + return displayDomain + } + + public func title() -> String? { + return linkPreview.title + } + + public func imageState() -> LinkPreviewImageState { + guard linkPreview.imageAttachmentId != nil else { + return .none + } + guard let imageAttachment = imageAttachment else { + owsFailDebug("Missing imageAttachment.") + return .none + } + guard let attachmentStream = imageAttachment as? TSAttachmentStream else { + return .loading + } + guard attachmentStream.isValidImage else { + return .invalid + } + return .loaded + } + + public func image() -> UIImage? { + assert(imageState() == .loaded) + + guard let attachmentStream = imageAttachment as? TSAttachmentStream else { + owsFailDebug("Could not load image.") + return nil + } + guard attachmentStream.isValidImage else { + return nil + } + guard let imageFilepath = attachmentStream.originalFilePath else { + owsFailDebug("Attachment is missing file path.") + return nil + } + guard let image = UIImage(contentsOfFile: imageFilepath) else { + owsFail("Could not load image: \(imageFilepath)") + } + return image + } +} + +// MARK: - + +@objc +public protocol LinkPreviewViewDelegate { + func linkPreviewCanCancel() -> Bool + @objc optional func linkPreviewDidCancel() + @objc optional func linkPreviewDidTap(urlString: String?) +} + +// MARK: - + +@objc +public class LinkPreviewView: UIStackView { + private weak var delegate: LinkPreviewViewDelegate? + private let state: LinkPreviewState + + @available(*, unavailable, message:"use other constructor instead.") + required init(coder aDecoder: NSCoder) { + notImplemented() + } + + @available(*, unavailable, message:"use other constructor instead.") + override init(frame: CGRect) { + notImplemented() + } + + private let imageView = UIImageView() + private let titleLabel = UILabel() + private let domainLabel = UILabel() + + @objc + public init(state: LinkPreviewState, + delegate: LinkPreviewViewDelegate?) { + self.state = state + self.delegate = delegate + + super.init(frame: .zero) + + createContents() + } + + private var isApproval: Bool { + return delegate != nil + } + + private func createContents() { + + self.isUserInteractionEnabled = true + self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(wasTapped))) + + guard state.isLoaded() else { + createLoadingContents() + return + } + guard isApproval else { + createMessageContents() + return + } + createApprovalContents() + } + + private func createMessageContents() { +// guard state.isLoaded() else { +// createLoadingContents() +// return +// } +// +// if let imageView = createImageView() { +// +// } +// +// switch state.imageState() { +// case .loaded: +// guard +// let imageView = UIImageView() +// +// case .loading: +// default: +// break +// } +// +// let textStack = UIStackView() +// self.axis = .vertical +// self.alignment = .leading +// self.spacing = 5 + } + + private let approvalHeight: CGFloat = 76 + + private var cancelButton: UIImageView? + + private func createApprovalContents() { + self.axis = .horizontal + self.alignment = .fill + self.distribution = .equalSpacing + self.spacing = 8 + + // Image + + if let imageView = createImageView() { + imageView.contentMode = .scaleAspectFill + imageView.autoPinToSquareAspectRatio() + let imageSize = approvalHeight + imageView.autoSetDimensions(to: CGSize(width: imageSize, height: imageSize)) + imageView.setContentHuggingHigh() + imageView.setCompressionResistanceHigh() + imageView.clipsToBounds = true + // TODO: Cropping, stroke. + addArrangedSubview(imageView) + } + + // Right + + let rightStack = UIStackView() + rightStack.axis = .horizontal + rightStack.alignment = .fill + rightStack.distribution = .equalSpacing + rightStack.spacing = 8 + rightStack.setContentHuggingHorizontalLow() + rightStack.setCompressionResistanceHorizontalLow() + addArrangedSubview(rightStack) + + // Text + + let textStack = UIStackView() + textStack.axis = .vertical + textStack.alignment = .leading + textStack.spacing = 2 + textStack.setContentHuggingHorizontalLow() + textStack.setCompressionResistanceHorizontalLow() + + if let title = state.title(), + title.count > 0 { + let label = UILabel() + label.text = title + label.textColor = Theme.primaryColor + label.font = UIFont.ows_dynamicTypeBody + textStack.addArrangedSubview(label) + } + if let displayDomain = state.displayDomain(), + displayDomain.count > 0 { + let label = UILabel() + label.text = displayDomain.uppercased() + label.textColor = Theme.secondaryColor + label.font = UIFont.ows_dynamicTypeCaption1 + textStack.addArrangedSubview(label) + } + + let textWrapper = UIStackView(arrangedSubviews: [textStack]) + textWrapper.axis = .horizontal + textWrapper.alignment = .center + textWrapper.setContentHuggingHorizontalLow() + textWrapper.setCompressionResistanceHorizontalLow() + + rightStack.addArrangedSubview(textWrapper) + + // Cancel + + let cancelStack = UIStackView() + cancelStack.axis = .horizontal + cancelStack.alignment = .top + cancelStack.setContentHuggingHigh() + cancelStack.setCompressionResistanceHigh() + + let cancelImage: UIImage = #imageLiteral(resourceName: "quoted-message-cancel").withRenderingMode(.alwaysTemplate) + let cancelButton = UIImageView(image: cancelImage) + self.cancelButton = cancelButton + cancelButton.tintColor = Theme.secondaryColor + cancelButton.setContentHuggingHigh() + cancelButton.setCompressionResistanceHigh() + cancelStack.addArrangedSubview(cancelButton) + + rightStack.addArrangedSubview(cancelStack) + + // Stroke + let strokeView = UIView() + strokeView.backgroundColor = Theme.secondaryColor + rightStack.addSubview(strokeView) + strokeView.autoPinWidthToSuperview() + strokeView.autoPinEdge(toSuperviewEdge: .bottom) + strokeView.autoSetDimension(.height, toSize: CGHairlineWidth()) + } + + private func createImageView() -> UIImageView? { + guard state.isLoaded() else { + owsFailDebug("State not loaded.") + return nil + } + + guard state.imageState() == .loaded else { + return nil + } + guard let image = state.image() else { + owsFailDebug("Could not load image.") + return nil + } + let imageView = UIImageView() + imageView.image = image + return imageView + } + + 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) + } + + // MARK: Events + + @objc func wasTapped(sender: UIGestureRecognizer) { + guard sender.state == .recognized else { + return + } + if let cancelButton = cancelButton { + let cancelLocation = sender.location(in: cancelButton) + // Permissive hot area to make it very easy to cancel the link preview. + let hotAreaInset: CGFloat = -20 + let cancelButtonHotArea = cancelButton.bounds.insetBy(dx: hotAreaInset, dy: hotAreaInset) + if cancelButtonHotArea.contains(cancelLocation) { + self.delegate?.linkPreviewDidCancel?() + return + } + } + self.delegate?.linkPreviewDidTap?(urlString: self.state.urlString()) + } +} diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index fc1c1050b..2ba7535d0 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -1182,6 +1182,9 @@ /* 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…"; + /* Menu item and navbar title for the device manager */ "LINKED_DEVICES_TITLE" = "Linked Devices";