Signal-iOS/Signal/Calls/UserInterface/CallsListViewController.swift
2024-06-17 12:15:42 -05:00

2045 lines
73 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalUI
import SignalServiceKit
import SignalRingRTC
// MARK: - CallCellDelegate
private protocol CallCellDelegate: AnyObject {
func joinCall(from viewModel: CallsListViewController.CallViewModel)
func returnToCall(from viewModel: CallsListViewController.CallViewModel)
func showCallInfo(from viewModel: CallsListViewController.CallViewModel)
}
// MARK: - CallsListViewController
class CallsListViewController: OWSViewController, HomeTabViewController, CallServiceStateObserver {
private typealias DiffableDataSource = UITableViewDiffableDataSource<Section, RowIdentifier>
private typealias Snapshot = NSDiffableDataSourceSnapshot<Section, RowIdentifier>
private enum Constants {
/// The maximum number of search results to match.
static let maxSearchResults: Int = 100
/// An interval to wait after the search term changes before actually
/// issuing a search.
static let searchDebounceInterval: TimeInterval = 0.1
}
// MARK: - Dependencies
private struct Dependencies {
let badgeManager: BadgeManager
let blockingManager: BlockingManager
let callRecordDeleteAllJobQueue: CallRecordDeleteAllJobQueue
let callRecordMissedCallManager: CallRecordMissedCallManager
let callRecordQuerier: CallRecordQuerier
let callRecordStore: CallRecordStore
let callService: CallService
let contactsManager: any ContactManager
let db: SDSDatabaseStorage
let interactionDeleteManager: InteractionDeleteManager
let interactionStore: InteractionStore
let searchableNameFinder: SearchableNameFinder
let threadStore: ThreadStore
let tsAccountManager: any TSAccountManager
}
private lazy var deps: Dependencies = Dependencies(
badgeManager: AppEnvironment.shared.badgeManager,
blockingManager: SSKEnvironment.shared.blockingManagerRef,
callRecordDeleteAllJobQueue: SSKEnvironment.shared.callRecordDeleteAllJobQueueRef,
callRecordMissedCallManager: DependenciesBridge.shared.callRecordMissedCallManager,
callRecordQuerier: DependenciesBridge.shared.callRecordQuerier,
callRecordStore: DependenciesBridge.shared.callRecordStore,
callService: AppEnvironment.shared.callService,
contactsManager: NSObject.contactsManager,
db: NSObject.databaseStorage,
interactionDeleteManager: DependenciesBridge.shared.interactionDeleteManager,
interactionStore: DependenciesBridge.shared.interactionStore,
searchableNameFinder: SearchableNameFinder(
contactManager: NSObject.contactsManager,
searchableNameIndexer: DependenciesBridge.shared.searchableNameIndexer,
phoneNumberVisibilityFetcher: DependenciesBridge.shared.phoneNumberVisibilityFetcher,
recipientDatabaseTable: DependenciesBridge.shared.recipientDatabaseTable
),
threadStore: DependenciesBridge.shared.threadStore,
tsAccountManager: DependenciesBridge.shared.tsAccountManager
)
// MARK: - Lifecycle
private var logger: PrefixedLogger = PrefixedLogger(prefix: "[CallsListVC]")
private lazy var emptyStateMessageView: UILabel = {
let label = UILabel()
label.numberOfLines = 0
label.textAlignment = .center
return label
}()
private lazy var noSearchResultsView: UILabel = {
let label = UILabel()
label.numberOfLines = 0
label.textAlignment = .center
label.font = .dynamicTypeBody
return label
}()
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.titleView = filterPicker
updateBarButtonItems()
view.addSubview(tableView)
tableView.autoPinEdgesToSuperviewEdges()
tableView.delegate = self
tableView.allowsSelectionDuringEditing = true
tableView.allowsMultipleSelectionDuringEditing = true
tableView.separatorStyle = .none
tableView.contentInset = .zero
// [CallLink] TODO: Add the icon & formatting to the table view row.
tableView.register(UITableViewCell.self, forCellReuseIdentifier: Self.createCallLinkReuseIdentifier)
tableView.register(CallCell.self, forCellReuseIdentifier: Self.callCellReuseIdentifier)
tableView.dataSource = dataSource
view.addSubview(emptyStateMessageView)
emptyStateMessageView.autoCenterInSuperview()
view.addSubview(noSearchResultsView)
noSearchResultsView.autoPinWidthToSuperviewMargins()
noSearchResultsView.autoPinEdge(toSuperviewMargin: .top, withInset: 80)
applyTheme()
attachSelfAsObservers()
loadCallRecordsAnew(animated: false)
}
override func viewWillAppear(_ animated: Bool) {
updateDisplayedDateForAllCallCells()
clearMissedCallsIfNecessary()
}
override func themeDidChange() {
super.themeDidChange()
applyTheme()
reloadAllRows()
}
private func updateBarButtonItems() {
if tableView.isEditing {
navigationItem.leftBarButtonItem = cancelMultiselectButton()
navigationItem.rightBarButtonItem = deleteAllCallsButton()
} else {
navigationItem.leftBarButtonItem = profileBarButtonItem()
navigationItem.rightBarButtonItem = newCallButton()
}
}
private func applyTheme() {
view.backgroundColor = Theme.backdropColor
tableView.backgroundColor = Theme.backgroundColor
}
// MARK: Profile button
private func profileBarButtonItem() -> UIBarButtonItem {
createSettingsBarButtonItem(
databaseStorage: databaseStorage,
actions: { settingsAction in
[
.init(
title: Strings.selectCallsButtonTitle,
image: Theme.iconImage(.contextMenuSelect),
attributes: []
) { [weak self] _ in
self?.startMultiselect()
},
settingsAction,
]
},
showAppSettings: { [weak self] in
self?.showAppSettings()
}
)
}
private func showAppSettings() {
AssertIsOnMainThread()
conversationSplitViewController?.selectedConversationViewController?.dismissMessageContextMenu(animated: true)
presentFormSheet(AppSettingsViewController.inModalNavigationController(), animated: true)
}
private func startMultiselect() {
Logger.debug("Select calls")
// Swipe actions count as edit mode, so cancel those
// before entering multiselection editing mode.
tableView.setEditing(false, animated: true)
tableView.setEditing(true, animated: true)
updateBarButtonItems()
showToolbar()
}
private var multiselectToolbarContainer: BlurredToolbarContainer?
private var multiselectToolbar: UIToolbar? {
multiselectToolbarContainer?.toolbar
}
private lazy var toolbarDeleteButton = UIBarButtonItem(
title: CommonStrings.deleteButton,
style: .plain,
target: self,
action: #selector(deleteSelectedCalls)
)
private func showToolbar() {
guard
// Don't create a new toolbar if we already have one
multiselectToolbarContainer == nil,
let tabController = tabBarController as? HomeTabBarController
else { return }
let toolbarContainer = BlurredToolbarContainer()
toolbarContainer.alpha = 0
view.addSubview(toolbarContainer)
toolbarContainer.autoPinWidthToSuperview()
toolbarContainer.autoPinEdge(toSuperviewEdge: .bottom)
self.multiselectToolbarContainer = toolbarContainer
let bottomInset = tabController.tabBar.height - tabController.tabBar.safeAreaInsets.bottom
self.tableView.contentInset.bottom = bottomInset
self.tableView.verticalScrollIndicatorInsets.bottom = bottomInset
tabController.setTabBarHidden(true, animated: true, duration: 0.1) { [weak self] _ in
guard let self else { return }
// See ChatListViewController.showToolbar for why this is async
DispatchQueue.main.async {
self.multiselectToolbar?.setItems(
[.flexibleSpace(), self.toolbarDeleteButton],
animated: false
)
self.updateMultiselectToolbarButtons()
}
UIView.animate(withDuration: 0.25) {
toolbarContainer.alpha = 1
}
}
}
private func updateMultiselectToolbarButtons() {
let selectedRows = tableView.indexPathsForSelectedRows ?? []
let hasSelectedEntries = !selectedRows.isEmpty
toolbarDeleteButton.isEnabled = hasSelectedEntries
}
@objc
private func deleteSelectedCalls() {
guard let selectedRows = tableView.indexPathsForSelectedRows else {
return
}
let selectedViewModelReferences: [CallViewModel.Reference] = selectedRows.map { idxPath in
viewModelLoader.loadedViewModelReferences[index: idxPath.row]
}
deleteCalls(viewModelReferences: selectedViewModelReferences)
}
// MARK: Call Link Button
private func createCallLink() {
CreateCallLinkViewController().createCallLinkOnServerAndPresent(fromViewController: self)
}
// MARK: New call button
private func newCallButton() -> UIBarButtonItem {
let barButtonItem = UIBarButtonItem(
image: Theme.iconImage(.buttonNewCall),
style: .plain,
target: self,
action: #selector(newCall)
)
barButtonItem.accessibilityLabel = OWSLocalizedString(
"NEW_CALL_LABEL",
comment: "Accessibility label for the new call button on the Calls Tab"
)
barButtonItem.accessibilityHint = OWSLocalizedString(
"NEW_CALL_HINT",
comment: "Accessibility hint describing the action of the new call button on the Calls Tab"
)
return barButtonItem
}
@objc
private func newCall() {
let viewController = NewCallViewController()
viewController.delegate = self
let modal = OWSNavigationController(rootViewController: viewController)
self.navigationController?.presentFormSheet(modal, animated: true)
}
// MARK: Cancel multiselect button
private func cancelMultiselectButton() -> UIBarButtonItem {
.cancelButton { [weak self] in
self?.cancelMultiselect()
}
}
private func cancelMultiselect() {
tableView.setEditing(false, animated: true)
updateBarButtonItems()
hideToolbar()
}
private func hideToolbar() {
guard let multiselectToolbarContainer else { return }
UIView.animate(withDuration: 0.25) {
multiselectToolbarContainer.alpha = 0
} completion: { _ in
multiselectToolbarContainer.removeFromSuperview()
self.multiselectToolbarContainer = nil
guard let tabController = self.tabBarController as? HomeTabBarController else { return }
tabController.setTabBarHidden(false, animated: true, duration: 0.1) { _ in
self.tableView.contentInset.bottom = 0
self.tableView.verticalScrollIndicatorInsets.bottom = 0
}
}
}
// MARK: Delete All button
private func deleteAllCallsButton() -> UIBarButtonItem {
return UIBarButtonItem(
title: Strings.deleteAllCallsButtonTitle,
style: .plain,
target: self,
action: #selector(promptAboutDeletingAllCalls)
)
}
@objc
private func promptAboutDeletingAllCalls() {
OWSActionSheets.showConfirmationAlert(
title: Strings.deleteAllCallsPromptTitle,
message: Strings.deleteAllCallsPromptMessage,
proceedTitle: Strings.deleteAllCallsButtonTitle,
proceedStyle: .destructive
) { _ in
/// Delete-all should use the timestamp of the most-recent call, at
/// the time the action was initiated, as the timestamp we delete
/// before (and include in the outgoing sync message).
///
/// If we don't have a most-recent call there's no point in
/// doing a delete anyway.
///
/// We also want to be sure we get the absolute most-recent call,
/// rather than the most recent call matching our UI state if the
/// user does delete-all while filtering to Missed, we still want to
/// actually delete all.
self.deps.db.asyncWrite { tx in
guard
let mostRecentCallRecord = try? self.deps.callRecordQuerier.fetchCursor(
ordering: .descending, tx: tx.asV2Read
)?.next()
else { return }
/// This will ultimately post "call records deleted"
/// notifications that this view is listening to, so we don't
/// need to do any manual UI updates.
self.deps.callRecordDeleteAllJobQueue.addJob(
sendDeleteAllSyncMessage: true,
deleteAllBefore: .callRecord(mostRecentCallRecord),
tx: tx
)
}
}
}
// MARK: Tab picker
private enum FilterMode: Int {
case all = 0
case missed = 1
}
private lazy var filterPicker: UISegmentedControl = {
let segmentedControl = UISegmentedControl(items: [
Strings.filterPickerOptionAll,
Strings.filterPickerOptionMissed
])
segmentedControl.selectedSegmentIndex = 0
segmentedControl.addTarget(self, action: #selector(filterChanged), for: .valueChanged)
return segmentedControl
}()
// MARK: Search bar
/// Sets the navigation item's search controller if it hasn't already been
/// set. Call this after loading the table the first time so that the search
/// bar is collapsed by default.
func setSearchControllerIfNeeded() {
guard navigationItem.searchController == nil else { return }
let searchController = UISearchController(searchResultsController: nil)
navigationItem.searchController = searchController
searchController.searchResultsUpdater = self
}
@objc
private func filterChanged() {
loadCallRecordsAnew(animated: true)
updateMultiselectToolbarButtons()
}
private var currentFilterMode: FilterMode {
FilterMode(rawValue: filterPicker.selectedSegmentIndex) ?? .all
}
// MARK: - Observers and Notifications
private func attachSelfAsObservers() {
deps.db.appendDatabaseChangeDelegate(self)
NotificationCenter.default.addObserver(
self,
selector: #selector(significantTimeChangeOccurred),
name: UIApplication.significantTimeChangeNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(groupCallInteractionWasUpdated),
name: GroupCallInteractionUpdatedNotification.name,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(receivedCallRecordStoreNotification),
name: CallRecordStoreNotification.name,
object: nil
)
// No need to sync state since we're still setting up the view.
deps.callService.callServiceState.addObserver(self, syncStateImmediately: false)
}
/// A significant time change has occurred, according to the system. We
/// should update the displayed date for all visible calls.
@objc
private func significantTimeChangeOccurred() {
updateDisplayedDateForAllCallCells()
}
private func updateDisplayedDateForAllCallCells() {
for callCell in tableView.visibleCells.compactMap({ $0 as? CallCell }) {
callCell.updateDisplayedDateAndScheduleRefresh()
}
}
/// When a group call interaction changes, we'll reload the row for the call
/// it represents (if that row is loaded) so as to reflect the latest state
/// for that group call.
///
/// Recall that we track "is a group call ongoing" as a property on the
/// interaction representing that group call, so we need this so we reload
/// when the call ends.
///
/// Note also that the ``didUpdateCall(from:to:)`` hook below is hit during
/// the group-call-join process but before we have actually joined the call,
/// due to the asynchronous nature of group calls. Consequently, we also
/// need this hook to reload when we ourselves have joined the call, as us
/// joining updates the "joined members" property also tracked on the group
/// call interaction.
@objc
private func groupCallInteractionWasUpdated(_ notification: NSNotification) {
guard let notification = GroupCallInteractionUpdatedNotification(notification) else {
owsFail("Unexpectedly failed to instantiate group call interaction updated notification!")
}
if DebugFlags.internalLogging {
logger.info("Group call interaction was updated, reloading.")
}
let callRecordIdForGroupCall = CallRecord.ID(
callId: notification.callId,
threadRowId: notification.groupThreadRowId
)
reloadRows(callRecordIds: [callRecordIdForGroupCall])
}
@objc
private func receivedCallRecordStoreNotification(_ notification: NSNotification) {
guard let callRecordStoreNotification = CallRecordStoreNotification(notification) else {
owsFail("Unexpected notification! \(type(of: notification))")
}
switch callRecordStoreNotification.updateType {
case .inserted:
newCallRecordWasInserted()
case .deleted(let callRecordIds):
existingCallRecordsWereDeleted(callRecordIds: callRecordIds)
case .statusUpdated(let callRecordId):
callRecordStatusWasUpdated(callRecordId: callRecordId)
}
}
/// When a call record is inserted, we'll try loading newer records.
///
/// The 99% case for a call record being inserted is that a new call was
/// started which is to say, the inserted call record is the most recent
/// call. For this case, by loading newer calls we'll load that new call and
/// present it at the top.
///
/// It is possible that we'll have a call inserted into the middle of our
/// existing calls, for example if we receive a delayed sync message about a
/// call from a while ago that we somehow never learned about on this
/// device. If that happens, we won't load and live-update with that call
/// instead, we'll see it the next time this view is reloaded.
private func newCallRecordWasInserted() {
/// Only attempt to load newer calls if the top row is visible. If not,
/// we'll load newer calls when the user scrolls up anyway.
let shouldLoadNewerCalls: Bool = {
guard
let visibleIndexPaths = tableView.indexPathsForVisibleRows,
!visibleIndexPaths.isEmpty
else {
return true
}
return visibleIndexPaths.contains(.indexPathForPrimarySection(row: 0))
}()
if shouldLoadNewerCalls {
loadMoreCalls(direction: .newer, animated: true)
}
}
private func existingCallRecordsWereDeleted(callRecordIds: [CallRecord.ID]) {
deps.db.read { tx in
viewModelLoader.dropCalls(
matching: callRecordIds, tx: tx.asV2Read
)
}
updateSnapshot(animated: true)
}
/// When the status of a call record changes, we'll reload the row it
/// represents (if that row is loaded) so as to reflect the latest state for
/// that record.
///
/// For example, imagine a ringing call that is declined on this device and
/// accepted on another device. The other device will tell us it accepted
/// via a sync message, and we should update this view to reflect the
/// accepted call.
private func callRecordStatusWasUpdated(callRecordId: CallRecord.ID) {
reloadRows(callRecordIds: [callRecordId])
}
// MARK: CallServiceStateObserver
/// When we learn that this device has joined or left a call, we'll reload
/// any rows related to that call so that we show the latest state in this
/// view.
///
/// Recall that any 1:1 call we are not actively joined to has ended, and
/// that that is not the case for group calls.
func didUpdateCall(from oldValue: SignalCall?, to newValue: SignalCall?) {
let callRecordIdsToReload = [oldValue, newValue].compactMap { call -> CallRecord.ID? in
return call?.callRecordId
}
reloadRows(callRecordIds: callRecordIdsToReload)
}
// MARK: - Clear missed calls
/// A serial queue for clearing the missed-call badge.
private let clearMissedCallQueue = DispatchQueue(label: "org.signal.calls-list-clear-missed")
/// Asynchronously clears any missed-call badges, avoiding write
/// transactions if possible.
///
/// - Important
/// The asynchronous work enqueued by this method is executed serially, such
/// that multiple calls to this method will not race.
private func clearMissedCallsIfNecessary() {
clearMissedCallQueue.async {
let unreadMissedCallCount = self.deps.db.read { tx in
self.deps.callRecordMissedCallManager.countUnreadMissedCalls(
tx: tx.asV2Read
)
}
/// We expect that the only unread calls to mark as read will be
/// missed calls, so if there's no unread missed calls no need to
/// open a write transaction.
guard unreadMissedCallCount > 0 else { return }
self.deps.db.write { tx in
self.deps.callRecordMissedCallManager.markUnreadCallsAsRead(
beforeTimestamp: nil,
sendSyncMessage: true,
tx: tx.asV2Write
)
}
}
}
// MARK: - Call loading
private var _viewModelLoader: ViewModelLoader!
private var viewModelLoader: ViewModelLoader! {
get {
AssertIsOnMainThread()
return _viewModelLoader
}
set(newValue) {
AssertIsOnMainThread()
_viewModelLoader = newValue
}
}
/// Used to avoid concurrent calls to ``loadCallRecordsAnew(animated:)``
/// from clobbering each other.
private var loadCallRecordsAnewCounter = 0
/// Asynchronously resets our current ``LoadedCalls`` for the current UI
/// state, then kicks off an initial page load.
///
/// - Note
/// This method will perform an FTS search for our current search term, if
/// we have one. That operation can be painfully slow for users with a large
/// FTS index, so we need to do it asynchronously.
private func loadCallRecordsAnew(animated: Bool) {
let searchTerm = self.searchTerm
let onlyLoadMissedCalls: Bool = {
switch self.currentFilterMode {
case .all: return false
case .missed: return true
}
}()
self.loadCallRecordsAnewCounter += 1
let loadCallRecordsAnewCounterSnapshot = self.loadCallRecordsAnewCounter
deps.db.asyncRead(
block: { tx -> CallRecordLoaderImpl.Configuration in
if let searchTerm {
guard let localIdentifiers = self.deps.tsAccountManager.localIdentifiers(tx: tx.asV2Read) else {
owsFail("Can't search if you've never been registered.")
}
var threadRowIdsMatchingSearchTerm = Set<Int64>()
let addresses = self.deps.searchableNameFinder.searchNames(
for: searchTerm,
maxResults: Constants.maxSearchResults,
localIdentifiers: localIdentifiers,
tx: tx.asV2Read,
checkCancellation: {},
addGroupThread: { groupThread in
guard let sqliteRowId = groupThread.sqliteRowId else {
owsFail("How did we match a thread in the FTS index that hasn't been inserted?")
}
threadRowIdsMatchingSearchTerm.insert(sqliteRowId)
},
addStoryThread: { _ in }
)
for address in addresses {
guard
let contactThread = TSContactThread.getWithContactAddress(address, transaction: tx),
contactThread.shouldThreadBeVisible
else {
continue
}
guard let sqliteRowId = contactThread.sqliteRowId else {
owsFail("How did we match a thread in the FTS index that hasn't been inserted?")
}
threadRowIdsMatchingSearchTerm.insert(sqliteRowId)
}
return CallRecordLoaderImpl.Configuration(
onlyLoadMissedCalls: onlyLoadMissedCalls,
onlyMatchThreadRowIds: Array(threadRowIdsMatchingSearchTerm)
)
} else {
return CallRecordLoaderImpl.Configuration(
onlyLoadMissedCalls: onlyLoadMissedCalls,
onlyMatchThreadRowIds: nil
)
}
},
completionQueue: .main,
completion: { configuration in
guard self.loadCallRecordsAnewCounter == loadCallRecordsAnewCounterSnapshot else {
/// While we were building the configuration, another caller
/// entered this method. Bail out in preference of the later
/// caller!
return
}
// Build a loader for this view's current state.
let callRecordLoader = CallRecordLoaderImpl(
callRecordQuerier: self.deps.callRecordQuerier,
configuration: configuration
)
/// We don't want to capture self in the blocks we pass to the
/// view model loader (and thereby create a retain cycle), so
/// instead we'll early-capture just the dependencies those
/// blocks actually need and give them a copy.
let capturedDeps = self.deps
// Reset our loaded calls.
self.viewModelLoader = ViewModelLoader(
callRecordLoader: callRecordLoader,
createCallViewModelBlock: { primaryCallRecord, coalescedCallRecords, tx in
return Self.createCallViewModel(
primaryCallRecord: primaryCallRecord,
coalescedCallRecords: coalescedCallRecords,
deps: capturedDeps,
tx: SDSDB.shimOnlyBridge(tx)
)
},
fetchCallRecordBlock: { callRecordId, tx -> CallRecord? in
return capturedDeps.callRecordStore.fetch(
callRecordId: callRecordId,
tx: SDSDB.shimOnlyBridge(tx)
).unwrapped
}
)
// Load the initial page of records. We've thrown away all our
// existing calls, so we want to always update the snapshot.
self.loadMoreCalls(
direction: .older,
animated: animated,
forceUpdateSnapshot: true
)
}
)
}
/// Load more calls as necessary given that a row for the given index path
/// is soon going to be presented.
///
/// - Returns
/// The call view model for this index path. A return value of `nil`
/// represents an unexpected error.
private func loadMoreCallsIfNecessary(
indexToBeDisplayed callIndex: Int
) -> CallViewModel? {
if callIndex == viewModelLoader.loadedViewModelReferences.count - 1 {
/// If this index path represents the oldest loaded call, try and
/// load another page of even-older calls.
loadMoreCalls(direction: .older, animated: false)
} else if !viewModelLoader.hasCachedViewModel(
loadedViewModelReferenceIndex: callIndex
) {
/// If we don't have a view model ready to go for this row, load
/// until we do.
///
/// This is probably because we're scrolling through so many calls
/// we can't keep them all cached and we've evicted the view model
/// for this row, but we've scrolled back to it and need to re-load
/// the view model.
///
/// Note that the table may request view models for totally disjoint
/// rows. For example, imagine we're scrolled down a couple hundred
/// calls and the user triggers the "scroll-to-top" gesture. The
/// table is gonna scroll super-fast to the top and request models
/// for rows right at the top, which have long been paged out. Along
/// the way, it may request view models for sporadic rows as it
/// passes over them.
///
/// All of that to say: we need support for "page in view models for
/// an arbitrary index path", which is what this does.
deps.db.read { tx in
viewModelLoader.loadUntilCached(
loadedViewModelReferenceIndex: callIndex, tx: tx.asV2Read
)
}
}
return viewModelLoader.getCachedViewModel(
loadedViewModelReferenceIndex: callIndex
)
}
/// Synchronously loads more calls, then asynchronously update the snapshot
/// if any new calls were actually loaded.
///
/// - Parameter forceUpdateSnapshot
/// Whether we should always update the snapshot, regardless of if any new
/// calls were loaded.
private func loadMoreCalls(
direction loadDirection: ViewModelLoader.LoadDirection,
animated: Bool,
forceUpdateSnapshot: Bool = false
) {
let shouldUpdateSnapshot = deps.db.read { tx in
return viewModelLoader.loadMore(
direction: loadDirection, tx: tx.asV2Read
)
}
guard forceUpdateSnapshot || shouldUpdateSnapshot else { return }
DispatchQueue.main.async {
self.updateSnapshot(animated: animated)
// Add the search bar after loading table content the first time so
// that it is collapsed by default.
self.setSearchControllerIfNeeded()
}
}
/// Converts ``CallRecord``s into a ``CallViewModel``.
///
/// - Important
/// The primary and and coalesced call records *must* all have the same
/// thread, direction, missed status, and call type.
private static func createCallViewModel(
primaryCallRecord: CallRecord,
coalescedCallRecords: [CallRecord],
deps: Dependencies,
tx: SDSAnyReadTransaction
) -> CallViewModel {
owsAssert(
coalescedCallRecords.allSatisfy { $0.threadRowId == primaryCallRecord.threadRowId },
"Coalesced call records were for a different thread than the primary!"
)
owsAssert(
coalescedCallRecords.allSatisfy { $0.callDirection == primaryCallRecord.callDirection },
"Coalesced call records were of a different direction than the primary!"
)
owsAssert(
coalescedCallRecords.allSatisfy { $0.callStatus.isMissedCall == primaryCallRecord.callStatus.isMissedCall },
"Coalesced call records were of a different missed status than the primary!"
)
owsAssert(
([primaryCallRecord] + coalescedCallRecords).isSortedByTimestamp(.descending),
"Primary and coalesced call records were not ordered descending by timestamp!"
)
guard let callThread = deps.threadStore.fetchThread(
rowId: primaryCallRecord.threadRowId,
tx: tx.asV2Read
) else {
owsFail("Missing thread for call record! This should be impossible, per the DB schema.")
}
let callDirection: CallViewModel.Direction = {
if primaryCallRecord.callStatus.isMissedCall {
return .missed
}
switch primaryCallRecord.callDirection {
case .incoming: return .incoming
case .outgoing: return .outgoing
}
}()
/// The call state may be different between the primary and the
/// coalesced calls. For the view model's state, we use the primary.
let callState: CallViewModel.State = {
let currentCallId: UInt64? = deps.callService.callServiceState.currentCall?.callId
switch primaryCallRecord.callStatus {
case .individual:
if primaryCallRecord.callId == currentCallId {
// We can have at most one 1:1 call active at a time, and if
// we have an active 1:1 call we must be in it. All other
// 1:1 calls must have ended.
return .participating
}
case .group:
guard let groupCallInteraction: OWSGroupCallMessage = deps.interactionStore
.fetchAssociatedInteraction(
callRecord: primaryCallRecord, tx: tx.asV2Read
)
else {
owsFail("Missing interaction for group call. This should be impossible per the DB schema!")
}
// We learn that a group call ended by peeking the group. During
// that peek, we update the group call interaction. It's a
// leetle wonky that we use the interaction to store that info,
// but such is life.
if !groupCallInteraction.hasEnded {
if primaryCallRecord.callId == currentCallId {
return .participating
}
return .active
}
}
return .ended
}()
if let contactThread = callThread as? TSContactThread {
let callType: CallViewModel.RecipientType.CallType = {
switch primaryCallRecord.callType {
case .audioCall:
return .audio
case .groupCall:
owsFailDebug("Had group call type for 1:1 call!")
fallthrough
case .videoCall:
return .video
}
}()
return CallViewModel(
primaryCallRecord: primaryCallRecord,
coalescedCallRecords: coalescedCallRecords,
title: deps.contactsManager.displayName(
for: contactThread.contactAddress, tx: tx
).resolvedValue(),
recipientType: .individual(type: callType, contactThread: contactThread),
direction: callDirection,
state: callState
)
} else if let groupThread = callThread as? TSGroupThread {
return CallViewModel(
primaryCallRecord: primaryCallRecord,
coalescedCallRecords: coalescedCallRecords,
title: groupThread.groupModel.groupNameOrDefault,
recipientType: .group(groupThread: groupThread),
direction: callDirection,
state: callState
)
} else {
owsFail("Call thread was neither contact nor group! This should be impossible.")
}
}
// MARK: - Search term
/// - Important
/// Don't use this directly use ``searchTerm``.
private var _searchTerm: String? {
didSet {
guard oldValue != searchTerm else {
// If the term hasn't changed, don't do anything.
return
}
searchTermDidChange()
}
}
/// The user's current search term. Coalesces empty strings into `nil`.
private var searchTerm: String? {
get { _searchTerm }
set { _searchTerm = newValue?.nilIfEmpty }
}
private var searchTermCounter = 0
private func searchTermDidChange() {
self.searchTermCounter += 1
let searchTermCounterSnapshot = self.searchTermCounter
DispatchQueue.main.asyncAfter(deadline: .now() + Constants.searchDebounceInterval) {
guard self.searchTermCounter == searchTermCounterSnapshot else {
/// The search term changed in the debounce period, so we'll
/// bail out here in preference for the later-changed search
/// term.
return
}
self.loadCallRecordsAnew(animated: true)
}
}
// MARK: - Table view
fileprivate enum Section: Int, Hashable {
case createCallLink
case existingCalls
}
fileprivate enum RowIdentifier: Hashable {
case createCallLink
case callViewModelReference(CallViewModel.Reference)
}
struct CallViewModel {
enum Reference: Hashable {
case singleCall(CallRecord.ID)
case coalescedCalls(
primary: CallRecord.ID,
coalesced: [CallRecord.ID]
)
var primaryId: CallRecord.ID {
switch self {
case .singleCall(let callRecordId): return callRecordId
case .coalescedCalls(let primary, _): return primary
}
}
}
enum Direction {
case outgoing
case incoming
case missed
var label: String {
switch self {
case .outgoing:
return Strings.callDirectionLabelOutgoing
case .incoming:
return Strings.callDirectionLabelIncoming
case .missed:
return Strings.callDirectionLabelMissed
}
}
}
enum State {
/// This call is active, but the user is not in it.
case active
/// The user is currently in this call.
case participating
/// The call is no longer active.
case ended
}
enum RecipientType {
case individual(type: CallType, contactThread: TSContactThread)
case group(groupThread: TSGroupThread)
enum CallType: Hashable {
case audio
case video
}
}
let primaryCallRecord: CallRecord
let coalescedCallRecords: [CallRecord]
var allCallRecords: [CallRecord] { [primaryCallRecord] + coalescedCallRecords }
var reference: Reference {
if coalescedCallRecords.isEmpty {
return .singleCall(primaryCallRecord.id)
}
return .coalescedCalls(
primary: primaryCallRecord.id,
coalesced: coalescedCallRecords.map { $0.id }
)
}
let title: String
let recipientType: RecipientType
let direction: Direction
let state: State
var callId: UInt64 { primaryCallRecord.callId }
var threadRowId: Int64 { primaryCallRecord.threadRowId }
var callBeganDate: Date { primaryCallRecord.callBeganDate }
/// The `callBeganTimestamp` of the oldest call contained herein.
var oldestContainedTimestamp: UInt64 {
if let lastCoalescedTimestamp = coalescedCallRecords.last?.callBeganTimestamp {
return lastCoalescedTimestamp
}
return primaryCallRecord.callBeganTimestamp
}
/// The `callBeganTimestamp` of the newest call contained herein.
var newestContainedTimestamp: UInt64 {
return primaryCallRecord.callBeganTimestamp
}
init(
primaryCallRecord: CallRecord,
coalescedCallRecords: [CallRecord],
title: String,
recipientType: RecipientType,
direction: Direction,
state: State
) {
self.primaryCallRecord = primaryCallRecord
self.coalescedCallRecords = coalescedCallRecords
self.title = title
self.recipientType = recipientType
self.direction = direction
self.state = state
}
var callType: RecipientType.CallType {
switch recipientType {
case let .individual(callType, _):
return callType
case .group(_):
return .video
}
}
/// The `TSThread` for the call. If a `TSContactThread` or
/// `TSGroupThread` is needed, switch on `recipientType`
/// instead of typecasting this property.
var thread: TSThread {
switch recipientType {
case let .individual(_, contactThread):
return contactThread
case let .group(groupThread):
return groupThread
}
}
var isMissed: Bool {
switch direction {
case .outgoing, .incoming:
return false
case .missed:
return true
}
}
}
let tableView = UITableView(frame: .zero, style: .plain)
private static let createCallLinkReuseIdentifier = "createCallLink"
private static let callCellReuseIdentifier = "callCell"
private lazy var dataSource = DiffableDataSource(
tableView: tableView
) { [weak self] tableView, indexPath, _ -> UITableViewCell? in
return self?.buildTableViewCell(tableView: tableView, indexPath: indexPath) ?? UITableViewCell()
}
private func buildTableViewCell(tableView: UITableView, indexPath: IndexPath) -> UITableViewCell? {
switch Section(rawValue: indexPath.section) {
case .createCallLink:
let createCallLinkCell = tableView.dequeueReusableCell(withIdentifier: Self.createCallLinkReuseIdentifier, for: indexPath)
// [CallLink] TODO: Localize.
createCallLinkCell.textLabel?.text = "Create a Call Link"
return createCallLinkCell
case .existingCalls:
guard
let callCell = tableView.dequeueReusableCell(
withIdentifier: Self.callCellReuseIdentifier
) as? CallCell
else {
return nil
}
/// This load should be sufficiently fast that doing it here
/// synchronously is fine.
if let viewModelForIndexPath = self.loadMoreCallsIfNecessary(
indexToBeDisplayed: indexPath.row
) {
callCell.delegate = self
callCell.viewModel = viewModelForIndexPath
return callCell
} else {
owsFailDebug("Missing cached view model how did this happen?")
/// Return an empty table cell, rather than a ``CallCell`` that's
/// gonna be incorrectly configured.
}
case .none:
break
}
return nil
}
private func getSnapshot() -> Snapshot {
var snapshot = Snapshot()
snapshot.appendSections([.createCallLink])
if FeatureFlags.callLinkCreate {
snapshot.appendItems([.createCallLink])
}
snapshot.appendSections([.existingCalls])
snapshot.appendItems(viewModelLoader.loadedViewModelReferences.allElements.map { .callViewModelReference($0) })
return snapshot
}
private func updateSnapshot(animated: Bool) {
dataSource.apply(getSnapshot(), animatingDifferences: animated)
updateEmptyStateMessage()
cancelMultiselectIfEmpty()
}
/// Reload any rows containing one of the given call record IDs.
private func reloadRows(callRecordIds callRecordIdsToReload: [CallRecord.ID]) {
/// Recreate the view models, so when the data source reloads the rows
/// it'll reflect the new underlying state for that row.
///
/// This step will also drop any IDs for models that are not currently
/// loaded, which should not be included in the snapshot.
let referencesToReload = deps.db.read { tx -> [CallViewModel.Reference] in
return viewModelLoader.refreshViewModels(
callRecordIds: callRecordIdsToReload, tx: tx.asV2Read
)
}
if DebugFlags.internalLogging {
logger.info("Reloading \(referencesToReload.count) rows.")
}
var snapshot = getSnapshot()
snapshot.reloadItems(referencesToReload.map { .callViewModelReference($0) })
dataSource.apply(snapshot)
}
private func reloadAllRows() {
var snapshot = getSnapshot()
snapshot.reloadSections([.existingCalls])
dataSource.apply(snapshot)
}
private func cancelMultiselectIfEmpty() {
if
tableView.isEditing,
viewModelLoader.loadedViewModelReferences.isEmpty
{
cancelMultiselect()
}
}
private func updateEmptyStateMessage() {
switch (viewModelLoader.loadedViewModelReferences.count, searchTerm) {
case (0, .some(let searchTerm)) where !searchTerm.isEmpty:
noSearchResultsView.text = String(
format: Strings.searchNoResultsFoundLabelFormat,
arguments: [searchTerm]
)
noSearchResultsView.layer.opacity = 1
emptyStateMessageView.layer.opacity = 0
case (0, _):
emptyStateMessageView.attributedText = NSAttributedString.composed(of: {
switch currentFilterMode {
case .all:
return [
Strings.noRecentCallsLabel,
"\n",
Strings.noRecentCallsSuggestionLabel
.styled(with: .font(.dynamicTypeSubheadline)),
]
case .missed:
return [
Strings.noMissedCallsLabel
]
}
}())
.styled(
with: .font(.dynamicTypeSubheadline.semibold())
)
noSearchResultsView.layer.opacity = 0
emptyStateMessageView.layer.opacity = 1
case (_, _):
// Hide empty state message
noSearchResultsView.layer.opacity = 0
emptyStateMessageView.layer.opacity = 0
}
}
}
private extension IndexPath {
static func indexPathForPrimarySection(row: Int) -> IndexPath {
return IndexPath(
row: row,
section: CallsListViewController.Section.existingCalls.rawValue
)
}
}
private extension SignalCall {
var callId: UInt64? {
switch mode {
case .individual(let individualCall):
return individualCall.callId
case .groupThread(let call as GroupCall), .callLink(let call as GroupCall):
return call.ringRtcCall.peekInfo?.eraId.map { callIdFromEra($0) }
}
}
var callRecordId: CallRecord.ID? {
guard let callId else { return nil }
return CallRecord.ID(callId: callId, threadRowId: threadRowId)
}
private var threadRowId: Int64 {
switch mode {
case .individual(let call):
return call.thread.sqliteRowId!
case .groupThread(let call):
return call.groupThread.sqliteRowId!
case .callLink:
owsFail("[CallLink] TODO: Can't fetch threadRowId for a CallLink call")
}
}
}
private extension CallRecordStore {
func fetch(
callRecordId: CallRecord.ID,
tx: SDSAnyReadTransaction
) -> CallRecordStore.MaybeDeletedFetchResult {
return fetch(
callId: callRecordId.callId,
threadRowId: callRecordId.threadRowId,
tx: tx.asV2Read
)
}
}
// MARK: UITableViewDelegate
extension CallsListViewController: UITableViewDelegate {
private func viewModel(
forIndexPathThatShouldHaveOne indexPath: IndexPath
) -> CallViewModel? {
owsAssert(
indexPath.section == Section.existingCalls.rawValue,
"Unexpected section for index path: \(indexPath.section)"
)
guard let viewModel = viewModelLoader.getCachedViewModel(
loadedViewModelReferenceIndex: indexPath.row
) else {
owsFailBeta("Missing view model for index path. How did this happen?")
return nil
}
return viewModel
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if tableView.isEditing {
updateMultiselectToolbarButtons()
return
}
tableView.deselectRow(at: indexPath, animated: true)
switch Section(rawValue: indexPath.section) {
case .createCallLink:
createCallLink()
case .existingCalls, .none:
guard let viewModel = viewModel(forIndexPathThatShouldHaveOne: indexPath) else {
return owsFailDebug("Missing view model")
}
callBack(from: viewModel)
}
}
func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
if tableView.isEditing {
updateMultiselectToolbarButtons()
}
}
func tableView(_ tableView: UITableView, shouldBeginMultipleSelectionInteractionAt indexPath: IndexPath) -> Bool {
switch Section(rawValue: indexPath.section) {
case .createCallLink:
return false
case .existingCalls, .none:
return true
}
}
func tableView(_ tableView: UITableView, didBeginMultipleSelectionInteractionAt indexPath: IndexPath) {
updateBarButtonItems()
showToolbar()
}
func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
switch Section(rawValue: indexPath.section) {
case .createCallLink:
return nil
case .existingCalls, .none:
break
}
return self.longPressActions(forRowAt: indexPath)
.map { actions in UIMenu.init(children: actions) }
.map { menu in
UIContextMenuConfiguration(identifier: indexPath as NSCopying, previewProvider: nil) { _ in menu }
}
}
func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
switch Section(rawValue: indexPath.section) {
case .createCallLink:
return nil
case .existingCalls, .none:
break
}
guard let viewModel = viewModel(forIndexPathThatShouldHaveOne: indexPath) else {
owsFailDebug("Missing call view model")
return nil
}
let goToChatAction = makeContextualAction(
style: .normal,
color: .ows_accentBlue,
image: "arrow-square-upright-fill",
title: Strings.goToChatActionTitle
) { [weak self] in
self?.goToChat(from: viewModel)
}
return .init(actions: [goToChatAction])
}
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
switch Section(rawValue: indexPath.section) {
case .createCallLink:
return nil
case .existingCalls, .none:
break
}
guard let viewModel = viewModel(forIndexPathThatShouldHaveOne: indexPath) else {
owsFailDebug("Missing call view model")
return nil
}
let deleteAction = makeContextualAction(
style: .destructive,
color: .ows_accentRed,
image: "trash-fill",
title: CommonStrings.deleteButton
) { [weak self] in
self?.deleteCalls(viewModelReferences: [viewModel.reference])
}
return .init(actions: [deleteAction])
}
private func makeContextualAction(
style: UIContextualAction.Style,
color: UIColor,
image: String,
title: String,
action: @escaping () -> Void
) -> UIContextualAction {
let action = UIContextualAction(
style: style,
title: nil
) { _, _, completion in
action()
completion(true)
}
action.backgroundColor = color
action.image = UIImage(named: image)?.withTitle(
title,
font: .dynamicTypeFootnote.medium(),
color: .ows_white,
maxTitleWidth: 68,
minimumScaleFactor: CGFloat(8) / CGFloat(13),
spacing: 4
)?.withRenderingMode(.alwaysTemplate)
return action
}
private func longPressActions(forRowAt indexPath: IndexPath) -> [UIAction]? {
guard let viewModel = viewModel(forIndexPathThatShouldHaveOne: indexPath) else {
owsFailDebug("Missing call view model")
return nil
}
var actions = [UIAction]()
switch viewModel.state {
case .active:
let joinCallTitle: String
let joinCallIconName: String
switch viewModel.callType {
case .audio:
joinCallTitle = Strings.joinVoiceCallActionTitle
joinCallIconName = Theme.iconName(.contextMenuVoiceCall)
case .video:
joinCallTitle = Strings.joinVideoCallActionTitle
joinCallIconName = Theme.iconName(.contextMenuVideoCall)
}
let joinCallAction = UIAction(
title: joinCallTitle,
image: UIImage(named: joinCallIconName),
attributes: []
) { [weak self] _ in
self?.joinCall(from: viewModel)
}
actions.append(joinCallAction)
case .participating:
let returnToCallIconName: String
switch viewModel.callType {
case .audio:
returnToCallIconName = Theme.iconName(.contextMenuVoiceCall)
case .video:
returnToCallIconName = Theme.iconName(.contextMenuVideoCall)
}
let returnToCallAction = UIAction(
title: Strings.returnToCallActionTitle,
image: UIImage(named: returnToCallIconName),
attributes: []
) { [weak self] _ in
self?.returnToCall(from: viewModel)
}
actions.append(returnToCallAction)
case .ended:
switch viewModel.recipientType {
case .individual:
let audioCallAction = UIAction(
title: Strings.startVoiceCallActionTitle,
image: Theme.iconImage(.contextMenuVoiceCall),
attributes: []
) { [weak self] _ in
self?.startVoiceCall(from: viewModel)
}
actions.append(audioCallAction)
case .group:
break
}
let videoCallAction = UIAction(
title: Strings.startVideoCallActionTitle,
image: Theme.iconImage(.contextMenuVideoCall),
attributes: []
) { [weak self] _ in
self?.startVideoCall(from: viewModel)
}
actions.append(videoCallAction)
}
let goToChatAction = UIAction(
title: Strings.goToChatActionTitle,
image: Theme.iconImage(.contextMenuOpenInChat),
attributes: []
) { [weak self] _ in
self?.goToChat(from: viewModel)
}
actions.append(goToChatAction)
let infoAction = UIAction(
title: Strings.viewCallInfoActionTitle,
image: Theme.iconImage(.contextMenuInfo),
attributes: []
) { [weak self] _ in
self?.showCallInfo(from: viewModel)
}
actions.append(infoAction)
let selectAction = UIAction(
title: Strings.selectCallActionTitle,
image: Theme.iconImage(.contextMenuSelect),
attributes: []
) { [weak self] _ in
self?.selectCall(forRowAt: indexPath)
}
actions.append(selectAction)
switch viewModel.state {
case .active, .ended:
let deleteAction = UIAction(
title: Strings.deleteCallActionTitle,
image: Theme.iconImage(.contextMenuDelete),
attributes: .destructive
) { [weak self] _ in
self?.deleteCalls(viewModelReferences: [viewModel.reference])
}
actions.append(deleteAction)
case .participating:
break
}
return actions
}
}
// MARK: - Actions
extension CallsListViewController: CallCellDelegate, NewCallViewControllerDelegate {
private func callBack(from viewModel: CallViewModel) {
switch viewModel.callType {
case .audio:
startVoiceCall(from: viewModel)
case .video:
startVideoCall(from: viewModel)
}
}
private var callStarterContext: CallStarter.Context {
.init(
blockingManager: deps.blockingManager,
databaseStorage: deps.db,
callService: deps.callService
)
}
private func startVoiceCall(from viewModel: CallViewModel) {
switch viewModel.recipientType {
case let .individual(_, contactThread):
CallStarter(
contactThread: contactThread,
withVideo: false,
context: self.callStarterContext
).startCall(from: self)
case .group:
owsFail("Shouldn't be able to start voice call from group")
}
}
private func startVideoCall(from viewModel: CallViewModel) {
switch viewModel.recipientType {
case let .individual(_, contactThread):
CallStarter(
contactThread: contactThread,
withVideo: true,
context: self.callStarterContext
).startCall(from: self)
case let .group(groupThread):
CallStarter(
groupThread: groupThread,
context: self.callStarterContext
).startCall(from: self)
}
}
private func goToChat(from viewModel: CallViewModel) {
goToChat(for: viewModel.thread)
}
private func deleteCalls(viewModelReferences: [CallViewModel.Reference]) {
deps.db.asyncWrite { tx in
let callRecordIdsToDelete: [CallRecord.ID] = viewModelReferences.flatMap { reference in
return reference.containedIds
}
let callRecordsToDelete = callRecordIdsToDelete.compactMap { callRecordId -> CallRecord? in
return self.deps.callRecordStore.fetch(
callRecordId: callRecordId, tx: tx
).unwrapped
}
/// Deleting these call records will trigger a ``CallRecordStoreNotification``,
/// which we're listening for in this view and will in turn lead us
/// to update the UI as appropriate.
self.deps.interactionDeleteManager.delete(
alongsideAssociatedCallRecords: callRecordsToDelete,
sideEffects: .default(),
tx: tx.asV2Write
)
}
}
private func selectCall(forRowAt indexPath: IndexPath) {
startMultiselect()
tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none)
}
// MARK: CallCellDelegate
fileprivate func joinCall(from viewModel: CallViewModel) {
guard case let .group(groupThread) = viewModel.recipientType else {
owsFailBeta("Individual call should not be showing a join button")
return
}
CallStarter(
groupThread: groupThread,
context: self.callStarterContext
).startCall(from: self)
}
fileprivate func returnToCall(from viewModel: CallViewModel) {
guard WindowManager.shared.hasCall else { return }
WindowManager.shared.returnToCallView()
}
fileprivate func showCallInfo(from viewModel: CallViewModel) {
AssertIsOnMainThread()
let (threadViewModel, isSystemContact) = deps.db.read { tx in
let thread = viewModel.thread
let threadViewModel = ThreadViewModel(
thread: thread,
forChatList: false,
transaction: tx
)
let isSystemContact = thread.isSystemContact(
contactsManager: deps.contactsManager,
tx: tx
)
return (threadViewModel, isSystemContact)
}
let callDetailsView = ConversationSettingsViewController(
threadViewModel: threadViewModel,
isSystemContact: isSystemContact,
// Nothing would have been revealed, so this can be a fresh instance
spoilerState: SpoilerRenderState(),
callViewModel: viewModel
)
callDetailsView.hidesBottomBarWhenPushed = true
navigationController?.pushViewController(callDetailsView, animated: true)
}
// MARK: NewCallViewControllerDelegate
func goToChat(for thread: TSThread) {
SignalApp.shared.presentConversationForThread(thread, action: .compose, animated: false)
}
}
// MARK: UISearchResultsUpdating
extension CallsListViewController: UISearchResultsUpdating {
func updateSearchResults(for searchController: UISearchController) {
self.searchTerm = searchController.searchBar.text
}
}
// MARK: - DatabaseChangeDelegate
extension CallsListViewController: DatabaseChangeDelegate {
/// If the database changed externally which is to say, in the NSE state
/// that this view relies on may have changed. We can't know if it'll have
/// affected us, so we'll simply load calls fresh and make the table view
/// reload all the cells.
func databaseChangesDidUpdateExternally() {
logger.info("Database changed externally, loading calls anew and reloading all rows.")
loadCallRecordsAnew(animated: false)
reloadAllRows()
}
func databaseChangesDidUpdate(databaseChanges: DatabaseChanges) {}
func databaseChangesDidReset() {}
}
// MARK: - Call cell
private extension CallsListViewController {
class CallCell: UITableViewCell {
private static var verticalMargin: CGFloat = 11
private static var horizontalMargin: CGFloat = 20
private static var joinButtonMargin: CGFloat = 18
weak var delegate: CallCellDelegate?
var viewModel: CallViewModel? {
didSet {
updateContents()
}
}
// MARK: Subviews
private lazy var avatarView = ConversationAvatarView(
sizeClass: .thirtySix,
localUserDisplayMode: .asUser
)
private lazy var titleLabel: UILabel = {
let label = UILabel()
label.font = .dynamicTypeHeadline
return label
}()
private lazy var subtitleLabel = UILabel()
private lazy var timestampLabel: UILabel = {
let label = UILabel()
label.font = .dynamicTypeBody2
return label
}()
private lazy var detailsButton: OWSButton = {
let button = OWSButton { [weak self] in
self?.detailsTapped()
}
// The info icon is the button's own image and should be `horizontalMargin` from the edge
button.contentEdgeInsets.trailing = Self.horizontalMargin
button.contentEdgeInsets.leading = 8
// The join button is a separate subview and should be `joinButtonMargin` from the edge
button.layoutMargins.trailing = Self.joinButtonMargin
return button
}()
private var joinPill: UIView?
private func makeJoinPill() -> UIView? {
guard let viewModel else { return nil }
let icon: UIImage
switch viewModel.callType {
case .audio:
icon = Theme.iconImage(.phoneFill16)
case .video:
icon = Theme.iconImage(.videoFill16)
}
let text: String
switch viewModel.state {
case .active:
text = Strings.joinCallButtonTitle
case .participating:
text = Strings.returnToCallButtonTitle
case .ended:
return nil
}
let button = OWSRoundedButton()
let font = UIFont.dynamicTypeBody2.bold()
let title = NSAttributedString.composed(of: [
NSAttributedString.with(
image: icon,
font: .dynamicTypeCallout,
centerVerticallyRelativeTo: font,
heightReference: .pointSize
),
" ",
text,
]).styled(
with: .font(font),
.color(.ows_white)
)
button.setAttributedTitle(title, for: .normal)
button.backgroundColor = .ows_accentGreen
button.contentEdgeInsets = .init(hMargin: 12, vMargin: 4)
button.setCompressionResistanceHigh()
button.isUserInteractionEnabled = false
return button
}
// MARK: Init
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
let bodyVStack = UIStackView(arrangedSubviews: [
titleLabel,
subtitleLabel,
])
bodyVStack.axis = .vertical
let leadingHStack = UIStackView(arrangedSubviews: [
avatarView,
bodyVStack,
])
leadingHStack.alignment = .center
leadingHStack.axis = .horizontal
leadingHStack.spacing = 12
let trailingHStack = UIStackView(arrangedSubviews: [
timestampLabel,
detailsButton,
])
trailingHStack.axis = .horizontal
trailingHStack.spacing = 0
let outerHStack = UIStackView(arrangedSubviews: [
leadingHStack,
UIView(),
trailingHStack,
])
outerHStack.axis = .horizontal
outerHStack.spacing = 4
// The details button should take up the entire trailing space,
// top to bottom, so the content should have zero margins.
contentView.preservesSuperviewLayoutMargins = false
contentView.layoutMargins = .zero
leadingHStack.preservesSuperviewLayoutMargins = false
leadingHStack.isLayoutMarginsRelativeArrangement = true
leadingHStack.layoutMargins = .init(
top: Self.verticalMargin,
leading: Self.horizontalMargin,
bottom: Self.verticalMargin,
trailing: 0
)
contentView.addSubview(outerHStack)
outerHStack.autoPinEdgesToSuperviewMargins()
tintColor = .ows_accentBlue
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
timestampDisplayRefreshTimer?.invalidate()
}
// MARK: Dynamically-refreshing timestamp
/// A timer tracking the next time this cell should refresh its
/// displayed timestamp.
private var timestampDisplayRefreshTimer: Timer?
/// Immediately update the display timestamp for this cell, and schedule
/// an automatic refresh of the display timestamp as appropriate.
func updateDisplayedDateAndScheduleRefresh() {
AssertIsOnMainThread()
timestampDisplayRefreshTimer?.invalidate()
timestampDisplayRefreshTimer = nil
guard let viewModel else { return }
let date: Date? = {
switch viewModel.state {
case .active, .participating:
/// Don't show a date for active calls.
return nil
case .ended:
return viewModel.callBeganDate
}
}()
guard let date else {
timestampLabel.text = nil
return
}
let (formattedDate, nextRefreshDate) = DateUtil.formatDynamicDateShort(date)
timestampLabel.text = formattedDate
if let nextRefreshDate {
timestampDisplayRefreshTimer = .scheduledTimer(
withTimeInterval: max(1, nextRefreshDate.timeIntervalSinceNow),
repeats: false
) { [weak self] _ in
guard let self else { return }
self.updateDisplayedDateAndScheduleRefresh()
}
}
}
// MARK: Updates
private func updateContents() {
applyTheme()
guard let viewModel else {
return owsFailDebug("Missing view model")
}
avatarView.updateWithSneakyTransactionIfNecessary { configuration in
configuration.dataSource = .thread(viewModel.thread)
}
let titleText: String = {
if viewModel.coalescedCallRecords.isEmpty {
return viewModel.title
}
return String(
format: Strings.coalescedCallsTitleFormat,
viewModel.title,
"\(viewModel.allCallRecords.count)"
)
}()
self.titleLabel.text = titleText
switch viewModel.direction {
case .incoming, .outgoing:
titleLabel.textColor = Theme.primaryTextColor
case .missed:
titleLabel.textColor = .ows_accentRed
}
self.subtitleLabel.attributedText = {
let icon: ThemeIcon
switch viewModel.callType {
case .audio:
icon = .phone16
case .video:
icon = .video16
}
return .composed(of: [
NSAttributedString.with(
image: Theme.iconImage(icon),
font: .dynamicTypeCallout,
centerVerticallyRelativeTo: .dynamicTypeBody2,
heightReference: .pointSize
),
" ",
viewModel.direction.label,
]).styled(with: .font(.dynamicTypeBody2))
}()
self.joinPill?.removeFromSuperview()
switch viewModel.state {
case .active, .participating:
// Join button
detailsButton.setImage(imageName: nil)
detailsButton.tintColor = .ows_white
if let joinPill = makeJoinPill() {
self.joinPill = joinPill
detailsButton.addSubview(joinPill)
joinPill.autoVCenterInSuperview()
joinPill.autoPinWidthToSuperviewMargins()
}
case .ended:
// Info button
detailsButton.setImage(imageName: "info")
detailsButton.tintColor = Theme.primaryIconColor
}
updateDisplayedDateAndScheduleRefresh()
}
private func applyTheme() {
backgroundColor = Theme.backgroundColor
selectedBackgroundView?.backgroundColor = Theme.tableCell2SelectedBackgroundColor
multipleSelectionBackgroundView?.backgroundColor = Theme.tableCell2MultiSelectedBackgroundColor
titleLabel.textColor = Theme.primaryTextColor
subtitleLabel.textColor = Theme.snippetColor
timestampLabel.textColor = Theme.snippetColor
}
// MARK: Actions
private func detailsTapped() {
guard let viewModel else {
return owsFailDebug("Missing view model")
}
guard let delegate else {
return owsFailDebug("Missing delegate")
}
switch viewModel.state {
case .active:
delegate.joinCall(from: viewModel)
case .participating:
delegate.returnToCall(from: viewModel)
case .ended:
delegate.showCallInfo(from: viewModel)
}
}
}
}