1593 lines
62 KiB
Swift
1593 lines
62 KiB
Swift
//
|
|
// Copyright 2019 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import AVKit
|
|
import Foundation
|
|
import LibSignalClient
|
|
public import SignalServiceKit
|
|
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: -
|
|
|
|
open class ConversationPickerViewController: OWSTableViewController2 {
|
|
|
|
public weak var pickerDelegate: ConversationPickerDelegate?
|
|
|
|
private let kMaxPickerSelection = 5
|
|
private let attachments: [SignalAttachment]?
|
|
private let textAttachment: UnsentTextAttachment?
|
|
private let maxVideoAttachmentDuration: TimeInterval?
|
|
|
|
private let creationDate = Date()
|
|
|
|
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 {
|
|
if
|
|
let firstSelectedStoryIndex = conversationCollection.storyConversations.firstIndex(where: { self.selection.isSelected(conversation: $0)}),
|
|
firstSelectedStoryIndex >= self.maxStoryConversationsToRender - 1 {
|
|
// If we've come in already having selected a story in the expanded section,
|
|
// expand right away.
|
|
self.isStorySectionExpanded = true
|
|
}
|
|
updateTableContents()
|
|
}
|
|
}
|
|
|
|
public var approvalTextMode: ApprovalFooterView.ApprovalTextMode {
|
|
get { footerView.approvalTextMode }
|
|
set { footerView.approvalTextMode = newValue }
|
|
}
|
|
|
|
/// Include attachments to display an attachment preview at the top (if configured with the `mediaPreview` section option)
|
|
public convenience init(
|
|
selection: ConversationPickerSelection,
|
|
attachments: [SignalAttachment]
|
|
) {
|
|
self.init(selection: selection, attachments: attachments, textAttachment: nil)
|
|
}
|
|
|
|
/// Include a text attachment to display an attachment preview at the top (if configured with the `mediaPreview` section option)
|
|
public convenience init(
|
|
selection: ConversationPickerSelection,
|
|
textAttacment: UnsentTextAttachment
|
|
) {
|
|
self.init(selection: selection, attachments: nil, textAttachment: textAttacment)
|
|
}
|
|
|
|
public init(
|
|
selection: ConversationPickerSelection,
|
|
attachments: [SignalAttachment]? = nil,
|
|
textAttachment: UnsentTextAttachment? = nil
|
|
) {
|
|
self.selection = selection
|
|
self.attachments = attachments
|
|
self.textAttachment = textAttachment
|
|
|
|
let maxVideoAttachmentDuration: TimeInterval? = attachments?
|
|
.lazy
|
|
.compactMap { attachment in
|
|
guard
|
|
attachment.isVideo,
|
|
let url = attachment.dataUrl
|
|
else {
|
|
return nil
|
|
}
|
|
return AVURLAsset(url: url).duration.seconds
|
|
}
|
|
.max()
|
|
|
|
self.maxVideoAttachmentDuration = maxVideoAttachmentDuration
|
|
|
|
super.init()
|
|
|
|
self.selectionBehavior = .toggleSelectionWithAction
|
|
self.shouldAvoidKeyboard = true
|
|
searchBarWrapper.addArrangedSubview(searchBar)
|
|
self.topHeader = searchBarWrapper
|
|
self.bottomFooter = footerView
|
|
selection.delegate = self
|
|
SUIEnvironment.shared.contactsViewHelperRef.addObserver(self)
|
|
}
|
|
|
|
private var approvalMode: ApprovalMode {
|
|
pickerDelegate?.approvalMode(self) ?? .send
|
|
}
|
|
|
|
public func updateApprovalMode() { footerView.updateContents() }
|
|
|
|
public var shouldShowSearchBar: Bool = true {
|
|
didSet {
|
|
if isViewLoaded {
|
|
ensureSearchBarVisibility()
|
|
}
|
|
}
|
|
}
|
|
|
|
public override var preferredNavigationBarStyle: OWSNavigationBarStyle {
|
|
return .solid
|
|
}
|
|
|
|
public struct SectionOptions: OptionSet {
|
|
public let rawValue: Int
|
|
|
|
public init(rawValue: Int) {
|
|
self.rawValue = rawValue
|
|
}
|
|
|
|
public static let mediaPreview = SectionOptions(rawValue: 1 << 0)
|
|
public static let stories = SectionOptions(rawValue: 1 << 1)
|
|
public static let recents = SectionOptions(rawValue: 1 << 2)
|
|
public static let contacts = SectionOptions(rawValue: 1 << 3)
|
|
public static let groups = SectionOptions(rawValue: 1 << 4)
|
|
|
|
public static let storiesOnly: SectionOptions = [.mediaPreview, .stories]
|
|
public static let allDestinations: SectionOptions = [.stories, .recents, .contacts, .groups]
|
|
}
|
|
|
|
public var sectionOptions: SectionOptions = [.recents, .contacts, .groups] {
|
|
didSet {
|
|
if isViewLoaded { updateTableContents() }
|
|
}
|
|
}
|
|
|
|
open nonisolated func threadFilter(_ isIncluded: TSThread) -> Bool { true }
|
|
|
|
public var maxStoryConversationsToRender = 3
|
|
public var isStorySectionExpanded = false
|
|
|
|
/// When `true`, each time the user selects an item for sending to we will fetch the identity keys for those recipients
|
|
/// and determine if there have been any safety number changes. When you continue from this screen, we will notify of
|
|
/// any safety number changes that have been identified during the batch updates. We don't do this all the time, as we
|
|
/// don't necessarily want to inject safety number changes into every flow where you select conversations (such as
|
|
/// picking members for a group).
|
|
public var shouldBatchUpdateIdentityKeys = false
|
|
|
|
public var shouldHideRecentConversationsTitle: Bool = 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 {
|
|
self.navigationItem.leftBarButtonItem = .cancelButton { [weak self] in
|
|
self?.onTouchCancelButton()
|
|
}
|
|
}
|
|
|
|
ensureSearchBarVisibility()
|
|
|
|
title = Strings.title
|
|
|
|
tableView.allowsMultipleSelection = true
|
|
tableView.register(ConversationPickerCell.self, forCellReuseIdentifier: ConversationPickerCell.reuseIdentifier)
|
|
|
|
footerView.delegate = self
|
|
|
|
conversationCollection = buildConversationCollection(sectionOptions: sectionOptions)
|
|
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(blockListDidChange),
|
|
name: BlockingManager.blockListDidChange,
|
|
object: nil
|
|
)
|
|
|
|
DispatchQueue.main.async {
|
|
if
|
|
!CurrentAppContext().isMainApp,
|
|
self.traitCollection.userInterfaceStyle != UITraitCollection.current.userInterfaceStyle
|
|
{
|
|
Theme.shareExtensionThemeOverride = self.traitCollection.userInterfaceStyle
|
|
}
|
|
}
|
|
}
|
|
|
|
var presentationTime: Date?
|
|
open override func viewWillAppear(_ animated: Bool) {
|
|
super.viewWillAppear(animated)
|
|
|
|
presentationTime = presentationTime ?? Date()
|
|
}
|
|
|
|
open override func themeDidChange() {
|
|
super.themeDidChange()
|
|
|
|
searchBar.searchFieldBackgroundColorOverride = Theme.searchFieldElevatedBackgroundColor
|
|
updateTableContents(shouldReload: false)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
private nonisolated func buildSearchResults(searchText: String) async -> RecipientSearchResultSet? {
|
|
guard searchText.count > 1 else {
|
|
return nil
|
|
}
|
|
|
|
return SSKEnvironment.shared.databaseStorageRef.read { tx in
|
|
FullTextSearcher.shared.searchForRecipients(
|
|
searchText: searchText,
|
|
includeLocalUser: true,
|
|
includeStories: true,
|
|
tx: tx
|
|
)
|
|
}
|
|
}
|
|
|
|
private nonisolated func buildGroupItem(
|
|
_ groupThread: TSGroupThread,
|
|
isBlocked: Bool,
|
|
transaction tx: SDSAnyReadTransaction
|
|
) -> GroupConversationItem {
|
|
let dmConfigurationStore = DependenciesBridge.shared.disappearingMessagesConfigurationStore
|
|
let dmConfig = dmConfigurationStore.fetchOrBuildDefault(for: .thread(groupThread), tx: tx.asV2Read)
|
|
return GroupConversationItem(
|
|
groupThreadId: groupThread.uniqueId,
|
|
isBlocked: isBlocked,
|
|
disappearingMessagesConfig: dmConfig
|
|
)
|
|
}
|
|
|
|
private nonisolated func buildContactItem(
|
|
_ address: SignalServiceAddress,
|
|
isBlocked: Bool,
|
|
transaction tx: SDSAnyReadTransaction
|
|
) -> ContactConversationItem {
|
|
let thread = TSContactThread.getWithContactAddress(address, transaction: tx)
|
|
let dmConfigurationStore = DependenciesBridge.shared.disappearingMessagesConfigurationStore
|
|
let dmConfig = thread.map { dmConfigurationStore.fetchOrBuildDefault(for: .thread($0), tx: tx.asV2Read) }
|
|
let displayName = SSKEnvironment.shared.contactManagerRef.displayName(for: address, tx: tx)
|
|
return ContactConversationItem(
|
|
address: address,
|
|
isBlocked: isBlocked,
|
|
disappearingMessagesConfig: dmConfig,
|
|
comparableName: ComparableDisplayName(address: address, displayName: displayName, config: .current())
|
|
)
|
|
}
|
|
|
|
private nonisolated func buildConversationCollection(sectionOptions: SectionOptions) -> ConversationCollection {
|
|
SSKEnvironment.shared.databaseStorageRef.read { transaction in
|
|
var pinnedItemsByThreadId: [String: RecentConversationItem] = [:]
|
|
var recentItems: [RecentConversationItem] = []
|
|
var contactItems: [ContactConversationItem] = []
|
|
var groupItems: [GroupConversationItem] = []
|
|
var seenAddresses: Set<SignalServiceAddress> = Set()
|
|
|
|
let pinnedThreadIds = DependenciesBridge.shared.pinnedThreadStore.pinnedThreadIds(tx: transaction.asV2Read)
|
|
|
|
// 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 self.threadFilter(thread) else { return }
|
|
|
|
guard thread.canSendChatMessagesToThread(ignoreAnnouncementOnly: true) else {
|
|
return
|
|
}
|
|
|
|
let isThreadBlocked = SSKEnvironment.shared.blockingManagerRef.isThreadBlocked(
|
|
thread,
|
|
transaction: transaction
|
|
)
|
|
if isThreadBlocked {
|
|
return
|
|
}
|
|
|
|
switch thread {
|
|
case let contactThread as TSContactThread:
|
|
let isThreadHidden = DependenciesBridge.shared.recipientHidingManager.isHiddenAddress(
|
|
contactThread.contactAddress,
|
|
tx: transaction.asV2Read
|
|
)
|
|
if isThreadHidden {
|
|
return
|
|
}
|
|
let item = self.buildContactItem(
|
|
contactThread.contactAddress,
|
|
isBlocked: isThreadBlocked,
|
|
transaction: transaction
|
|
)
|
|
|
|
seenAddresses.insert(contactThread.contactAddress)
|
|
if sectionOptions.contains(.recents) && pinnedThreadIds.contains(thread.uniqueId) {
|
|
let recentItem = RecentConversationItem(backingItem: .contact(item))
|
|
pinnedItemsByThreadId[thread.uniqueId] = recentItem
|
|
} else if sectionOptions.contains(.recents) && 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,
|
|
isBlocked: isThreadBlocked,
|
|
transaction: transaction
|
|
)
|
|
|
|
if sectionOptions.contains(.recents) && pinnedThreadIds.contains(thread.uniqueId) {
|
|
let recentItem = RecentConversationItem(backingItem: .group(item))
|
|
pinnedItemsByThreadId[thread.uniqueId] = recentItem
|
|
} else if sectionOptions.contains(.recents) && recentItems.count < maxRecentCount {
|
|
let recentItem = RecentConversationItem(backingItem: .group(item))
|
|
recentItems.append(recentItem)
|
|
} else {
|
|
groupItems.append(item)
|
|
}
|
|
default:
|
|
owsFailDebug("unexpected thread: \(thread.uniqueId)")
|
|
}
|
|
}
|
|
|
|
try! ThreadFinder().enumerateVisibleThreads(isArchived: false, transaction: transaction) { thread in
|
|
addThread(thread)
|
|
}
|
|
|
|
try! ThreadFinder().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 isContactBlocked = SSKEnvironment.shared.blockingManagerRef.isAddressBlocked(
|
|
address,
|
|
transaction: transaction
|
|
)
|
|
|
|
if isContactBlocked {
|
|
return
|
|
}
|
|
|
|
let isRecipientHidden = DependenciesBridge.shared.recipientHidingManager.isHiddenAddress(
|
|
address,
|
|
tx: transaction.asV2Read
|
|
)
|
|
if isRecipientHidden {
|
|
return
|
|
}
|
|
|
|
let contactItem = self.buildContactItem(
|
|
address,
|
|
isBlocked: isContactBlocked,
|
|
transaction: transaction
|
|
)
|
|
|
|
contactItems.append(contactItem)
|
|
}
|
|
contactItems.sort(by: <)
|
|
|
|
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 }
|
|
|
|
let storyItems = StoryConversationItem.allItems(
|
|
includeImplicitGroupThreads: true,
|
|
excludeHiddenContexts: true,
|
|
prioritizeThreadsCreatedAfter: creationDate,
|
|
blockingManager: SSKEnvironment.shared.blockingManagerRef,
|
|
transaction: transaction
|
|
)
|
|
|
|
return ConversationCollection(contactConversations: contactItems,
|
|
recentConversations: pinnedItems + recentItems,
|
|
groupConversations: groupItems,
|
|
storyConversations: storyItems,
|
|
isSearchResults: false)
|
|
}
|
|
}
|
|
|
|
private nonisolated func buildConversationCollection(sectionOptions: SectionOptions, searchResults: RecipientSearchResultSet?) async -> ConversationCollection {
|
|
guard let searchResults = searchResults else {
|
|
return buildConversationCollection(sectionOptions: sectionOptions)
|
|
}
|
|
|
|
return SSKEnvironment.shared.databaseStorageRef.read { transaction in
|
|
let groupItems = searchResults.groupThreads.compactMap { groupThread -> GroupConversationItem? in
|
|
guard
|
|
self.threadFilter(groupThread),
|
|
groupThread.canSendChatMessagesToThread(ignoreAnnouncementOnly: true)
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
let isThreadBlocked = SSKEnvironment.shared.blockingManagerRef.isThreadBlocked(
|
|
groupThread,
|
|
transaction: transaction
|
|
)
|
|
|
|
if isThreadBlocked {
|
|
return nil
|
|
}
|
|
|
|
return self.buildGroupItem(
|
|
groupThread,
|
|
isBlocked: isThreadBlocked,
|
|
transaction: transaction
|
|
)
|
|
}
|
|
|
|
let contactItems = searchResults.contactResults.map { contactResult -> ContactConversationItem in
|
|
return self.buildContactItem(
|
|
contactResult.recipientAddress,
|
|
isBlocked: false,
|
|
transaction: transaction
|
|
)
|
|
}
|
|
|
|
let storyItems = StoryConversationItem.buildItems(
|
|
from: searchResults.storyThreads,
|
|
excludeHiddenContexts: false,
|
|
blockingManager: SSKEnvironment.shared.blockingManagerRef,
|
|
transaction: transaction
|
|
)
|
|
|
|
return ConversationCollection(
|
|
contactConversations: contactItems,
|
|
recentConversations: [],
|
|
groupConversations: groupItems,
|
|
storyConversations: storyItems,
|
|
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
|
|
|
|
private func onTouchCancelButton() {
|
|
pickerDelegate?.conversationPickerDidCancel(self)
|
|
}
|
|
|
|
@objc
|
|
private func blockListDidChange(_ notification: NSNotification) {
|
|
AssertIsOnMainThread()
|
|
|
|
self.conversationCollection = buildConversationCollection(sectionOptions: sectionOptions)
|
|
}
|
|
|
|
private func updateTableContents(shouldReload: Bool = true) {
|
|
AssertIsOnMainThread()
|
|
|
|
self.defaultSeparatorInsetLeading = (OWSTableViewController2.cellHInnerMargin +
|
|
CGFloat(ContactCellView.avatarSizeClass.diameter) +
|
|
ContactCellView.avatarTextHSpacing)
|
|
|
|
let conversationCollection = self.conversationCollection
|
|
|
|
let contents = OWSTableContents()
|
|
|
|
var hasContents = false
|
|
|
|
// Media Preview Section
|
|
do {
|
|
let section = OWSTableSection()
|
|
if
|
|
!conversationCollection.isSearchResults,
|
|
sectionOptions.contains(.mediaPreview),
|
|
let attachments = attachments,
|
|
!attachments.isEmpty
|
|
{
|
|
addMediaPreview(to: section, attachments: attachments)
|
|
} else if
|
|
!conversationCollection.isSearchResults,
|
|
sectionOptions.contains(.mediaPreview),
|
|
let textAttachment = textAttachment
|
|
{
|
|
addMediaPreview(to: section, textAttachment: textAttachment)
|
|
}
|
|
contents.add(section)
|
|
}
|
|
|
|
// Stories Section
|
|
do {
|
|
let section = OWSTableSection()
|
|
if StoryManager.areStoriesEnabled && sectionOptions.contains(.stories) && !conversationCollection.storyConversations.isEmpty {
|
|
section.customHeaderView = NewStoryHeaderView(
|
|
title: Strings.storiesSection,
|
|
showsNewStoryButton: !conversationCollection.isSearchResults,
|
|
delegate: self
|
|
)
|
|
|
|
if conversationCollection.isSearchResults {
|
|
addConversations(to: section, conversations: conversationCollection.storyConversations)
|
|
} else {
|
|
addExpandableConversations(
|
|
to: section,
|
|
sectionIndex: .stories,
|
|
conversations: conversationCollection.storyConversations,
|
|
maxConversationsToRender: maxStoryConversationsToRender,
|
|
isExpanded: isStorySectionExpanded,
|
|
markAsExpanded: { [weak self] in self?.isStorySectionExpanded = true }
|
|
)
|
|
}
|
|
hasContents = true
|
|
}
|
|
contents.add(section)
|
|
}
|
|
|
|
// Recents Section
|
|
do {
|
|
let section = OWSTableSection()
|
|
if sectionOptions.contains(.recents) && !conversationCollection.recentConversations.isEmpty {
|
|
if !shouldHideRecentConversationsTitle || sectionOptions == .recents {
|
|
section.headerTitle = Strings.recentsSection
|
|
}
|
|
addConversations(to: section, conversations: conversationCollection.recentConversations)
|
|
hasContents = true
|
|
}
|
|
contents.add(section)
|
|
}
|
|
|
|
// Contacts Section
|
|
do {
|
|
let section = OWSTableSection()
|
|
if sectionOptions.contains(.contacts) && !conversationCollection.contactConversations.isEmpty {
|
|
if sectionOptions != .contacts {
|
|
section.headerTitle = Strings.signalContactsSection
|
|
}
|
|
addConversations(to: section, conversations: conversationCollection.contactConversations)
|
|
hasContents = true
|
|
}
|
|
contents.add(section)
|
|
}
|
|
|
|
// Groups Section
|
|
do {
|
|
let section = OWSTableSection()
|
|
if sectionOptions.contains(.groups) && !conversationCollection.groupConversations.isEmpty {
|
|
if sectionOptions != .groups {
|
|
section.headerTitle = Strings.groupsSection
|
|
}
|
|
addConversations(to: section, conversations: conversationCollection.groupConversations)
|
|
hasContents = true
|
|
}
|
|
contents.add(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.add(section)
|
|
}
|
|
|
|
setContents(contents, shouldReload: shouldReload)
|
|
restoreSelection()
|
|
}
|
|
|
|
private func addConversations(to section: OWSTableSection, conversations: [ConversationItem]) {
|
|
for conversation in conversations {
|
|
addConversationPickerCell(to: section, for: conversation)
|
|
}
|
|
}
|
|
|
|
/// This must be retained for as long as we want to be able
|
|
/// to display recipient context menus in this view controller.
|
|
private lazy var recipientContextMenuHelper = {
|
|
return RecipientContextMenuHelper(
|
|
databaseStorage: SSKEnvironment.shared.databaseStorageRef,
|
|
blockingManager: SSKEnvironment.shared.blockingManagerRef,
|
|
recipientHidingManager: DependenciesBridge.shared.recipientHidingManager,
|
|
accountManager: DependenciesBridge.shared.tsAccountManager,
|
|
contactsManager: SSKEnvironment.shared.contactManagerRef,
|
|
fromViewController: self
|
|
)
|
|
}()
|
|
|
|
private func addConversationPickerCell(to section: OWSTableSection, for item: ConversationItem) {
|
|
var contextMenuActionProvider: UIContextMenuActionProvider?
|
|
if case let .contact(address) = item.messageRecipient {
|
|
contextMenuActionProvider = recipientContextMenuHelper.actionProvider(address: address)
|
|
}
|
|
section.add(OWSTableItem(dequeueCellBlock: { tableView in
|
|
guard let cell = tableView.dequeueReusableCell(withIdentifier: ConversationPickerCell.reuseIdentifier) as? ConversationPickerCell else {
|
|
owsFailDebug("Missing cell.")
|
|
return UITableViewCell()
|
|
}
|
|
SSKEnvironment.shared.databaseStorageRef.read { transaction in
|
|
cell.configure(conversationItem: item, transaction: transaction)
|
|
}
|
|
return cell
|
|
},
|
|
actionBlock: { [weak self] in
|
|
self?.didToggleSelection(conversation: item)
|
|
},
|
|
contextMenuActionProvider: contextMenuActionProvider))
|
|
}
|
|
|
|
private func addMediaPreview(
|
|
to section: OWSTableSection,
|
|
attachments: [SignalAttachment]
|
|
) {
|
|
guard let firstAttachment = attachments.first else {
|
|
owsFailDebug("Cannot add media preview section without attachments")
|
|
return
|
|
}
|
|
|
|
guard let mediaPreview = makeMediaPreview(firstAttachment) else {
|
|
return
|
|
}
|
|
let container = addPrimaryMediaPreviewView(mediaPreview, to: section)
|
|
|
|
if let secondAttachment = attachments[safe: 1], let secondMediaPreview = makeMediaPreview(secondAttachment) {
|
|
let mediaPreviewBorder = UIView()
|
|
mediaPreviewBorder.backgroundColor = self.tableBackgroundColor
|
|
mediaPreviewBorder.layer.masksToBounds = true
|
|
mediaPreviewBorder.layer.cornerRadius = mediaPreview.layer.cornerRadius
|
|
container.insertSubview(mediaPreviewBorder, belowSubview: mediaPreview)
|
|
|
|
mediaPreviewBorder.autoPinEdges(toEdgesOf: mediaPreview, with: .init(margin: -3))
|
|
|
|
secondMediaPreview.layer.masksToBounds = true
|
|
secondMediaPreview.layer.cornerRadius = 18
|
|
|
|
container.insertSubview(secondMediaPreview, belowSubview: mediaPreviewBorder)
|
|
secondMediaPreview.autoVCenterInSuperview()
|
|
secondMediaPreview.autoConstrainAttribute(.vertical, to: .vertical, of: mediaPreview, withOffset: -26)
|
|
secondMediaPreview.autoSetDimensions(to: mediaPreviewSize.applying(.scale(0.85)))
|
|
|
|
secondMediaPreview.transform = .identity.rotated(by: (CurrentAppContext().isRTL ? 15 : -15) * CGFloat.pi / 180)
|
|
}
|
|
}
|
|
|
|
private func makeMediaPreview(_ attachment: SignalAttachment) -> UIView? {
|
|
if attachment.isVideo || attachment.isImage || attachment.isAnimatedImage {
|
|
let mediaPreview = MediaMessageView(attachment: attachment, contentMode: .scaleAspectFill)
|
|
mediaPreview.layer.masksToBounds = true
|
|
mediaPreview.layer.cornerRadius = 18
|
|
return mediaPreview
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func addMediaPreview(
|
|
to section: OWSTableSection,
|
|
textAttachment: UnsentTextAttachment
|
|
) {
|
|
let previewView = TextAttachmentView(attachment: textAttachment).asThumbnailView()
|
|
previewView.layer.masksToBounds = true
|
|
previewView.layer.cornerRadius = 18
|
|
addPrimaryMediaPreviewView(previewView, to: section)
|
|
}
|
|
|
|
@discardableResult
|
|
private func addPrimaryMediaPreviewView(
|
|
_ previewView: UIView,
|
|
to section: OWSTableSection
|
|
) -> UIView {
|
|
let container = UIView()
|
|
container.preservesSuperviewLayoutMargins = true
|
|
|
|
container.addSubview(previewView)
|
|
previewView.autoPinEdge(toSuperviewEdge: .top, withInset: 3)
|
|
previewView.autoPinEdge(toSuperviewEdge: .bottom, withInset: 3)
|
|
previewView.autoHCenterInSuperview()
|
|
previewView.autoSetDimensions(to: mediaPreviewSize)
|
|
|
|
section.customHeaderView = container
|
|
return container
|
|
}
|
|
|
|
private var mediaPreviewSize: CGSize {
|
|
if UIDevice.current.isShorterThaniPhoneX {
|
|
return .init(width: 90, height: 160)
|
|
} else {
|
|
return .init(width: 140, height: 248)
|
|
}
|
|
}
|
|
|
|
private func addExpandableConversations(
|
|
to section: OWSTableSection,
|
|
sectionIndex: ConversationPickerSection,
|
|
conversations: [ConversationItem],
|
|
maxConversationsToRender: Int,
|
|
isExpanded: Bool,
|
|
markAsExpanded: @escaping () -> Void
|
|
) {
|
|
var conversationsToRender = conversations
|
|
let hasMoreConversations = !isExpanded && conversationsToRender.count > maxConversationsToRender
|
|
if hasMoreConversations {
|
|
conversationsToRender = Array(conversationsToRender.prefix(maxConversationsToRender - 1))
|
|
}
|
|
|
|
for conversation in conversationsToRender {
|
|
addConversationPickerCell(to: section, for: conversation)
|
|
}
|
|
|
|
if hasMoreConversations {
|
|
let expandedConversationIndices = (conversationsToRender.count..<conversations.count).map {
|
|
IndexPath(row: $0, section: sectionIndex.rawValue)
|
|
}
|
|
|
|
section.add(OWSTableItem(
|
|
customCellBlock: {
|
|
let cell = OWSTableItem.newCell()
|
|
cell.preservesSuperviewLayoutMargins = true
|
|
cell.contentView.preservesSuperviewLayoutMargins = true
|
|
|
|
let iconView = OWSTableItem.buildIconInCircleView(
|
|
icon: .groupInfoShowAllMembers,
|
|
iconSize: AvatarBuilder.smallAvatarSizePoints,
|
|
innerIconSize: 20,
|
|
iconTintColor: Theme.primaryTextColor
|
|
)
|
|
|
|
let rowLabel = UILabel()
|
|
rowLabel.text = CommonStrings.seeAllButton
|
|
rowLabel.textColor = Theme.primaryTextColor
|
|
rowLabel.font = OWSTableItem.primaryLabelFont
|
|
rowLabel.lineBreakMode = .byTruncatingTail
|
|
|
|
let contentRow = UIStackView(arrangedSubviews: [ iconView, rowLabel ])
|
|
contentRow.spacing = ContactCellView.avatarTextHSpacing
|
|
|
|
cell.contentView.addSubview(contentRow)
|
|
contentRow.autoPinWidthToSuperviewMargins()
|
|
contentRow.autoPinHeightToSuperview(withMargin: 7)
|
|
|
|
return cell
|
|
},
|
|
actionBlock: { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
markAsExpanded()
|
|
|
|
if !expandedConversationIndices.isEmpty, let firstIndex = expandedConversationIndices.first {
|
|
self.tableView.beginUpdates()
|
|
|
|
// Delete the "See All" row.
|
|
self.tableView.deleteRows(at: [IndexPath(row: firstIndex.row, section: firstIndex.section)], with: .top)
|
|
|
|
// Insert the new rows.
|
|
self.tableView.insertRows(at: expandedConversationIndices, with: .top)
|
|
|
|
self.updateTableContents(shouldReload: false)
|
|
self.tableView.endUpdates()
|
|
} else {
|
|
self.updateTableContents()
|
|
}
|
|
}
|
|
))
|
|
}
|
|
}
|
|
|
|
public override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
|
super.tableView(tableView, willDisplay: cell, forRowAt: indexPath)
|
|
|
|
guard let conversation = conversation(for: indexPath) else {
|
|
return
|
|
}
|
|
if selection.isSelected(conversation: conversation) {
|
|
cell.setSelected(true, animated: false)
|
|
tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none)
|
|
} else {
|
|
cell.setSelected(false, animated: false)
|
|
tableView.deselectRow(at: indexPath, animated: false)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
if
|
|
let maxVideoAttachmentDuration = maxVideoAttachmentDuration,
|
|
let durationLimit = conversation.videoAttachmentDurationLimit,
|
|
durationLimit < maxVideoAttachmentDuration
|
|
{
|
|
// Show a tooltip the first time this happens, but still let the
|
|
// user select.
|
|
showVideoSegmentingTooltip(on: indexPath)
|
|
} else {
|
|
// dismiss the tooltip when selecting.
|
|
currentTooltip = nil
|
|
}
|
|
|
|
return indexPath
|
|
}
|
|
|
|
public override func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
|
|
super.tableView(tableView, didDeselectRowAt: indexPath)
|
|
|
|
// dismiss the tooltip when unselecting
|
|
currentTooltip = nil
|
|
}
|
|
|
|
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(sectionOptions: self.sectionOptions)
|
|
}
|
|
case .group(let groupThreadId):
|
|
guard let groupThread = SSKEnvironment.shared.databaseStorageRef.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(sectionOptions: self.sectionOptions)
|
|
}
|
|
case .privateStory:
|
|
owsFailDebug("Unexpectedly attempted to show unblock UI for story thread")
|
|
}
|
|
}
|
|
|
|
fileprivate func didToggleSelection(conversation: ConversationItem) {
|
|
AssertIsOnMainThread()
|
|
|
|
if selection.isSelected(conversation: conversation) {
|
|
didDeselect(conversation: conversation)
|
|
} else {
|
|
didSelect(conversation: conversation)
|
|
searchBar.resignFirstResponder()
|
|
}
|
|
}
|
|
|
|
private func didSelect(conversation: ConversationItem) {
|
|
AssertIsOnMainThread()
|
|
|
|
let isBlocked: Bool = SSKEnvironment.shared.databaseStorageRef.read { transaction in
|
|
guard let thread = conversation.getExistingThread(transaction: transaction) else {
|
|
return false
|
|
}
|
|
return !thread.canSendChatMessagesToThread(ignoreAnnouncementOnly: false)
|
|
}
|
|
guard !isBlocked else {
|
|
restoreSelection()
|
|
showBlockedByAnnouncementOnlyToast()
|
|
return
|
|
}
|
|
|
|
if let storyConversationItem = conversation as? StoryConversationItem {
|
|
if
|
|
!isStorySectionExpanded,
|
|
let index = conversationCollection.storyConversations.firstIndex(where: {
|
|
($0 as? StoryConversationItem)?.threadId == storyConversationItem.threadId
|
|
}),
|
|
index >= maxStoryConversationsToRender - 1 {
|
|
// Expand so we can see the selection.
|
|
isStorySectionExpanded = true
|
|
updateTableContents(shouldReload: false)
|
|
}
|
|
|
|
if storyConversationItem.isMyStory,
|
|
SSKEnvironment.shared.databaseStorageRef.read(block: { !StoryManager.hasSetMyStoriesPrivacy(transaction: $0) }) {
|
|
// Show first time story privacy settings if selecting my story and settings have'nt been
|
|
// changed before.
|
|
|
|
// Reload the row when we show the sheet, and when it goes away, so we reflect changes.
|
|
let reloadRowBlock = { [weak self] in
|
|
self?.tableView.reloadData()
|
|
if SSKEnvironment.shared.databaseStorageRef.read(block: { StoryManager.hasSetMyStoriesPrivacy(transaction: $0) }) {
|
|
self?.selection.add(conversation)
|
|
self?.updateUIForCurrentSelection(animated: true)
|
|
self?.tableView.selectRow(at: IndexPath(row: 0, section: 0), animated: false, scrollPosition: .none)
|
|
}
|
|
}
|
|
let sheetController = MyStorySettingsSheetViewController(willDisappear: reloadRowBlock)
|
|
self.present(sheetController, animated: true, completion: reloadRowBlock)
|
|
} else {
|
|
selection.add(conversation)
|
|
}
|
|
} else {
|
|
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)
|
|
}
|
|
|
|
public func updateUIForCurrentSelection(animated: Bool) {
|
|
let conversations = selection.conversations
|
|
let labelText = conversations.map { $0.titleWithSneakyTransaction }.joined(separator: ", ")
|
|
footerView.setNamesText(labelText, animated: animated)
|
|
footerView.proceedButton.isEnabled = !conversations.isEmpty
|
|
}
|
|
|
|
private func showTooManySelectedToast() {
|
|
Logger.info("Showing toast for too many chats selected")
|
|
|
|
let toastFormat = OWSLocalizedString("CONVERSATION_PICKER_CAN_SELECT_NO_MORE_CONVERSATIONS_%d", tableName: "PluralAware",
|
|
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.localizedStringWithFormat(toastFormat, kMaxPickerSelection)
|
|
showToast(message: toastText)
|
|
}
|
|
|
|
private func showToast(message: String) {
|
|
Logger.info("")
|
|
|
|
let toastController = ToastController(text: message)
|
|
|
|
let bottomInset = (view.bounds.height - tableView.frame.maxY)
|
|
let kToastInset: CGFloat = bottomInset + 10
|
|
toastController.presentToastView(from: .bottom, of: view, inset: kToastInset)
|
|
}
|
|
|
|
private var shownTooltipTypes = Set<ConversationItemMessageType>()
|
|
private var currentTooltip: VideoSegmentingTooltipView? {
|
|
didSet {
|
|
oldValue?.removeFromSuperview()
|
|
}
|
|
}
|
|
|
|
private func showVideoSegmentingTooltip(on indexPath: IndexPath) {
|
|
guard
|
|
let conversation = self.conversation(for: indexPath),
|
|
let cell = tableView.cellForRow(at: indexPath) as? ConversationPickerCell
|
|
else {
|
|
owsFailDebug("Showing a video trimming tooltop for an invalid index path")
|
|
return
|
|
}
|
|
|
|
guard let text = conversation.videoAttachmentStoryLengthTooltipString else {
|
|
return
|
|
}
|
|
|
|
let typeIdentifier = conversation.outgoingMessageType
|
|
guard !shownTooltipTypes.contains(typeIdentifier) else {
|
|
// We've already shown the tooltip for this type.
|
|
return
|
|
}
|
|
shownTooltipTypes.insert(typeIdentifier)
|
|
|
|
self.currentTooltip = VideoSegmentingTooltipView(
|
|
fromView: tableView,
|
|
widthReferenceView: cell,
|
|
tailReferenceView: cell.tooltipTailReferenceView,
|
|
text: text
|
|
)
|
|
}
|
|
|
|
public override func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
|
// dismiss the tooltip when scrolling.
|
|
currentTooltip = nil
|
|
}
|
|
}
|
|
|
|
private class VideoSegmentingTooltipView: TooltipView {
|
|
|
|
let text: String
|
|
|
|
init(
|
|
fromView: UIView,
|
|
widthReferenceView: UIView,
|
|
tailReferenceView: UIView,
|
|
text: String
|
|
) {
|
|
self.text = text
|
|
super.init(
|
|
fromView: fromView,
|
|
widthReferenceView: widthReferenceView,
|
|
tailReferenceView: tailReferenceView,
|
|
wasTappedBlock: nil
|
|
)
|
|
}
|
|
|
|
required init?(coder aDecoder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
public override func bubbleContentView() -> UIView {
|
|
let label = UILabel()
|
|
label.text = text
|
|
label.font = .dynamicTypeFootnoteClamped
|
|
label.textColor = .ows_white
|
|
label.numberOfLines = 0
|
|
|
|
let containerView = UIView()
|
|
|
|
containerView.addSubview(label)
|
|
label.autoPinEdgesToSuperviewEdges(with: .init(hMargin: 12, vMargin: 8))
|
|
|
|
return containerView
|
|
}
|
|
|
|
public override var bubbleColor: UIColor { .ows_accentBlue }
|
|
public override var bubbleHSpacing: CGFloat { 28 }
|
|
public override var bubbleInsets: UIEdgeInsets { .zero }
|
|
public override var stretchesBubbleHorizontally: Bool { true }
|
|
|
|
public override var tailDirection: TooltipView.TailDirection { .up }
|
|
public override var dismissOnTap: Bool { true }
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ConversationPickerViewController: NewStoryHeaderDelegate {
|
|
public func newStoryHeaderView(_ newStoryHeaderView: NewStoryHeaderView, didCreateNewStoryItems items: [StoryConversationItem]) {
|
|
isStorySectionExpanded = true
|
|
conversationCollection = buildConversationCollection(sectionOptions: sectionOptions)
|
|
items.forEach { selection.add($0) }
|
|
restoreSelection()
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ConversationPickerViewController: UISearchBarDelegate {
|
|
public func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
|
|
Task { [self] in
|
|
let searchResults = await buildSearchResults(searchText: searchText)
|
|
guard searchBar.text == searchText else { return }
|
|
let conversationCollection = await buildConversationCollection(sectionOptions: sectionOptions, searchResults: searchResults)
|
|
guard searchBar.text == searchText else { return }
|
|
self.conversationCollection = conversationCollection
|
|
}
|
|
}
|
|
|
|
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(sectionOptions: sectionOptions)
|
|
pickerDelegate?.conversationPickerSearchBarActiveDidChange(self)
|
|
}
|
|
|
|
public func resetSearchBarText() {
|
|
guard nil != searchBar.text?.nilIfEmpty else {
|
|
return
|
|
}
|
|
searchBar.text = nil
|
|
conversationCollection = buildConversationCollection(sectionOptions: sectionOptions)
|
|
}
|
|
|
|
public var isSearchBarActive: Bool {
|
|
searchBar.isFirstResponder
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ConversationPickerViewController: ApprovalFooterDelegate {
|
|
public func approvalFooterDelegateDidRequestProceed(_ approvalFooterView: ApprovalFooterView) {
|
|
tryToProceed(untrustedThreshold: presentationTime?.addingTimeInterval(-OWSIdentityManagerImpl.Constants.defaultUntrustedInterval))
|
|
}
|
|
|
|
private func tryToProceed(untrustedThreshold: Date?) {
|
|
guard let pickerDelegate = pickerDelegate else {
|
|
owsFailDebug("Missing delegate.")
|
|
return
|
|
}
|
|
let conversations = selection.conversations
|
|
guard conversations.count > 0 else {
|
|
Logger.warn("No conversations selected.")
|
|
return
|
|
}
|
|
|
|
if shouldBatchUpdateIdentityKeys {
|
|
guard let untrustedThreshold else {
|
|
owsFailDebug("Unexpectedly missing presentation time")
|
|
return
|
|
}
|
|
|
|
let selectedRecipients = SSKEnvironment.shared.databaseStorageRef.read { transaction in
|
|
conversations.flatMap { conversation in
|
|
conversation.getExistingThread(transaction: transaction)?.recipientAddresses(with: transaction) ?? []
|
|
}
|
|
}
|
|
|
|
// Before continuing, prompt for any safety number changes that we have
|
|
// learned about since the view was presented (to handle batch identity key
|
|
// updates) or since we last checked (to handle the recursive passes
|
|
// through this method).
|
|
let newUntrustedThreshold = Date()
|
|
let didHaveSafetyNumberChanges = SafetyNumberConfirmationSheet.presentIfNecessary(
|
|
addresses: selectedRecipients,
|
|
confirmationText: SafetyNumberStrings.confirmSendButton,
|
|
untrustedThreshold: untrustedThreshold
|
|
) { didConfirmSafetyNumberChange in
|
|
guard didConfirmSafetyNumberChange else { return }
|
|
self.tryToProceed(untrustedThreshold: newUntrustedThreshold)
|
|
}
|
|
|
|
guard !didHaveSafetyNumberChanges else { 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")
|
|
static let storiesSection = OWSLocalizedString("CONVERSATION_PICKER_SECTION_STORIES", comment: "table section header for section containing stories")
|
|
}
|
|
}
|
|
|
|
// MARK: - ConversationPickerCell
|
|
|
|
internal 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)
|
|
case .privateStory(_, let isMyStory):
|
|
if isMyStory {
|
|
guard let localAddress = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aciAddress else {
|
|
owsFailDebug("Unexpectedly missing local address")
|
|
return
|
|
}
|
|
configuration = ContactCellConfiguration(address: localAddress, localUserDisplayMode: .asUser)
|
|
configuration.customName = conversationItem.title(transaction: transaction)
|
|
} else {
|
|
guard let image = conversationItem.image else {
|
|
owsFailDebug("Unexpectedly missing image for private story")
|
|
return
|
|
}
|
|
configuration = ContactCellConfiguration(name: conversationItem.title(transaction: transaction), avatar: image)
|
|
}
|
|
}
|
|
if conversationItem.isBlocked {
|
|
configuration.accessoryMessage = MessageStrings.conversationIsBlocked
|
|
} else {
|
|
configuration.accessoryView = buildAccessoryView(disappearingMessagesConfig: conversationItem.disappearingMessagesConfig)
|
|
}
|
|
|
|
if let storyItem = conversationItem as? StoryConversationItem {
|
|
configuration.attributedSubtitle = storyItem.subtitle(transaction: transaction)?.asAttributedString
|
|
configuration.storyState = storyItem.storyState
|
|
} else {
|
|
configuration.storyState = nil
|
|
}
|
|
|
|
super.configure(configuration: configuration, transaction: transaction)
|
|
|
|
// Apply theme.
|
|
unselectedBadgeView.layer.borderColor = Theme.primaryIconColor.cgColor
|
|
|
|
selectionStyle = .none
|
|
applySelection()
|
|
}
|
|
|
|
public var showsSelectionUI: Bool = true {
|
|
didSet {
|
|
selectionView.isHidden = !showsSelectionUI
|
|
}
|
|
}
|
|
|
|
// 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 = .ows_middleGray
|
|
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: Theme.iconImage(.circle))
|
|
imageView.tintColor = .ows_gray25
|
|
return imageView
|
|
}()
|
|
|
|
lazy var selectedBadgeView: UIView = {
|
|
let imageView = UIImageView(image: Theme.iconImage(.checkCircleFill))
|
|
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()
|
|
|
|
var shouldBatchUpdateIdentityKeys: Bool { get }
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public class ConversationPickerSelection {
|
|
fileprivate weak var delegate: ConversationPickerSelectionDelegate?
|
|
|
|
public private(set) var conversations: [ConversationItem] = []
|
|
|
|
public init() {}
|
|
|
|
public func add(_ conversation: ConversationItem) {
|
|
conversations.append(conversation)
|
|
delegate?.conversationPickerSelectionDidAdd()
|
|
|
|
guard delegate?.shouldBatchUpdateIdentityKeys == true else { return }
|
|
|
|
let recipients: [ServiceId] = SSKEnvironment.shared.databaseStorageRef.read { transaction in
|
|
guard let thread = conversation.getExistingThread(transaction: transaction) else { return [] }
|
|
return thread.recipientAddresses(with: transaction).compactMap { $0.serviceId }
|
|
}
|
|
|
|
Logger.info("Batch updating identity keys for \(recipients.count) selected recipients.")
|
|
let identityManager = DependenciesBridge.shared.identityManager
|
|
identityManager.batchUpdateIdentityKeys(for: recipients).done {
|
|
Logger.info("Successfully batch updated identity keys.")
|
|
}.catch { error in
|
|
owsFailDebug("Failed to batch update identity keys: \(error)")
|
|
}
|
|
}
|
|
|
|
public func remove(_ conversation: ConversationItem) {
|
|
conversations.removeAll {
|
|
($0 is StoryConversationItem) == (conversation is StoryConversationItem) && $0.messageRecipient == conversation.messageRecipient
|
|
}
|
|
delegate?.conversationPickerSelectionDidRemove()
|
|
}
|
|
|
|
public func isSelected(conversation: ConversationItem) -> Bool {
|
|
conversations.contains {
|
|
($0 is StoryConversationItem) == (conversation is StoryConversationItem) && $0.messageRecipient == conversation.messageRecipient
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private enum ConversationPickerSection: Int, CaseIterable {
|
|
case mediaPreview, stories, recents, signalContacts, groups, emptySearchResults
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private struct ConversationCollection {
|
|
static let empty: ConversationCollection = ConversationCollection(contactConversations: [],
|
|
recentConversations: [],
|
|
groupConversations: [],
|
|
storyConversations: [],
|
|
isSearchResults: false)
|
|
|
|
let contactConversations: [ConversationItem]
|
|
let recentConversations: [ConversationItem]
|
|
let groupConversations: [ConversationItem]
|
|
let storyConversations: [ConversationItem]
|
|
let isSearchResults: Bool
|
|
|
|
var allConversations: [ConversationItem] {
|
|
recentConversations + contactConversations + groupConversations + storyConversations
|
|
}
|
|
|
|
private func conversations(section: ConversationPickerSection) -> [ConversationItem] {
|
|
switch section {
|
|
case .recents:
|
|
return recentConversations
|
|
case .signalContacts:
|
|
return contactConversations
|
|
case .groups:
|
|
return groupConversations
|
|
case .stories:
|
|
return storyConversations
|
|
case .emptySearchResults:
|
|
return []
|
|
case .mediaPreview:
|
|
owsFailDebug("Should not be fetching conversations for media preview section")
|
|
return []
|
|
}
|
|
}
|
|
|
|
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: ConversationPickerSection.recents.rawValue)
|
|
} else if let row = (contactConversations.map { $0.messageRecipient }).firstIndex(of: conversation.messageRecipient) {
|
|
return IndexPath(row: row, section: ConversationPickerSection.signalContacts.rawValue)
|
|
} else {
|
|
return nil
|
|
}
|
|
case .group:
|
|
if conversation is StoryConversationItem {
|
|
if let row = (storyConversations.map { $0.messageRecipient }).firstIndex(of: conversation.messageRecipient) {
|
|
return IndexPath(row: row, section: ConversationPickerSection.stories.rawValue)
|
|
} else {
|
|
return nil
|
|
}
|
|
} else if let row = (recentConversations.map { $0.messageRecipient }).firstIndex(of: conversation.messageRecipient) {
|
|
return IndexPath(row: row, section: ConversationPickerSection.recents.rawValue)
|
|
} else if let row = (groupConversations.map { $0.messageRecipient }).firstIndex(of: conversation.messageRecipient) {
|
|
return IndexPath(row: row, section: ConversationPickerSection.groups.rawValue)
|
|
} else {
|
|
return nil
|
|
}
|
|
case .privateStory:
|
|
if let row = (storyConversations.map { $0.messageRecipient }).firstIndex(of: conversation.messageRecipient) {
|
|
return IndexPath(row: row, section: ConversationPickerSection.stories.rawValue)
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
fileprivate func conversation(for indexPath: IndexPath) -> ConversationItem? {
|
|
guard let section = ConversationPickerSection(rawValue: indexPath.section) else {
|
|
owsFailDebug("section was unexpectedly nil")
|
|
return nil
|
|
}
|
|
return conversations(section: section)[safe: 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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension ConversationPickerViewController: ContactsViewHelperObserver {
|
|
public func contactsViewHelperDidUpdateContacts() {
|
|
/// Triggers subsequent call to `updateTableContents`.
|
|
self.conversationCollection = self.buildConversationCollection(sectionOptions: sectionOptions)
|
|
}
|
|
}
|