777 lines
32 KiB
Swift
777 lines
32 KiB
Swift
//
|
|
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import PromiseKit
|
|
|
|
public protocol ConversationPickerDelegate: AnyObject {
|
|
var selectedConversationsForConversationPicker: [ConversationItem] { get }
|
|
|
|
func conversationPicker(_ conversationPickerViewController: ConversationPickerViewController,
|
|
didSelectConversation conversation: ConversationItem)
|
|
|
|
func conversationPicker(_ conversationPickerViewController: ConversationPickerViewController,
|
|
didDeselectConversation conversation: ConversationItem)
|
|
|
|
func conversationPickerDidCompleteSelection(_ conversationPickerViewController: ConversationPickerViewController)
|
|
|
|
func conversationPickerCanCancel(_ conversationPickerViewController: ConversationPickerViewController) -> Bool
|
|
|
|
func conversationPickerDidCancel(_ conversationPickerViewController: ConversationPickerViewController)
|
|
|
|
func approvalMode(_ conversationPickerViewController: ConversationPickerViewController) -> ApprovalMode
|
|
}
|
|
|
|
@objc
|
|
open class ConversationPickerViewController: OWSViewController {
|
|
|
|
public weak var delegate: ConversationPickerDelegate?
|
|
|
|
enum Section: Int, CaseIterable {
|
|
case recents, signalContacts, groups
|
|
}
|
|
|
|
let kMaxPickerSelection = 5
|
|
|
|
private let tableView = UITableView()
|
|
private let footerView = ApprovalFooterView()
|
|
private var bottomConstraint: NSLayoutConstraint?
|
|
|
|
private lazy var searchBar: OWSSearchBar = {
|
|
let searchBar = OWSSearchBar()
|
|
searchBar.placeholder = CommonStrings.searchPlaceholder
|
|
searchBar.delegate = self
|
|
return searchBar
|
|
}()
|
|
|
|
// MARK: - UIViewController
|
|
|
|
private lazy var inputAccessoryPlaceholder: InputAccessoryViewPlaceholder = {
|
|
let placeholder = InputAccessoryViewPlaceholder()
|
|
placeholder.delegate = self
|
|
placeholder.referenceView = view
|
|
return placeholder
|
|
}()
|
|
|
|
public override var canBecomeFirstResponder: Bool {
|
|
return true
|
|
}
|
|
|
|
public override var inputAccessoryView: UIView? {
|
|
return inputAccessoryPlaceholder
|
|
}
|
|
|
|
private var approvalMode: ApprovalMode {
|
|
guard let delegate = delegate else {
|
|
return .send
|
|
}
|
|
return delegate.approvalMode(self)
|
|
}
|
|
|
|
public func updateApprovalMode() { footerView.updateContents() }
|
|
|
|
public override func loadView() {
|
|
self.view = UIView()
|
|
view.backgroundColor = Theme.backgroundColor
|
|
view.addSubview(tableView)
|
|
tableView.separatorColor = Theme.cellSeparatorColor
|
|
tableView.backgroundColor = Theme.backgroundColor
|
|
tableView.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .bottom)
|
|
self.autoPinView(toBottomOfViewControllerOrKeyboard: tableView, avoidNotch: true)
|
|
|
|
searchBar.sizeToFit()
|
|
tableView.tableHeaderView = searchBar
|
|
|
|
if delegate?.conversationPickerCanCancel(self) ?? false {
|
|
let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(onTouchCancelButton))
|
|
self.navigationItem.leftBarButtonItem = cancelButton
|
|
}
|
|
|
|
view.addSubview(footerView)
|
|
footerView.autoPinWidthToSuperview()
|
|
bottomConstraint = footerView.autoPinEdge(toSuperviewEdge: .bottom)
|
|
}
|
|
|
|
public override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
title = Strings.title
|
|
blockListCache.startObservingAndSyncState(delegate: self)
|
|
tableView.dataSource = self
|
|
tableView.delegate = self
|
|
tableView.allowsMultipleSelection = true
|
|
tableView.register(ConversationPickerCell.self, forCellReuseIdentifier: ConversationPickerCell.reuseIdentifier)
|
|
tableView.register(DarkThemeTableSectionHeader.self, forHeaderFooterViewReuseIdentifier: DarkThemeTableSectionHeader.reuseIdentifier)
|
|
|
|
footerView.delegate = self
|
|
|
|
conversationCollection = buildConversationCollection()
|
|
restoreSelection(tableView: tableView)
|
|
}
|
|
|
|
// MARK: - ConversationCollection
|
|
|
|
func restoreSelection(tableView: UITableView) {
|
|
guard let delegate = delegate else { return }
|
|
|
|
tableView.indexPathsForSelectedRows?.forEach { tableView.deselectRow(at: $0, animated: false) }
|
|
|
|
for selectedConversation in delegate.selectedConversationsForConversationPicker {
|
|
guard let index = conversationCollection.indexPath(conversation: selectedConversation) else {
|
|
// This can happen when restoring selection while the currently displayed results
|
|
// are filtered.
|
|
continue
|
|
}
|
|
tableView.selectRow(at: index, animated: false, scrollPosition: .none)
|
|
}
|
|
updateUIForCurrentSelection(animated: false)
|
|
}
|
|
|
|
let blockListCache = BlockListCache()
|
|
|
|
func buildSearchResults(searchText: String) -> Promise<ComposeScreenSearchResultSet?> {
|
|
guard searchText.count > 1 else {
|
|
return Promise.value(nil)
|
|
}
|
|
|
|
return DispatchQueue.global().async(.promise) {
|
|
return self.databaseStorage.read { transaction in
|
|
return self.fullTextSearcher.searchForComposeScreen(searchText: searchText,
|
|
omitLocalUser: false,
|
|
transaction: transaction)
|
|
}
|
|
}
|
|
}
|
|
|
|
func buildGroupItem(_ groupThread: TSGroupThread, transaction: SDSAnyReadTransaction) -> GroupConversationItem {
|
|
let isBlocked = self.blockListCache.isBlocked(thread: groupThread)
|
|
let dmConfig = groupThread.disappearingMessagesConfiguration(with: transaction)
|
|
return GroupConversationItem(groupThreadId: groupThread.uniqueId,
|
|
isBlocked: isBlocked,
|
|
disappearingMessagesConfig: dmConfig)
|
|
}
|
|
|
|
func buildContactItem(_ address: SignalServiceAddress, transaction: SDSAnyReadTransaction) -> ContactConversationItem {
|
|
let isBlocked = self.blockListCache.isBlocked(address: address)
|
|
let dmConfig = TSContactThread.getWithContactAddress(address, transaction: transaction)?.disappearingMessagesConfiguration(with: transaction)
|
|
|
|
let contactName = contactsManager.displayName(for: address,
|
|
transaction: transaction)
|
|
|
|
let comparableName = contactsManager.comparableName(for: address,
|
|
transaction: transaction)
|
|
|
|
return ContactConversationItem(address: address,
|
|
isBlocked: isBlocked,
|
|
disappearingMessagesConfig: dmConfig,
|
|
contactName: contactName,
|
|
comparableName: comparableName)
|
|
}
|
|
|
|
func buildConversationCollection() -> ConversationCollection {
|
|
return self.databaseStorage.uiRead { transaction in
|
|
var pinnedItemsByThreadId: [String: RecentConversationItem] = [:]
|
|
var recentItems: [RecentConversationItem] = []
|
|
var contactItems: [ContactConversationItem] = []
|
|
var groupItems: [GroupConversationItem] = []
|
|
var seenAddresses: Set<SignalServiceAddress> = Set()
|
|
|
|
let pinnedThreadIds = PinnedThreadManager.pinnedThreadIds
|
|
|
|
// We append any pinned threads at the start of the "recent"
|
|
// section, so we decrease our maximum recent items based
|
|
// on how many threads are currently pinned.
|
|
let maxRecentCount = 25 - pinnedThreadIds.count
|
|
|
|
let addThread = { (thread: TSThread) -> Void in
|
|
guard thread.canSendToThread else {
|
|
return
|
|
}
|
|
|
|
switch thread {
|
|
case let contactThread as TSContactThread:
|
|
let item = self.buildContactItem(contactThread.contactAddress, transaction: transaction)
|
|
seenAddresses.insert(contactThread.contactAddress)
|
|
if pinnedThreadIds.contains(thread.uniqueId) {
|
|
let recentItem = RecentConversationItem(backingItem: .contact(item))
|
|
pinnedItemsByThreadId[thread.uniqueId] = recentItem
|
|
} else if recentItems.count < maxRecentCount {
|
|
let recentItem = RecentConversationItem(backingItem: .contact(item))
|
|
recentItems.append(recentItem)
|
|
} else {
|
|
contactItems.append(item)
|
|
}
|
|
case let groupThread as TSGroupThread:
|
|
guard groupThread.isLocalUserFullMember else {
|
|
return
|
|
}
|
|
let item = self.buildGroupItem(groupThread, transaction: transaction)
|
|
if pinnedThreadIds.contains(thread.uniqueId) {
|
|
let recentItem = RecentConversationItem(backingItem: .group(item))
|
|
pinnedItemsByThreadId[thread.uniqueId] = recentItem
|
|
} else if recentItems.count < maxRecentCount {
|
|
let recentItem = RecentConversationItem(backingItem: .group(item))
|
|
recentItems.append(recentItem)
|
|
} else {
|
|
groupItems.append(item)
|
|
}
|
|
default:
|
|
owsFailDebug("unexpected thread: \(thread.uniqueId)")
|
|
}
|
|
}
|
|
|
|
try! AnyThreadFinder().enumerateVisibleThreads(isArchived: false, transaction: transaction) { thread in
|
|
addThread(thread)
|
|
}
|
|
|
|
try! AnyThreadFinder().enumerateVisibleThreads(isArchived: true, transaction: transaction) { thread in
|
|
addThread(thread)
|
|
}
|
|
|
|
SignalAccount.anyEnumerate(transaction: transaction) { signalAccount, _ in
|
|
let address = signalAccount.recipientAddress
|
|
guard !seenAddresses.contains(address) else {
|
|
return
|
|
}
|
|
seenAddresses.insert(address)
|
|
|
|
let contactItem = self.buildContactItem(address, transaction: transaction)
|
|
contactItems.append(contactItem)
|
|
}
|
|
contactItems.sort()
|
|
|
|
let pinnedItems = pinnedItemsByThreadId.sorted { lhs, rhs in
|
|
guard let lhsIndex = pinnedThreadIds.firstIndex(of: lhs.key),
|
|
let rhsIndex = pinnedThreadIds.firstIndex(of: rhs.key) else {
|
|
owsFailDebug("Unexpectedly have pinned item without pinned thread id")
|
|
return false
|
|
}
|
|
|
|
return lhsIndex < rhsIndex
|
|
}.map { $0.value }
|
|
|
|
return ConversationCollection(contactConversations: contactItems,
|
|
recentConversations: pinnedItems + recentItems,
|
|
groupConversations: groupItems)
|
|
}
|
|
}
|
|
|
|
func buildConversationCollection(searchResults: ComposeScreenSearchResultSet?) -> Promise<ConversationCollection> {
|
|
guard let searchResults = searchResults else {
|
|
return Promise.value(buildConversationCollection())
|
|
}
|
|
|
|
return DispatchQueue.global().async(.promise) {
|
|
return self.databaseStorage.read { transaction in
|
|
let groupItems = searchResults.groupThreads.compactMap { groupThread -> GroupConversationItem? in
|
|
guard groupThread.canSendToThread else {
|
|
return nil
|
|
}
|
|
return self.buildGroupItem(groupThread, transaction: transaction)
|
|
}
|
|
let contactItems = searchResults.signalAccounts.map { self.buildContactItem($0.recipientAddress, transaction: transaction) }
|
|
|
|
return ConversationCollection(contactConversations: contactItems,
|
|
recentConversations: [],
|
|
groupConversations: groupItems)
|
|
}
|
|
}
|
|
}
|
|
|
|
func conversation(for indexPath: IndexPath) -> ConversationItem? {
|
|
guard let section = Section(rawValue: indexPath.section) else {
|
|
owsFailDebug("section was unexpectedly nil")
|
|
return nil
|
|
}
|
|
|
|
return conversationCollection.conversations(section: section)[indexPath.row]
|
|
}
|
|
|
|
public func conversation(for thread: TSThread) -> ConversationItem? {
|
|
return conversationCollection.allConversations.first { item in
|
|
if let thread = thread as? TSGroupThread, case .group(let otherThreadId) = item.messageRecipient {
|
|
return thread.uniqueId == otherThreadId
|
|
} else if let thread = thread as? TSContactThread, case .contact(let otherAddress) = item.messageRecipient {
|
|
return thread.contactAddress == otherAddress
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
var conversationCollection: ConversationCollection = .empty
|
|
|
|
struct ConversationCollection {
|
|
static let empty: ConversationCollection = ConversationCollection(contactConversations: [],
|
|
recentConversations: [],
|
|
groupConversations: [])
|
|
let contactConversations: [ConversationItem]
|
|
let recentConversations: [ConversationItem]
|
|
let groupConversations: [ConversationItem]
|
|
|
|
var allConversations: [ConversationItem] {
|
|
recentConversations + contactConversations + groupConversations
|
|
}
|
|
|
|
func conversations(section: Section) -> [ConversationItem] {
|
|
switch section {
|
|
case .recents:
|
|
return recentConversations
|
|
case .signalContacts:
|
|
return contactConversations
|
|
case .groups:
|
|
return groupConversations
|
|
}
|
|
}
|
|
|
|
func indexPath(conversation: ConversationItem) -> IndexPath? {
|
|
switch conversation.messageRecipient {
|
|
case .contact:
|
|
if let row = (recentConversations.map { $0.messageRecipient }).firstIndex(of: conversation.messageRecipient) {
|
|
return IndexPath(row: row, section: Section.recents.rawValue)
|
|
} else if let row = (contactConversations.map { $0.messageRecipient }).firstIndex(of: conversation.messageRecipient) {
|
|
return IndexPath(row: row, section: Section.signalContacts.rawValue)
|
|
} else {
|
|
return nil
|
|
}
|
|
case .group:
|
|
if let row = (recentConversations.map { $0.messageRecipient }).firstIndex(of: conversation.messageRecipient) {
|
|
return IndexPath(row: row, section: Section.recents.rawValue)
|
|
} else if let row = (groupConversations.map { $0.messageRecipient }).firstIndex(of: conversation.messageRecipient) {
|
|
return IndexPath(row: row, section: Section.groups.rawValue)
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Button Actions
|
|
|
|
@objc func onTouchCancelButton() {
|
|
delegate?.conversationPickerDidCancel(self)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ConversationPickerViewController: BlockListCacheDelegate {
|
|
public func blockListCacheDidUpdate(_ blocklistCache: BlockListCache) {
|
|
Logger.debug("")
|
|
self.conversationCollection = buildConversationCollection()
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ConversationPickerViewController: UITableViewDataSource {
|
|
public func numberOfSections(in tableView: UITableView) -> Int {
|
|
return Section.allCases.count
|
|
}
|
|
|
|
public func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
|
guard !Theme.isDarkThemeEnabled else {
|
|
// we build a custom header for dark theme
|
|
return nil
|
|
}
|
|
|
|
return titleForHeader(inSection: section)
|
|
}
|
|
|
|
func titleForHeader(inSection section: Int) -> String? {
|
|
guard let section = Section(rawValue: section) else {
|
|
owsFailDebug("section was unexpectedly nil")
|
|
return nil
|
|
}
|
|
|
|
guard conversationCollection.conversations(section: section).count > 0 else {
|
|
return nil
|
|
}
|
|
|
|
switch section {
|
|
case .recents:
|
|
return Strings.recentsSection
|
|
case .signalContacts:
|
|
return Strings.signalContactsSection
|
|
case .groups:
|
|
return Strings.groupsSection
|
|
}
|
|
}
|
|
|
|
public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
|
guard let section = Section(rawValue: section) else {
|
|
owsFailDebug("section was unexpectedly nil")
|
|
return 0
|
|
}
|
|
|
|
return conversationCollection.conversations(section: section).count
|
|
}
|
|
|
|
public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
|
guard let conversationItem = conversation(for: indexPath) else {
|
|
owsFail("conversation was unexpectedly nil")
|
|
}
|
|
|
|
guard let cell = tableView.dequeueReusableCell(withIdentifier: ConversationPickerCell.reuseIdentifier,
|
|
for: indexPath) as? ConversationPickerCell else {
|
|
owsFail("cell was unexpectedly nil for indexPath: \(indexPath)")
|
|
}
|
|
|
|
databaseStorage.uiRead { transaction in
|
|
cell.configure(conversationItem: conversationItem, transaction: transaction)
|
|
}
|
|
|
|
return cell
|
|
}
|
|
|
|
public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
|
guard Theme.isDarkThemeEnabled else {
|
|
return nil
|
|
}
|
|
|
|
guard let title = titleForHeader(inSection: section) else {
|
|
// empty sections will have no title - don't show a header.
|
|
let dummyView = UIView()
|
|
dummyView.backgroundColor = .yellow
|
|
return dummyView
|
|
}
|
|
|
|
guard let sectionHeader = tableView.dequeueReusableHeaderFooterView(withIdentifier: DarkThemeTableSectionHeader.reuseIdentifier) as? DarkThemeTableSectionHeader else {
|
|
owsFailDebug("unable to build section header for section: \(section)")
|
|
return nil
|
|
}
|
|
|
|
sectionHeader.configure(title: title)
|
|
|
|
return sectionHeader
|
|
}
|
|
|
|
public func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
|
|
guard titleForHeader(inSection: section) != nil else {
|
|
// empty sections will have no title - don't show a header.
|
|
return 0
|
|
}
|
|
|
|
return ThemeHeaderView.desiredHeight
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ConversationPickerViewController: UITableViewDelegate {
|
|
public func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
|
|
guard let delegate = delegate else { return nil }
|
|
|
|
guard let item = conversation(for: indexPath) else {
|
|
owsFailDebug("item was unexpectedly nil")
|
|
return nil
|
|
}
|
|
|
|
guard delegate.selectedConversationsForConversationPicker.count < kMaxPickerSelection else {
|
|
showTooManySelectedToast()
|
|
return nil
|
|
}
|
|
|
|
guard !item.isBlocked else {
|
|
// TODO remove these passed in dependencies.
|
|
switch item.messageRecipient {
|
|
case .contact(let address):
|
|
BlockListUIUtils.showUnblockAddressActionSheet(address,
|
|
from: self) { isStillBlocked in
|
|
AssertIsOnMainThread()
|
|
|
|
guard !isStillBlocked else {
|
|
return
|
|
}
|
|
|
|
self.conversationCollection = self.buildConversationCollection()
|
|
tableView.reloadData()
|
|
self.restoreSelection(tableView: tableView)
|
|
}
|
|
case .group(let groupThreadId):
|
|
guard let groupThread = databaseStorage.read(block: { transaction in
|
|
return TSGroupThread.anyFetchGroupThread(uniqueId: groupThreadId, transaction: transaction)
|
|
}) else {
|
|
owsFailDebug("Missing group thread for blocked thread")
|
|
return nil
|
|
}
|
|
BlockListUIUtils.showUnblockThreadActionSheet(groupThread,
|
|
from: self) { isStillBlocked in
|
|
AssertIsOnMainThread()
|
|
|
|
guard !isStillBlocked else {
|
|
return
|
|
}
|
|
|
|
self.conversationCollection = self.buildConversationCollection()
|
|
tableView.reloadData()
|
|
self.restoreSelection(tableView: tableView)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
return indexPath
|
|
}
|
|
|
|
public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
|
guard let conversation = conversation(for: indexPath) else {
|
|
owsFailDebug("conversation was unexpectedly nil")
|
|
return
|
|
}
|
|
delegate?.conversationPicker(self, didSelectConversation: conversation)
|
|
updateUIForCurrentSelection(animated: true)
|
|
}
|
|
|
|
public func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
|
|
guard let conversation = conversation(for: indexPath) else {
|
|
owsFailDebug("conversation was unexpectedly nil")
|
|
return
|
|
}
|
|
delegate?.conversationPicker(self, didDeselectConversation: conversation)
|
|
updateUIForCurrentSelection(animated: true)
|
|
}
|
|
|
|
private func updateUIForCurrentSelection(animated: Bool) {
|
|
guard let delegate = delegate else { return }
|
|
|
|
let conversations = delegate.selectedConversationsForConversationPicker
|
|
let labelText = conversations.map { $0.title }.joined(separator: ", ")
|
|
footerView.setNamesText(labelText, animated: animated)
|
|
}
|
|
|
|
private func showTooManySelectedToast() {
|
|
Logger.info("")
|
|
|
|
let toastFormat = NSLocalizedString("CONVERSATION_PICKER_CAN_SELECT_NO_MORE_CONVERSATIONS",
|
|
comment: "Momentarily shown to the user when attempting to select more conversations than is allowed. Embeds {{max number of conversations}} that can be selected.")
|
|
|
|
let toastText = String(format: toastFormat, NSNumber(value: kMaxPickerSelection))
|
|
let toastController = ToastController(text: toastText)
|
|
|
|
let bottomInset = (view.bounds.height - tableView.frame.height)
|
|
let kToastInset: CGFloat = bottomInset + 10
|
|
toastController.presentToastView(fromBottomOfView: view, inset: kToastInset)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ConversationPickerViewController: UISearchBarDelegate {
|
|
public func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
|
|
firstly {
|
|
buildSearchResults(searchText: searchText)
|
|
}.then { [weak self] searchResults -> Promise<ConversationCollection> in
|
|
guard let self = self else {
|
|
throw PMKError.cancelled
|
|
}
|
|
|
|
return self.buildConversationCollection(searchResults: searchResults)
|
|
}.done { [weak self] conversationCollection in
|
|
guard let self = self else { return }
|
|
|
|
self.conversationCollection = conversationCollection
|
|
self.tableView.reloadData()
|
|
self.restoreSelection(tableView: self.tableView)
|
|
}.catch { error in
|
|
owsFailDebug("Error: \(error)")
|
|
}
|
|
}
|
|
|
|
public func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
|
|
searchBar.setShowsCancelButton(true, animated: true)
|
|
}
|
|
|
|
public func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
|
|
searchBar.setShowsCancelButton(false, animated: true)
|
|
}
|
|
|
|
public func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
|
|
searchBar.text = nil
|
|
searchBar.resignFirstResponder()
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ConversationPickerViewController: ApprovalFooterDelegate {
|
|
public func approvalFooterDelegateDidRequestProceed(_ approvalFooterView: ApprovalFooterView) {
|
|
guard let delegate = delegate else {
|
|
owsFailDebug("Missing delegate.")
|
|
return
|
|
}
|
|
let conversations = delegate.selectedConversationsForConversationPicker
|
|
guard conversations.count > 0 else {
|
|
owsFailDebug("No conversations selected.")
|
|
return
|
|
}
|
|
delegate.conversationPickerDidCompleteSelection(self)
|
|
}
|
|
|
|
public func approvalMode(_ approvalFooterView: ApprovalFooterView) -> ApprovalMode {
|
|
return approvalMode
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ConversationPickerViewController {
|
|
private struct Strings {
|
|
static let title = NSLocalizedString("CONVERSATION_PICKER_TITLE", comment: "navbar header")
|
|
static let recentsSection = NSLocalizedString("CONVERSATION_PICKER_SECTION_RECENTS", comment: "table section header for section containing recent conversations")
|
|
static let signalContactsSection = NSLocalizedString("CONVERSATION_PICKER_SECTION_SIGNAL_CONTACTS", comment: "table section header for section containing contacts")
|
|
static let groupsSection = NSLocalizedString("CONVERSATION_PICKER_SECTION_GROUPS", comment: "table section header for section containing groups")
|
|
}
|
|
}
|
|
|
|
// MARK: - ConversationPickerCell
|
|
|
|
private class ConversationPickerCell: ContactTableViewCell {
|
|
open override class var reuseIdentifier: String { "ConversationPickerCell" }
|
|
|
|
// MARK: - UITableViewCell
|
|
|
|
override func setSelected(_ selected: Bool, animated: Bool) {
|
|
super.setSelected(selected, animated: animated)
|
|
selectedBadgeView.isHidden = !selected
|
|
unselectedBadgeView.isHidden = selected
|
|
}
|
|
|
|
// MARK: - ContactTableViewCell
|
|
|
|
public func configure(conversationItem: ConversationItem, transaction: SDSAnyReadTransaction) {
|
|
let content: ConversationContent
|
|
switch conversationItem.messageRecipient {
|
|
case .contact(let address):
|
|
content = ConversationContent.forAddress(address, transaction: transaction)
|
|
case .group(let groupThreadId):
|
|
guard let groupThread = TSGroupThread.anyFetchGroupThread(
|
|
uniqueId: groupThreadId,
|
|
transaction: transaction
|
|
) else {
|
|
owsFailDebug("Failed to find group thread")
|
|
return
|
|
}
|
|
content = ConversationContent.forThread(groupThread)
|
|
}
|
|
let configuration = ContactCellConfiguration(content: content,
|
|
localUserDisplayMode: .noteToSelf)
|
|
if conversationItem.isBlocked {
|
|
configuration.accessoryMessage = MessageStrings.conversationIsBlocked
|
|
} else {
|
|
configuration.accessoryView = buildAccessoryView(disappearingMessagesConfig: conversationItem.disappearingMessagesConfig)
|
|
}
|
|
super.configure(configuration: configuration, transaction: transaction)
|
|
|
|
selectionStyle = .none
|
|
}
|
|
|
|
// MARK: - Subviews
|
|
|
|
static let selectedBadgeImage = #imageLiteral(resourceName: "image_editor_checkmark_full").withRenderingMode(.alwaysTemplate)
|
|
|
|
let selectionBadgeSize = CGSize(square: 20)
|
|
lazy var selectionView: UIView = {
|
|
let container = UIView()
|
|
container.layoutMargins = .zero
|
|
container.autoSetDimensions(to: selectionBadgeSize)
|
|
|
|
container.addSubview(unselectedBadgeView)
|
|
unselectedBadgeView.autoPinEdgesToSuperviewMargins()
|
|
|
|
container.addSubview(selectedBadgeView)
|
|
selectedBadgeView.autoPinEdgesToSuperviewMargins()
|
|
|
|
return container
|
|
}()
|
|
|
|
func buildAccessoryView(disappearingMessagesConfig: OWSDisappearingMessagesConfiguration?) -> ContactCellAccessoryView {
|
|
|
|
selectionView.removeFromSuperview()
|
|
let selectionWrapper = ManualLayoutView.wrapSubviewUsingIOSAutoLayout(selectionView)
|
|
|
|
guard let disappearingMessagesConfig = disappearingMessagesConfig,
|
|
disappearingMessagesConfig.isEnabled else {
|
|
return ContactCellAccessoryView(accessoryView: selectionWrapper,
|
|
size: selectionBadgeSize)
|
|
}
|
|
|
|
let timerView = DisappearingTimerConfigurationView(durationSeconds: disappearingMessagesConfig.durationSeconds)
|
|
timerView.tintColor = Theme.middleGrayColor
|
|
let timerSize = CGSize(square: 44)
|
|
|
|
let stackView = ManualStackView(name: "stackView")
|
|
let stackConfig = OWSStackView.Config(axis: .horizontal,
|
|
alignment: .center,
|
|
spacing: 0,
|
|
layoutMargins: .zero)
|
|
let stackMeasurement = stackView.configure(config: stackConfig,
|
|
subviews: [timerView, selectionWrapper],
|
|
subviewInfos: [
|
|
timerSize.asManualSubviewInfo,
|
|
selectionBadgeSize.asManualSubviewInfo
|
|
])
|
|
let stackSize = stackMeasurement.measuredSize
|
|
return ContactCellAccessoryView(accessoryView: stackView, size: stackSize)
|
|
}
|
|
|
|
lazy var unselectedBadgeView: UIView = {
|
|
let circleView = CircleView()
|
|
circleView.autoSetDimensions(to: selectionBadgeSize)
|
|
circleView.layer.borderWidth = 1.0
|
|
circleView.layer.borderColor = Theme.outlineColor.cgColor
|
|
return circleView
|
|
}()
|
|
|
|
lazy var selectedBadgeView: UIView = {
|
|
let imageView = UIImageView()
|
|
imageView.autoSetDimensions(to: selectionBadgeSize)
|
|
imageView.image = ConversationPickerCell.selectedBadgeImage
|
|
imageView.tintColor = .ows_accentBlue
|
|
return imageView
|
|
}()
|
|
}
|
|
|
|
extension ConversationPickerViewController: InputAccessoryViewPlaceholderDelegate {
|
|
public func inputAccessoryPlaceholderKeyboardIsPresenting(animationDuration: TimeInterval, animationCurve: UIView.AnimationCurve) {
|
|
handleKeyboardStateChange(animationDuration: animationDuration, animationCurve: animationCurve)
|
|
}
|
|
|
|
public func inputAccessoryPlaceholderKeyboardDidPresent() {
|
|
updateFooterViewPosition()
|
|
}
|
|
|
|
public func inputAccessoryPlaceholderKeyboardIsDismissing(animationDuration: TimeInterval, animationCurve: UIView.AnimationCurve) {
|
|
handleKeyboardStateChange(animationDuration: animationDuration, animationCurve: animationCurve)
|
|
}
|
|
|
|
public func inputAccessoryPlaceholderKeyboardDidDismiss() {
|
|
updateFooterViewPosition()
|
|
}
|
|
|
|
public func inputAccessoryPlaceholderKeyboardIsDismissingInteractively() {
|
|
updateFooterViewPosition()
|
|
}
|
|
|
|
func handleKeyboardStateChange(animationDuration: TimeInterval, animationCurve: UIView.AnimationCurve) {
|
|
guard animationDuration > 0 else { return updateFooterViewPosition() }
|
|
|
|
UIView.beginAnimations("keyboardStateChange", context: nil)
|
|
UIView.setAnimationBeginsFromCurrentState(true)
|
|
UIView.setAnimationCurve(animationCurve)
|
|
UIView.setAnimationDuration(animationDuration)
|
|
updateFooterViewPosition()
|
|
UIView.commitAnimations()
|
|
}
|
|
|
|
func updateFooterViewPosition() {
|
|
bottomConstraint?.constant = -inputAccessoryPlaceholder.keyboardOverlap
|
|
|
|
// We always want to apply the new bottom bar position immediately,
|
|
// as this only happens during animations (interactive or otherwise)
|
|
view.layoutIfNeeded()
|
|
}
|
|
}
|