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() {
// 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]

@ -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()
}
}

@ -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)

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

@ -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

@ -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

@ -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)

Loading…
Cancel
Save