diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 90ef7a481..cc46161c1 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -92,6 +92,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc } func handleLibraryButtonTapped() { + // FIXME: We're not yet handling the case where the user only gives access to selected photos/videos guard requestLibraryPermissionIfNeeded() else { return } let sendMediaNavController = SendMediaNavigationController.showingMediaLibraryFirst() sendMediaNavController.sendMediaNavDelegate = self @@ -122,7 +123,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc } func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { - SNAppearance.switchToSessionAppearance() + SNAppearance.switchToSessionAppearance() // Switch back to the correct appearance } func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { @@ -337,6 +338,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc // MARK: View Item Interaction func handleViewItemLongPressed(_ viewItem: ConversationViewItem) { + // 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.bubbleView.snapshotView(afterScreenUpdates: false), contextMenuWindow == nil, @@ -363,6 +365,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc func handleViewItemTapped(_ viewItem: ConversationViewItem, gestureRecognizer: UITapGestureRecognizer) { if let message = viewItem.interaction as? TSOutgoingMessage, message.messageState == .failed { + // Show the failed message sheet showFailedMessageSheet(for: message) } else { switch viewItem.messageCellType { @@ -371,12 +374,14 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc 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 locationInCell = gestureRecognizer.location(in: cell) + // Figure out whether the "read more" button was tapped if let overlayView = cell.mediaTextOverlayView { let locationInOverlayView = cell.convert(locationInCell, to: overlayView) 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) guard let mediaView = albumView.mediaView(forLocation: locationInAlbumView) else { return } if albumView.isMoreItemsView(mediaView: mediaView) && viewItem.mediaAlbumHasFailedAttachment() { @@ -392,13 +397,16 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc let gallery = MediaGallery(thread: thread, options: [ .sliderEnabled, .showAllMediaButton ]) gallery.presentDetailView(fromViewController: self, mediaAttachment: stream) case .genericAttachment: + // Open the document if possible guard let url = viewItem.attachmentStream?.originalMediaURL else { return } let shareVC = UIActivityViewController(activityItems: [ url ], applicationActivities: nil) navigationController!.present(shareVC, animated: true, completion: nil) case .textOnlyMessage: if let preview = viewItem.linkPreview, let urlAsString = preview.urlString, let url = URL(string: urlAsString) { + // Open the link preview URL openURL(url) } else if let reply = viewItem.quotedReply { + // Scroll to the source of the reply guard let indexPath = viewModel.ensureLoadWindowContainsQuotedReply(reply) else { return } messagesTableView.scrollToRow(at: indexPath, at: UITableView.ScrollPosition.middle, animated: true) } @@ -436,7 +444,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc func handleViewItemDoubleTapped(_ viewItem: ConversationViewItem) { 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 } } @@ -466,6 +474,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc } func copySessionID(_ viewItem: ConversationViewItem) { + // FIXME: Copying media guard let message = viewItem.interaction as? TSIncomingMessage else { return } UIPasteboard.general.string = message.authorId } @@ -497,6 +506,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc } func openURL(_ url: URL) { + // URLs can be unsafe, so always ask the user whether they want to open one let urlModal = URLModal(url: url) urlModal.modalPresentationStyle = .overFullScreen urlModal.modalTransitionStyle = .crossDissolve @@ -509,6 +519,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc // MARK: Voice Message Playback @objc func handleAudioDidFinishPlayingNotification(_ notification: Notification) { + // Play the next voice message if there is one guard let audioPlayer = audioPlayer, let viewItem = audioPlayer.owner as? ConversationViewItem, let index = viewItems.firstIndex(where: { $0 === viewItem }), index < (viewItems.endIndex - 1) else { return } let nextViewItem = viewItems[index + 1] diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 25a0c933d..6fd05f912 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -1,14 +1,13 @@ -// TODO -// • Slight paging glitch -// • Photo rounding -// • Scroll button behind mentions view +// TODO: +// • Slight paging glitch when scrolling up and loading more content +// • Photo rounding (the small corners don't have the correct rounding) // • Remaining search glitchiness final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversationSettingsViewDelegate, ConversationSearchControllerDelegate, UITableViewDataSource, UITableViewDelegate { let thread: TSThread - let focusedMessageID: String? - var didConstrainScrollButton = false + let focusedMessageID: String? // This isn't actually used ATM + var didConstrainScrollButton = false // Part of a workaround to get the scroll button to show up in the right place // Search var isShowingSearchUI = false 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 { let bottomInset = messagesTableView.adjustedContentInset.bottom return messagesTableView.bounds.height - bottomInset } + /// The offset at which the table view is exactly scrolled to the bottom. var lastPageTop: CGFloat { return messagesTableView.contentSize.height - tableViewUnobscuredHeight } @@ -107,7 +109,9 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat }() // 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 + /// The table view will start loading more content when the content offset becomes less than this. 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. static let scrollButtonFullVisibilityThreshold: CGFloat = 80 @@ -162,6 +166,7 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() if !didFinishInitialLayout { + // Scroll to the last unread message if possible; otherwise scroll to the bottom. var unreadCount: UInt = 0 Storage.read { transaction in unreadCount = self.thread.unreadMessageCount(transaction: transaction) @@ -244,8 +249,8 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat @objc func handleKeyboardWillChangeFrameNotification(_ notification: Notification) { guard let newHeight = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue.size.height else { return } if !didConstrainScrollButton { - // Bit of a hack to do this here, but it works out. - scrollButton.pin(.bottom, to: .bottom, of: view, withInset: -(newHeight + 16)) + // 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)) // + 16 to match the bottom inset of the table view didConstrainScrollButton = true } 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() { - + // Not currently in use } func conversationViewModelDidUpdate(_ conversationUpdate: ConversationUpdate) { guard self.isViewLoaded else { return } - // TODO: Reload the thread if it's a group thread? let updateType = conversationUpdate.conversationUpdateType guard updateType != .minor else { return } // No view items were affected if updateType == .reload { @@ -298,11 +302,9 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat } } let batchUpdatesCompletion: (Bool) -> Void = { isFinished in - // TODO: Update last visible sort ID? if shouldScrollToBottom { self.scrollToBottom(isAnimated: true) } - // TODO: Update last known distance from bottom } if shouldAnimate { messagesTableView.performBatchUpdates(batchUpdates, completion: batchUpdatesCompletion) @@ -327,11 +329,11 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat } } } - // TODO: Set last reload date? } func conversationViewModelWillLoadMoreItems() { view.layoutIfNeeded() + // The scroll distance to bottom will be restored in conversationViewModelDidLoadMoreItems scrollDistanceToBottomBeforeUpdate = messagesTableView.contentSize.height - messagesTableView.contentOffset.y } @@ -343,19 +345,19 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat } func conversationViewModelDidLoadPrevPage() { - + // Not currently in use } func conversationViewModelRangeDidChange() { - + // Not currently in use } func conversationViewModelDidReset() { - + // Not currently in use } @objc private func handleGroupUpdatedNotification() { - thread.reload() + thread.reload() // Needed so that thread.isCurrentUserMemberInGroup() is up to date reloadInputViews() } @@ -398,7 +400,6 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat let firstContentPageTop: CGFloat = 0 let contentOffsetY = max(firstContentPageTop, lastPageTop) messagesTableView.setContentOffset(CGPoint(x: 0, y: contentOffsetY), animated: isAnimated) - // TODO: Did scroll to bottom } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { @@ -430,14 +431,14 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat } func groupWasUpdated(_ groupModel: TSGroupModel) { - // Do nothing + // Not currently in use } // MARK: Search func conversationSettingsDidRequestConversationSearch(_ conversationSettingsViewController: OWSConversationSettingsViewController) { showSearchUI() 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() } } diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index 08b4db040..9d957d566 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -52,6 +52,10 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, }() 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 maxWidth = UIScreen.main.bounds.width - 2 * InputViewButton.expandedSize - 2 * Values.smallSpacing - 2 * (Values.mediumSpacing - adjustment) return InputTextView(delegate: self, maxWidth: maxWidth) @@ -134,12 +138,16 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, 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() { additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } linkPreviewInfo = nil guard let quoteDraftInfo = quoteDraftInfo else { return } 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 quoteView = QuoteView(for: quoteDraftInfo.model, direction: direction, hInset: hInset, maxWidth: maxWidth, delegate: self) additionalContentContainer.addSubview(quoteView) @@ -200,6 +208,7 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, // MARK: Interaction 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, attachmentsButton.libraryButton, attachmentsButton.documentButton, attachmentsButton.gifButton ] 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 ] let isPointInsideAttachmentsButton = buttonContainers.contains { $0.superview!.convert($0.frame, to: self).contains(point) } if isPointInsideAttachmentsButton { + // Needed so that the user can tap the buttons when the expanding attachments button is expanded return true } else if mentionsViewContainer.frame.contains(point) { + // Needed so that the user can tap mentions return true } else { return super.point(inside: point, with: event) diff --git a/Session/Conversations/Input View/InputViewButton.swift b/Session/Conversations/Input View/InputViewButton.swift index 10a26fbc0..480ff3cc0 100644 --- a/Session/Conversations/Input View/InputViewButton.swift +++ b/Session/Conversations/Input View/InputViewButton.swift @@ -93,6 +93,9 @@ final class InputViewButton : UIView { } // MARK: Interaction + + // We want to detect both taps and long presses + override func touchesBegan(_ touches: Set, with event: UIEvent?) { UIImpactFeedbackGenerator(style: .heavy).impactOccurred() expand() diff --git a/Session/Conversations/Message Cells/Content Views/MediaTextOverlayView.swift b/Session/Conversations/Message Cells/Content Views/MediaTextOverlayView.swift index b67f089cb..8b152d6a9 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaTextOverlayView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaTextOverlayView.swift @@ -1,4 +1,5 @@ +/// Shown over a media message if it has a message body. final class MediaTextOverlayView : UIView { private let viewItem: ConversationViewItem private let albumViewWidth: CGFloat diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index bb1f42131..e24bbeb14 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -122,6 +122,12 @@ final class QuoteView : UIView { } 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 thumbnailSize = QuoteView.thumbnailSize let iconSize = QuoteView.iconSize diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 8e991caa4..bb0e6d370 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -396,14 +396,16 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { } 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 { if gestureRecognizer == panGestureRecognizer { 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 } - return abs(v.x) > abs(v.y) + return abs(v.x) > abs(v.y) // It has to be more horizontal than vertical } else { return true } @@ -435,13 +437,14 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { let translationX = gestureRecognizer.translation(in: self).x.clamp(-CGFloat.greatestFiniteMagnitude, 0) switch gestureRecognizer.state { case .changed: + // The idea here is to asymptotically approach a maximum drag distance let damping: CGFloat = 20 let sign: CGFloat = -1 let x = (damping * (sqrt(abs(translationX)) / sqrt(damping))) * sign viewsToMove.forEach { $0.transform = CGAffineTransform(translationX: x, y: 0) } replyButton.alpha = abs(translationX) / VisibleMessageCell.maxBubbleTranslationX 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 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 { + // Take care of: + // • Highlighting mentions + // • Linkification + // • Highlighting search results guard let message = viewItem.interaction as? TSMessage else { preconditionFailure() } let isOutgoing = (message.interactionType() == .outgoingMessage) let result = BodyTextView(snDelegate: delegate)