ConversationPicker shared checkmark asset with media editor toolbar. The checkmark was a little bit too small, also checked and unchecked indicators had different size. This commit updates ConversationPicker to use the same checkmark styling that group UI has.
886 lines
34 KiB
Swift
886 lines
34 KiB
Swift
//
|
|
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import SignalMessaging
|
|
import UIKit
|
|
|
|
public protocol ConversationPickerDelegate: AnyObject {
|
|
func conversationPickerSelectionDidChange(_ conversationPickerViewController: ConversationPickerViewController)
|
|
|
|
func conversationPickerDidCompleteSelection(_ conversationPickerViewController: ConversationPickerViewController)
|
|
|
|
func conversationPickerCanCancel(_ conversationPickerViewController: ConversationPickerViewController) -> Bool
|
|
|
|
func conversationPickerDidCancel(_ conversationPickerViewController: ConversationPickerViewController)
|
|
|
|
func approvalMode(_ conversationPickerViewController: ConversationPickerViewController) -> ApprovalMode
|
|
|
|
func conversationPickerDidBeginEditingText()
|
|
|
|
func conversationPickerSearchBarActiveDidChange(_ conversationPickerViewController: ConversationPickerViewController)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
@objc
|
|
open class ConversationPickerViewController: OWSTableViewController2 {
|
|
|
|
public weak var pickerDelegate: ConversationPickerDelegate?
|
|
|
|
private let kMaxPickerSelection = 5
|
|
|
|
public let selection: ConversationPickerSelection
|
|
|
|
private let footerView = ApprovalFooterView()
|
|
|
|
fileprivate lazy var searchBar: OWSSearchBar = {
|
|
let searchBar = OWSSearchBar()
|
|
searchBar.placeholder = CommonStrings.searchPlaceholder
|
|
searchBar.delegate = self
|
|
return searchBar
|
|
}()
|
|
|
|
private let searchBarWrapper: UIStackView = {
|
|
let searchBarWrapper = UIStackView()
|
|
searchBarWrapper.axis = .vertical
|
|
searchBarWrapper.alignment = .fill
|
|
return searchBarWrapper
|
|
}()
|
|
|
|
public var textInput: String? {
|
|
footerView.textInput
|
|
}
|
|
|
|
private var conversationCollection: ConversationCollection = .empty {
|
|
didSet {
|
|
updateTableContents()
|
|
}
|
|
}
|
|
|
|
public var approvalTextMode: ApprovalFooterView.ApprovalTextMode {
|
|
get { footerView.approvalTextMode }
|
|
set { footerView.approvalTextMode = newValue }
|
|
}
|
|
|
|
public init(selection: ConversationPickerSelection) {
|
|
self.selection = selection
|
|
|
|
super.init()
|
|
|
|
self.selectionBehavior = .toggleSelectionWithAction
|
|
self.shouldAvoidKeyboard = true
|
|
searchBarWrapper.addArrangedSubview(searchBar)
|
|
self.topHeader = searchBarWrapper
|
|
self.bottomFooter = footerView
|
|
selection.delegate = self
|
|
}
|
|
|
|
private var approvalMode: ApprovalMode {
|
|
pickerDelegate?.approvalMode(self) ?? .send
|
|
}
|
|
|
|
public func updateApprovalMode() { footerView.updateContents() }
|
|
|
|
public var shouldShowSearchBar: Bool = true {
|
|
didSet {
|
|
if isViewLoaded {
|
|
ensureSearchBarVisibility()
|
|
}
|
|
}
|
|
}
|
|
|
|
public var shouldHideRecentConversationsTitle = false {
|
|
didSet {
|
|
if isViewLoaded {
|
|
updateTableContents()
|
|
}
|
|
}
|
|
}
|
|
|
|
public var shouldHideSearchBarIfCancelled = false
|
|
|
|
private func ensureSearchBarVisibility() {
|
|
AssertIsOnMainThread()
|
|
|
|
searchBar.isHidden = !shouldShowSearchBar
|
|
}
|
|
|
|
public func selectSearchBar() {
|
|
AssertIsOnMainThread()
|
|
|
|
shouldShowSearchBar = true
|
|
searchBar.becomeFirstResponder()
|
|
}
|
|
|
|
open override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
if pickerDelegate?.conversationPickerCanCancel(self) ?? false {
|
|
let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(onTouchCancelButton))
|
|
self.navigationItem.leftBarButtonItem = cancelButton
|
|
}
|
|
|
|
ensureSearchBarVisibility()
|
|
|
|
title = Strings.title
|
|
|
|
tableView.allowsMultipleSelection = true
|
|
tableView.register(ConversationPickerCell.self, forCellReuseIdentifier: ConversationPickerCell.reuseIdentifier)
|
|
|
|
footerView.delegate = self
|
|
|
|
conversationCollection = buildConversationCollection()
|
|
|
|
NotificationCenter.default.addObserver(self,
|
|
selector: #selector(blockListDidChange),
|
|
name: BlockingManager.blockListDidChange,
|
|
object: nil)
|
|
}
|
|
|
|
open override func applyTheme() {
|
|
super.applyTheme()
|
|
|
|
searchBar.searchFieldBackgroundColorOverride = Theme.searchFieldElevatedBackgroundColor
|
|
}
|
|
|
|
open override func viewSafeAreaInsetsDidChange() {
|
|
super.viewSafeAreaInsetsDidChange()
|
|
|
|
searchBar.layoutMargins = cellOuterInsets
|
|
}
|
|
|
|
// MARK: - ConversationCollection
|
|
|
|
private func restoreSelection() {
|
|
AssertIsOnMainThread()
|
|
|
|
tableView.indexPathsForSelectedRows?.forEach { tableView.deselectRow(at: $0, animated: false) }
|
|
|
|
for selectedConversation in selection.conversations {
|
|
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)
|
|
}
|
|
|
|
func buildSearchResults(searchText: String) -> Promise<ComposeScreenSearchResultSet?> {
|
|
guard searchText.count > 1 else {
|
|
return Promise.value(nil)
|
|
}
|
|
|
|
return firstly(on: .global()) {
|
|
Self.databaseStorage.read { transaction in
|
|
self.fullTextSearcher.searchForComposeScreen(searchText: searchText,
|
|
omitLocalUser: false,
|
|
transaction: transaction)
|
|
}
|
|
}
|
|
}
|
|
|
|
func buildGroupItem(_ groupThread: TSGroupThread, transaction: SDSAnyReadTransaction) -> GroupConversationItem {
|
|
let isBlocked = self.blockingManager.isThreadBlocked(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.blockingManager.isAddressBlocked(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)
|
|
}
|
|
|
|
fileprivate func buildConversationCollection() -> ConversationCollection {
|
|
self.databaseStorage.read { 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.canSendChatMessagesToThread(ignoreAnnouncementOnly: true) 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,
|
|
isSearchResults: false)
|
|
}
|
|
}
|
|
|
|
fileprivate func buildConversationCollection(searchResults: ComposeScreenSearchResultSet?) -> Promise<ConversationCollection> {
|
|
guard let searchResults = searchResults else {
|
|
return Promise.value(buildConversationCollection())
|
|
}
|
|
|
|
return firstly(on: .global()) {
|
|
Self.databaseStorage.read { transaction in
|
|
let groupItems = searchResults.groupThreads.compactMap { groupThread -> GroupConversationItem? in
|
|
guard groupThread.canSendChatMessagesToThread(ignoreAnnouncementOnly: true) 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,
|
|
isSearchResults: true)
|
|
}
|
|
}
|
|
}
|
|
|
|
public func conversation(for indexPath: IndexPath) -> ConversationItem? {
|
|
conversationCollection.conversation(for: indexPath)
|
|
}
|
|
|
|
public func conversation(for thread: TSThread) -> ConversationItem? {
|
|
conversationCollection.conversation(for: thread)
|
|
}
|
|
|
|
// MARK: - Button Actions
|
|
|
|
@objc func onTouchCancelButton() {
|
|
pickerDelegate?.conversationPickerDidCancel(self)
|
|
}
|
|
|
|
@objc
|
|
private func blockListDidChange(_ notification: NSNotification) {
|
|
AssertIsOnMainThread()
|
|
|
|
self.conversationCollection = buildConversationCollection()
|
|
}
|
|
|
|
private func updateTableContents() {
|
|
AssertIsOnMainThread()
|
|
|
|
self.defaultSeparatorInsetLeading = (OWSTableViewController2.cellHInnerMargin +
|
|
CGFloat(ContactCellView.avatarSizeClass.diameter) +
|
|
ContactCellView.avatarTextHSpacing)
|
|
|
|
let conversationCollection = self.conversationCollection
|
|
|
|
let contents = OWSTableContents()
|
|
|
|
var hasContents = false
|
|
|
|
// Recents Section
|
|
do {
|
|
let section = OWSTableSection()
|
|
if !conversationCollection.recentConversations.isEmpty {
|
|
if !shouldHideRecentConversationsTitle {
|
|
section.headerTitle = Strings.recentsSection
|
|
}
|
|
addConversations(toSection: section,
|
|
conversations: conversationCollection.recentConversations)
|
|
hasContents = true
|
|
}
|
|
contents.addSection(section)
|
|
}
|
|
|
|
// Contacts Section
|
|
do {
|
|
let section = OWSTableSection()
|
|
if !conversationCollection.contactConversations.isEmpty {
|
|
section.headerTitle = Strings.signalContactsSection
|
|
addConversations(toSection: section,
|
|
conversations: conversationCollection.contactConversations)
|
|
hasContents = true
|
|
}
|
|
contents.addSection(section)
|
|
}
|
|
|
|
// Groups Section
|
|
do {
|
|
let section = OWSTableSection()
|
|
if !conversationCollection.groupConversations.isEmpty {
|
|
section.headerTitle = Strings.groupsSection
|
|
addConversations(toSection: section,
|
|
conversations: conversationCollection.groupConversations)
|
|
hasContents = true
|
|
}
|
|
contents.addSection(section)
|
|
}
|
|
|
|
// "No matches" Section
|
|
if conversationCollection.isSearchResults,
|
|
!hasContents {
|
|
let section = OWSTableSection()
|
|
section.add(.label(withText: OWSLocalizedString("CONVERSATION_SEARCH_NO_RESULTS",
|
|
comment: "keyboard toolbar label when no messages match the search string")))
|
|
contents.addSection(section)
|
|
}
|
|
|
|
self.contents = contents
|
|
restoreSelection()
|
|
}
|
|
|
|
private func addConversations(toSection section: OWSTableSection,
|
|
conversations: [ConversationItem]) {
|
|
for conversation in conversations {
|
|
section.add(OWSTableItem(dequeueCellBlock: { tableView in
|
|
guard let cell = tableView.dequeueReusableCell(withIdentifier: ConversationPickerCell.reuseIdentifier) as? ConversationPickerCell else {
|
|
owsFailDebug("Missing cell.")
|
|
return UITableViewCell()
|
|
}
|
|
Self.databaseStorage.read { transaction in
|
|
cell.configure(conversationItem: conversation, transaction: transaction)
|
|
}
|
|
return cell
|
|
},
|
|
actionBlock: { [weak self] in
|
|
self?.didToggleSection(conversation: conversation)
|
|
}))
|
|
}
|
|
}
|
|
|
|
public override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
|
|
guard let indexPath = super.tableView(tableView, willSelectRowAt: indexPath) else {
|
|
return nil
|
|
}
|
|
|
|
guard selection.conversations.count < kMaxPickerSelection else {
|
|
showTooManySelectedToast()
|
|
return nil
|
|
}
|
|
|
|
guard let conversation = conversation(for: indexPath) else {
|
|
owsFailDebug("item was unexpectedly nil")
|
|
return nil
|
|
}
|
|
|
|
guard !conversation.isBlocked else {
|
|
showUnblockUI(conversation: conversation)
|
|
return nil
|
|
}
|
|
|
|
return indexPath
|
|
}
|
|
|
|
private func showUnblockUI(conversation: ConversationItem) {
|
|
switch conversation.messageRecipient {
|
|
case .contact(let address):
|
|
BlockListUIUtils.showUnblockAddressActionSheet(address,
|
|
from: self) { isStillBlocked in
|
|
AssertIsOnMainThread()
|
|
|
|
guard !isStillBlocked else {
|
|
return
|
|
}
|
|
|
|
self.conversationCollection = self.buildConversationCollection()
|
|
}
|
|
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
|
|
}
|
|
BlockListUIUtils.showUnblockThreadActionSheet(groupThread,
|
|
from: self) { isStillBlocked in
|
|
AssertIsOnMainThread()
|
|
|
|
guard !isStillBlocked else {
|
|
return
|
|
}
|
|
|
|
self.conversationCollection = self.buildConversationCollection()
|
|
}
|
|
}
|
|
}
|
|
|
|
fileprivate func didToggleSection(conversation: ConversationItem) {
|
|
AssertIsOnMainThread()
|
|
|
|
if selection.isSelected(conversation: conversation) {
|
|
didDeselect(conversation: conversation)
|
|
} else {
|
|
didSelect(conversation: conversation)
|
|
}
|
|
}
|
|
|
|
private func didSelect(conversation: ConversationItem) {
|
|
AssertIsOnMainThread()
|
|
|
|
let isBlocked: Bool = databaseStorage.read { transaction in
|
|
guard let thread = conversation.getExistingThread(transaction: transaction) else {
|
|
return false
|
|
}
|
|
return !thread.canSendChatMessagesToThread(ignoreAnnouncementOnly: false)
|
|
}
|
|
guard !isBlocked else {
|
|
restoreSelection()
|
|
showBlockedByAnnouncementOnlyToast()
|
|
return
|
|
}
|
|
selection.add(conversation)
|
|
updateUIForCurrentSelection(animated: true)
|
|
}
|
|
|
|
private func showBlockedByAnnouncementOnlyToast() {
|
|
Logger.info("")
|
|
|
|
let toastFormat = OWSLocalizedString("CONVERSATION_PICKER_BLOCKED_BY_ANNOUNCEMENT_ONLY",
|
|
comment: "Message indicating that only administrators can send message to an announcement-only group.")
|
|
|
|
let toastText = String(format: toastFormat, NSNumber(value: kMaxPickerSelection))
|
|
showToast(message: toastText)
|
|
}
|
|
|
|
private func didDeselect(conversation: ConversationItem) {
|
|
AssertIsOnMainThread()
|
|
|
|
selection.remove(conversation)
|
|
updateUIForCurrentSelection(animated: true)
|
|
}
|
|
|
|
private func updateUIForCurrentSelection(animated: Bool) {
|
|
let conversations = selection.conversations
|
|
let labelText = conversations.map { $0.title }.joined(separator: ", ")
|
|
footerView.setNamesText(labelText, animated: animated)
|
|
footerView.proceedButton.isEnabled = !conversations.isEmpty
|
|
}
|
|
|
|
private func showTooManySelectedToast() {
|
|
Logger.info("")
|
|
|
|
let toastFormat = OWSLocalizedString("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))
|
|
showToast(message: toastText)
|
|
}
|
|
|
|
private func showToast(message: String) {
|
|
Logger.info("")
|
|
|
|
let toastController = ToastController(text: message)
|
|
|
|
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 PromiseError.cancelled
|
|
}
|
|
|
|
// Make sure the search text hasn't changed since we started searching, otherwise bail.
|
|
guard searchBar.text == searchText else { throw PromiseError.cancelled }
|
|
|
|
return self.buildConversationCollection(searchResults: searchResults)
|
|
}.done(on: .main) { [weak self] conversationCollection in
|
|
guard let self = self else { return }
|
|
|
|
self.conversationCollection = conversationCollection
|
|
}.catch { error in
|
|
if let error = error as? PromiseError, error == .cancelled { return }
|
|
owsFailDebug("Error: \(error)")
|
|
}
|
|
}
|
|
|
|
public func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
|
|
searchBar.setShowsCancelButton(true, animated: true)
|
|
pickerDelegate?.conversationPickerSearchBarActiveDidChange(self)
|
|
restoreSelection()
|
|
}
|
|
|
|
public func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
|
|
searchBar.setShowsCancelButton(false, animated: true)
|
|
pickerDelegate?.conversationPickerSearchBarActiveDidChange(self)
|
|
restoreSelection()
|
|
}
|
|
|
|
public func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
|
|
searchBar.text = nil
|
|
searchBar.resignFirstResponder()
|
|
if shouldHideSearchBarIfCancelled {
|
|
self.shouldShowSearchBar = false
|
|
}
|
|
conversationCollection = buildConversationCollection()
|
|
pickerDelegate?.conversationPickerSearchBarActiveDidChange(self)
|
|
}
|
|
|
|
public func resetSearchBarText() {
|
|
guard nil != searchBar.text?.nilIfEmpty else {
|
|
return
|
|
}
|
|
searchBar.text = nil
|
|
conversationCollection = buildConversationCollection()
|
|
}
|
|
|
|
public var isSearchBarActive: Bool {
|
|
searchBar.isFirstResponder
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ConversationPickerViewController: ApprovalFooterDelegate {
|
|
public func approvalFooterDelegateDidRequestProceed(_ approvalFooterView: ApprovalFooterView) {
|
|
guard let pickerDelegate = pickerDelegate else {
|
|
owsFailDebug("Missing delegate.")
|
|
return
|
|
}
|
|
let conversations = selection.conversations
|
|
guard conversations.count > 0 else {
|
|
Logger.warn("No conversations selected.")
|
|
return
|
|
}
|
|
pickerDelegate.conversationPickerDidCompleteSelection(self)
|
|
}
|
|
|
|
public func approvalMode(_ approvalFooterView: ApprovalFooterView) -> ApprovalMode {
|
|
return approvalMode
|
|
}
|
|
|
|
public func approvalFooterDidBeginEditingText() {
|
|
AssertIsOnMainThread()
|
|
|
|
pickerDelegate?.conversationPickerDidBeginEditingText()
|
|
shouldShowSearchBar = false
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ConversationPickerViewController {
|
|
private struct Strings {
|
|
static let title = OWSLocalizedString("CONVERSATION_PICKER_TITLE", comment: "navbar header")
|
|
static let recentsSection = OWSLocalizedString("CONVERSATION_PICKER_SECTION_RECENTS", comment: "table section header for section containing recent conversations")
|
|
static let signalContactsSection = OWSLocalizedString("CONVERSATION_PICKER_SECTION_SIGNAL_CONTACTS", comment: "table section header for section containing contacts")
|
|
static let groupsSection = OWSLocalizedString("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)
|
|
applySelection()
|
|
}
|
|
|
|
private func applySelection() {
|
|
selectedBadgeView.isHidden = !self.isSelected
|
|
unselectedBadgeView.isHidden = self.isSelected
|
|
}
|
|
|
|
// MARK: - ContactTableViewCell
|
|
|
|
public func configure(conversationItem: ConversationItem, transaction: SDSAnyReadTransaction) {
|
|
let configuration: ContactCellConfiguration
|
|
switch conversationItem.messageRecipient {
|
|
case .contact(let address):
|
|
configuration = ContactCellConfiguration(address: address, localUserDisplayMode: .noteToSelf)
|
|
case .group(let groupThreadId):
|
|
guard let groupThread = TSGroupThread.anyFetchGroupThread(
|
|
uniqueId: groupThreadId,
|
|
transaction: transaction
|
|
) else {
|
|
owsFailDebug("Failed to find group thread")
|
|
return
|
|
}
|
|
configuration = ContactCellConfiguration(groupThread: groupThread, localUserDisplayMode: .noteToSelf)
|
|
}
|
|
if conversationItem.isBlocked {
|
|
configuration.accessoryMessage = MessageStrings.conversationIsBlocked
|
|
} else {
|
|
configuration.accessoryView = buildAccessoryView(disappearingMessagesConfig: conversationItem.disappearingMessagesConfig)
|
|
}
|
|
super.configure(configuration: configuration, transaction: transaction)
|
|
|
|
// Apply theme.
|
|
unselectedBadgeView.layer.borderColor = Theme.primaryIconColor.cgColor
|
|
|
|
selectionStyle = .none
|
|
applySelection()
|
|
}
|
|
|
|
// MARK: - Subviews
|
|
|
|
let selectionBadgeSize = CGSize(square: 24)
|
|
|
|
lazy var selectionView: UIView = {
|
|
let container = UIView()
|
|
|
|
container.addSubview(unselectedBadgeView)
|
|
unselectedBadgeView.autoPinEdgesToSuperviewEdges()
|
|
|
|
container.addSubview(selectedBadgeView)
|
|
selectedBadgeView.autoPinEdgesToSuperviewEdges()
|
|
|
|
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 imageView = UIImageView(image: #imageLiteral(resourceName: "empty-circle-outline-24").withRenderingMode(.alwaysTemplate))
|
|
imageView.tintColor = .ows_gray25
|
|
return imageView
|
|
}()
|
|
|
|
lazy var selectedBadgeView: UIView = {
|
|
let imageView = UIImageView(image: #imageLiteral(resourceName: "check-circle-solid-24").withRenderingMode(.alwaysTemplate))
|
|
imageView.tintColor = Theme.accentBlueColor
|
|
return imageView
|
|
}()
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ConversationPickerViewController: ConversationPickerSelectionDelegate {
|
|
func conversationPickerSelectionDidAdd() {
|
|
AssertIsOnMainThread()
|
|
|
|
pickerDelegate?.conversationPickerSelectionDidChange(self)
|
|
|
|
// Clear the search text, if any.
|
|
resetSearchBarText()
|
|
}
|
|
|
|
func conversationPickerSelectionDidRemove() {
|
|
AssertIsOnMainThread()
|
|
|
|
pickerDelegate?.conversationPickerSelectionDidChange(self)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
protocol ConversationPickerSelectionDelegate: AnyObject {
|
|
func conversationPickerSelectionDidAdd()
|
|
func conversationPickerSelectionDidRemove()
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public class ConversationPickerSelection {
|
|
fileprivate weak var delegate: ConversationPickerSelectionDelegate?
|
|
|
|
public private(set) var conversations: [ConversationItem] = []
|
|
|
|
public required init() {}
|
|
|
|
public func add(_ conversation: ConversationItem) {
|
|
conversations.append(conversation)
|
|
delegate?.conversationPickerSelectionDidAdd()
|
|
}
|
|
|
|
public func remove(_ conversation: ConversationItem) {
|
|
conversations.removeAll { $0.messageRecipient == conversation.messageRecipient }
|
|
delegate?.conversationPickerSelectionDidRemove()
|
|
}
|
|
|
|
public func isSelected(conversation: ConversationItem) -> Bool {
|
|
!conversations.filter { $0.messageRecipient == conversation.messageRecipient }.isEmpty
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private struct ConversationCollection {
|
|
static let empty: ConversationCollection = ConversationCollection(contactConversations: [],
|
|
recentConversations: [],
|
|
groupConversations: [],
|
|
isSearchResults: false)
|
|
|
|
let contactConversations: [ConversationItem]
|
|
let recentConversations: [ConversationItem]
|
|
let groupConversations: [ConversationItem]
|
|
let isSearchResults: Bool
|
|
|
|
var allConversations: [ConversationItem] {
|
|
recentConversations + contactConversations + groupConversations
|
|
}
|
|
|
|
private func conversations(section: Section) -> [ConversationItem] {
|
|
switch section {
|
|
case .recents:
|
|
return recentConversations
|
|
case .signalContacts:
|
|
return contactConversations
|
|
case .groups:
|
|
return groupConversations
|
|
}
|
|
}
|
|
|
|
private enum Section: Int, CaseIterable {
|
|
case recents, signalContacts, groups
|
|
}
|
|
|
|
fileprivate 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
|
|
}
|
|
}
|
|
}
|
|
|
|
fileprivate func conversation(for indexPath: IndexPath) -> ConversationItem? {
|
|
guard let section = Section(rawValue: indexPath.section) else {
|
|
owsFailDebug("section was unexpectedly nil")
|
|
return nil
|
|
}
|
|
return conversations(section: section)[indexPath.row]
|
|
}
|
|
|
|
fileprivate func conversation(for thread: TSThread) -> ConversationItem? {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|