// TODO:
// • S l i g h t p a g i n g g l i t c h w h e n s c r o l l i n g u p a n d l o a d i n g m o r e c o n t e n t
// • P h o t o r o u n d i n g ( t h e s m a l l c o r n e r s d o n ' t h a v e t h e c o r r e c t r o u n d i n g )
// • R e m a i n i n g s e a r c h g l i t c h i n e s s
final class ConversationVC : BaseVC , ConversationViewModelDelegate , OWSConversationSettingsViewDelegate , ConversationSearchControllerDelegate , UITableViewDataSource , UITableViewDelegate {
let thread : TSThread
let focusedMessageID : String ? // T h i s i s n ' t a c t u a l l y u s e d A T M
var unreadViewItems : [ ConversationViewItem ] = [ ]
var didConstrainScrollButton = false // P a r t o f a w o r k a r o u n d t o g e t t h e s c r o l l b u t t o n t o s h o w u p i n t h e r i g h t p l a c e
// S e a r c h
var isShowingSearchUI = false
var lastSearchedText : String ?
// 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 ?
var audioTimer : Timer ?
// C o n t e x t m e n u
var contextMenuWindow : ContextMenuWindow ?
var contextMenuVC : ContextMenuVC ?
// M e n t i o n s
var oldText = " "
var currentMentionStartIndex : String . Index ?
var mentions : [ Mention ] = [ ]
// S c r o l l i n g & p a g i n g
var isUserScrolling = false
var didFinishInitialLayout = false
var isLoadingMore = false
var scrollDistanceToBottomBeforeUpdate : CGFloat ?
var audioSession : OWSAudioSession { Environment . shared . audioSession }
var dbConnection : YapDatabaseConnection { OWSPrimaryStorage . shared ( ) . uiDatabaseConnection }
var viewItems : [ ConversationViewItem ] { viewModel . viewState . viewItems }
override var canBecomeFirstResponder : Bool { true }
override var inputAccessoryView : UIView ? {
if let thread = thread as ? TSGroupThread , thread . groupModel . groupType = = . closedGroup && ! thread . isCurrentUserMemberInGroup ( ) {
return nil
} else {
return isShowingSearchUI ? searchController . resultsBar : snInputView
}
}
// / T h e h e i g h t o f t h e v i s i b l e p a r t o f t h e t a b l e v i e w , i . e . t h e d i s t a n c e f r o m t h e n a v i g a t i o n b a r ( w h e r e t h e t a b l e v i e w ' s o r i g i n i s )
// / t o t h e t o p o f t h e i n p u t v i e w ( ` m e s s a g e s T a b l e V i e w . a d j u s t e d C o n t e n t I n s e t . b o t t o m ` ) .
var tableViewUnobscuredHeight : CGFloat {
let bottomInset = messagesTableView . adjustedContentInset . bottom
return messagesTableView . bounds . height - bottomInset
}
// / T h e o f f s e t a t w h i c h t h e t a b l e v i e w i s e x a c t l y s c r o l l e d t o t h e b o t t o m .
var lastPageTop : CGFloat {
return messagesTableView . contentSize . height - tableViewUnobscuredHeight
}
var lastContentOffset : CGFloat ? = nil
var initialKeyboardHeight : CGFloat = 0
lazy var viewModel = ConversationViewModel ( thread : thread , focusMessageIdOnOpen : focusedMessageID , delegate : self )
lazy var mediaCache : NSCache < NSString , AnyObject > = {
let result = NSCache < NSString , AnyObject > ( )
result . countLimit = 40
return result
} ( )
lazy var recordVoiceMessageActivity = AudioActivity ( audioDescription : " Voice message " , behavior : . playAndRecord )
lazy var searchController : ConversationSearchController = {
let result = ConversationSearchController ( thread : thread )
result . delegate = self
if #available ( iOS 13 , * ) {
result . uiSearchController . obscuresBackgroundDuringPresentation = false
} else {
result . uiSearchController . dimsBackgroundDuringPresentation = false
}
return result
} ( )
// MARK: U I C o m p o n e n t s
lazy var titleView : ConversationTitleView = {
let result = ConversationTitleView ( thread : thread )
result . delegate = self
return result
} ( )
lazy var messagesTableView : MessagesTableView = {
let result = MessagesTableView ( )
result . dataSource = self
result . delegate = self
return result
} ( )
lazy var snInputView = InputView ( delegate : self )
lazy var unreadCountView : UIView = {
let result = UIView ( )
result . backgroundColor = Colors . text . withAlphaComponent ( Values . veryLowOpacity )
let size = ConversationVC . unreadCountViewSize
result . set ( . width , to : size )
result . set ( . height , to : size )
result . layer . masksToBounds = true
result . layer . cornerRadius = size / 2
return result
} ( )
lazy var unreadCountLabel : UILabel = {
let result = UILabel ( )
result . font = . boldSystemFont ( ofSize : Values . verySmallFontSize )
result . textColor = Colors . text
result . textAlignment = . center
return result
} ( )
lazy var scrollButton = ScrollToBottomButton ( delegate : self )
lazy var blockedBanner : InfoBanner = {
let name : String
if let thread = thread as ? TSContactThread {
let publicKey = thread . contactIdentifier ( )
let context = Contact . context ( for : thread )
name = Storage . shared . getContact ( with : publicKey ) ? . displayName ( for : context ) ? ? publicKey
} else {
name = " Thread "
}
let message = " \( name ) is blocked. Unblock them? "
let result = InfoBanner ( message : message , backgroundColor : Colors . destructive )
let tapGestureRecognizer = UITapGestureRecognizer ( target : self , action : #selector ( unblock ) )
result . addGestureRecognizer ( tapGestureRecognizer )
return result
} ( )
// MARK: S e t t i n g s
static let unreadCountViewSize : CGFloat = 20
// / T h e t a b l e v i e w ' s b o t t o m i n s e t ( c o n t e n t w i l l h a v e t h i s d i s t a n c e t o t h e b o t t o m i f t h e t a b l e v i e w i s f u l l y s c r o l l e d d o w n ) .
static let bottomInset = Values . mediumSpacing
// / T h e t a b l e v i e w w i l l s t a r t l o a d i n g m o r e c o n t e n t w h e n t h e c o n t e n t o f f s e t b e c o m e s l e s s t h a n t h i s .
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 .
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 .
static let scrollButtonNoVisibilityThreshold : CGFloat = 20
// MARK: L i f e c y c l e
init ( thread : TSThread , focusedMessageID : String ? = nil ) {
self . thread = thread
self . focusedMessageID = focusedMessageID
super . init ( nibName : nil , bundle : nil )
var unreadCount : UInt = 0
Storage . read { transaction in
unreadCount = self . thread . unreadMessageCount ( transaction : transaction )
}
unreadViewItems = unreadCount != 0 ? [ ConversationViewItem ] ( viewItems [ viewItems . endIndex - Int ( unreadCount ) . . < viewItems . endIndex ] ) : [ ]
}
required init ? ( coder : NSCoder ) {
preconditionFailure ( " Use init(thread:) instead. " )
}
override func viewDidLoad ( ) {
super . viewDidLoad ( )
// G r a d i e n t
setUpGradientBackground ( )
// N a v b a r
setUpNavBarStyle ( )
navigationItem . titleView = titleView
updateNavBarButtons ( )
// C o n s t r a i n t s
view . addSubview ( messagesTableView )
messagesTableView . pin ( to : view )
view . addSubview ( scrollButton )
scrollButton . pin ( . right , to : . right , of : view , withInset : - 16 )
// U n r e a d c o u n t v i e w
view . addSubview ( unreadCountView )
unreadCountView . addSubview ( unreadCountLabel )
unreadCountLabel . pin ( to : unreadCountView )
unreadCountView . centerYAnchor . constraint ( equalTo : scrollButton . topAnchor ) . isActive = true
unreadCountView . center ( . horizontal , in : scrollButton )
updateUnreadCountView ( )
// B l o c k e d b a n n e r
addOrRemoveBlockedBanner ( )
// N o t i f i c a t i o n s
let notificationCenter = NotificationCenter . default
notificationCenter . addObserver ( self , selector : #selector ( handleKeyboardWillChangeFrameNotification ( _ : ) ) , name : UIResponder . keyboardWillChangeFrameNotification , object : nil )
notificationCenter . addObserver ( self , selector : #selector ( handleKeyboardWillHideNotification ( _ : ) ) , name : UIResponder . keyboardWillHideNotification , object : nil )
notificationCenter . addObserver ( self , selector : #selector ( handleAudioDidFinishPlayingNotification ( _ : ) ) , name : . SNAudioDidFinishPlaying , object : nil )
notificationCenter . addObserver ( self , selector : #selector ( addOrRemoveBlockedBanner ) , name : NSNotification . Name ( rawValue : kNSNotificationName_BlockListDidChange ) , object : nil )
notificationCenter . addObserver ( self , selector : #selector ( handleGroupUpdatedNotification ) , name : . groupThreadUpdated , object : nil )
notificationCenter . addObserver ( self , selector : #selector ( sendScreenshotNotificationIfNeeded ) , name : UIApplication . userDidTakeScreenshotNotification , object : nil )
// M e n t i o n s
MentionsManager . populateUserPublicKeyCacheIfNeeded ( for : thread . uniqueId ! )
// D r a f t
var draft = " "
Storage . read { transaction in
draft = self . thread . currentDraft ( with : transaction )
}
if ! draft . isEmpty {
snInputView . text = draft
}
}
override func viewDidLayoutSubviews ( ) {
super . viewDidLayoutSubviews ( )
if ! didFinishInitialLayout {
// S c r o l l t o t h e l a s t u n r e a d m e s s a g e i f p o s s i b l e ; o t h e r w i s e s c r o l l t o t h e b o t t o m .
var unreadCount : UInt = 0
Storage . read { transaction in
unreadCount = self . thread . unreadMessageCount ( transaction : transaction )
}
DispatchQueue . main . async {
if unreadCount > 0 , let viewItem = self . viewItems [ ifValid : self . viewItems . count - Int ( unreadCount ) ] , let interactionID = viewItem . interaction . uniqueId {
self . scrollToInteraction ( with : interactionID , position : . top , isAnimated : false )
self . scrollButton . alpha = self . getScrollButtonOpacity ( )
self . unreadCountView . alpha = self . scrollButton . alpha
} else {
self . scrollToBottom ( isAnimated : false )
}
}
}
}
override func viewWillAppear ( _ animated : Bool ) {
super . viewWillAppear ( animated )
if let y = lastContentOffset {
messagesTableView . setContentOffset ( CGPoint ( x : 0 , y : y ) , animated : false )
}
}
override func viewDidAppear ( _ animated : Bool ) {
super . viewDidAppear ( animated )
didFinishInitialLayout = true
markAllAsRead ( )
}
override func viewWillDisappear ( _ animated : Bool ) {
super . viewWillDisappear ( animated )
lastContentOffset = messagesTableView . contentOffset . y
if ( messagesTableView . keyboardHeight > initialKeyboardHeight ) {
lastContentOffset ! -= messagesTableView . keyboardHeight - initialKeyboardHeight
}
let text = snInputView . text
if ! text . isEmpty {
Storage . write { transaction in
self . thread . setDraft ( text , transaction : transaction )
}
}
}
override func viewDidDisappear ( _ animated : Bool ) {
super . viewDidDisappear ( animated )
mediaCache . removeAllObjects ( )
}
deinit {
NotificationCenter . default . removeObserver ( self )
}
// MARK: T a b l e V i e w D a t a S o u r c e
func tableView ( _ tableView : UITableView , numberOfRowsInSection section : Int ) -> Int {
return viewItems . count
}
func tableView ( _ tableView : UITableView , cellForRowAt indexPath : IndexPath ) -> UITableViewCell {
let viewItem = viewItems [ indexPath . row ]
let cell = tableView . dequeueReusableCell ( withIdentifier : MessageCell . getCellType ( for : viewItem ) . identifier ) as ! MessageCell
cell . delegate = self
cell . viewItem = viewItem
return cell
}
// MARK: U p d a t i n g
func updateNavBarButtons ( ) {
navigationItem . hidesBackButton = isShowingSearchUI
if isShowingSearchUI {
navigationItem . rightBarButtonItems = [ ]
} else {
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
}
}
@objc func handleKeyboardWillChangeFrameNotification ( _ notification : Notification ) {
guard let newHeight = ( notification . userInfo ? [ UIResponder . keyboardFrameEndUserInfoKey ] as ? NSValue ) ? . cgRectValue . size . height else { return }
if ( newHeight > initialKeyboardHeight && initialKeyboardHeight = = 0 ) {
initialKeyboardHeight = newHeight
self . messagesTableView . keyboardHeight = newHeight
}
if ! didConstrainScrollButton {
// H A C K : P a r t o f a w o r k a r o u n d t o g e t t h e s c r o l l b u t t o n t o s h o w u p i n t h e r i g h t p l a c e
scrollButton . pin ( . bottom , to : . bottom , of : view , withInset : - ( newHeight + 16 ) ) // + 1 6 t o m a t c h t h e b o t t o m i n s e t o f t h e t a b l e v i e w
didConstrainScrollButton = true
}
let shouldScroll = ( newHeight > 200 ) // A r b i t r a r y v a l u e t h a t ' s h i g h e r t h a n t h e c o l l a p s e d s i z e a n d l o w e r t h a n t h e e x p a n d e d s i z e
print ( " Ryan: keyboardWillChangeFrame, new height: \( newHeight ) , old height: \( self . messagesTableView . keyboardHeight ) , contentOffsetY: \( self . messagesTableView . contentOffset . y ) " )
UIView . animate ( withDuration : 0.25 ) {
if shouldScroll {
self . messagesTableView . contentOffset . y += ( newHeight - self . messagesTableView . keyboardHeight )
self . messagesTableView . keyboardHeight = newHeight
}
self . scrollButton . alpha = 0
}
print ( " Ryan: keyboardWillChangeFrame, contentOffsetY: \( self . messagesTableView . contentOffset . y ) " )
}
@objc func handleKeyboardWillHideNotification ( _ notification : Notification ) {
print ( " Ryan: handleKeyboardWillHide " )
UIView . animate ( withDuration : 0.25 ) {
self . messagesTableView . contentOffset . y -= ( self . messagesTableView . keyboardHeight - self . initialKeyboardHeight )
self . messagesTableView . keyboardHeight = self . initialKeyboardHeight
self . scrollButton . alpha = self . getScrollButtonOpacity ( )
self . unreadCountView . alpha = self . scrollButton . alpha
}
}
func conversationViewModelWillUpdate ( ) {
// N o t c u r r e n t l y i n u s e
}
func conversationViewModelDidUpdate ( _ conversationUpdate : ConversationUpdate ) {
guard self . isViewLoaded else { return }
let updateType = conversationUpdate . conversationUpdateType
guard updateType != . minor else { return } // N o v i e w i t e m s w e r e a f f e c t e d
if updateType = = . reload {
return messagesTableView . reloadData ( )
}
var shouldScrollToBottom = false
let shouldAnimate = conversationUpdate . shouldAnimateUpdates
let batchUpdates : ( ) -> Void = {
for update in conversationUpdate . updateItems ! {
switch update . updateItemType {
case . delete :
self . messagesTableView . deleteRows ( at : [ IndexPath ( row : Int ( update . oldIndex ) , section : 0 ) ] , with : . fade )
case . insert :
// P e r f o r m i n s e r t s b e f o r e u p d a t e s
self . messagesTableView . insertRows ( at : [ IndexPath ( row : Int ( update . newIndex ) , section : 0 ) ] , with : . fade )
let viewItem = update . viewItem
if viewItem ? . interaction is TSOutgoingMessage {
shouldScrollToBottom = true
}
case . update :
self . messagesTableView . reloadRows ( at : [ IndexPath ( row : Int ( update . oldIndex ) , section : 0 ) ] , with : . fade )
default : preconditionFailure ( )
}
}
}
let batchUpdatesCompletion : ( Bool ) -> Void = { isFinished in
if shouldScrollToBottom {
self . scrollToBottom ( isAnimated : true )
}
}
if shouldAnimate {
messagesTableView . performBatchUpdates ( batchUpdates , completion : batchUpdatesCompletion )
} else {
// H A C K : W e u s e ` U I V i e w . a n i m a t e W i t h D u r a t i o n : 0 ` r a t h e r t h a n ` U I V i e w . p e r f o r m W i t h A n i m a t i o n ` t o w o r k a r o u n d a
// U I K i t C r a s h l i k e :
//
// * * * A s s e r t i o n f a i l u r e i n - [ C o n v e r s a t i o n V i e w L a y o u t p r e p a r e F o r C o l l e c t i o n V i e w U p d a t e s : ] ,
// / B u i l d R o o t / L i b r a r y / C a c h e s / c o m . a p p l e . x b s / S o u r c e s / U I K i t _ S i m / U I K i t - 3 6 0 0 . 7 . 4 7 / U I C o l l e c t i o n V i e w L a y o u t . m : 7 6 0
// * * * T e r m i n a t i n g a p p d u e t o u n c a u g h t e x c e p t i o n ' N S I n t e r n a l I n c o n s i s t e n c y E x c e p t i o n ' , r e a s o n : ' W h i l e
// p r e p a r i n g u p d a t e a v i s i b l e v i e w a t < N S I n d e x P a t h : 0 x c 0 0 0 0 0 0 0 1 1 c 0 0 0 1 6 > { l e n g t h = 2 , p a t h = 0 - 1 4 2 }
// w a s n ' t f o u n d i n t h e c u r r e n t d a t a m o d e l a n d w a s n o t i n a n u p d a t e a n i m a t i o n . T h i s i s a n i n t e r n a l
// e r r o r . '
//
// I ' m u n c l e a r i f t h i s i s a b u g i n U I K i t , o r i f w e ' r e d o i n g s o m e t h i n g c r a z y i n
// C o n v e r s a t i o n V i e w L a y o u t # p r e p a r e L a y o u t . T o r e p r o d u c e , r a p i d i l y i n s e r t a n d d e l e t e i t e m s i n t o t h e
// c o n v e r s a t i o n .
UIView . animate ( withDuration : 0 ) {
self . messagesTableView . performBatchUpdates ( batchUpdates , completion : batchUpdatesCompletion )
if shouldScrollToBottom {
self . scrollToBottom ( isAnimated : false )
}
}
}
}
func conversationViewModelWillLoadMoreItems ( ) {
view . layoutIfNeeded ( )
// T h e s c r o l l d i s t a n c e t o b o t t o m w i l l b e r e s t o r e d i n c o n v e r s a t i o n V i e w M o d e l D i d L o a d M o r e I t e m s
scrollDistanceToBottomBeforeUpdate = messagesTableView . contentSize . height - messagesTableView . contentOffset . y
}
func conversationViewModelDidLoadMoreItems ( ) {
guard let scrollDistanceToBottomBeforeUpdate = scrollDistanceToBottomBeforeUpdate else { return }
view . layoutIfNeeded ( )
messagesTableView . contentOffset . y = messagesTableView . contentSize . height - scrollDistanceToBottomBeforeUpdate
isLoadingMore = false
}
func conversationViewModelDidLoadPrevPage ( ) {
// N o t c u r r e n t l y i n u s e
}
func conversationViewModelRangeDidChange ( ) {
// N o t c u r r e n t l y i n u s e
}
func conversationViewModelDidReset ( ) {
// N o t c u r r e n t l y i n u s e
}
@objc private func handleGroupUpdatedNotification ( ) {
thread . reload ( ) // N e e d e d s o t h a t t h r e a d . i s C u r r e n t U s e r M e m b e r I n G r o u p ( ) i s u p t o d a t e
reloadInputViews ( )
}
// MARK: G e n e r a l
@objc func addOrRemoveBlockedBanner ( ) {
func detach ( ) {
blockedBanner . removeFromSuperview ( )
}
guard let thread = thread as ? TSContactThread else { return detach ( ) }
if OWSBlockingManager . shared ( ) . isRecipientIdBlocked ( thread . contactIdentifier ( ) ) {
view . addSubview ( blockedBanner )
blockedBanner . pin ( [ UIView . HorizontalEdge . left , UIView . VerticalEdge . top , UIView . HorizontalEdge . right ] , to : view )
} else {
detach ( )
}
}
func markAllAsRead ( ) {
guard let lastSortID = viewItems . last ? . interaction . sortId else { return }
OWSReadReceiptManager . shared ( ) . markAsReadLocally ( beforeSortId : lastSortID , thread : thread )
}
func tableView ( _ tableView : UITableView , estimatedHeightForRowAt indexPath : IndexPath ) -> CGFloat {
return UITableView . automaticDimension
}
func tableView ( _ tableView : UITableView , heightForRowAt indexPath : IndexPath ) -> CGFloat {
return UITableView . automaticDimension
}
func getMediaCache ( ) -> NSCache < NSString , AnyObject > {
return mediaCache
}
func scrollToBottom ( isAnimated : Bool ) {
guard ! isUserScrolling else { return }
// E n s u r e t h e v i e w i s f u l l y u p t o d a t e b e f o r e w e t r y t o s c r o l l t o t h e b o t t o m , s i n c e
// w e u s e t h e t a b l e v i e w ' s b o u n d s t o d e t e r m i n e w h e r e t h e b o t t o m i s .
view . layoutIfNeeded ( )
let firstContentPageTop : CGFloat = 0
let contentOffsetY = max ( firstContentPageTop , lastPageTop )
lastContentOffset = contentOffsetY
messagesTableView . setContentOffset ( CGPoint ( x : 0 , y : contentOffsetY ) , animated : isAnimated )
print ( " Ryan: Scroll to bottom, contentOffSetY: \( self . messagesTableView . contentOffset . y ) " )
}
func scrollViewWillBeginDragging ( _ scrollView : UIScrollView ) {
isUserScrolling = true
}
func scrollViewDidEndDragging ( _ scrollView : UIScrollView , willDecelerate decelerate : Bool ) {
isUserScrolling = false
}
func scrollViewDidScroll ( _ scrollView : UIScrollView ) {
scrollButton . alpha = getScrollButtonOpacity ( )
unreadCountView . alpha = scrollButton . alpha
autoLoadMoreIfNeeded ( )
updateUnreadCountView ( )
}
func updateUnreadCountView ( ) {
let visibleViewItems = ( messagesTableView . indexPathsForVisibleRows ? ? [ ] ) . map { viewItems [ $0 . row ] }
for visibleItem in visibleViewItems {
guard let index = unreadViewItems . firstIndex ( where : { $0 = = = visibleItem } ) else { continue }
unreadViewItems . remove ( at : index )
}
let unreadCount = unreadViewItems . count
unreadCountLabel . text = unreadCount < 100 ? " \( unreadCount ) " : " 99+ "
let fontSize = ( unreadCount < 100 ) ? Values . verySmallFontSize : 8
unreadCountLabel . font = . boldSystemFont ( ofSize : fontSize )
unreadCountView . isHidden = ( unreadCount = = 0 )
}
func autoLoadMoreIfNeeded ( ) {
let isMainAppAndActive = CurrentAppContext ( ) . isMainAppAndActive
guard isMainAppAndActive && viewModel . canLoadMoreItems ( ) && ! isLoadingMore
&& messagesTableView . contentOffset . y < ConversationVC . loadMoreThreshold else { return }
isLoadingMore = true
viewModel . loadAnotherPageOfMessages ( )
}
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 ) {
// N o t c u r r e n t l y i n u s e
}
// MARK: S e a r c h
func conversationSettingsDidRequestConversationSearch ( _ conversationSettingsViewController : OWSConversationSettingsViewController ) {
showSearchUI ( )
popAllConversationSettingsViews {
DispatchQueue . main . asyncAfter ( deadline : . now ( ) + 0.5 ) { // W i t h o u t t h i s d e l a y t h e s e a r c h b a r d o e s n ' t s h o w
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 ( )
reloadInputViews ( )
}
func didDismissSearchController ( _ searchController : UISearchController ) {
hideSearchUI ( )
}
func conversationSearchController ( _ conversationSearchController : ConversationSearchController , didUpdateSearchResults resultSet : ConversationScreenSearchResultSet ? ) {
lastSearchedText = resultSet ? . searchText
messagesTableView . reloadRows ( at : messagesTableView . indexPathsForVisibleRows ? ? [ ] , with : UITableView . RowAnimation . none )
}
func conversationSearchController ( _ conversationSearchController : ConversationSearchController , didSelectMessageId interactionID : String ) {
scrollToInteraction ( with : interactionID )
}
func scrollToInteraction ( with interactionID : String , position : UITableView . ScrollPosition = . middle , isAnimated : Bool = true ) {
guard let indexPath = viewModel . ensureLoadWindowContainsInteractionId ( interactionID ) else { return }
messagesTableView . scrollToRow ( at : indexPath , at : position , animated : isAnimated )
}
}