@ -7,12 +7,13 @@
// • P h o t o r o u n d i n g
// • D i s a p p e a r i n g m e s s a g e s t i m e r
// • S c r o l l b u t t o n b e h i n d m e n t i o n s v i e w
// • Se a r c h . . .
// • Re m a i n i n g s e a r c h b u g s
final class ConversationVC : BaseVC , ConversationViewModelDelegate , UITableViewDataSource, UITableViewDelegate {
final class ConversationVC : BaseVC , ConversationViewModelDelegate , OWSConversationSettingsViewDelegate, ConversationSearchControllerDelegate , UITableViewDataSource, UITableViewDelegate {
let thread : TSThread
private let focusedMessageID : String ?
private var didConstrainScrollButton = false
let focusedMessageID : String ?
var didConstrainScrollButton = false
var isShowingSearchUI = false
// A u d i o p l a y b a c k & r e c o r d i n g
var audioPlayer : OWSAudioPlayer ?
var audioRecorder : AVAudioRecorder ?
@ -25,30 +26,30 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, UITableViewD
var currentMentionStartIndex : String . Index ?
var mentions : [ Mention ] = [ ]
// S c r o l l i n g & p a g i n g
private var isUserScrolling = false
private var didFinishInitialLayout = false
private var isLoadingMore = false
private var scrollDistanceToBottomBeforeUpdate : CGFloat ?
var isUserScrolling = false
var didFinishInitialLayout = false
var isLoadingMore = false
var scrollDistanceToBottomBeforeUpdate : CGFloat ?
var audioSession : OWSAudioSession { Environment . shared . audioSession }
private var dbConnection : YapDatabaseConnection { OWSPrimaryStorage . shared ( ) . uiDatabaseConnection }
var dbConnection : YapDatabaseConnection { OWSPrimaryStorage . shared ( ) . uiDatabaseConnection }
var viewItems : [ ConversationViewItem ] { viewModel . viewState . viewItems }
func conversationStyle ( ) -> ConversationStyle { return ConversationStyle ( thread : thread ) }
override var inputAccessoryView : UIView ? { snInputView }
override var inputAccessoryView : UIView ? { isShowingSearchUI ? searchController . resultsBar : snInputView }
override var canBecomeFirstResponder : Bool { true }
private var tableViewUnobscuredHeight : CGFloat {
var tableViewUnobscuredHeight : CGFloat {
let bottomInset = messagesTableView . adjustedContentInset . bottom
return messagesTableView . bounds . height - bottomInset
}
private var lastPageTop : CGFloat {
var lastPageTop : CGFloat {
return messagesTableView . contentSize . height - tableViewUnobscuredHeight
}
lazy var viewModel = ConversationViewModel ( thread : thread , focusMessageIdOnOpen : focusedMessageID , delegate : self )
private lazy var mediaCache : NSCache < NSString , AnyObject > = {
lazy var mediaCache : NSCache < NSString , AnyObject > = {
let result = NSCache < NSString , AnyObject > ( )
result . countLimit = 40
return result
@ -56,8 +57,14 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, UITableViewD
lazy var recordVoiceMessageActivity = AudioActivity ( audioDescription : " Voice message " , behavior : . playAndRecord )
lazy var searchController : ConversationSearchController = {
let result = ConversationSearchController ( thread : thread )
result . delegate = self
return result
} ( )
// MARK: U I C o m p o n e n t s
private lazy var titleView = ConversationTitleViewV2 ( thread : thread )
lazy var titleView = ConversationTitleViewV2 ( thread : thread )
lazy var messagesTableView : MessagesTableView = {
let result = MessagesTableView ( )
@ -86,12 +93,12 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, UITableViewD
} ( )
// MARK: S e t t i n g s
private static let bottomInset = Values . mediumSpacing
private static let loadMoreThreshold : CGFloat = 120
static let bottomInset = Values . mediumSpacing
static let loadMoreThreshold : CGFloat = 120
// / T h e b u t t o n w i l l b e f u l l y v i s i b l e o n c e t h e u s e r h a s s c r o l l e d t h i s a m o u n t f r o m t h e b o t t o m o f t h e t a b l e v i e w .
private static let scrollButtonFullVisibilityThreshold : CGFloat = 80
static let scrollButtonFullVisibilityThreshold : CGFloat = 80
// / T h e b u t t o n w i l l b e i n v i s i b l e u n t i l t h e u s e r h a s s c r o l l e d a t l e a s t t h i s a m o u n t f r o m t h e b o t t o m o f t h e t a b l e v i e w .
private static let scrollButtonNoVisibilityThreshold : CGFloat = 20
static let scrollButtonNoVisibilityThreshold : CGFloat = 20
// MARK: L i f e c y c l e
init ( thread : TSThread , focusedMessageID : String ? = nil ) {
@ -168,28 +175,33 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, UITableViewD
}
// MARK: U p d a t i n g
private func updateNavBarButtons ( ) {
let rightBarButtonItem : UIBarButtonItem
if thread is TSContactThread {
let size = Values . verySmallProfilePictureSize
let profilePictureView = ProfilePictureView ( )
profilePictureView . accessibilityLabel = " Settings button "
profilePictureView . size = size
profilePictureView . update ( for : thread )
profilePictureView . set ( . width , to : size )
profilePictureView . set ( . height , to : size )
let tapGestureRecognizer = UITapGestureRecognizer ( target : self , action : #selector ( openSettings ) )
profilePictureView . addGestureRecognizer ( tapGestureRecognizer )
rightBarButtonItem = UIBarButtonItem ( customView : profilePictureView )
func updateNavBarButtons ( ) {
navigationItem . hidesBackButton = isShowingSearchUI
if isShowingSearchUI {
navigationItem . rightBarButtonItems = [ ]
} else {
rightBarButtonItem = UIBarButtonItem ( image : UIImage ( named : " Gear " ) , style : . plain , target : self , action : #selector ( openSettings ) )
let rightBarButtonItem : UIBarButtonItem
if thread is TSContactThread {
let size = Values . verySmallProfilePictureSize
let profilePictureView = ProfilePictureView ( )
profilePictureView . accessibilityLabel = " Settings button "
profilePictureView . size = size
profilePictureView . update ( for : thread )
profilePictureView . set ( . width , to : size )
profilePictureView . set ( . height , to : size )
let tapGestureRecognizer = UITapGestureRecognizer ( target : self , action : #selector ( openSettings ) )
profilePictureView . addGestureRecognizer ( tapGestureRecognizer )
rightBarButtonItem = UIBarButtonItem ( customView : profilePictureView )
} else {
rightBarButtonItem = UIBarButtonItem ( image : UIImage ( named : " Gear " ) , style : . plain , target : self , action : #selector ( openSettings ) )
}
rightBarButtonItem . accessibilityLabel = " Settings button "
rightBarButtonItem . isAccessibilityElement = true
navigationItem . rightBarButtonItem = rightBarButtonItem
}
rightBarButtonItem . accessibilityLabel = " Settings button "
rightBarButtonItem . isAccessibilityElement = true
navigationItem . rightBarButtonItem = rightBarButtonItem
}
@objc private func handleKeyboardWillChangeFrameNotification ( _ notification : Notification ) {
@objc func handleKeyboardWillChangeFrameNotification ( _ notification : Notification ) {
guard let newHeight = ( notification . userInfo ? [ UIResponder . keyboardFrameEndUserInfoKey ] as ? NSValue ) ? . cgRectValue . size . height else { return }
if ! didConstrainScrollButton {
// B i t o f a h a c k t o d o t h i s h e r e , b u t i t w o r k s o u t .
@ -202,7 +214,7 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, UITableViewD
}
}
@objc private func handleKeyboardWillHideNotification ( _ notification : Notification ) {
@objc func handleKeyboardWillHideNotification ( _ notification : Notification ) {
UIView . animate ( withDuration : 0.25 ) {
self . messagesTableView . keyboardHeight = 0
self . scrollButton . alpha = self . getScrollButtonOpacity ( )
@ -298,7 +310,8 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, UITableViewD
}
@objc private func addOrRemoveBlockedBanner ( ) {
// MARK: G e n e r a l
@objc func addOrRemoveBlockedBanner ( ) {
func detach ( ) {
blockedBanner . removeFromSuperview ( )
}
@ -311,7 +324,6 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, UITableViewD
}
}
// MARK: G e n e r a l
func markAllAsRead ( ) {
guard let lastSortID = viewItems . last ? . interaction . sortId else { return }
OWSReadReceiptManager . shared ( ) . markAsReadLocally ( beforeSortId : lastSortID , thread : thread )
@ -353,7 +365,7 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, UITableViewD
autoLoadMoreIfNeeded ( )
}
private func autoLoadMoreIfNeeded ( ) {
func autoLoadMoreIfNeeded ( ) {
let isMainAppAndActive = CurrentAppContext ( ) . isMainAppAndActive
guard isMainAppAndActive && viewModel . canLoadMoreItems ( ) && ! isLoadingMore
&& messagesTableView . contentOffset . y < ConversationVC . loadMoreThreshold else { return }
@ -361,11 +373,118 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, UITableViewD
viewModel . loadAnotherPageOfMessages ( )
}
// MARK: C o n v e n i e n c e
func getScrollButtonOpacity ( ) -> CGFloat {
let contentOffsetY = messagesTableView . contentOffset . y
let x = ( lastPageTop - ConversationVC . bottomInset - contentOffsetY ) . clamp ( 0 , . greatestFiniteMagnitude )
let a = 1 / ( ConversationVC . scrollButtonFullVisibilityThreshold - ConversationVC . scrollButtonNoVisibilityThreshold )
return a * x
}
func groupWasUpdated ( _ groupModel : TSGroupModel ) {
// D o n o t h i n g
}
// MARK: S e a r c h
func conversationSettingsDidRequestConversationSearch ( _ conversationSettingsViewController : OWSConversationSettingsViewController ) {
showSearchUI ( )
popAllConversationSettingsViews {
DispatchQueue . main . asyncAfter ( deadline : . now ( ) + 0.5 ) {
self . searchController . uiSearchController . searchBar . becomeFirstResponder ( )
}
}
}
func popAllConversationSettingsViews ( completion completionBlock : ( ( ) -> Void ) ? = nil ) {
if presentedViewController != nil {
dismiss ( animated : true ) {
self . navigationController ! . popToViewController ( self , animated : true , completion : completionBlock )
}
} else {
navigationController ! . popToViewController ( self , animated : true , completion : completionBlock )
}
}
func showSearchUI ( ) {
isShowingSearchUI = true
// S e a r c h b a r
let searchBar = searchController . uiSearchController . searchBar
searchBar . searchBarStyle = . minimal
searchBar . barStyle = . black
searchBar . tintColor = Colors . accent
let searchIcon = UIImage ( named : " searchbar_search " ) ! . asTintedImage ( color : Colors . searchBarPlaceholder )
searchBar . setImage ( searchIcon , for : . search , state : UIControl . State . normal )
let clearIcon = UIImage ( named : " searchbar_clear " ) ! . asTintedImage ( color : Colors . searchBarPlaceholder )
searchBar . setImage ( clearIcon , for : . clear , state : UIControl . State . normal )
let searchTextField : UITextField
if #available ( iOS 13 , * ) {
searchTextField = searchBar . searchTextField
} else {
searchTextField = searchBar . value ( forKey : " _searchField " ) as ! UITextField
}
searchTextField . backgroundColor = Colors . searchBarBackground
searchTextField . textColor = Colors . text
searchTextField . attributedPlaceholder = NSAttributedString ( string : " Search " , attributes : [ . foregroundColor : Colors . searchBarPlaceholder ] )
searchTextField . keyboardAppearance = isLightMode ? . default : . dark
searchBar . setPositionAdjustment ( UIOffset ( horizontal : 4 , vertical : 0 ) , for : . search )
searchBar . searchTextPositionAdjustment = UIOffset ( horizontal : 2 , vertical : 0 )
searchBar . setPositionAdjustment ( UIOffset ( horizontal : - 4 , vertical : 0 ) , for : . clear )
navigationItem . titleView = searchBar
// N a v b a r b u t t o n s
updateNavBarButtons ( )
// H a c k s o t h a t t h e R e s u l t s B a r s t a y s o n t h e s c r e e n w h e n d i s m i s s i n g t h e s e a r c h f i e l d
// k e y b o a r d .
//
// D e t a i l s :
//
// W h e n t h e s e a r c h U I i s a c t i v a t e d , b o t h t h e S e a r c h F i e l d a n d t h e C o n v e r s a t i o n V C
// h a v e t h e r e s u l t s B a r a s t h e i r i n p u t A c c e s s o r y V i e w .
//
// S o w h e n t h e S e a r c h F i e l d i s f i r s t r e s p o n d e r , t h e R e s u l t s B a r i s s h o w n o n t o p o f t h e k e y b o a r d .
// W h e n t h e C o n v e r s a t i o n V C i s f i r s t r e s p o n d e r , t h e R e s u l t s B a r i s s h o w n a t t h e b o t t o m o f t h e
// s c r e e n .
//
// W h e n t h e u s e r s w i p e s t o d i s m i s s t h e k e y b o a r d , t r y i n g t o s e e m o r e o f t h e c o n t e n t w h i l e
// s e a r c h i n g , w e w a n t t h e R e s u l t s B a r t o s t a y a t t h e b o t t o m o f t h e s c r e e n - t h a t i s , w e
// w a n t t h e C o n v e r s a t i o n V C t o b e c o m e F i r s t R e s p o n d e r .
//
// I f t h e S e a r c h F i e l d w e r e a s u b v i e w o f C o n v e r s a t i o n V C . v i e w , t h i s w o u l d a l l b e a u t o m a t i c ,
// a s f i r s t r e s p o n d e r s t a t u s i s p e r c o l a t e d u p t h e r e s p o n d e r c h a i n v i a ` n e x t R e s p o n d e r ` , w h i c h
// b a s i c a l l y t r a v e r e s e s e a c h s u p e r V i e w , u n t i l y o u ' r e a t a r o o t V i e w , a t w h i c h p o i n t t h e n e x t
// r e s p o n d e r i s t h e V i e w C o n t r o l l e r w h i c h c o n t r o l s t h a t V i e w .
//
// H o w e v e r , b e c a u s e S e a r c h F i e l d l i v e s i n t h e N a v b a r , i t ' s " c o n t r o l l e d " b y t h e
// N a v i g a t i o n C o n t r o l l e r , n o t t h e C o n v e r s a t i o n V C .
//
// S o h e r e w e s t u b t h e n e x t r e s p o n d e r o n t h e n a v B a r s o t h a t w h e n t h e s e a r c h B a r r e s i g n s
// f i r s t r e s p o n d e r , t h e C o n v e r s a t i o n V C w i l l b e i n i t ' s r e s p o n d e r c h a i n - k e e e p i n g t h e
// R e s u l t s B a r o n t h e b o t t o m o f t h e s c r e e n a f t e r d i s m i s s i n g t h e k e y b o a r d .
let navBar = navigationController ! . navigationBar as ! OWSNavigationBar
navBar . stubbedNextResponder = self
}
func hideSearchUI ( ) {
isShowingSearchUI = false
navigationItem . titleView = titleView
updateNavBarButtons ( )
let navBar = navigationController ! . navigationBar as ! OWSNavigationBar
navBar . stubbedNextResponder = nil
becomeFirstResponder ( )
}
func didDismissSearchController ( _ searchController : UISearchController ) {
hideSearchUI ( )
}
func conversationSearchController ( _ conversationSearchController : ConversationSearchController , didUpdateSearchResults resultSet : ConversationScreenSearchResultSet ? ) {
messagesTableView . reloadRows ( at : messagesTableView . indexPathsForVisibleRows ? ? [ ] , with : UITableView . RowAnimation . none )
}
func conversationSearchController ( _ conversationSearchController : ConversationSearchController , didSelectMessageId interactionID : String ) {
scrollToInteraction ( with : interactionID )
}
private func scrollToInteraction ( with interactionID : String ) {
guard let indexPath = viewModel . ensureLoadWindowContainsInteractionId ( interactionID ) else { return }
messagesTableView . scrollToRow ( at : indexPath , at : UITableView . ScrollPosition . middle , animated : true )
}
}