mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
Added migrations for contacts and started working through thread migration (have contact and closed group threads migrating) Deprecated usage of ECKeyPair in the migrations (want to be able to remove Curve25519Kit in the future)
434 lines
18 KiB
Swift
434 lines
18 KiB
Swift
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import UIKit
|
|
import GRDB
|
|
import SessionUIKit
|
|
import SessionMessagingKit
|
|
|
|
@objc
|
|
class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDataSource {
|
|
private var threads: YapDatabaseViewMappings! = {
|
|
let result = YapDatabaseViewMappings(groups: [ TSMessageRequestGroup ], view: TSThreadDatabaseViewExtensionName)
|
|
result.setIsReversed(true, forGroup: TSMessageRequestGroup)
|
|
return result
|
|
}()
|
|
private var threadViewModelCache: [String: ThreadViewModel] = [:] // Thread ID to ThreadViewModel
|
|
private var tableViewTopConstraint: NSLayoutConstraint!
|
|
|
|
private var messageRequestCount: UInt {
|
|
threads.numberOfItems(inGroup: TSMessageRequestGroup)
|
|
}
|
|
|
|
private lazy var dbConnection: YapDatabaseConnection = {
|
|
let result = OWSPrimaryStorage.shared().newDatabaseConnection()
|
|
result.objectCacheLimit = 500
|
|
|
|
return result
|
|
}()
|
|
|
|
// MARK: - UI
|
|
|
|
private lazy var tableView: UITableView = {
|
|
let result: UITableView = UITableView()
|
|
result.translatesAutoresizingMaskIntoConstraints = false
|
|
result.backgroundColor = .clear
|
|
result.separatorStyle = .none
|
|
result.register(MessageRequestsCell.self, forCellReuseIdentifier: MessageRequestsCell.reuseIdentifier)
|
|
result.register(ConversationCell.self, forCellReuseIdentifier: ConversationCell.reuseIdentifier)
|
|
result.dataSource = self
|
|
result.delegate = self
|
|
|
|
let bottomInset = Values.newConversationButtonBottomOffset + NewConversationButtonSet.expandedButtonSize + Values.largeSpacing + NewConversationButtonSet.collapsedButtonSize
|
|
result.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0)
|
|
result.showsVerticalScrollIndicator = false
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var emptyStateLabel: UILabel = {
|
|
let result: UILabel = UILabel()
|
|
result.translatesAutoresizingMaskIntoConstraints = false
|
|
result.isUserInteractionEnabled = false
|
|
result.font = UIFont.systemFont(ofSize: Values.smallFontSize)
|
|
result.text = NSLocalizedString("MESSAGE_REQUESTS_EMPTY_TEXT", comment: "")
|
|
result.textColor = Colors.text
|
|
result.textAlignment = .center
|
|
result.numberOfLines = 0
|
|
result.isHidden = true
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var fadeView: UIView = {
|
|
let result: UIView = UIView()
|
|
result.translatesAutoresizingMaskIntoConstraints = false
|
|
result.isUserInteractionEnabled = false
|
|
result.setGradient(Gradients.homeVCFade)
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var clearAllButton: Button = {
|
|
let result: Button = Button(style: .destructiveOutline, size: .large)
|
|
result.translatesAutoresizingMaskIntoConstraints = false
|
|
result.setTitle(NSLocalizedString("MESSAGE_REQUESTS_CLEAR_ALL", comment: ""), for: .normal)
|
|
result.setBackgroundImage(
|
|
Colors.destructive
|
|
.withAlphaComponent(isDarkMode ? 0.2 : 0.06)
|
|
.toImage(isDarkMode: isDarkMode),
|
|
for: .highlighted
|
|
)
|
|
result.addTarget(self, action: #selector(clearAllTapped), for: .touchUpInside)
|
|
|
|
return result
|
|
}()
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
ViewControllerUtilities.setUpDefaultSessionStyle(for: self, title: NSLocalizedString("MESSAGE_REQUESTS_TITLE", comment: ""), hasCustomBackButton: false)
|
|
|
|
// Add the UI (MUST be done after the thread freeze so the 'tableView' creation and setting
|
|
// the dataSource has the correct data)
|
|
view.addSubview(tableView)
|
|
view.addSubview(emptyStateLabel)
|
|
view.addSubview(fadeView)
|
|
view.addSubview(clearAllButton)
|
|
setupLayout()
|
|
|
|
// Notifications
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(handleYapDatabaseModifiedNotification(_:)),
|
|
name: .YapDatabaseModified,
|
|
object: OWSPrimaryStorage.shared().dbNotificationObject
|
|
)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(handleProfileDidChangeNotification(_:)),
|
|
name: Notification.Name.otherUsersProfileDidChange,
|
|
object: nil
|
|
)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(handleBlockedContactsUpdatedNotification(_:)),
|
|
name: .blockedContactsUpdated,
|
|
object: nil
|
|
)
|
|
|
|
reload()
|
|
}
|
|
|
|
override func viewDidAppear(_ animated: Bool) {
|
|
super.viewDidAppear(animated)
|
|
reload()
|
|
}
|
|
|
|
deinit {
|
|
NotificationCenter.default.removeObserver(self)
|
|
}
|
|
|
|
// MARK: - Layout
|
|
|
|
private func setupLayout() {
|
|
NSLayoutConstraint.activate([
|
|
tableView.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.smallSpacing),
|
|
tableView.leftAnchor.constraint(equalTo: view.leftAnchor),
|
|
tableView.rightAnchor.constraint(equalTo: view.rightAnchor),
|
|
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
|
|
emptyStateLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.massiveSpacing),
|
|
emptyStateLabel.leftAnchor.constraint(equalTo: view.leftAnchor, constant: Values.mediumSpacing),
|
|
emptyStateLabel.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -Values.mediumSpacing),
|
|
emptyStateLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
|
|
|
fadeView.topAnchor.constraint(equalTo: view.topAnchor, constant: (0.15 * view.bounds.height)),
|
|
fadeView.leftAnchor.constraint(equalTo: view.leftAnchor),
|
|
fadeView.rightAnchor.constraint(equalTo: view.rightAnchor),
|
|
fadeView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
|
|
clearAllButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
|
clearAllButton.bottomAnchor.constraint(
|
|
equalTo: view.safeAreaLayoutGuide.bottomAnchor,
|
|
constant: -Values.largeSpacing
|
|
),
|
|
// Note: The '182' is to match the 'Next' button on the New DM page (which doesn't have a fixed width)
|
|
clearAllButton.widthAnchor.constraint(equalToConstant: 182),
|
|
clearAllButton.heightAnchor.constraint(equalToConstant: NewConversationButtonSet.collapsedButtonSize)
|
|
])
|
|
}
|
|
|
|
// MARK: - UITableViewDataSource
|
|
|
|
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
|
return Int(messageRequestCount)
|
|
}
|
|
|
|
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
|
let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell
|
|
cell.threadViewModel = threadViewModel(at: indexPath.row)
|
|
return cell
|
|
}
|
|
|
|
// MARK: - Updating
|
|
|
|
private func reload() {
|
|
AssertIsOnMainThread()
|
|
dbConnection.beginLongLivedReadTransaction() // Jump to the latest commit
|
|
dbConnection.read { transaction in
|
|
self.threads.update(with: transaction)
|
|
}
|
|
threadViewModelCache.removeAll()
|
|
tableView.reloadData()
|
|
clearAllButton.isHidden = (messageRequestCount == 0)
|
|
emptyStateLabel.isHidden = (messageRequestCount != 0)
|
|
}
|
|
|
|
@objc private func handleYapDatabaseModifiedNotification(_ yapDatabase: YapDatabase) {
|
|
// NOTE: This code is very finicky and crashes easily. Modify with care.
|
|
AssertIsOnMainThread()
|
|
|
|
// If we don't capture `threads` here, a race condition can occur where the
|
|
// `thread.snapshotOfLastUpdate != firstSnapshot - 1` check below evaluates to
|
|
// `false`, but `threads` then changes between that check and the
|
|
// `ext.getSectionChanges(§ionChanges, rowChanges: &rowChanges, for: notifications, with: threads)`
|
|
// line. This causes `tableView.endUpdates()` to crash with an `NSInternalInconsistencyException`.
|
|
let threads = threads!
|
|
|
|
// Create a stable state for the connection and jump to the latest commit
|
|
let notifications = dbConnection.beginLongLivedReadTransaction()
|
|
|
|
guard !notifications.isEmpty else { return }
|
|
|
|
let ext = dbConnection.ext(TSThreadDatabaseViewExtensionName) as! YapDatabaseViewConnection
|
|
let hasChanges = ext.hasChanges(forGroup: TSMessageRequestGroup, in: notifications)
|
|
|
|
guard hasChanges else { return }
|
|
|
|
if let firstChangeSet = notifications[0].userInfo {
|
|
let firstSnapshot = firstChangeSet[YapDatabaseSnapshotKey] as! UInt64
|
|
|
|
if threads.snapshotOfLastUpdate != firstSnapshot - 1 {
|
|
return reload() // The code below will crash if we try to process multiple commits at once
|
|
}
|
|
}
|
|
|
|
var sectionChanges = NSArray()
|
|
var rowChanges = NSArray()
|
|
ext.getSectionChanges(§ionChanges, rowChanges: &rowChanges, for: notifications, with: threads)
|
|
|
|
guard sectionChanges.count > 0 || rowChanges.count > 0 else { return }
|
|
|
|
tableView.beginUpdates()
|
|
|
|
rowChanges.forEach { rowChange in
|
|
let rowChange = rowChange as! YapDatabaseViewRowChange
|
|
let key = rowChange.collectionKey.key
|
|
threadViewModelCache[key] = nil
|
|
switch rowChange.type {
|
|
case .delete: tableView.deleteRows(at: [ rowChange.indexPath! ], with: UITableView.RowAnimation.automatic)
|
|
case .insert: tableView.insertRows(at: [ rowChange.newIndexPath! ], with: UITableView.RowAnimation.automatic)
|
|
case .update: tableView.reloadRows(at: [ rowChange.indexPath! ], with: UITableView.RowAnimation.automatic)
|
|
default: break
|
|
}
|
|
}
|
|
tableView.endUpdates()
|
|
|
|
// HACK: Moves can have conflicts with the other 3 types of change.
|
|
// Just batch perform all the moves separately to prevent crashing.
|
|
// Since all the changes are from the original state to the final state,
|
|
// it will still be correct if we pick the moves out.
|
|
|
|
tableView.beginUpdates()
|
|
|
|
rowChanges.forEach { rowChange in
|
|
let rowChange = rowChange as! YapDatabaseViewRowChange
|
|
let key = rowChange.collectionKey.key
|
|
threadViewModelCache[key] = nil
|
|
|
|
switch rowChange.type {
|
|
case .move: tableView.moveRow(at: rowChange.indexPath!, to: rowChange.newIndexPath!)
|
|
default: break
|
|
}
|
|
}
|
|
|
|
tableView.endUpdates()
|
|
clearAllButton.isHidden = (messageRequestCount == 0)
|
|
emptyStateLabel.isHidden = (messageRequestCount != 0)
|
|
}
|
|
|
|
@objc private func handleProfileDidChangeNotification(_ notification: Notification) {
|
|
tableView.reloadData() // TODO: Just reload the affected cell
|
|
}
|
|
|
|
@objc private func handleBlockedContactsUpdatedNotification(_ notification: Notification) {
|
|
tableView.reloadData() // TODO: Just reload the affected cell
|
|
}
|
|
|
|
@objc override internal func handleAppModeChangedNotification(_ notification: Notification) {
|
|
super.handleAppModeChangedNotification(notification)
|
|
|
|
let gradient = Gradients.homeVCFade
|
|
fadeView.setGradient(gradient) // Re-do the gradient
|
|
tableView.reloadData()
|
|
}
|
|
|
|
// MARK: - UITableViewDelegate
|
|
|
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
|
tableView.deselectRow(at: indexPath, animated: true)
|
|
|
|
guard let thread = self.thread(at: indexPath.row) else { return }
|
|
|
|
let conversationVC = ConversationVC(thread: thread)
|
|
self.navigationController?.pushViewController(conversationVC, animated: true)
|
|
}
|
|
|
|
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
|
|
return true
|
|
}
|
|
|
|
func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
|
|
guard let thread = self.thread(at: indexPath.row) else { return [] }
|
|
|
|
let delete = UITableViewRowAction(style: .destructive, title: NSLocalizedString("TXT_DELETE_TITLE", comment: "")) { [weak self] _, _ in
|
|
self?.delete(thread)
|
|
}
|
|
delete.backgroundColor = Colors.destructive
|
|
|
|
return [ delete ]
|
|
}
|
|
|
|
// MARK: - Interaction
|
|
|
|
@objc private func clearAllTapped() {
|
|
let threadCount: Int = Int(messageRequestCount)
|
|
let threads: [TSThread] = (0..<threadCount).compactMap { self.thread(at: $0) }
|
|
var needsSync: Bool = false
|
|
|
|
let alertVC: UIAlertController = UIAlertController(title: NSLocalizedString("MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE", comment: ""), message: nil, preferredStyle: .actionSheet)
|
|
alertVC.addAction(UIAlertAction(title: NSLocalizedString("MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON", comment: ""), style: .destructive) { _ in
|
|
// Clear the requests
|
|
|
|
GRDBStorage.shared.writeAsync(
|
|
updates: { db in
|
|
threads.forEach { thread in
|
|
guard
|
|
let sessionId: String = (thread as? TSContactThread)?.contactSessionID(),
|
|
let contact: Contact = try? Contact.fetchOrCreate(db, id: sessionId),
|
|
!contact.isBlocked
|
|
else { return }
|
|
|
|
try? contact
|
|
.with(
|
|
isApproved: false,
|
|
isBlocked: true
|
|
)
|
|
.save(db)
|
|
|
|
needsSync = true
|
|
}
|
|
},
|
|
completion: { db, _ in
|
|
Storage.write(
|
|
with: { transaction in
|
|
threads.forEach { thread in
|
|
if let uniqueId: String = thread.uniqueId {
|
|
Storage.shared.cancelPendingMessageSendJobs(for: uniqueId, using: transaction)
|
|
}
|
|
|
|
// Delete all thread content
|
|
thread.removeAllThreadInteractions(with: transaction)
|
|
thread.remove(with: transaction)
|
|
}
|
|
},
|
|
completion: {
|
|
// Force a config sync
|
|
if needsSync {
|
|
// TODO: This might crash due to a "wrong thread" error
|
|
MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
|
|
}
|
|
}
|
|
)
|
|
}
|
|
)
|
|
})
|
|
alertVC.addAction(UIAlertAction(title: NSLocalizedString("TXT_CANCEL_TITLE", comment: ""), style: .cancel, handler: nil))
|
|
self.present(alertVC, animated: true, completion: nil)
|
|
}
|
|
|
|
private func delete(_ thread: TSThread) {
|
|
guard let uniqueId: String = thread.uniqueId else { return }
|
|
|
|
let alertVC: UIAlertController = UIAlertController(title: NSLocalizedString("MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON", comment: ""), message: nil, preferredStyle: .actionSheet)
|
|
alertVC.addAction(UIAlertAction(title: NSLocalizedString("TXT_DELETE_TITLE", comment: ""), style: .destructive) { _ in
|
|
GRDBStorage.shared.writeAsync(
|
|
updates: { db in
|
|
guard
|
|
let sessionId: String = (thread as? TSContactThread)?.contactSessionID(),
|
|
let contact: Contact = try? Contact.fetchOrCreate(db, id: sessionId),
|
|
!contact.isBlocked
|
|
else { return }
|
|
|
|
try? contact
|
|
.with(
|
|
isApproved: false,
|
|
isBlocked: true
|
|
)
|
|
.save(db)
|
|
},
|
|
completion: { db, _ in
|
|
Storage.write(
|
|
with: { transaction in
|
|
Storage.shared.cancelPendingMessageSendJobs(for: uniqueId, using: transaction)
|
|
|
|
// Delete all thread content
|
|
thread.removeAllThreadInteractions(with: transaction)
|
|
thread.remove(with: transaction)
|
|
},
|
|
completion: {
|
|
// Force a config sync
|
|
// TODO: This might crash due to a "wrong thread" error
|
|
MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
|
|
}
|
|
)
|
|
}
|
|
)
|
|
})
|
|
alertVC.addAction(UIAlertAction(title: NSLocalizedString("TXT_CANCEL_TITLE", comment: ""), style: .cancel, handler: nil))
|
|
self.present(alertVC, animated: true, completion: nil)
|
|
}
|
|
|
|
// MARK: - Convenience
|
|
|
|
private func thread(at index: Int) -> TSThread? {
|
|
var thread: TSThread? = nil
|
|
|
|
dbConnection.read { transaction in
|
|
let ext: YapDatabaseViewTransaction? = transaction.ext(TSThreadDatabaseViewExtensionName) as? YapDatabaseViewTransaction
|
|
thread = ext?.object(atRow: UInt(index), inSection: 0, with: self.threads) as? TSThread
|
|
}
|
|
|
|
return thread
|
|
}
|
|
|
|
private func threadViewModel(at index: Int) -> ThreadViewModel? {
|
|
guard let thread = thread(at: index), let uniqueId: String = thread.uniqueId else { return nil }
|
|
|
|
if let cachedThreadViewModel = threadViewModelCache[uniqueId] {
|
|
return cachedThreadViewModel
|
|
}
|
|
else {
|
|
var threadViewModel: ThreadViewModel? = nil
|
|
dbConnection.read { transaction in
|
|
threadViewModel = ThreadViewModel(thread: thread, transaction: transaction)
|
|
}
|
|
threadViewModelCache[uniqueId] = threadViewModel
|
|
|
|
return threadViewModel
|
|
}
|
|
}
|
|
}
|