mirror of https://github.com/oxen-io/session-ios
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1105 lines
46 KiB
Swift
1105 lines
46 KiB
Swift
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import UIKit
|
|
import GRDB
|
|
import DifferenceKit
|
|
import SessionUIKit
|
|
import SessionMessagingKit
|
|
import SessionUtilitiesKit
|
|
import SignalUtilitiesKit
|
|
|
|
// 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, OWSConversationSettingsViewDelegate, ConversationSearchControllerDelegate, UITableViewDataSource, UITableViewDelegate {
|
|
internal let viewModel: ConversationViewModel
|
|
private var dataChangeObservable: DatabaseCancellable?
|
|
private var hasLoadedInitialData: Bool = false
|
|
|
|
/// This flag indicates whether the data has been reloaded after a disappearance (it defaults to true as it will never
|
|
/// have disappeared before)
|
|
private var hasReloadedDataAfterDisappearance: Bool = true
|
|
|
|
var focusedMessageIndexPath: IndexPath?
|
|
var initialUnreadCount: UInt = 0
|
|
var unreadViewItems: [ConversationViewItem] = []
|
|
var scrollButtonBottomConstraint: NSLayoutConstraint?
|
|
var scrollButtonMessageRequestsBottomConstraint: NSLayoutConstraint?
|
|
var messageRequestsViewBotomConstraint: NSLayoutConstraint?
|
|
|
|
// Search
|
|
var isShowingSearchUI = false
|
|
var lastSearchedText: String?
|
|
|
|
// Audio playback & recording
|
|
var audioPlayer: OWSAudioPlayer?
|
|
var audioRecorder: AVAudioRecorder?
|
|
var audioTimer: Timer?
|
|
|
|
// Context menu
|
|
var contextMenuWindow: ContextMenuWindow?
|
|
var contextMenuVC: ContextMenuVC?
|
|
|
|
// Mentions
|
|
var currentMentionStartIndex: String.Index?
|
|
var mentions: [ConversationViewModel.MentionInfo] = []
|
|
|
|
// Scrolling & paging
|
|
var isUserScrolling = false
|
|
var didFinishInitialLayout = false
|
|
var isLoadingMore = false
|
|
var scrollDistanceToBottomBeforeUpdate: CGFloat?
|
|
var baselineKeyboardHeight: CGFloat = 0
|
|
|
|
var audioSession: OWSAudioSession { Environment.shared.audioSession }
|
|
|
|
/// This flag is used to temporarily prevent the ConversationVC from becoming the first responder (primarily used with
|
|
/// custom transitions from preventing them from being buggy
|
|
var delayFirstResponder: Bool = false
|
|
override var canBecomeFirstResponder: Bool { !delayFirstResponder }
|
|
|
|
override var inputAccessoryView: UIView? {
|
|
guard
|
|
viewModel.threadData.threadVariant != .closedGroup ||
|
|
viewModel.threadData.currentUserIsClosedGroupMember == true
|
|
else { return nil }
|
|
|
|
return (isShowingSearchUI ? searchController.resultsBar : snInputView)
|
|
}
|
|
|
|
/// 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 (`tableView.adjustedContentInset.bottom`).
|
|
var tableViewUnobscuredHeight: CGFloat {
|
|
let bottomInset = tableView.adjustedContentInset.bottom
|
|
return tableView.bounds.height - bottomInset
|
|
}
|
|
|
|
/// The offset at which the table view is exactly scrolled to the bottom.
|
|
var lastPageTop: CGFloat {
|
|
return tableView.contentSize.height - tableViewUnobscuredHeight
|
|
}
|
|
|
|
var isCloseToBottom: Bool {
|
|
let margin = (self.lastPageTop - self.tableView.contentOffset.y)
|
|
return margin <= ConversationVC.scrollToBottomMargin
|
|
}
|
|
|
|
lazy var mnemonic: String = {
|
|
if let hexEncodedSeed: String = Identity.fetchHexEncodedSeed() {
|
|
return Mnemonic.encode(hexEncodedString: hexEncodedSeed)
|
|
}
|
|
|
|
// Legacy account
|
|
return Mnemonic.encode(hexEncodedString: Identity.fetchUserPrivateKey()!.toHexString())
|
|
}()
|
|
|
|
// FIXME: Would be good to create a Swift-based cache and replace this
|
|
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 = ConversationSearchController()
|
|
result.uiSearchController.obscuresBackgroundDuringPresentation = false
|
|
result.delegate = self
|
|
|
|
return result
|
|
}()
|
|
|
|
// MARK: - UI
|
|
|
|
private static let messageRequestButtonHeight: CGFloat = 34
|
|
|
|
lazy var titleView: ConversationTitleView = {
|
|
let result: ConversationTitleView = ConversationTitleView()
|
|
let tapGestureRecognizer = UITapGestureRecognizer(
|
|
target: self,
|
|
action: #selector(handleTitleViewTapped)
|
|
)
|
|
result.addGestureRecognizer(tapGestureRecognizer)
|
|
|
|
return result
|
|
}()
|
|
|
|
lazy var tableView: InsetLockableTableView = {
|
|
let result: InsetLockableTableView = InsetLockableTableView()
|
|
result.separatorStyle = .none
|
|
result.backgroundColor = .clear
|
|
result.showsVerticalScrollIndicator = false
|
|
result.contentInsetAdjustmentBehavior = .never
|
|
result.keyboardDismissMode = .interactive
|
|
result.contentInset = UIEdgeInsets(
|
|
top: 0,
|
|
leading: 0,
|
|
bottom: Values.mediumSpacing,
|
|
trailing: 0
|
|
)
|
|
result.register(view: VisibleMessageCell.self)
|
|
result.register(view: InfoMessageCell.self)
|
|
result.register(view: TypingIndicatorCell.self)
|
|
result.dataSource = self
|
|
result.delegate = self
|
|
|
|
return result
|
|
}()
|
|
|
|
lazy var snInputView: InputView = InputView(
|
|
threadVariant: self.viewModel.threadData.threadVariant,
|
|
delegate: self
|
|
)
|
|
|
|
lazy var unreadCountView: UIView = {
|
|
let result: UIView = UIView()
|
|
result.backgroundColor = Colors.text.withAlphaComponent(Values.veryLowOpacity)
|
|
result.set(.width, greaterThanOrEqualTo: ConversationVC.unreadCountViewSize)
|
|
result.set(.height, to: ConversationVC.unreadCountViewSize)
|
|
result.layer.masksToBounds = true
|
|
result.layer.cornerRadius = (ConversationVC.unreadCountViewSize / 2)
|
|
|
|
return result
|
|
}()
|
|
|
|
lazy var unreadCountLabel: UILabel = {
|
|
let result: UILabel = UILabel()
|
|
result.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
|
|
result.textColor = Colors.text
|
|
result.textAlignment = .center
|
|
|
|
return result
|
|
}()
|
|
|
|
lazy var blockedBanner: InfoBanner = {
|
|
let result: InfoBanner = InfoBanner(
|
|
message: self.viewModel.blockedBannerMessage,
|
|
backgroundColor: Colors.destructive
|
|
)
|
|
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(unblock))
|
|
result.addGestureRecognizer(tapGestureRecognizer)
|
|
|
|
return result
|
|
}()
|
|
|
|
lazy var footerControlsStackView: UIStackView = {
|
|
let result: UIStackView = UIStackView()
|
|
result.translatesAutoresizingMaskIntoConstraints = false
|
|
result.axis = .vertical
|
|
result.alignment = .trailing
|
|
result.distribution = .equalSpacing
|
|
result.spacing = 10
|
|
result.layoutMargins = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20)
|
|
result.isLayoutMarginsRelativeArrangement = true
|
|
|
|
return result
|
|
}()
|
|
|
|
lazy var scrollButton: ScrollToBottomButton = ScrollToBottomButton(delegate: self)
|
|
|
|
lazy var messageRequestView: UIView = {
|
|
let result: UIView = UIView()
|
|
result.translatesAutoresizingMaskIntoConstraints = false
|
|
result.isHidden = (self.viewModel.threadData.threadIsMessageRequest == false)
|
|
result.setGradient(Gradients.defaultBackground)
|
|
|
|
return result
|
|
}()
|
|
|
|
private let messageRequestDescriptionLabel: UILabel = {
|
|
let result: UILabel = UILabel()
|
|
result.translatesAutoresizingMaskIntoConstraints = false
|
|
result.font = UIFont.systemFont(ofSize: 12)
|
|
result.text = NSLocalizedString("MESSAGE_REQUESTS_INFO", comment: "")
|
|
result.textColor = Colors.sessionMessageRequestsInfoText
|
|
result.textAlignment = .center
|
|
result.numberOfLines = 2
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var messageRequestAcceptButton: UIButton = {
|
|
let result: UIButton = UIButton()
|
|
result.translatesAutoresizingMaskIntoConstraints = false
|
|
result.clipsToBounds = true
|
|
result.titleLabel?.font = UIFont.boldSystemFont(ofSize: 18)
|
|
result.setTitle(NSLocalizedString("TXT_DELETE_ACCEPT", comment: ""), for: .normal)
|
|
result.setTitleColor(Colors.sessionHeading, for: .normal)
|
|
result.setBackgroundImage(
|
|
Colors.sessionHeading
|
|
.withAlphaComponent(isDarkMode ? 0.2 : 0.06)
|
|
.toImage(isDarkMode: isDarkMode),
|
|
for: .highlighted
|
|
)
|
|
result.layer.cornerRadius = (ConversationVC.messageRequestButtonHeight / 2)
|
|
result.layer.borderColor = {
|
|
if #available(iOS 13.0, *) {
|
|
return Colors.sessionHeading
|
|
.resolvedColor(
|
|
// Note: This is needed for '.cgColor' to support dark mode
|
|
with: UITraitCollection(userInterfaceStyle: isDarkMode ? .dark : .light)
|
|
).cgColor
|
|
}
|
|
|
|
return Colors.sessionHeading.cgColor
|
|
}()
|
|
result.layer.borderWidth = 1
|
|
result.addTarget(self, action: #selector(acceptMessageRequest), for: .touchUpInside)
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var messageRequestDeleteButton: UIButton = {
|
|
let result: UIButton = UIButton()
|
|
result.translatesAutoresizingMaskIntoConstraints = false
|
|
result.clipsToBounds = true
|
|
result.titleLabel?.font = UIFont.boldSystemFont(ofSize: 18)
|
|
result.setTitle(NSLocalizedString("TXT_DELETE_TITLE", comment: ""), for: .normal)
|
|
result.setTitleColor(Colors.destructive, for: .normal)
|
|
result.setBackgroundImage(
|
|
Colors.destructive
|
|
.withAlphaComponent(isDarkMode ? 0.2 : 0.06)
|
|
.toImage(isDarkMode: isDarkMode),
|
|
for: .highlighted
|
|
)
|
|
result.layer.cornerRadius = (ConversationVC.messageRequestButtonHeight / 2)
|
|
result.layer.borderColor = {
|
|
if #available(iOS 13.0, *) {
|
|
return Colors.destructive
|
|
.resolvedColor(
|
|
// Note: This is needed for '.cgColor' to support dark mode
|
|
with: UITraitCollection(userInterfaceStyle: isDarkMode ? .dark : .light)
|
|
).cgColor
|
|
}
|
|
|
|
return Colors.destructive.cgColor
|
|
}()
|
|
result.layer.borderWidth = 1
|
|
result.addTarget(self, action: #selector(deleteMessageRequest), for: .touchUpInside)
|
|
|
|
return result
|
|
}()
|
|
|
|
// MARK: - Settings
|
|
|
|
static let unreadCountViewSize: CGFloat = 20
|
|
/// 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
|
|
/// The button will be invisible until the user has scrolled at least this amount from the bottom of the table view.
|
|
static let scrollButtonNoVisibilityThreshold: CGFloat = 20
|
|
/// Automatically scroll to the bottom of the conversation when sending a message if the scroll distance from the bottom is less than this number.
|
|
static let scrollToBottomMargin: CGFloat = 60
|
|
|
|
// MARK: - Initialization
|
|
|
|
init?(threadId: String, focusedInteractionId: Int64? = nil) {
|
|
guard let viewModel: ConversationViewModel = ConversationViewModel(threadId: threadId, focusedInteractionId: focusedInteractionId) else {
|
|
return nil
|
|
}
|
|
|
|
self.viewModel = viewModel
|
|
|
|
super.init(nibName: nil, bundle: nil)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
preconditionFailure("Use init(thread:) instead.")
|
|
}
|
|
|
|
deinit {
|
|
NotificationCenter.default.removeObserver(self)
|
|
}
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
// Gradient
|
|
setUpGradientBackground()
|
|
|
|
// Nav bar
|
|
setUpNavBarStyle()
|
|
navigationItem.titleView = titleView
|
|
|
|
titleView.update(
|
|
with: viewModel.threadData.displayName,
|
|
mutedUntilTimestamp: viewModel.threadData.threadMutedUntilTimestamp,
|
|
onlyNotifyForMentions: (viewModel.threadData.threadOnlyNotifyForMentions == true),
|
|
userCount: viewModel.threadData.userCount
|
|
)
|
|
updateNavBarButtons(threadData: viewModel.threadData)
|
|
|
|
// Constraints
|
|
view.addSubview(tableView)
|
|
tableView.pin(to: view)
|
|
|
|
// Blocked banner
|
|
addOrRemoveBlockedBanner(threadIsBlocked: (viewModel.threadData.threadIsBlocked == true))
|
|
|
|
// Message requests view & scroll to bottom
|
|
view.addSubview(scrollButton)
|
|
view.addSubview(messageRequestView)
|
|
|
|
messageRequestView.addSubview(messageRequestDescriptionLabel)
|
|
messageRequestView.addSubview(messageRequestAcceptButton)
|
|
messageRequestView.addSubview(messageRequestDeleteButton)
|
|
|
|
scrollButton.pin(.right, to: .right, of: view, withInset: -20)
|
|
messageRequestView.pin(.left, to: .left, of: view)
|
|
messageRequestView.pin(.right, to: .right, of: view)
|
|
self.messageRequestsViewBotomConstraint = messageRequestView.pin(.bottom, to: .bottom, of: view, withInset: -16)
|
|
self.scrollButtonBottomConstraint = scrollButton.pin(.bottom, to: .bottom, of: view, withInset: -16)
|
|
self.scrollButtonBottomConstraint?.isActive = false // Note: Need to disable this to avoid a conflict with the other bottom constraint
|
|
self.scrollButtonMessageRequestsBottomConstraint = scrollButton.pin(.bottom, to: .top, of: messageRequestView, withInset: -16)
|
|
self.scrollButtonMessageRequestsBottomConstraint?.isActive = (viewModel.threadData.threadIsMessageRequest == true)
|
|
self.scrollButtonBottomConstraint?.isActive = (viewModel.threadData.threadIsMessageRequest == false)
|
|
|
|
messageRequestDescriptionLabel.pin(.top, to: .top, of: messageRequestView, withInset: 10)
|
|
messageRequestDescriptionLabel.pin(.left, to: .left, of: messageRequestView, withInset: 40)
|
|
messageRequestDescriptionLabel.pin(.right, to: .right, of: messageRequestView, withInset: -40)
|
|
|
|
messageRequestAcceptButton.pin(.top, to: .bottom, of: messageRequestDescriptionLabel, withInset: 20)
|
|
messageRequestAcceptButton.pin(.left, to: .left, of: messageRequestView, withInset: 20)
|
|
messageRequestAcceptButton.pin(.bottom, to: .bottom, of: messageRequestView)
|
|
messageRequestAcceptButton.set(.height, to: ConversationVC.messageRequestButtonHeight)
|
|
|
|
messageRequestAcceptButton.pin(.top, to: .bottom, of: messageRequestDescriptionLabel, withInset: 20)
|
|
messageRequestAcceptButton.pin(.left, to: .left, of: messageRequestView, withInset: 20)
|
|
messageRequestAcceptButton.pin(.bottom, to: .bottom, of: messageRequestView)
|
|
messageRequestAcceptButton.set(.height, to: ConversationVC.messageRequestButtonHeight)
|
|
|
|
messageRequestDeleteButton.pin(.top, to: .bottom, of: messageRequestDescriptionLabel, withInset: 20)
|
|
messageRequestDeleteButton.pin(.left, to: .right, of: messageRequestAcceptButton, withInset: 20)
|
|
messageRequestDeleteButton.pin(.right, to: .right, of: messageRequestView, withInset: -20)
|
|
messageRequestDeleteButton.pin(.bottom, to: .bottom, of: messageRequestView)
|
|
messageRequestDeleteButton.set(.width, to: .width, of: messageRequestAcceptButton)
|
|
messageRequestDeleteButton.set(.height, to: ConversationVC.messageRequestButtonHeight)
|
|
|
|
// Unread count view
|
|
view.addSubview(unreadCountView)
|
|
unreadCountView.addSubview(unreadCountLabel)
|
|
unreadCountLabel.pin(.top, to: .top, of: unreadCountView)
|
|
unreadCountLabel.pin(.bottom, to: .bottom, of: unreadCountView)
|
|
unreadCountView.pin(.leading, to: .leading, of: unreadCountLabel, withInset: -4)
|
|
unreadCountView.pin(.trailing, to: .trailing, of: unreadCountLabel, withInset: 4)
|
|
unreadCountView.centerYAnchor.constraint(equalTo: scrollButton.topAnchor).isActive = true
|
|
unreadCountView.center(.horizontal, in: scrollButton)
|
|
updateUnreadCountView(unreadCount: viewModel.threadData.threadUnreadCount)
|
|
|
|
// Notifications
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(applicationDidBecomeActive(_:)),
|
|
name: UIApplication.didBecomeActiveNotification,
|
|
object: nil
|
|
)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(applicationDidResignActive(_:)),
|
|
name: UIApplication.didEnterBackgroundNotification, object: nil
|
|
)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(handleKeyboardWillChangeFrameNotification(_:)),
|
|
name: UIResponder.keyboardWillChangeFrameNotification,
|
|
object: nil
|
|
)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(handleKeyboardWillHideNotification(_:)),
|
|
name: UIResponder.keyboardWillHideNotification,
|
|
object: nil
|
|
)
|
|
|
|
// Draft
|
|
if let draft: String = viewModel.threadData.threadMessageDraft, !draft.isEmpty {
|
|
snInputView.text = draft
|
|
}
|
|
|
|
// Update the input state
|
|
snInputView.setEnabledMessageTypes(viewModel.threadData.enabledMessageTypes, message: nil)
|
|
|
|
}
|
|
|
|
override func viewDidLayoutSubviews() {
|
|
super.viewDidLayoutSubviews()
|
|
|
|
guard !didFinishInitialLayout else { return }
|
|
|
|
// Scroll to the last unread message if possible; otherwise scroll to the bottom.
|
|
// When the unread message count is more than the number of view items of a page,
|
|
// the screen will scroll to the bottom instead of the first unread message
|
|
DispatchQueue.main.async {
|
|
if let focusedInteractionId: Int64 = self.viewModel.focusedInteractionId {
|
|
self.scrollToInteraction(with: focusedInteractionId, isAnimated: false, highlighted: true)
|
|
}
|
|
else if let firstUnreadInteractionId: Int64 = self.viewModel.threadData.threadFirstUnreadInteractionId {
|
|
self.scrollToInteraction(with: firstUnreadInteractionId, position: .top, isAnimated: false)
|
|
self.unreadCountView.alpha = self.scrollButton.alpha
|
|
}
|
|
else {
|
|
self.scrollToBottom(isAnimated: false)
|
|
}
|
|
|
|
self.scrollButton.alpha = self.getScrollButtonOpacity()
|
|
}
|
|
}
|
|
|
|
override func viewWillAppear(_ animated: Bool) {
|
|
super.viewWillAppear(animated)
|
|
|
|
startObservingChanges()
|
|
}
|
|
|
|
override func viewDidAppear(_ animated: Bool) {
|
|
super.viewDidAppear(animated)
|
|
|
|
highlightFocusedMessageIfNeeded()
|
|
didFinishInitialLayout = true
|
|
viewModel.markAllAsRead()
|
|
|
|
if delayFirstResponder {
|
|
delayFirstResponder = false
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) { [weak self] in
|
|
self?.becomeFirstResponder()
|
|
}
|
|
}
|
|
}
|
|
|
|
override func viewWillDisappear(_ animated: Bool) {
|
|
super.viewWillDisappear(animated)
|
|
|
|
stopObservingChanges()
|
|
viewModel.updateDraft(to: snInputView.text)
|
|
inputAccessoryView?.resignFirstResponder()
|
|
}
|
|
|
|
override func viewDidDisappear(_ animated: Bool) {
|
|
super.viewDidDisappear(animated)
|
|
|
|
mediaCache.removeAllObjects()
|
|
hasReloadedDataAfterDisappearance = false
|
|
}
|
|
|
|
@objc func applicationDidBecomeActive(_ notification: Notification) {
|
|
startObservingChanges()
|
|
}
|
|
|
|
@objc func applicationDidResignActive(_ notification: Notification) {
|
|
stopObservingChanges()
|
|
}
|
|
|
|
// MARK: - Updating
|
|
|
|
private func startObservingChanges() {
|
|
// Start observing for data changes
|
|
dataChangeObservable = GRDBStorage.shared.start(
|
|
viewModel.observableThreadData,
|
|
onError: { _ in },
|
|
onChange: { [weak self] maybeThreadData in
|
|
guard let threadData: ConversationCell.ViewModel = maybeThreadData else { return }
|
|
|
|
// The default scheduler emits changes on the main thread
|
|
self?.handleThreadUpdates(threadData)
|
|
}
|
|
)
|
|
|
|
self.viewModel.onInteractionChange = { [weak self] updatedInteractionData in
|
|
self?.handleInteractionUpdates(updatedInteractionData)
|
|
}
|
|
}
|
|
|
|
private func stopObservingChanges() {
|
|
// Stop observing database changes
|
|
dataChangeObservable?.cancel()
|
|
self.viewModel.onInteractionChange = nil
|
|
}
|
|
|
|
private func handleThreadUpdates(_ updatedThreadData: ConversationCell.ViewModel, initialLoad: Bool = false) {
|
|
// Ensure the first load or a load when returning from a child screen runs without animations (if
|
|
// we don't do this the cells will animate in from a frame of CGRect.zero or have a buggy transition)
|
|
guard hasLoadedInitialData && hasReloadedDataAfterDisappearance else {
|
|
hasLoadedInitialData = true
|
|
hasReloadedDataAfterDisappearance = true
|
|
UIView.performWithoutAnimation { handleThreadUpdates(updatedThreadData, initialLoad: true) }
|
|
return
|
|
}
|
|
// Update general conversation UI
|
|
|
|
if
|
|
initialLoad ||
|
|
viewModel.threadData.displayName != updatedThreadData.displayName ||
|
|
viewModel.threadData.threadMutedUntilTimestamp != updatedThreadData.threadMutedUntilTimestamp ||
|
|
viewModel.threadData.threadOnlyNotifyForMentions != updatedThreadData.threadOnlyNotifyForMentions ||
|
|
viewModel.threadData.userCount != updatedThreadData.userCount
|
|
{
|
|
titleView.update(
|
|
with: updatedThreadData.displayName,
|
|
mutedUntilTimestamp: updatedThreadData.threadMutedUntilTimestamp,
|
|
onlyNotifyForMentions: (updatedThreadData.threadOnlyNotifyForMentions == true),
|
|
userCount: updatedThreadData.userCount
|
|
)
|
|
}
|
|
|
|
if
|
|
initialLoad ||
|
|
viewModel.threadData.threadRequiresApproval != updatedThreadData.threadRequiresApproval ||
|
|
viewModel.threadData.profile != updatedThreadData.profile
|
|
{
|
|
updateNavBarButtons(threadData: updatedThreadData)
|
|
}
|
|
|
|
if viewModel.threadData.currentUserIsClosedGroupMember != updatedThreadData.currentUserIsClosedGroupMember {
|
|
reloadInputViews()
|
|
}
|
|
|
|
if initialLoad || viewModel.threadData.enabledMessageTypes != updatedThreadData.enabledMessageTypes {
|
|
snInputView.setEnabledMessageTypes(
|
|
updatedThreadData.enabledMessageTypes,
|
|
message: nil
|
|
)
|
|
}
|
|
|
|
if initialLoad || viewModel.threadData.threadIsBlocked != updatedThreadData.threadIsBlocked {
|
|
addOrRemoveBlockedBanner(threadIsBlocked: (updatedThreadData.threadIsBlocked == true))
|
|
}
|
|
|
|
if initialLoad || viewModel.threadData.threadUnreadCount != updatedThreadData.threadUnreadCount {
|
|
updateUnreadCountView(unreadCount: updatedThreadData.threadUnreadCount)
|
|
}
|
|
}
|
|
|
|
private func handleInteractionUpdates(_ updatedViewData: [MessageCell.ViewModel], initialLoad: Bool = false) {
|
|
// Ensure the first load or a load when returning from a child screen runs without animations (if
|
|
// we don't do this the cells will animate in from a frame of CGRect.zero or have a buggy transition)
|
|
guard hasLoadedInitialData && hasReloadedDataAfterDisappearance else {
|
|
hasLoadedInitialData = true
|
|
hasReloadedDataAfterDisappearance = true
|
|
UIView.performWithoutAnimation { handleInteractionUpdates(updatedViewData, initialLoad: true) }
|
|
return
|
|
}
|
|
|
|
// Reload the table content (animate changes after the first load)
|
|
let changeset = StagedChangeset(source: viewModel.interactionData, target: updatedViewData)
|
|
tableView.reload(
|
|
using: StagedChangeset(source: viewModel.interactionData, target: updatedViewData),
|
|
deleteSectionsAnimation: .bottom,
|
|
insertSectionsAnimation: .bottom,
|
|
reloadSectionsAnimation: .none,
|
|
deleteRowsAnimation: .bottom,
|
|
insertRowsAnimation: .bottom,
|
|
reloadRowsAnimation: .none,
|
|
interrupt: { $0.changeCount > ConversationViewModel.pageSize }
|
|
) { [weak self] updatedData in
|
|
self?.viewModel.updateInteractionData(updatedData)
|
|
}
|
|
|
|
// Scroll to the bottom if we just inserted a message and are close enough
|
|
// to the bottom
|
|
if
|
|
changeset.contains(where: { !$0.elementInserted.isEmpty }) && (
|
|
updatedViewData.items.last?.interactionVariant == .standardOutgoing ||
|
|
isCloseToBottom
|
|
)
|
|
{
|
|
scrollToBottom(isAnimated: true)
|
|
}
|
|
|
|
// Mark received messages as read
|
|
viewModel.markAllAsRead()
|
|
viewModel.sentMessageBeforeUpdate = false
|
|
}
|
|
|
|
func updateNavBarButtons(threadData: ConversationCell.ViewModel) {
|
|
navigationItem.hidesBackButton = isShowingSearchUI
|
|
|
|
if isShowingSearchUI {
|
|
navigationItem.leftBarButtonItem = nil
|
|
navigationItem.rightBarButtonItems = []
|
|
}
|
|
else {
|
|
guard threadData.threadRequiresApproval == false else {
|
|
// Note: Adding an empty button because without it the title alignment is
|
|
// busted (Note: The size was taken from the layout inspector for the back
|
|
// button in Xcode
|
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
|
customView: UIView(
|
|
frame: CGRect(
|
|
x: 0,
|
|
y: 0,
|
|
width: (44 - 16), // Width of the standard back button
|
|
height: 44
|
|
)
|
|
)
|
|
)
|
|
return
|
|
}
|
|
|
|
switch threadData.threadVariant {
|
|
case .contact:
|
|
let profilePictureView = ProfilePictureView()
|
|
profilePictureView.size = Values.verySmallProfilePictureSize
|
|
profilePictureView.update(
|
|
publicKey: threadData.threadId, // Contact thread uses the contactId
|
|
profile: threadData.profile,
|
|
threadVariant: threadData.threadVariant
|
|
)
|
|
profilePictureView.set(.width, to: (44 - 16)) // Width of the standard back button
|
|
profilePictureView.set(.height, to: Values.verySmallProfilePictureSize)
|
|
|
|
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(openSettings))
|
|
profilePictureView.addGestureRecognizer(tapGestureRecognizer)
|
|
|
|
let rightBarButtonItem: UIBarButtonItem = UIBarButtonItem(customView: profilePictureView)
|
|
rightBarButtonItem.accessibilityLabel = "Settings button"
|
|
rightBarButtonItem.isAccessibilityElement = true
|
|
|
|
navigationItem.rightBarButtonItem = rightBarButtonItem
|
|
|
|
default:
|
|
let rightBarButtonItem: UIBarButtonItem = UIBarButtonItem(image: UIImage(named: "Gear"), style: .plain, target: self, action: #selector(openSettings))
|
|
rightBarButtonItem.accessibilityLabel = "Settings button"
|
|
rightBarButtonItem.isAccessibilityElement = true
|
|
|
|
navigationItem.rightBarButtonItem = rightBarButtonItem
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// MARK: Notifications
|
|
|
|
private func highlightFocusedMessageIfNeeded() {
|
|
if let indexPath = focusedMessageIndexPath, let cell = tableView.cellForRow(at: indexPath) as? VisibleMessageCell {
|
|
cell.highlight()
|
|
focusedMessageIndexPath = nil
|
|
}
|
|
}
|
|
|
|
@objc func handleKeyboardWillChangeFrameNotification(_ notification: Notification) {
|
|
// Please refer to https://github.com/mapbox/mapbox-navigation-ios/issues/1600
|
|
// and https://stackoverflow.com/a/25260930 to better understand what we are
|
|
// doing with the UIViewAnimationOptions
|
|
let userInfo: [AnyHashable: Any] = (notification.userInfo ?? [:])
|
|
let duration = ((userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval) ?? 0)
|
|
let curveValue: Int = ((userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int) ?? Int(UIView.AnimationOptions.curveEaseInOut.rawValue))
|
|
let options: UIView.AnimationOptions = UIView.AnimationOptions(rawValue: UInt(curveValue << 16))
|
|
let keyboardRect: CGRect = ((userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect) ?? CGRect.zero)
|
|
|
|
// Calculate new positions (Need the ensure the 'messageRequestView' has been layed out as it's
|
|
// needed for proper calculations, so force an initial layout if it doesn't have a size)
|
|
var hasDoneLayout: Bool = true
|
|
|
|
if messageRequestView.bounds.height <= CGFloat.leastNonzeroMagnitude {
|
|
hasDoneLayout = false
|
|
|
|
UIView.performWithoutAnimation {
|
|
self.view.layoutIfNeeded()
|
|
}
|
|
}
|
|
|
|
let keyboardTop = (UIScreen.main.bounds.height - keyboardRect.minY)
|
|
let messageRequestsOffset: CGFloat = (messageRequestView.isHidden ? 0 : messageRequestView.bounds.height + 16)
|
|
let oldContentInset: UIEdgeInsets = tableView.contentInset
|
|
let newContentInset: UIEdgeInsets = UIEdgeInsets(
|
|
top: 0,
|
|
leading: 0,
|
|
bottom: (Values.mediumSpacing + keyboardTop + messageRequestsOffset),
|
|
trailing: 0
|
|
)
|
|
let newContentOffsetY: CGFloat = (tableView.contentOffset.y + (newContentInset.bottom - oldContentInset.bottom))
|
|
let changes = { [weak self] in
|
|
self?.scrollButtonBottomConstraint?.constant = -(keyboardTop + 16)
|
|
self?.messageRequestsViewBotomConstraint?.constant = -(keyboardTop + 16)
|
|
self?.tableView.contentInset = newContentInset
|
|
self?.tableView.contentOffset.y = newContentOffsetY
|
|
|
|
let scrollButtonOpacity: CGFloat = (self?.getScrollButtonOpacity() ?? 0)
|
|
self?.scrollButton.alpha = scrollButtonOpacity
|
|
|
|
self?.view.setNeedsLayout()
|
|
self?.view.layoutIfNeeded()
|
|
}
|
|
|
|
// Perform the changes (don't animate if the initial layout hasn't been completed)
|
|
guard hasDoneLayout else {
|
|
UIView.performWithoutAnimation {
|
|
changes()
|
|
}
|
|
return
|
|
}
|
|
|
|
UIView.animate(
|
|
withDuration: duration,
|
|
delay: 0,
|
|
options: options,
|
|
animations: changes,
|
|
completion: nil
|
|
)
|
|
}
|
|
|
|
@objc func handleKeyboardWillHideNotification(_ notification: Notification) {
|
|
// Please refer to https://github.com/mapbox/mapbox-navigation-ios/issues/1600
|
|
// and https://stackoverflow.com/a/25260930 to better understand what we are
|
|
// doing with the UIViewAnimationOptions
|
|
let userInfo: [AnyHashable: Any] = (notification.userInfo ?? [:])
|
|
let duration = ((userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval) ?? 0)
|
|
let curveValue: Int = ((userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int) ?? Int(UIView.AnimationOptions.curveEaseInOut.rawValue))
|
|
let options: UIView.AnimationOptions = UIView.AnimationOptions(rawValue: UInt(curveValue << 16))
|
|
|
|
let keyboardRect: CGRect = ((userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect) ?? CGRect.zero)
|
|
let keyboardTop = (UIScreen.main.bounds.height - keyboardRect.minY)
|
|
|
|
UIView.animate(
|
|
withDuration: duration,
|
|
delay: 0,
|
|
options: options,
|
|
animations: { [weak self] in
|
|
self?.scrollButtonBottomConstraint?.constant = -(keyboardTop + 16)
|
|
self?.messageRequestsViewBotomConstraint?.constant = -(keyboardTop + 16)
|
|
|
|
let scrollButtonOpacity: CGFloat = (self?.getScrollButtonOpacity() ?? 0)
|
|
self?.scrollButton.alpha = scrollButtonOpacity
|
|
self?.unreadCountView.alpha = scrollButtonOpacity
|
|
|
|
self?.view.setNeedsLayout()
|
|
self?.view.layoutIfNeeded()
|
|
},
|
|
completion: nil
|
|
)
|
|
}
|
|
|
|
func conversationViewModelWillUpdate() {
|
|
// Not currently in use
|
|
}
|
|
|
|
func conversationViewModelDidUpdate(_ conversationUpdate: ConversationUpdate) {
|
|
guard self.isViewLoaded else { return }
|
|
let updateType = conversationUpdate.conversationUpdateType
|
|
guard updateType != .minor else { return } // No view items were affected
|
|
if updateType == .reload {
|
|
if threadStartedAsMessageRequest {
|
|
updateNavBarButtons() // In case the message request was approved
|
|
}
|
|
|
|
return messagesTableView.reloadData()
|
|
}
|
|
var shouldScrollToBottom = false
|
|
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: .none)
|
|
case .insert:
|
|
// Perform inserts before updates
|
|
self.messagesTableView.insertRows(at: [ IndexPath(row: Int(update.newIndex), section: 0) ], with: .none)
|
|
if update.viewItem?.interaction is TSOutgoingMessage {
|
|
shouldScrollToBottom = true
|
|
} else {
|
|
shouldScrollToBottom = self.isCloseToBottom
|
|
}
|
|
case .update:
|
|
self.messagesTableView.reloadRows(at: [ IndexPath(row: Int(update.oldIndex), section: 0) ], with: .none)
|
|
default: preconditionFailure()
|
|
}
|
|
|
|
// Update the nav items if the message request was approved
|
|
if (update.viewItem?.interaction as? TSInfoMessage)?.messageType == .messageRequestAccepted {
|
|
self.updateNavBarButtons()
|
|
}
|
|
}
|
|
}
|
|
UIView.performWithoutAnimation {
|
|
messagesTableView.performBatchUpdates(batchUpdates) { _ in
|
|
if shouldScrollToBottom {
|
|
self.scrollToBottom(isAnimated: false)
|
|
}
|
|
self.markAllAsRead()
|
|
}
|
|
}
|
|
|
|
// Update the input state if this is a contact thread
|
|
if let contactThread: TSContactThread = thread as? TSContactThread {
|
|
let contact: Contact? = GRDBStorage.shared.read { db in try Contact.fetchOne(db, id: contactThread.contactSessionID()) }
|
|
|
|
// If the contact doesn't exist yet then it's a message request without the first message sent
|
|
// so only allow text-based messages
|
|
self.snInputView.setEnabledMessageTypes(
|
|
(thread.isNoteToSelf() || contact?.didApproveMe == true || thread.isMessageRequest() ?
|
|
.all : .textOnly
|
|
),
|
|
message: nil
|
|
)
|
|
}
|
|
}
|
|
|
|
func conversationViewModelWillLoadMoreItems() {
|
|
view.layoutIfNeeded()
|
|
// The scroll distance to bottom will be restored in conversationViewModelDidLoadMoreItems
|
|
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() {
|
|
// Not currently in use
|
|
}
|
|
|
|
func conversationViewModelRangeDidChange() {
|
|
// Not currently in use
|
|
}
|
|
|
|
func conversationViewModelDidReset() {
|
|
// Not currently in use
|
|
}
|
|
|
|
@objc private func handleMessageSentStatusChanged() {
|
|
DispatchQueue.main.async {
|
|
guard let indexPaths = self.tableView.indexPathsForVisibleRows else { return }
|
|
var indexPathsToReload: [IndexPath] = []
|
|
for indexPath in indexPaths {
|
|
guard let cell = self.tableView.cellForRow(at: indexPath) as? VisibleMessageCell else { continue }
|
|
let isLast = (indexPath.item == (self.tableView.numberOfRows(inSection: 0) - 1))
|
|
guard !isLast else { continue }
|
|
if !cell.messageStatusImageView.isHidden {
|
|
indexPathsToReload.append(indexPath)
|
|
}
|
|
}
|
|
UIView.performWithoutAnimation {
|
|
self.tableView.reloadRows(at: indexPathsToReload, with: .none)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - General
|
|
|
|
func addOrRemoveBlockedBanner(threadIsBlocked: Bool) {
|
|
guard threadIsBlocked else {
|
|
self.blockedBanner.removeFromSuperview()
|
|
return
|
|
}
|
|
|
|
self.view.addSubview(self.blockedBanner)
|
|
self.blockedBanner.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.HorizontalEdge.right ], to: self.view)
|
|
}
|
|
|
|
// MARK: - UITableViewDataSource
|
|
|
|
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
|
return viewModel.interactionData.count
|
|
}
|
|
|
|
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
|
let cellViewModel: MessageCell.ViewModel = viewModel.interactionData[indexPath.row]
|
|
let cell: MessageCell = tableView.dequeue(type: MessageCell.cellType(for: cellViewModel), for: indexPath)
|
|
cell.update(
|
|
with: cellViewModel,
|
|
mediaCache: mediaCache,
|
|
playbackInfo: viewModel.playbackInfo(for: cellViewModel) { updatedInfo, error in
|
|
DispatchQueue.main.async {
|
|
guard error == nil else {
|
|
OWSAlerts.showErrorAlert(message: "INVALID_AUDIO_FILE_ALERT_ERROR_MESSAGE".localized())
|
|
return
|
|
}
|
|
|
|
cell.dynamicUpdate(with: cellViewModel, playbackInfo: updatedInfo)
|
|
}
|
|
},
|
|
lastSearchText: viewModel.lastSearchedText
|
|
)
|
|
cell.delegate = self
|
|
|
|
return cell
|
|
}
|
|
|
|
// MARK: - UITableViewDelegate
|
|
|
|
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
|
return UITableView.automaticDimension
|
|
}
|
|
|
|
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
|
return UITableView.automaticDimension
|
|
}
|
|
|
|
func scrollToBottom(isAnimated: Bool) {
|
|
guard
|
|
!isUserScrolling,
|
|
let messagesSectionIndex: Int = self.viewModel.interactionData
|
|
.firstIndex(where: { $0.model == .messages }),
|
|
!self.viewModel.interactionData[messagesSectionIndex]
|
|
.elements
|
|
.isEmpty
|
|
else { return }
|
|
|
|
tableView.scrollToRow(
|
|
at: IndexPath(
|
|
row: viewModel.interactionData[messagesSectionIndex].elements.count - 1,
|
|
section: messagesSectionIndex
|
|
),
|
|
at: .bottom,
|
|
animated: isAnimated
|
|
)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func updateUnreadCountView(unreadCount: UInt?) {
|
|
let unreadCount: Int = Int(unreadCount ?? 0)
|
|
let fontSize: CGFloat = (unreadCount < 10000 ? Values.verySmallFontSize : 8)
|
|
unreadCountLabel.text = (unreadCount < 10000 ? "\(unreadCount)" : "9999+")
|
|
unreadCountLabel.font = .boldSystemFont(ofSize: fontSize)
|
|
unreadCountView.isHidden = (unreadCount == 0)
|
|
}
|
|
|
|
func getScrollButtonOpacity() -> CGFloat {
|
|
let contentOffsetY = tableView.contentOffset.y
|
|
let x = (lastPageTop - ConversationVC.bottomInset - contentOffsetY).clamp(0, .greatestFiniteMagnitude)
|
|
let a = 1 / (ConversationVC.scrollButtonFullVisibilityThreshold - ConversationVC.scrollButtonNoVisibilityThreshold)
|
|
return a * x
|
|
}
|
|
|
|
// MARK: - Search
|
|
|
|
func conversationSettingsDidRequestConversationSearch(_ conversationSettingsViewController: OWSConversationSettingsViewController) {
|
|
showSearchUI()
|
|
popAllConversationSettingsViews {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { // Without this delay the search bar doesn't show
|
|
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
|
|
|
|
// Search bar
|
|
let searchBar = searchController.uiSearchController.searchBar
|
|
searchBar.setUpSessionStyle()
|
|
navigationItem.titleView = searchBar
|
|
|
|
// Nav bar buttons
|
|
updateNavBarButtons(threadData: self.viewModel.threadData)
|
|
|
|
// Hack so that the ResultsBar stays on the screen when dismissing the search field
|
|
// keyboard.
|
|
//
|
|
// Details:
|
|
//
|
|
// When the search UI is activated, both the SearchField and the ConversationVC
|
|
// have the resultsBar as their inputAccessoryView.
|
|
//
|
|
// So when the SearchField is first responder, the ResultsBar is shown on top of the keyboard.
|
|
// When the ConversationVC is first responder, the ResultsBar is shown at the bottom of the
|
|
// screen.
|
|
//
|
|
// When the user swipes to dismiss the keyboard, trying to see more of the content while
|
|
// searching, we want the ResultsBar to stay at the bottom of the screen - that is, we
|
|
// want the ConversationVC to becomeFirstResponder.
|
|
//
|
|
// If the SearchField were a subview of ConversationVC.view, this would all be automatic,
|
|
// as first responder status is percolated up the responder chain via `nextResponder`, which
|
|
// basically travereses each superView, until you're at a rootView, at which point the next
|
|
// responder is the ViewController which controls that View.
|
|
//
|
|
// However, because SearchField lives in the Navbar, it's "controlled" by the
|
|
// NavigationController, not the ConversationVC.
|
|
//
|
|
// So here we stub the next responder on the navBar so that when the searchBar resigns
|
|
// first responder, the ConversationVC will be in it's responder chain - keeeping the
|
|
// ResultsBar on the bottom of the screen after dismissing the keyboard.
|
|
let navBar = navigationController!.navigationBar as! OWSNavigationBar
|
|
navBar.stubbedNextResponder = self
|
|
}
|
|
|
|
func hideSearchUI() {
|
|
isShowingSearchUI = false
|
|
navigationItem.titleView = titleView
|
|
updateNavBarButtons(threadData: self.viewModel.threadData)
|
|
|
|
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
|
|
tableView.reloadRows(at: tableView.indexPathsForVisibleRows ?? [], with: UITableView.RowAnimation.none)
|
|
}
|
|
|
|
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionId interactionId: Int64) {
|
|
scrollToInteraction(with: interactionId)
|
|
}
|
|
|
|
func scrollToInteraction(
|
|
with interactionId: Int64,
|
|
position: UITableView.ScrollPosition = .middle,
|
|
isAnimated: Bool = true,
|
|
highlighted: Bool = false
|
|
) {
|
|
// Ensure the interaction is loaded
|
|
self.viewModel.pagedDataObserver?.load(.untilInclusive(id: interactionId, padding: 0))
|
|
|
|
guard
|
|
let messageSectionIndex: Int = self.viewModel.interactionData
|
|
.firstIndex(where: { $0.model == .messages }),
|
|
let targetMessageIndex = self.viewModel.interactionData[messageSectionIndex]
|
|
.elements
|
|
.firstIndex(where: { $0.id == interactionId })
|
|
else { return }
|
|
|
|
tableView.scrollToRow(
|
|
at: IndexPath(
|
|
row: targetMessageIndex,
|
|
section: messageSectionIndex
|
|
),
|
|
at: position,
|
|
animated: isAnimated
|
|
)
|
|
|
|
if highlighted {
|
|
focusedMessageId = interactionId
|
|
}
|
|
}
|
|
}
|