Add documentation

pull/360/head
Niels Andriesse 3 years ago
parent 21c7d0ce03
commit 67ea1782ef

@ -92,6 +92,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
} }
func handleLibraryButtonTapped() { func handleLibraryButtonTapped() {
// FIXME: We're not yet handling the case where the user only gives access to selected photos/videos
guard requestLibraryPermissionIfNeeded() else { return } guard requestLibraryPermissionIfNeeded() else { return }
let sendMediaNavController = SendMediaNavigationController.showingMediaLibraryFirst() let sendMediaNavController = SendMediaNavigationController.showingMediaLibraryFirst()
sendMediaNavController.sendMediaNavDelegate = self sendMediaNavController.sendMediaNavDelegate = self
@ -122,7 +123,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
} }
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
SNAppearance.switchToSessionAppearance() SNAppearance.switchToSessionAppearance() // Switch back to the correct appearance
} }
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
@ -337,6 +338,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
// MARK: View Item Interaction // MARK: View Item Interaction
func handleViewItemLongPressed(_ viewItem: ConversationViewItem) { func handleViewItemLongPressed(_ viewItem: ConversationViewItem) {
// Show the context menu if applicable
guard let index = viewItems.firstIndex(where: { $0 === viewItem }), guard let index = viewItems.firstIndex(where: { $0 === viewItem }),
let cell = messagesTableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell, let cell = messagesTableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell,
let snapshot = cell.bubbleView.snapshotView(afterScreenUpdates: false), contextMenuWindow == nil, let snapshot = cell.bubbleView.snapshotView(afterScreenUpdates: false), contextMenuWindow == nil,
@ -363,6 +365,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
func handleViewItemTapped(_ viewItem: ConversationViewItem, gestureRecognizer: UITapGestureRecognizer) { func handleViewItemTapped(_ viewItem: ConversationViewItem, gestureRecognizer: UITapGestureRecognizer) {
if let message = viewItem.interaction as? TSOutgoingMessage, message.messageState == .failed { if let message = viewItem.interaction as? TSOutgoingMessage, message.messageState == .failed {
// Show the failed message sheet
showFailedMessageSheet(for: message) showFailedMessageSheet(for: message)
} else { } else {
switch viewItem.messageCellType { switch viewItem.messageCellType {
@ -371,12 +374,14 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
guard let index = viewItems.firstIndex(where: { $0 === viewItem }), guard let index = viewItems.firstIndex(where: { $0 === viewItem }),
let cell = messagesTableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell, let albumView = cell.albumView else { return } let cell = messagesTableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell, let albumView = cell.albumView else { return }
let locationInCell = gestureRecognizer.location(in: cell) let locationInCell = gestureRecognizer.location(in: cell)
// Figure out whether the "read more" button was tapped
if let overlayView = cell.mediaTextOverlayView { if let overlayView = cell.mediaTextOverlayView {
let locationInOverlayView = cell.convert(locationInCell, to: overlayView) let locationInOverlayView = cell.convert(locationInCell, to: overlayView)
if let readMoreButton = overlayView.readMoreButton, readMoreButton.frame.contains(locationInOverlayView) { if let readMoreButton = overlayView.readMoreButton, readMoreButton.frame.contains(locationInOverlayView) {
return showFullText(viewItem) // FIXME: Bit of a hack to do it this way return showFullText(viewItem) // HACK: This is a dirty way to do this
} }
} }
// Otherwise, figure out which of the media views was tapped
let locationInAlbumView = cell.convert(locationInCell, to: albumView) let locationInAlbumView = cell.convert(locationInCell, to: albumView)
guard let mediaView = albumView.mediaView(forLocation: locationInAlbumView) else { return } guard let mediaView = albumView.mediaView(forLocation: locationInAlbumView) else { return }
if albumView.isMoreItemsView(mediaView: mediaView) && viewItem.mediaAlbumHasFailedAttachment() { if albumView.isMoreItemsView(mediaView: mediaView) && viewItem.mediaAlbumHasFailedAttachment() {
@ -392,13 +397,16 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
let gallery = MediaGallery(thread: thread, options: [ .sliderEnabled, .showAllMediaButton ]) let gallery = MediaGallery(thread: thread, options: [ .sliderEnabled, .showAllMediaButton ])
gallery.presentDetailView(fromViewController: self, mediaAttachment: stream) gallery.presentDetailView(fromViewController: self, mediaAttachment: stream)
case .genericAttachment: case .genericAttachment:
// Open the document if possible
guard let url = viewItem.attachmentStream?.originalMediaURL else { return } guard let url = viewItem.attachmentStream?.originalMediaURL else { return }
let shareVC = UIActivityViewController(activityItems: [ url ], applicationActivities: nil) let shareVC = UIActivityViewController(activityItems: [ url ], applicationActivities: nil)
navigationController!.present(shareVC, animated: true, completion: nil) navigationController!.present(shareVC, animated: true, completion: nil)
case .textOnlyMessage: case .textOnlyMessage:
if let preview = viewItem.linkPreview, let urlAsString = preview.urlString, let url = URL(string: urlAsString) { if let preview = viewItem.linkPreview, let urlAsString = preview.urlString, let url = URL(string: urlAsString) {
// Open the link preview URL
openURL(url) openURL(url)
} else if let reply = viewItem.quotedReply { } else if let reply = viewItem.quotedReply {
// Scroll to the source of the reply
guard let indexPath = viewModel.ensureLoadWindowContainsQuotedReply(reply) else { return } guard let indexPath = viewModel.ensureLoadWindowContainsQuotedReply(reply) else { return }
messagesTableView.scrollToRow(at: indexPath, at: UITableView.ScrollPosition.middle, animated: true) messagesTableView.scrollToRow(at: indexPath, at: UITableView.ScrollPosition.middle, animated: true)
} }
@ -436,7 +444,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
func handleViewItemDoubleTapped(_ viewItem: ConversationViewItem) { func handleViewItemDoubleTapped(_ viewItem: ConversationViewItem) {
switch viewItem.messageCellType { switch viewItem.messageCellType {
case .audio: speedUpAudio(for: viewItem) case .audio: speedUpAudio(for: viewItem) // The user can double tap a voice message when it's playing to speed it up
default: break default: break
} }
} }
@ -466,6 +474,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
} }
func copySessionID(_ viewItem: ConversationViewItem) { func copySessionID(_ viewItem: ConversationViewItem) {
// FIXME: Copying media
guard let message = viewItem.interaction as? TSIncomingMessage else { return } guard let message = viewItem.interaction as? TSIncomingMessage else { return }
UIPasteboard.general.string = message.authorId UIPasteboard.general.string = message.authorId
} }
@ -497,6 +506,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
} }
func openURL(_ url: URL) { func openURL(_ url: URL) {
// URLs can be unsafe, so always ask the user whether they want to open one
let urlModal = URLModal(url: url) let urlModal = URLModal(url: url)
urlModal.modalPresentationStyle = .overFullScreen urlModal.modalPresentationStyle = .overFullScreen
urlModal.modalTransitionStyle = .crossDissolve urlModal.modalTransitionStyle = .crossDissolve
@ -509,6 +519,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
// MARK: Voice Message Playback // MARK: Voice Message Playback
@objc func handleAudioDidFinishPlayingNotification(_ notification: Notification) { @objc func handleAudioDidFinishPlayingNotification(_ notification: Notification) {
// Play the next voice message if there is one
guard let audioPlayer = audioPlayer, let viewItem = audioPlayer.owner as? ConversationViewItem, guard let audioPlayer = audioPlayer, let viewItem = audioPlayer.owner as? ConversationViewItem,
let index = viewItems.firstIndex(where: { $0 === viewItem }), index < (viewItems.endIndex - 1) else { return } let index = viewItems.firstIndex(where: { $0 === viewItem }), index < (viewItems.endIndex - 1) else { return }
let nextViewItem = viewItems[index + 1] let nextViewItem = viewItems[index + 1]

@ -1,14 +1,13 @@
// TODO // TODO:
// Slight paging glitch // Slight paging glitch when scrolling up and loading more content
// Photo rounding // Photo rounding (the small corners don't have the correct rounding)
// Scroll button behind mentions view
// Remaining search glitchiness // Remaining search glitchiness
final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversationSettingsViewDelegate, ConversationSearchControllerDelegate, UITableViewDataSource, UITableViewDelegate { final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversationSettingsViewDelegate, ConversationSearchControllerDelegate, UITableViewDataSource, UITableViewDelegate {
let thread: TSThread let thread: TSThread
let focusedMessageID: String? let focusedMessageID: String? // This isn't actually used ATM
var didConstrainScrollButton = false var didConstrainScrollButton = false // Part of a workaround to get the scroll button to show up in the right place
// Search // Search
var isShowingSearchUI = false var isShowingSearchUI = false
var lastSearchedText: String? var lastSearchedText: String?
@ -42,11 +41,14 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat
} }
} }
/// The height of the visible part of the table view, i.e. the distance from the navigation bar (where the table view's origin is)
/// to the top of the input view (`messagesTableView.adjustedContentInset.bottom`).
var tableViewUnobscuredHeight: CGFloat { var tableViewUnobscuredHeight: CGFloat {
let bottomInset = messagesTableView.adjustedContentInset.bottom let bottomInset = messagesTableView.adjustedContentInset.bottom
return messagesTableView.bounds.height - bottomInset return messagesTableView.bounds.height - bottomInset
} }
/// The offset at which the table view is exactly scrolled to the bottom.
var lastPageTop: CGFloat { var lastPageTop: CGFloat {
return messagesTableView.contentSize.height - tableViewUnobscuredHeight return messagesTableView.contentSize.height - tableViewUnobscuredHeight
} }
@ -107,7 +109,9 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat
}() }()
// MARK: Settings // MARK: Settings
/// The table view's bottom inset (content will have this distance to the bottom if the table view is fully scrolled down).
static let bottomInset = Values.mediumSpacing static let bottomInset = Values.mediumSpacing
/// The table view will start loading more content when the content offset becomes less than this.
static let loadMoreThreshold: CGFloat = 120 static let loadMoreThreshold: CGFloat = 120
/// The button will be fully visible once the user has scrolled this amount from the bottom of the table view. /// The button will be fully visible once the user has scrolled this amount from the bottom of the table view.
static let scrollButtonFullVisibilityThreshold: CGFloat = 80 static let scrollButtonFullVisibilityThreshold: CGFloat = 80
@ -162,6 +166,7 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat
override func viewDidLayoutSubviews() { override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews() super.viewDidLayoutSubviews()
if !didFinishInitialLayout { if !didFinishInitialLayout {
// Scroll to the last unread message if possible; otherwise scroll to the bottom.
var unreadCount: UInt = 0 var unreadCount: UInt = 0
Storage.read { transaction in Storage.read { transaction in
unreadCount = self.thread.unreadMessageCount(transaction: transaction) unreadCount = self.thread.unreadMessageCount(transaction: transaction)
@ -244,8 +249,8 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat
@objc func handleKeyboardWillChangeFrameNotification(_ notification: Notification) { @objc func handleKeyboardWillChangeFrameNotification(_ notification: Notification) {
guard let newHeight = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue.size.height else { return } guard let newHeight = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue.size.height else { return }
if !didConstrainScrollButton { if !didConstrainScrollButton {
// Bit of a hack to do this here, but it works out. // HACK: Part of a workaround to get the scroll button to show up in the right place
scrollButton.pin(.bottom, to: .bottom, of: view, withInset: -(newHeight + 16)) scrollButton.pin(.bottom, to: .bottom, of: view, withInset: -(newHeight + 16)) // + 16 to match the bottom inset of the table view
didConstrainScrollButton = true didConstrainScrollButton = true
} }
let shouldScroll = (newHeight > 200) // Arbitrary value that's higher than the collapsed size and lower than the expanded size let shouldScroll = (newHeight > 200) // Arbitrary value that's higher than the collapsed size and lower than the expanded size
@ -266,12 +271,11 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat
} }
func conversationViewModelWillUpdate() { func conversationViewModelWillUpdate() {
// Not currently in use
} }
func conversationViewModelDidUpdate(_ conversationUpdate: ConversationUpdate) { func conversationViewModelDidUpdate(_ conversationUpdate: ConversationUpdate) {
guard self.isViewLoaded else { return } guard self.isViewLoaded else { return }
// TODO: Reload the thread if it's a group thread?
let updateType = conversationUpdate.conversationUpdateType let updateType = conversationUpdate.conversationUpdateType
guard updateType != .minor else { return } // No view items were affected guard updateType != .minor else { return } // No view items were affected
if updateType == .reload { if updateType == .reload {
@ -298,11 +302,9 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat
} }
} }
let batchUpdatesCompletion: (Bool) -> Void = { isFinished in let batchUpdatesCompletion: (Bool) -> Void = { isFinished in
// TODO: Update last visible sort ID?
if shouldScrollToBottom { if shouldScrollToBottom {
self.scrollToBottom(isAnimated: true) self.scrollToBottom(isAnimated: true)
} }
// TODO: Update last known distance from bottom
} }
if shouldAnimate { if shouldAnimate {
messagesTableView.performBatchUpdates(batchUpdates, completion: batchUpdatesCompletion) messagesTableView.performBatchUpdates(batchUpdates, completion: batchUpdatesCompletion)
@ -327,11 +329,11 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat
} }
} }
} }
// TODO: Set last reload date?
} }
func conversationViewModelWillLoadMoreItems() { func conversationViewModelWillLoadMoreItems() {
view.layoutIfNeeded() view.layoutIfNeeded()
// The scroll distance to bottom will be restored in conversationViewModelDidLoadMoreItems
scrollDistanceToBottomBeforeUpdate = messagesTableView.contentSize.height - messagesTableView.contentOffset.y scrollDistanceToBottomBeforeUpdate = messagesTableView.contentSize.height - messagesTableView.contentOffset.y
} }
@ -343,19 +345,19 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat
} }
func conversationViewModelDidLoadPrevPage() { func conversationViewModelDidLoadPrevPage() {
// Not currently in use
} }
func conversationViewModelRangeDidChange() { func conversationViewModelRangeDidChange() {
// Not currently in use
} }
func conversationViewModelDidReset() { func conversationViewModelDidReset() {
// Not currently in use
} }
@objc private func handleGroupUpdatedNotification() { @objc private func handleGroupUpdatedNotification() {
thread.reload() thread.reload() // Needed so that thread.isCurrentUserMemberInGroup() is up to date
reloadInputViews() reloadInputViews()
} }
@ -398,7 +400,6 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat
let firstContentPageTop: CGFloat = 0 let firstContentPageTop: CGFloat = 0
let contentOffsetY = max(firstContentPageTop, lastPageTop) let contentOffsetY = max(firstContentPageTop, lastPageTop)
messagesTableView.setContentOffset(CGPoint(x: 0, y: contentOffsetY), animated: isAnimated) messagesTableView.setContentOffset(CGPoint(x: 0, y: contentOffsetY), animated: isAnimated)
// TODO: Did scroll to bottom
} }
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
@ -430,14 +431,14 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat
} }
func groupWasUpdated(_ groupModel: TSGroupModel) { func groupWasUpdated(_ groupModel: TSGroupModel) {
// Do nothing // Not currently in use
} }
// MARK: Search // MARK: Search
func conversationSettingsDidRequestConversationSearch(_ conversationSettingsViewController: OWSConversationSettingsViewController) { func conversationSettingsDidRequestConversationSearch(_ conversationSettingsViewController: OWSConversationSettingsViewController) {
showSearchUI() showSearchUI()
popAllConversationSettingsViews { popAllConversationSettingsViews {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { // Without this delay the search bar doesn't show
self.searchController.uiSearchController.searchBar.becomeFirstResponder() self.searchController.uiSearchController.searchBar.becomeFirstResponder()
} }
} }

@ -52,6 +52,10 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
}() }()
private lazy var inputTextView: InputTextView = { private lazy var inputTextView: InputTextView = {
// HACK: When restoring a draft the input text view won't have a frame yet, and therefore it won't
// be able to calculate what size it should be to accommodate the draft text. As a workaround, we
// just calculate the max width that the input text view is allowed to be and pass it in. See
// setUpViewHierarchy() for why these values are the way they are.
let adjustment = (InputViewButton.expandedSize - InputViewButton.size) / 2 let adjustment = (InputViewButton.expandedSize - InputViewButton.size) / 2
let maxWidth = UIScreen.main.bounds.width - 2 * InputViewButton.expandedSize - 2 * Values.smallSpacing - 2 * (Values.mediumSpacing - adjustment) let maxWidth = UIScreen.main.bounds.width - 2 * InputViewButton.expandedSize - 2 * Values.smallSpacing - 2 * (Values.mediumSpacing - adjustment)
return InputTextView(delegate: self, maxWidth: maxWidth) return InputTextView(delegate: self, maxWidth: maxWidth)
@ -134,12 +138,16 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
delegate.inputTextViewDidChangeContent(inputTextView) delegate.inputTextViewDidChangeContent(inputTextView)
} }
// We want to show either a link preview or a quote draft, but never both at the same time. When trying to
// generate a link preview, wait until we're sure that we'll be able to build a link preview from the given
// URL before removing the quote draft.
private func handleQuoteDraftChanged() { private func handleQuoteDraftChanged() {
additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
linkPreviewInfo = nil linkPreviewInfo = nil
guard let quoteDraftInfo = quoteDraftInfo else { return } guard let quoteDraftInfo = quoteDraftInfo else { return }
let direction: QuoteView.Direction = quoteDraftInfo.isOutgoing ? .outgoing : .incoming let direction: QuoteView.Direction = quoteDraftInfo.isOutgoing ? .outgoing : .incoming
let hInset: CGFloat = 6 let hInset: CGFloat = 6 // Slight visual adjustment
let maxWidth = additionalContentContainer.bounds.width let maxWidth = additionalContentContainer.bounds.width
let quoteView = QuoteView(for: quoteDraftInfo.model, direction: direction, hInset: hInset, maxWidth: maxWidth, delegate: self) let quoteView = QuoteView(for: quoteDraftInfo.model, direction: direction, hInset: hInset, maxWidth: maxWidth, delegate: self)
additionalContentContainer.addSubview(quoteView) additionalContentContainer.addSubview(quoteView)
@ -200,6 +208,7 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
// MARK: Interaction // MARK: Interaction
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// Needed so that the user can tap the buttons when the expanding attachments button is expanded
let buttonContainers = [ attachmentsButton.mainButton, attachmentsButton.cameraButton, let buttonContainers = [ attachmentsButton.mainButton, attachmentsButton.cameraButton,
attachmentsButton.libraryButton, attachmentsButton.documentButton, attachmentsButton.gifButton ] attachmentsButton.libraryButton, attachmentsButton.documentButton, attachmentsButton.gifButton ]
let buttonContainer = buttonContainers.first { $0.superview!.convert($0.frame, to: self).contains(point) } let buttonContainer = buttonContainers.first { $0.superview!.convert($0.frame, to: self).contains(point) }
@ -215,8 +224,10 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
attachmentsButton.libraryButtonContainer, attachmentsButton.cameraButtonContainer, attachmentsButton.mainButtonContainer ] attachmentsButton.libraryButtonContainer, attachmentsButton.cameraButtonContainer, attachmentsButton.mainButtonContainer ]
let isPointInsideAttachmentsButton = buttonContainers.contains { $0.superview!.convert($0.frame, to: self).contains(point) } let isPointInsideAttachmentsButton = buttonContainers.contains { $0.superview!.convert($0.frame, to: self).contains(point) }
if isPointInsideAttachmentsButton { if isPointInsideAttachmentsButton {
// Needed so that the user can tap the buttons when the expanding attachments button is expanded
return true return true
} else if mentionsViewContainer.frame.contains(point) { } else if mentionsViewContainer.frame.contains(point) {
// Needed so that the user can tap mentions
return true return true
} else { } else {
return super.point(inside: point, with: event) return super.point(inside: point, with: event)

@ -93,6 +93,9 @@ final class InputViewButton : UIView {
} }
// MARK: Interaction // MARK: Interaction
// We want to detect both taps and long presses
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
UIImpactFeedbackGenerator(style: .heavy).impactOccurred() UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
expand() expand()

@ -1,4 +1,5 @@
/// Shown over a media message if it has a message body.
final class MediaTextOverlayView : UIView { final class MediaTextOverlayView : UIView {
private let viewItem: ConversationViewItem private let viewItem: ConversationViewItem
private let albumViewWidth: CGFloat private let albumViewWidth: CGFloat

@ -122,6 +122,12 @@ final class QuoteView : UIView {
} }
private func setUpViewHierarchy() { private func setUpViewHierarchy() {
// There's quite a bit of calculation going on here. It's a bit complex so don't make changes
// if you don't need to. If you do then test:
// Quoted text in both private chats and group chats
// Quoted images and videos in both private chats and group chats
// Quoted voice messages and documents in both private chats and group chats
// All of the above in both dark mode and light mode
let hasAttachments = !attachments.isEmpty let hasAttachments = !attachments.isEmpty
let thumbnailSize = QuoteView.thumbnailSize let thumbnailSize = QuoteView.thumbnailSize
let iconSize = QuoteView.iconSize let iconSize = QuoteView.iconSize

@ -396,14 +396,16 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate {
} }
override func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { override func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true return true // Needed for the pan gesture recognizer to work with the table view's pan gesture recognizer
} }
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer == panGestureRecognizer { if gestureRecognizer == panGestureRecognizer {
let v = panGestureRecognizer.velocity(in: self) let v = panGestureRecognizer.velocity(in: self)
// Only allow swipes to the left; allowing swipes to the right gets in the way of the default
// iOS swipe to go back gesture
guard v.x < 0 else { return false } guard v.x < 0 else { return false }
return abs(v.x) > abs(v.y) return abs(v.x) > abs(v.y) // It has to be more horizontal than vertical
} else { } else {
return true return true
} }
@ -435,13 +437,14 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate {
let translationX = gestureRecognizer.translation(in: self).x.clamp(-CGFloat.greatestFiniteMagnitude, 0) let translationX = gestureRecognizer.translation(in: self).x.clamp(-CGFloat.greatestFiniteMagnitude, 0)
switch gestureRecognizer.state { switch gestureRecognizer.state {
case .changed: case .changed:
// The idea here is to asymptotically approach a maximum drag distance
let damping: CGFloat = 20 let damping: CGFloat = 20
let sign: CGFloat = -1 let sign: CGFloat = -1
let x = (damping * (sqrt(abs(translationX)) / sqrt(damping))) * sign let x = (damping * (sqrt(abs(translationX)) / sqrt(damping))) * sign
viewsToMove.forEach { $0.transform = CGAffineTransform(translationX: x, y: 0) } viewsToMove.forEach { $0.transform = CGAffineTransform(translationX: x, y: 0) }
replyButton.alpha = abs(translationX) / VisibleMessageCell.maxBubbleTranslationX replyButton.alpha = abs(translationX) / VisibleMessageCell.maxBubbleTranslationX
if abs(translationX) > VisibleMessageCell.swipeToReplyThreshold && abs(previousX) < VisibleMessageCell.swipeToReplyThreshold { if abs(translationX) > VisibleMessageCell.swipeToReplyThreshold && abs(previousX) < VisibleMessageCell.swipeToReplyThreshold {
UIImpactFeedbackGenerator(style: .heavy).impactOccurred() UIImpactFeedbackGenerator(style: .heavy).impactOccurred() // Let the user know when they've hit the swipe to reply threshold
} }
previousX = translationX previousX = translationX
case .ended, .cancelled: case .ended, .cancelled:
@ -566,6 +569,10 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate {
} }
static func getBodyTextView(for viewItem: ConversationViewItem, with availableWidth: CGFloat, textColor: UIColor, searchText: String?, delegate: UITextViewDelegate & BodyTextViewDelegate) -> UITextView { static func getBodyTextView(for viewItem: ConversationViewItem, with availableWidth: CGFloat, textColor: UIColor, searchText: String?, delegate: UITextViewDelegate & BodyTextViewDelegate) -> UITextView {
// Take care of:
// Highlighting mentions
// Linkification
// Highlighting search results
guard let message = viewItem.interaction as? TSMessage else { preconditionFailure() } guard let message = viewItem.interaction as? TSMessage else { preconditionFailure() }
let isOutgoing = (message.interactionType() == .outgoingMessage) let isOutgoing = (message.interactionType() == .outgoingMessage)
let result = BodyTextView(snDelegate: delegate) let result = BodyTextView(snDelegate: delegate)

Loading…
Cancel
Save