From 1d9e4c88c27bd3234ed9ce1ea302ff32e49ee736 Mon Sep 17 00:00:00 2001 From: ryanzhao Date: Fri, 1 Jul 2022 16:42:55 +1000 Subject: [PATCH] fix snapshot not completed issue --- Session.xcodeproj/project.pbxproj | 4 + .../ConversationVC+Interaction.swift | 2 +- .../Conversations/Input View/InputView.swift | 4 + .../Content Views/LinkPreviewView.swift | 5 +- .../Message Cells/VisibleMessageCell.swift | 77 +++++++++------ SessionUIKit/Components/TappableLabel.swift | 99 +++++++++++++++++++ 6 files changed, 159 insertions(+), 32 deletions(-) create mode 100644 SessionUIKit/Components/TappableLabel.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 46215da68..28670dec8 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -183,6 +183,7 @@ 7BAF54D927ACD0E3003D12F8 /* String+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BAF54D627ACD0E3003D12F8 /* String+Localization.swift */; }; 7BAF54DA27ACD0E3003D12F8 /* UITableView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BAF54D727ACD0E3003D12F8 /* UITableView+ReusableView.swift */; }; 7BAF54DC27ACD12B003D12F8 /* UIColor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BAF54DB27ACD12B003D12F8 /* UIColor+Extensions.swift */; }; + 7BBBDC44286EAD2D00747E59 /* TappableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BBBDC43286EAD2D00747E59 /* TappableLabel.swift */; }; 7BC01A3E241F40AB00BC7C55 /* NotificationServiceExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BC01A3D241F40AB00BC7C55 /* NotificationServiceExtension.swift */; }; 7BC01A42241F40AB00BC7C55 /* SessionNotificationServiceExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 7BC01A3B241F40AB00BC7C55 /* SessionNotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 7BC707F227290ACB002817AD /* SessionCallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BC707F127290ACB002817AD /* SessionCallManager.swift */; }; @@ -1191,6 +1192,7 @@ 7BAF54D627ACD0E3003D12F8 /* String+Localization.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Localization.swift"; sourceTree = ""; }; 7BAF54D727ACD0E3003D12F8 /* UITableView+ReusableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UITableView+ReusableView.swift"; sourceTree = ""; }; 7BAF54DB27ACD12B003D12F8 /* UIColor+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIColor+Extensions.swift"; sourceTree = ""; }; + 7BBBDC43286EAD2D00747E59 /* TappableLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TappableLabel.swift; sourceTree = ""; }; 7BC01A3B241F40AB00BC7C55 /* SessionNotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SessionNotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 7BC01A3D241F40AB00BC7C55 /* NotificationServiceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationServiceExtension.swift; sourceTree = ""; }; 7BC01A3F241F40AB00BC7C55 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -2916,6 +2918,7 @@ B8CCF638239721E20091D419 /* TabBar.swift */, B8BB82B423947F2D00BA5194 /* TextField.swift */, C3C3CF8824D8EED300E1CCE7 /* TextView.swift */, + 7BBBDC43286EAD2D00747E59 /* TappableLabel.swift */, ); path = Components; sourceTree = ""; @@ -4533,6 +4536,7 @@ buildActionMask = 2147483647; files = ( C331FF972558FA6B00070591 /* Fonts.swift in Sources */, + 7BBBDC44286EAD2D00747E59 /* TappableLabel.swift in Sources */, C331FF9B2558FA6B00070591 /* Gradients.swift in Sources */, C331FFB82558FA8D00070591 /* DeviceUtilities.swift in Sources */, C331FFE72558FB0000070591 /* TextField.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index de40e565a..946f2324a 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -513,7 +513,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc // Show the context menu if applicable guard let index = viewItems.firstIndex(where: { $0 === viewItem }), let cell = messagesTableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell, - let snapshot = cell.snapshot(afterScreenUpdates: false), contextMenuWindow == nil, + let snapshot = cell.bubbleView.snapshotView(afterScreenUpdates: false), contextMenuWindow == nil, !ContextMenuVC.actions(for: viewItem, delegate: self).isEmpty else { return } UIImpactFeedbackGenerator(style: .heavy).impactOccurred() let frame = cell.convert(cell.bubbleView.frame, to: UIApplication.shared.keyWindow!) diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index cb7d4779b..dc3ba7d42 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -398,6 +398,10 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) { delegate?.handleMentionSelected(mention, from: view) } + + func tapableLabel(_ label: TappableLabel, didTapUrl url: String, atRange range: NSRange) { + // Do nothing + } // MARK: Convenience private func container(for button: InputViewButton) -> UIView { diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift index 086adece9..8d76a588d 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift @@ -1,4 +1,5 @@ import NVActivityIndicatorView +import SessionUIKit final class LinkPreviewView : UIView { private let viewItem: ConversationViewItem? @@ -59,7 +60,7 @@ final class LinkPreviewView : UIView { return result }() - var bodyTextView: UITextView? + var bodyTextView: TappableLabel? // MARK: Settings private static let loaderSize: CGFloat = 24 @@ -163,7 +164,7 @@ final class LinkPreviewView : UIView { } // MARK: Delegate -protocol LinkPreviewViewDelegate : UITextViewDelegate & BodyTextViewDelegate { +protocol LinkPreviewViewDelegate : TappableLabelDelegate { var lastSearchedText: String? { get } func handleLinkPreviewCanceled() diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 3a859edc0..e50b8aeda 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -1,11 +1,12 @@ import UIKit +import SessionUIKit final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { private var isHandlingLongPress: Bool = false private var unloadContent: (() -> Void)? private var previousX: CGFloat = 0 var albumView: MediaAlbumView? - var bodyTextView: UITextView? + var bodyTextView: TappableLabel? // Constraints private lazy var headerViewTopConstraint = headerView.pin(.top, to: .top, of: self, withInset: 1) private lazy var authorLabelHeightConstraint = authorLabel.set(.height, to: 0) @@ -631,9 +632,10 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { } } - func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { - delegate?.openURL(URL) - return false + func tapableLabel(_ label: TappableLabel, didTapUrl url: String, atRange range: NSRange) { + if let URL = URL(string: url) { + delegate?.openURL(URL) + } } private func resetReply() { @@ -776,50 +778,67 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { return isGroupThread && viewItem.shouldShowSenderProfilePicture && senderSessionID != nil } - static func getBodyTextView(for viewItem: ConversationViewItem, with availableWidth: CGFloat, textColor: UIColor, delegate: UITextViewDelegate & BodyTextViewDelegate) -> UITextView { + static func getBodyTextView(for viewItem: ConversationViewItem, with availableWidth: CGFloat, textColor: UIColor, delegate: TappableLabelDelegate) -> TappableLabel { // Take care of: // • Highlighting mentions // • Linkification // • Highlighting search results + + func detectLinks(body: String?) -> [String: NSRange] { + var links: [String: NSRange] = [:] + guard let body = body else { return links } + let detector: NSDataDetector + do { + detector = try NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) + } catch { + return [:] + } + let matches = detector.matches(in: body, options: [], range: NSRange(location: 0, length: body.count)) + for match in matches { + guard let matchURL = match.url else { continue } + + // If the URL entered didn't have a scheme it will default to 'http', we want to catch this and + // set the scheme to 'https' instead as we don't load previews for 'http' so this will result + // in more previews actually getting loaded without forcing the user to enter 'https://' before + // every URL they enter + let urlString: String = (matchURL.absoluteString == "http://\(body)" ? + "https://\(body)" : + matchURL.absoluteString + ) + if URL(string: urlString) != nil { + links[urlString] = (body as NSString).range(of: urlString) + } + } + return links + } + guard let message = viewItem.interaction as? TSMessage else { preconditionFailure() } let isOutgoing = (message.interactionType() == .outgoingMessage) - let result = BodyTextView(snDelegate: delegate) - result.isEditable = false let attributes: [NSAttributedString.Key:Any] = [ .foregroundColor : textColor, .font : UIFont.systemFont(ofSize: getFontSize(for: viewItem)) ] let attributedText = NSMutableAttributedString(attributedString: MentionUtilities.highlightMentions(in: message.body ?? "", isOutgoingMessage: isOutgoing, threadID: viewItem.interaction.uniqueThreadId, attributes: attributes)) + let links = detectLinks(body: message.body) + for (urlString, range) in links { + let linkCustomAttributes: [NSAttributedString.Key : Any] = [ + .font: UIFont.systemFont(ofSize: getFontSize(for: viewItem)), + .foregroundColor: textColor, + .underlineColor: textColor, + .underlineStyle: NSUnderlineStyle.single.rawValue, + .attachment: URL(string: urlString)!] + attributedText.addAttributes(linkCustomAttributes, range: range) + } + + let result = TappableLabel() result.attributedText = attributedText - result.dataDetectorTypes = .link result.backgroundColor = .clear result.isOpaque = false - result.textContainerInset = UIEdgeInsets.zero - result.contentInset = UIEdgeInsets.zero - result.textContainer.lineFragmentPadding = 0 - result.isScrollEnabled = false result.isUserInteractionEnabled = true result.delegate = delegate - result.linkTextAttributes = [ .foregroundColor : textColor, .underlineStyle : NSUnderlineStyle.single.rawValue ] let availableSpace = CGSize(width: availableWidth, height: .greatestFiniteMagnitude) let size = result.sizeThatFits(availableSpace) result.set(.height, to: size.height) return result } } - -extension VisibleMessageCell { - public func snapshot(afterScreenUpdates afterUpdates: Bool) -> UIView? { - let labelForRendering = UILabel() - labelForRendering.numberOfLines = 0 - labelForRendering.backgroundColor = self.bubbleView.backgroundColor - if let bodyTextView = self.bodyTextView { - labelForRendering.attributedText = bodyTextView.attributedText - self.snContentView.addSubview(labelForRendering) - labelForRendering.frame = self.snContentView.convert(bodyTextView.frame, to: self.snContentView) - } - let snapshot = self.bubbleView.snapshotView(afterScreenUpdates: true) - labelForRendering.removeFromSuperview() - return snapshot - } -} diff --git a/SessionUIKit/Components/TappableLabel.swift b/SessionUIKit/Components/TappableLabel.swift new file mode 100644 index 000000000..a37eea2d9 --- /dev/null +++ b/SessionUIKit/Components/TappableLabel.swift @@ -0,0 +1,99 @@ +import UIKit + +// Requirements: +// • Links should show up properly and be tappable. +// • Text should * not * be selectable. +// • The long press interaction that shows the context menu should still work. + +// See https://stackoverflow.com/questions/47983838/how-can-you-change-the-color-of-links-in-a-uilabel + +public protocol TappableLabelDelegate: AnyObject { + func tapableLabel(_ label: TappableLabel, didTapUrl url: String, atRange range: NSRange) +} + +public class TappableLabel: UILabel { + + private var links: [String: NSRange] = [:] + private(set) var layoutManager = NSLayoutManager() + private(set) var textContainer = NSTextContainer(size: CGSize.zero) + private(set) var textStorage = NSTextStorage() { + didSet { + textStorage.addLayoutManager(layoutManager) + } + } + + public weak var delegate: TappableLabelDelegate? + + public override var attributedText: NSAttributedString? { + didSet { + if let attributedText = attributedText { + textStorage = NSTextStorage(attributedString: attributedText) + findLinksAndRange(attributeString: attributedText) + } else { + textStorage = NSTextStorage() + links = [:] + } + } + } + + public override var lineBreakMode: NSLineBreakMode { + didSet { + textContainer.lineBreakMode = lineBreakMode + } + } + + public override var numberOfLines: Int { + didSet { + textContainer.maximumNumberOfLines = numberOfLines + } + } + + public override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + public required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + setup() + } + + private func setup() { + isUserInteractionEnabled = true + layoutManager.addTextContainer(textContainer) + textContainer.lineFragmentPadding = 0 + textContainer.lineBreakMode = lineBreakMode + textContainer.maximumNumberOfLines = numberOfLines + numberOfLines = 0 + } + + public override func layoutSubviews() { + super.layoutSubviews() + textContainer.size = bounds.size + } + + private func findLinksAndRange(attributeString: NSAttributedString) { + links = [:] + let enumerationBlock: (Any?, NSRange, UnsafeMutablePointer) -> Void = { [weak self] value, range, isStop in + guard let strongSelf = self else { return } + if let value = value { + let stringValue = "\(value)" + strongSelf.links[stringValue] = range + } + } + attributeString.enumerateAttribute(.link, in: NSRange(0.., with event: UIEvent?) { + guard let locationOfTouch = touches.first?.location(in: self) else { + return + } + textContainer.size = bounds.size + let indexOfCharacter = layoutManager.glyphIndex(for: locationOfTouch, in: textContainer) + for (urlString, range) in links where NSLocationInRange(indexOfCharacter, range) { + delegate?.tapableLabel(self, didTapUrl: urlString, atRange: range) + return + } + } +}