diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m b/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m index 59f9fcc01..a3f54ef18 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m @@ -303,15 +303,12 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) if (!displayableText) { NSString *text = textBlock(); - // Only show up to 2kb of text. - const NSUInteger kMaxTextDisplayLength = 2 * 1024; - text = [text ows_stripped]; + // Only show up to N characters of text. + const NSUInteger kMaxTextDisplayLength = 1024; NSString *_Nullable fullText = [DisplayableText displayableText:text]; BOOL isTextTruncated = NO; if (!fullText) { fullText = @""; - } else { - fullText = fullText; } NSString *_Nullable displayText = fullText; if (displayText.length > kMaxTextDisplayLength) { @@ -324,8 +321,6 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) } if (!displayText) { displayText = @""; - } else { - displayText = displayText; } displayableText = @@ -336,70 +331,81 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) return displayableText; } +- (nullable TSAttachment *)firstAttachmentIfAnyOfMessage:(TSMessage *)message +{ + if (message.attachmentIds.count == 0) { + return nil; + } + NSString *_Nullable attachmentId = message.attachmentIds.firstObject; + if (attachmentId.length == 0) { + return nil; + } + return [TSAttachment fetchObjectWithUniqueID:attachmentId]; +} + - (void)ensureViewState { - OWSAssert([self.interaction isKindOfClass:[TSMessage class]]); + OWSAssert([self.interaction isKindOfClass:[TSOutgoingMessage class]] || + [self.interaction isKindOfClass:[TSIncomingMessage class]]); if (self.hasViewState) { return; } self.hasViewState = YES; - TSMessage *interaction = (TSMessage *)self.interaction; - if (interaction.body != nil) { - self.messageCellType = OWSMessageCellType_TextMessage; - self.displayableText = [self displayableTextForText:interaction.body interactionId:interaction.uniqueId]; - return; - } else { - NSString *_Nullable attachmentId = interaction.attachmentIds.firstObject; - if (attachmentId.length > 0) { - TSAttachment *_Nullable attachment = [TSAttachment fetchObjectWithUniqueID:attachmentId]; - if ([attachment isKindOfClass:[TSAttachmentStream class]]) { - self.attachmentStream = (TSAttachmentStream *)attachment; + TSMessage *message = (TSMessage *)self.interaction; + TSAttachment *_Nullable attachment = [self firstAttachmentIfAnyOfMessage:message]; + if (attachment) { + if ([attachment isKindOfClass:[TSAttachmentStream class]]) { + self.attachmentStream = (TSAttachmentStream *)attachment; - if ([attachment.contentType isEqualToString:OWSMimeTypeOversizeTextMessage]) { - self.messageCellType = OWSMessageCellType_OversizeTextMessage; - self.displayableText = [self displayableTextForAttachmentStream:self.attachmentStream - interactionId:interaction.uniqueId]; - return; - } else if ([self.attachmentStream isAnimated] || [self.attachmentStream isImage] || - [self.attachmentStream isVideo]) { - if ([self.attachmentStream isAnimated]) { - self.messageCellType = OWSMessageCellType_AnimatedImage; - } else if ([self.attachmentStream isImage]) { - self.messageCellType = OWSMessageCellType_StillImage; - } else if ([self.attachmentStream isVideo]) { - self.messageCellType = OWSMessageCellType_Video; - } else { - OWSFail(@"%@ unexpected attachment type.", self.tag); - self.messageCellType = OWSMessageCellType_GenericAttachment; - return; - } - self.contentSize = [self.attachmentStream imageSizeWithoutTransaction]; - if (self.contentSize.width <= 0 || self.contentSize.height <= 0) { - self.messageCellType = OWSMessageCellType_GenericAttachment; - } - return; - } else if ([self.attachmentStream isAudio]) { - self.messageCellType = OWSMessageCellType_Audio; - return; + if ([attachment.contentType isEqualToString:OWSMimeTypeOversizeTextMessage]) { + self.messageCellType = OWSMessageCellType_OversizeTextMessage; + self.displayableText = + [self displayableTextForAttachmentStream:self.attachmentStream interactionId:message.uniqueId]; + return; + } else if ([self.attachmentStream isAnimated] || [self.attachmentStream isImage] || + [self.attachmentStream isVideo]) { + if ([self.attachmentStream isAnimated]) { + self.messageCellType = OWSMessageCellType_AnimatedImage; + } else if ([self.attachmentStream isImage]) { + self.messageCellType = OWSMessageCellType_StillImage; + } else if ([self.attachmentStream isVideo]) { + self.messageCellType = OWSMessageCellType_Video; } else { + OWSFail(@"%@ unexpected attachment type.", self.tag); self.messageCellType = OWSMessageCellType_GenericAttachment; return; } - } else if ([attachment isKindOfClass:[TSAttachmentPointer class]]) { - self.messageCellType = OWSMessageCellType_DownloadingAttachment; - self.attachmentPointer = (TSAttachmentPointer *)attachment; + self.contentSize = [self.attachmentStream imageSizeWithoutTransaction]; + if (self.contentSize.width <= 0 || self.contentSize.height <= 0) { + self.messageCellType = OWSMessageCellType_GenericAttachment; + } + return; + } else if ([self.attachmentStream isAudio]) { + self.messageCellType = OWSMessageCellType_Audio; return; } else { - OWSFail(@"%@ Unknown attachment type", self.tag); + self.messageCellType = OWSMessageCellType_GenericAttachment; + return; } + } else if ([attachment isKindOfClass:[TSAttachmentPointer class]]) { + self.messageCellType = OWSMessageCellType_DownloadingAttachment; + self.attachmentPointer = (TSAttachmentPointer *)attachment; + return; } else { - OWSFail(@"%@ Message has neither attachment nor body", self.tag); + OWSFail(@"%@ Unknown attachment type", self.tag); } + } else if (message.body != nil) { + self.messageCellType = OWSMessageCellType_TextMessage; + self.displayableText = [self displayableTextForText:message.body interactionId:message.uniqueId]; + OWSAssert(self.displayableText); + return; + } else { + OWSFail(@"%@ Message has neither attachment nor body", self.tag); } - DDLogVerbose(@"%@ interaction: %@", self.tag, interaction.description); + DDLogVerbose(@"%@ message: %@", self.tag, message.description); OWSFail(@"%@ Unknown cell type", self.tag); self.messageCellType = OWSMessageCellType_Unknown; diff --git a/Signal/src/ViewControllers/MessageDetailViewController.swift b/Signal/src/ViewControllers/MessageDetailViewController.swift index 8e7f52a09..c4b69eac5 100644 --- a/Signal/src/ViewControllers/MessageDetailViewController.swift +++ b/Signal/src/ViewControllers/MessageDetailViewController.swift @@ -10,7 +10,7 @@ enum MessageMetadataViewMode: UInt { case focusOnMetadata } -class MessageDetailViewController: OWSViewController { +class MessageDetailViewController: OWSViewController, UIScrollViewDelegate { static let TAG = "[MessageDetailViewController]" let TAG = "[MessageDetailViewController]" @@ -30,6 +30,13 @@ class MessageDetailViewController: OWSViewController { var mediaMessageView: MediaMessageView? + // See comments on updateTextLayout. + var messageTextView: UITextView? + var messageTextProxyView: UIView? + var messageTextTopConstraint: NSLayoutConstraint? + var messageTextHeightLayoutConstraint: NSLayoutConstraint? + var messageTextProxyViewHeightConstraint: NSLayoutConstraint? + var scrollView: UIScrollView? var contentView: UIView? @@ -89,6 +96,8 @@ class MessageDetailViewController: OWSViewController { super.viewWillAppear(animated) mediaMessageView?.viewWillAppear(animated) + + updateTextLayout() } override func viewWillDisappear(_ animated: Bool) { @@ -103,6 +112,7 @@ class MessageDetailViewController: OWSViewController { view.backgroundColor = UIColor.white let scrollView = UIScrollView() + scrollView.delegate = self self.scrollView = scrollView view.addSubview(scrollView) scrollView.autoPinWidthToSuperview(withMargin: 0) @@ -305,23 +315,34 @@ class MessageDetailViewController: OWSViewController { // on the size of its backing buffer, especially when we're // embedding it "full-size' within a UIScrollView as we do in this view. // - // TODO: We could use CoreText instead, or we could dynamically - // manipulate the size/position of our UITextView to - // reflect scroll state. - let bodyLabel = UITextView() - bodyLabel.font = UIFont.ows_dynamicTypeBody() - bodyLabel.backgroundColor = UIColor.clear - bodyLabel.isOpaque = false - bodyLabel.isEditable = false - bodyLabel.isSelectable = true - bodyLabel.textContainerInset = UIEdgeInsets.zero - bodyLabel.contentInset = UIEdgeInsets.zero - bodyLabel.isScrollEnabled = false - bodyLabel.textColor = isIncoming ? UIColor.black : UIColor.white - bodyLabel.text = messageBody + // Therefore we're doing something unusual here. + // See comments on updateTextLayout. + let messageTextView = UITextView() + self.messageTextView = messageTextView + messageTextView.font = UIFont.ows_dynamicTypeBody() + messageTextView.backgroundColor = UIColor.clear + messageTextView.isOpaque = false + messageTextView.isEditable = false + messageTextView.isSelectable = true + messageTextView.textContainerInset = UIEdgeInsets.zero + messageTextView.contentInset = UIEdgeInsets.zero + messageTextView.isScrollEnabled = true + messageTextView.showsHorizontalScrollIndicator = false + messageTextView.showsVerticalScrollIndicator = false + messageTextView.isUserInteractionEnabled = false + messageTextView.textColor = isIncoming ? UIColor.black : UIColor.white + messageTextView.text = messageBody let bubbleImageData = isIncoming ? bubbleFactory.incoming : bubbleFactory.outgoing + let messageTextProxyView = UIView() + messageTextProxyView.layoutMargins = UIEdgeInsets.zero + self.messageTextProxyView = messageTextProxyView + messageTextProxyView.addSubview(messageTextView) + messageTextView.autoPinWidthToSuperview() + self.messageTextTopConstraint = messageTextView.autoPinEdge(toSuperviewEdge: .top, withInset: 0) + self.messageTextHeightLayoutConstraint = messageTextView.autoSetDimension(.height, toSize:0) + let leadingMargin: CGFloat = isIncoming ? 15 : 10 let trailingMargin: CGFloat = isIncoming ? 10 : 15 @@ -329,11 +350,12 @@ class MessageDetailViewController: OWSViewController { self.bubbleView = bubbleView bubbleView.layer.cornerRadius = 10 - bubbleView.addSubview(bodyLabel) + bubbleView.addSubview(messageTextProxyView) - bodyLabel.autoPinEdge(toSuperviewEdge: .leading, withInset: leadingMargin) - bodyLabel.autoPinEdge(toSuperviewEdge: .trailing, withInset: trailingMargin) - bodyLabel.autoPinHeightToSuperview(withMargin: 10) + messageTextProxyView.autoPinEdge(toSuperviewEdge: .leading, withInset: leadingMargin) + messageTextProxyView.autoPinEdge(toSuperviewEdge: .trailing, withInset: trailingMargin) + messageTextProxyView.autoPinHeightToSuperview(withMargin: 10) + self.messageTextProxyViewHeightConstraint = messageTextProxyView.autoSetDimension(.height, toSize:0) let row = UIView() row.addSubview(bubbleView) @@ -567,4 +589,93 @@ class MessageDetailViewController: OWSViewController { comment: "Status label for messages which are failed.") } } + + // MARK: - Text Layout + + // UITextView can't render extremely long text due to constraints on the size + // of its backing buffer, especially when we're embedding it "full-size' + // within a UIScrollView as we do in this view. Therefore if we do the naive + // thing and embed a full-size UITextView inside our UIScrollView, it will + // fail to render any text if the text message is sufficiently long. + // + // Therefore we're doing something unusual. + // + // * We use an empty UIView "messageTextProxyView" as a placeholder for the + // the UITextView. It has the size and position of where the UITextView + // would be normally. + // * We use a UITextView inside that proxy that is just large enough to + // render the content onscreen. We then move it around within the proxy + // bounds to render the parts of the proxy which are onscreen. + private func updateTextLayout() { + guard let messageTextView = messageTextView else { + return + } + guard let messageTextProxyView = messageTextProxyView else { + owsFail("\(TAG) Missing messageTextProxyView") + return + } + guard let messageTextTopConstraint = messageTextTopConstraint else { + owsFail("\(TAG) Missing messageTextProxyView") + return + } + guard let messageTextHeightLayoutConstraint = messageTextHeightLayoutConstraint else { + owsFail("\(TAG) Missing messageTextProxyView") + return + } + guard let messageTextProxyViewHeightConstraint = messageTextProxyViewHeightConstraint else { + owsFail("\(TAG) Missing messageTextProxyView") + return + } + guard let scrollView = scrollView else { + owsFail("\(TAG) Missing scrollView") + return + } + guard let contentView = contentView else { + owsFail("\(TAG) Missing contentView") + return + } + + if messageTextView.width() != messageTextProxyView.width() { + owsFail("\(TAG) messageTextView.width \(messageTextView.width) != messageTextProxyView.width \(messageTextProxyView.width)") + } + + // Measure the total text size. + let textSize = messageTextView.sizeThatFits(CGSize(width:messageTextView.width(), height:CGFloat.greatestFiniteMagnitude)) + // Measure the size of the scroll view viewport. + let scrollViewSize = scrollView.frame.size + // Obtain the current scroll view content offset (scroll state). + let scrollViewContentOffset = scrollView.contentOffset + // Obtain the location of the text view proxy relative to the content view. + let textProxyOffset = contentView.convert(CGPoint.zero, from:messageTextProxyView) + + // 1. The text proxy should always be sized large enough to hold the + // entire text content. + let messageTextProxyViewHeight = textSize.height + messageTextProxyViewHeightConstraint.constant = messageTextProxyViewHeight + + // 2. We only want to render a single screenful of text content at a time. + // The height of the text view should reflect the height of the scrollview's + // viewport. + let messageTextViewHeight = min(textSize.height, scrollViewSize.height) + messageTextHeightLayoutConstraint.constant = messageTextViewHeight + + // 3. We want to move the text view around within the proxy in response to + // scroll state changes so that it can render the part of the proxy which + // is on screen. + let minMessageTextViewY = CGFloat(0) + let maxMessageTextViewY = messageTextProxyViewHeight - messageTextViewHeight + let rawMessageTextViewY = -textProxyOffset.y + scrollViewContentOffset.y + let messageTextViewY = max(minMessageTextViewY, min(maxMessageTextViewY, rawMessageTextViewY)) + messageTextTopConstraint.constant = messageTextViewY + + // 4. We want to scroll the text view's content so that the text view + // renders the appropriate content for the scrollview's scroll state. + messageTextView.contentOffset = CGPoint(x:0, y:messageTextViewY) + } + + public func scrollViewDidScroll(_ scrollView: UIScrollView) { + Logger.verbose("\(TAG) scrollViewDidScroll") + + updateTextLayout() + } } diff --git a/Signal/src/util/DisplayableText.swift b/Signal/src/util/DisplayableText.swift index 11daa20e0..87a558b6b 100644 --- a/Signal/src/util/DisplayableText.swift +++ b/Signal/src/util/DisplayableText.swift @@ -24,7 +24,7 @@ import Foundation @objc class func displayableText(_ text: String?) -> String? { - guard let text = text else { + guard let text = text?.ows_stripped() else { return nil }