// // Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import LibSignalClient import SignalUI import SignalRingRTC import SignalServiceKit // MARK: - CallCellDelegate private protocol CallCellDelegate: AnyObject { func joinCall(from viewModel: CallsListViewController.CallViewModel) func returnToCall(from viewModel: CallsListViewController.CallViewModel) } // MARK: - CallsListViewController class CallsListViewController: OWSViewController, HomeTabViewController, CallServiceStateObserver, GroupCallObserver { private typealias Snapshot = NSDiffableDataSourceSnapshot 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 adHocCallRecordManager: any AdHocCallRecordManager let badgeManager: BadgeManager let blockingManager: BlockingManager let callLinkStore: any CallLinkRecordStore let callRecordDeleteAllJobQueue: CallRecordDeleteAllJobQueue let callRecordDeleteManager: any CallRecordDeleteManager let callRecordMissedCallManager: CallRecordMissedCallManager let callRecordQuerier: CallRecordQuerier let callRecordStore: CallRecordStore let callService: CallService let contactsManager: any ContactManager let databaseChangeObserver: DatabaseChangeObserver let databaseStorage: SDSDatabaseStorage let db: any DB let groupCallManager: GroupCallManager let interactionDeleteManager: InteractionDeleteManager let interactionStore: InteractionStore let searchableNameFinder: SearchableNameFinder let threadStore: ThreadStore let tsAccountManager: any TSAccountManager } private nonisolated let deps: Dependencies = Dependencies( adHocCallRecordManager: DependenciesBridge.shared.adHocCallRecordManager, badgeManager: AppEnvironment.shared.badgeManager, blockingManager: SSKEnvironment.shared.blockingManagerRef, callLinkStore: DependenciesBridge.shared.callLinkStore, callRecordDeleteAllJobQueue: SSKEnvironment.shared.callRecordDeleteAllJobQueueRef, callRecordDeleteManager: DependenciesBridge.shared.callRecordDeleteManager, callRecordMissedCallManager: DependenciesBridge.shared.callRecordMissedCallManager, callRecordQuerier: DependenciesBridge.shared.callRecordQuerier, callRecordStore: DependenciesBridge.shared.callRecordStore, callService: AppEnvironment.shared.callService, contactsManager: SSKEnvironment.shared.contactManagerRef, databaseChangeObserver: DependenciesBridge.shared.databaseChangeObserver, databaseStorage: SSKEnvironment.shared.databaseStorageRef, db: DependenciesBridge.shared.db, groupCallManager: SSKEnvironment.shared.groupCallManagerRef, interactionDeleteManager: DependenciesBridge.shared.interactionDeleteManager, interactionStore: DependenciesBridge.shared.interactionStore, searchableNameFinder: SearchableNameFinder( contactManager: SSKEnvironment.shared.contactManagerRef, searchableNameIndexer: DependenciesBridge.shared.searchableNameIndexer, phoneNumberVisibilityFetcher: DependenciesBridge.shared.phoneNumberVisibilityFetcher, recipientDatabaseTable: DependenciesBridge.shared.recipientDatabaseTable ), threadStore: DependenciesBridge.shared.threadStore, tsAccountManager: DependenciesBridge.shared.tsAccountManager ) private let appReadiness: AppReadinessSetter init(appReadiness: AppReadinessSetter) { self.appReadiness = appReadiness super.init() } // 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() updateBarButtonItems() OWSTableViewController2.removeBackButtonText(viewController: self) if #available(iOS 26, *), FeatureFlags.iOS26SDKIsAvailable { toolbarDeleteButton.image = UIImage(resource: .trash) self.toolbarItems = [.flexibleSpace(), toolbarDeleteButton] } view.addSubview(tableView) tableView.autoPinEdgesToSuperviewEdges() tableView.delegate = self tableView.allowsSelectionDuringEditing = true tableView.allowsMultipleSelectionDuringEditing = true tableView.separatorStyle = .none tableView.contentInset = .zero tableView.register(CreateCallLinkCell.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() initializeLoadedViewModels() attachSelfAsObservers() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) updateDisplayedDateForAllCallCells() clearMissedCallsIfNecessary() isPeekingEnabled = true schedulePeekTimerIfNeeded() peekOnAppear() peekIfPossible() } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) isPeekingEnabled = false } override func themeDidChange() { super.themeDidChange() applyTheme() reloadAllRows() } func updateBarButtonItems() { if tableView.isEditing { navigationItem.leftBarButtonItem = cancelMultiselectButton() navigationItem.rightBarButtonItem = deleteAllCallsButton() } else { navigationItem.leftBarButtonItem = profileBarButtonItem() navigationItem.rightBarButtonItem = newCallButton() } if splitViewController?.isCollapsed == false { navigationItem.titleView = sidebarFilterPickerContainer } else { navigationItem.titleView = filterPicker } } private func applyTheme() { view.backgroundColor = Theme.backdropColor tableView.backgroundColor = Theme.backgroundColor } // MARK: Profile button private func profileBarButtonItem() -> UIBarButtonItem { createSettingsBarButtonItem( databaseStorage: SSKEnvironment.shared.databaseStorageRef, buildActions: { settingsAction -> [UIMenuElement] in return [ UIAction( title: Strings.selectCallsButtonTitle, image: Theme.iconImage(.contextMenuSelect), handler: { [weak self] _ in self?.startMultiselect() } ), settingsAction, ] }, showAppSettings: { [weak self] in self?.showAppSettings() } ) } private func showAppSettings() { AssertIsOnMainThread() conversationSplitViewController?.selectedConversationViewController?.dismissMessageContextMenu(animated: true) presentFormSheet(AppSettingsViewController.inModalNavigationController(appReadiness: appReadiness), 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() { if #available(iOS 26, *), FeatureFlags.iOS26SDKIsAvailable { navigationController?.setToolbarHidden(false, animated: true) (tabBarController as? HomeTabBarController)?.setTabBarHidden(true) return } 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 selectedModelReferenceses: [ViewModelLoader.ModelReferences] = selectedRows.map { idxPath in return viewModelLoader.modelReferences(at: idxPath.row) } promptToDeleteMultiple(count: selectedModelReferenceses.count) { [weak self] in do { try await self?.deleteCalls(modelReferenceses: selectedModelReferenceses) self?.presentToast(text: String.localizedStringWithFormat( Strings.deleteMultipleSuccessFormat, selectedModelReferenceses.count )) } catch { Logger.warn("\(error)") self?.presentSomeCallLinkDeletionError() } } } // MARK: Call Link Button private func createCallLink() { CreateCallLinkViewController.createCallLinkOnServerAndPresent(from: 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() { if #available(iOS 26, *), FeatureFlags.iOS26SDKIsAvailable { self.navigationController?.setToolbarHidden(true, animated: true) (self.tabBarController as? HomeTabBarController)?.setTabBarHidden(false) return } 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 ) { [weak self] _ in Task { do { try await self?.deleteAllCalls() } catch { Logger.warn("\(error)") self?.presentSomeCallLinkDeletionError() } } } } private func deleteAllCalls() async throws { let callLinksToDelete: [(rootKey: CallLinkRootKey, adminPasskey: Data)] callLinksToDelete = await self.deps.databaseStorage.awaitableWrite { tx in // We might have call links that have never been used. Plus, any call link // for which we're the admin isn't deleted by a "clear all" operation // because they must first be deleted on the server. (We delete them // individually at the end of this method.) let callLinksToDelete: [(rootKey: CallLinkRootKey, adminPasskey: Data)] callLinksToDelete = (try? self.deps.callLinkStore.fetchAll(tx: tx).compactMap { guard let adminPasskey = $0.adminPasskey else { return nil } return ($0.rootKey, adminPasskey) }) ?? [] /// 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. let mostRecentCursor = self.deps.callRecordQuerier.fetchCursor(ordering: .descending, tx: tx) if let mostRecentCallRecord = try? mostRecentCursor?.next() { /// 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 ) } return callLinksToDelete } try await deleteCallLinks(callLinksToDelete: callLinksToDelete) } // 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(filterChangedFromPrimary), for: .valueChanged) return segmentedControl }() // Having a UISegmentedControl as a titleView a split view sidebar on iOS 26 // looks too large and is cut off at the top. But putting it in a container // doesn't look as good when not in a sidebar. private lazy var sidebarFilterPicker: UISegmentedControl = { let segmentedControl = UISegmentedControl(items: [ Strings.filterPickerOptionAll, Strings.filterPickerOptionMissed ]) segmentedControl.selectedSegmentIndex = 0 segmentedControl.addTarget(self, action: #selector(filterChangedFromSidebar), for: .valueChanged) return segmentedControl }() private lazy var sidebarFilterPickerContainer: UIView = { let container = UIView() container.addSubview(sidebarFilterPicker) sidebarFilterPicker.autoPinWidthToSuperview() // idk why but it's gotta baaarely shift to align with the profile pic sidebarFilterPicker.autoAlignAxis(.horizontal, toSameAxisOf: container, withOffset: -2/3) sidebarFilterPicker.autoPinHeightToSuperview(relation: .lessThanOrEqual) return container }() // 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 filterChangedFromPrimary() { sidebarFilterPicker.selectedSegmentIndex = filterPicker.selectedSegmentIndex filterChanged() } @objc private func filterChangedFromSidebar() { filterPicker.selectedSegmentIndex = sidebarFilterPicker.selectedSegmentIndex filterChanged() } private func filterChanged() { reinitializeLoadedViewModels(debounceInterval: 0, animated: true) updateMultiselectToolbarButtons() } private var currentFilterMode: FilterMode { FilterMode(rawValue: filterPicker.selectedSegmentIndex) ?? .all } // MARK: - Observers and Notifications private func attachSelfAsObservers() { deps.databaseChangeObserver.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 ) // There might be an ongoing call when the calls tab appears, and if that's // the case, we want to grab its eraId before it ends. deps.callService.callServiceState.addObserver(self, syncStateImmediately: true) } /// 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( conversationId: .thread(threadRowId: notification.groupThreadRowId), callId: notification.callId ) 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() { loadMoreCalls(direction: .newer, animated: true) } private func existingCallRecordsWereDeleted(callRecordIds: [CallRecord.ID]) { deps.db.read { tx in viewModelLoader.dropCalls(matching: callRecordIds, tx: tx) } updateSnapshot(updatedReferences: [], 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 private var currentCallId: UInt64? /// 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?) { // When a SignalCall ends, reload it if we know its callId. if let oldValue, let callId = self.currentCallId { switch oldValue.mode { case .individual(_): break case .groupThread(let call as GroupCall), .callLink(let call as GroupCall): call.removeObserver(self) } reloadRow(forCall: oldValue.mode, callId: callId) self.currentCallId = nil } // When a SignalCall starts, reload it as soon as we learn its callId. if let newValue { switch newValue.mode { case .individual(let call): if let callId = call.callId { reloadRow(forCall: newValue.mode, callId: callId) self.currentCallId = callId } else { owsFailDebug("Can't start individual calls without callIds.") } case .groupThread(let call as GroupCall), .callLink(let call as GroupCall): // We need to fetch the eraId asynchronously, so there's nothing to refresh. call.addObserver(self, syncStateImmediately: true) } } } // MARK: GroupCallObserver func groupCallPeekChanged(_ call: GroupCall) { guard self.currentCallId == nil, let eraId = call.ringRtcCall.peekInfo?.eraId else { // We've already set it or still don't know it. return } let callId = callIdFromEra(eraId) self.currentCallId = callId reloadRow(forCall: CallMode(groupCall: call), callId: callId) } // MARK: Reloading the Current Call private func reloadRow(forCall callMode: CallMode, callId: UInt64) { let conversationId: CallRecord.ConversationID switch callMode { case .individual(let call): conversationId = .thread(threadRowId: call.thread.sqliteRowId!) case .groupThread(let call): let rowId = deps.db.read { tx in deps.threadStore.fetchGroupThread(groupId: call.groupId, tx: tx)?.sqliteRowId } guard let rowId else { owsFailDebug("Can't reload call with non-existent group thread.") return } conversationId = .thread(threadRowId: rowId) case .callLink(let call): // Query the database separately when starting & ending calls because the // row will usually be inserted during the call (ie `rowId` may be nil when // starting the call but nonnil when ending the very same call). let rowId = deps.db.read { tx in try? deps.callLinkStore.fetch(roomId: call.callLink.rootKey.deriveRoomId(), tx: tx)?.id } guard let rowId else { // If you open the lobby for an ongoing call that you've never joined, // we'll call this method after the peek succeeds. However, you haven't // joined the call yet, so there's no calls tab item that needs to be // reloaded to show the "Return" button. If you do join the call, a new row // will be added, and it will be (implicitly re)loaded because it's new. return } conversationId = .callLink(callLinkRowId: rowId) } reloadRows(callRecordIds: [CallRecord.ID(conversationId: conversationId, callId: callId)]) } // 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) } /// 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 ) } } } // MARK: - Call loading private var _viewModelLoader: ViewModelLoader! private var viewModelLoader: ViewModelLoader! { get { AssertIsOnMainThread() return _viewModelLoader } set(newValue) { AssertIsOnMainThread() _viewModelLoader = newValue } } private func initializeLoadedViewModels() { /// On initialization, we are not filtering to only missed calls. let onlyLoadMissedCalls = false /// On initialization, we are not filtering for any search term. let onlyMatchThreadRowIds: [Int64]? = nil setAndPrimeViewModelLoader( onlyLoadMissedCalls: onlyLoadMissedCalls, onlyMatchThreadRowIds: onlyMatchThreadRowIds, animated: false ) } /// Used to avoid concurrent calls to `reinitializeLoadedViewModels` from /// clobbering each other. private var reinitializeLoadedViewModelsTask: Task? /// Asynchronously resets our `viewModelLoader` 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 _reinitializeLoadedViewModels(animated: Bool) async throws(CancellationError) { let searchTerm = self.searchTerm let onlyLoadMissedCalls: Bool = { switch self.currentFilterMode { case .all: return false case .missed: return true } }() let threadRowIdsMatchingSearchTerm: [Int64]? if let searchTerm { threadRowIdsMatchingSearchTerm = try await findThreadRowIdsMatchingSearchTerm(searchTerm) } else { threadRowIdsMatchingSearchTerm = nil } if Task.isCancelled { /// While we were performing a search above, another caller entered /// this method. Bail out in preference of the later caller! throw CancellationError() } setAndPrimeViewModelLoader( onlyLoadMissedCalls: onlyLoadMissedCalls, onlyMatchThreadRowIds: threadRowIdsMatchingSearchTerm, animated: animated ) } /// Finds the row IDs of threads matching the given search term. /// /// - Note /// This operation can be slow, as it involves a potentially-heavy FTS /// query. Importantly, this method is `nonisolated` such that it doesn't /// inherit the `@MainActor` isolation of `UIViewController`. private nonisolated func findThreadRowIdsMatchingSearchTerm( _ searchTerm: String ) async throws(CancellationError) -> [Int64] { return try self.deps.databaseStorage.read { tx throws(CancellationError) -> [Int64] in guard let localIdentifiers = self.deps.tsAccountManager.localIdentifiers(tx: tx) else { owsFail("Can't search if you've never been registered.") } var threadRowIdsMatchingSearchTerm = Set() let addresses = try self.deps.searchableNameFinder.searchNames( for: searchTerm, maxResults: Constants.maxSearchResults, localIdentifiers: localIdentifiers, tx: tx, 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 Array(threadRowIdsMatchingSearchTerm) } } private func setAndPrimeViewModelLoader( onlyLoadMissedCalls: Bool, onlyMatchThreadRowIds: [Int64]?, animated: Bool ) { let callRecordLoader = CallRecordLoaderImpl( callRecordQuerier: self.deps.callRecordQuerier, configuration: CallRecordLoaderImpl.Configuration( onlyLoadMissedCalls: onlyLoadMissedCalls, onlyMatchThreadRowIds: onlyMatchThreadRowIds ) ) /// We don't want to capture self in the blocks we pass when creating /// the view model loader (and thereby create a retain cycle), so we'll /// early-capture the dependencies those blocks need. let capturedDeps = self.deps self.viewModelLoader = ViewModelLoader( callLinkStore: self.deps.callLinkStore, callRecordLoader: callRecordLoader, callViewModelForCallRecords: { callRecords, tx in return Self.callViewModel( forCallRecords: callRecords, upcomingCallLinkRowId: nil, deps: capturedDeps, tx: SDSDB.shimOnlyBridge(tx) ) }, callViewModelForUpcomingCallLink: { callLinkRowId, tx in return Self.callViewModel( forCallRecords: [], upcomingCallLinkRowId: callLinkRowId, deps: capturedDeps, tx: SDSDB.shimOnlyBridge(tx) ) }, fetchCallRecordBlock: { callRecordId, tx -> CallRecord? in return capturedDeps.callRecordStore.fetch( callRecordId: callRecordId, tx: SDSDB.shimOnlyBridge(tx) ).unwrapped }, shouldFetchUpcomingCallLinks: !onlyLoadMissedCalls && onlyMatchThreadRowIds == nil ) self.reloadUpcomingCallLinks() // 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. private func loadMoreCallsIfNecessary(indexToBeDisplayed callIndex: Int) { if callIndex + 1 == viewModelLoader.totalCount { /// If this index path represents the oldest loaded call, try and /// load another page of even-older calls. loadMoreCalls(direction: .older, animated: false) } } private func reloadUpcomingCallLinks() { deps.db.read { tx in viewModelLoader.reloadUpcomingCallLinkReferences(tx: tx) } } /// 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, updatedReferences) = deps.db.read { tx in return viewModelLoader.loadCallHistoryItemReferences(direction: loadDirection, tx: tx) } guard forceUpdateSnapshot || shouldUpdateSnapshot || !updatedReferences.isEmpty else { return } DispatchQueue.main.async { self.updateSnapshot(updatedReferences: updatedReferences, 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 callViewModel( forCallRecords callRecords: [CallRecord], upcomingCallLinkRowId: Int64?, deps: Dependencies, tx: DBReadTransaction ) -> CallViewModel { owsPrecondition( Set(callRecords.map(\.conversationId)).count <= 1, "Coalesced call records were for a different conversation than the primary!" ) owsPrecondition( Set(callRecords.map(\.callDirection)).count <= 1, "Coalesced call records were of a different direction than the primary!" ) owsPrecondition( Set(callRecords.map(\.callStatus.isMissedCall)).count <= 1, "Coalesced call records were of a different missed status than the primary!" ) owsPrecondition( callRecords.isSortedByTimestamp(.descending), "Primary and coalesced call records were not ordered descending by timestamp!" ) let callLinkRecord = { () -> CallLinkRecord? in let callLinkRowId: Int64 if let upcomingCallLinkRowId { callLinkRowId = upcomingCallLinkRowId } else if case .callLink(let callLinkRowId2) = callRecords.first!.conversationId { callLinkRowId = callLinkRowId2 } else { return nil } do { return try deps.callLinkStore.fetch(rowId: callLinkRowId, tx: tx) ?? { owsFail("Couldn't load CallLinkRecord that must exist!") }() } catch { owsFail("Couldn't load CallLinkRecord that must exist: \(error)") } }() if let callLinkRecord { return CallViewModel( reference: .callLink(rowId: callLinkRecord.id), callRecords: callRecords, title: callLinkRecord.state.localizedName, recipientType: .callLink(callLinkRecord.rootKey), direction: .callLink, medium: .link, state: { () -> CallViewModel.State in if let activeCallId = callLinkRecord.activeCallId { if deps.callService.callServiceState.currentCall?.callId == activeCallId { return .participating } return .active } return .inactive }() ) } // If it's not a CallLink, we MUST have at least one CallRecord. let callRecord = callRecords.first! let callDirection: CallViewModel.Direction = { if callRecord.callStatus.isMissedCall { return .missed } switch callRecord.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 callRecord.callStatus { case .individual: if callRecord.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: callRecord, tx: tx) 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 callRecord.callId == currentCallId { return .participating } return .active } case .callLink: owsFail("Can't reach this point because we've already handled Call Links.") } return .inactive }() let title: String let medium: CallViewModel.Medium let recipientType: CallViewModel.RecipientType switch callRecord.conversationId { case .thread(let threadRowId): guard let callThread = deps.threadStore.fetchThread( rowId: threadRowId, tx: tx ) else { owsFail("Missing thread for call record! This should be impossible, per the DB schema.") } switch callThread { case let contactThread as TSContactThread: title = deps.contactsManager.displayName(for: contactThread.contactAddress, tx: tx).resolvedValue() let callType: CallViewModel.RecipientType.IndividualCallType switch callRecords.first!.callType { case .audioCall: medium = .audio callType = .audio case .adHocCall, .groupCall: owsFailDebug("Had group call type for 1:1 call!") fallthrough case .videoCall: medium = .video callType = .video } recipientType = .individual(type: callType, contactThread: contactThread) case let groupThread as TSGroupThread: title = groupThread.groupModel.groupNameOrDefault medium = .video recipientType = .groupThread(groupId: groupThread.groupId) default: owsFail("Call thread was neither contact nor group! This should be impossible.") } case .callLink(_): owsFail("Can't reach this point because we've already handled Call Links.") } return CallViewModel( reference: .callRecords(oldestId: callRecords.last!.id), callRecords: callRecords, title: title, recipientType: recipientType, direction: callDirection, medium: medium, state: callState ) } // MARK: - Peeking /// Fires when active calls should be checked again. Also replenishes /// `peekAllowance`. May be nonnil even when `isPeekingEnabled` is false to /// handle rapid tab switching. private var peekTimer: Timer? /// Remaining peeks that can be performed. Replenishes every 30 seconds. private var peekAllowance = 10 /// If true, call links & active calls should be peeked periodically. private var isPeekingEnabled = false /// An ordered list of groups & call links that would be useful to peek. private var peekQueue = [Peekable]() /// Identifiers that have been scheduled in this 30 second interval. We /// remove items when `peekTimer` fires to avoid peeking the same values /// repeatedly when scrolling. private var peekQueueIdentifiers = Set() /// Timestamps when call links were recently peeked. private var callLinkPeekDates = [Data: MonotonicDate]() private enum Peekable { case groupThread(groupId: GroupIdentifier) case callLink(rootKey: CallLinkRootKey) var identifier: Data { switch self { case .groupThread(let groupId): groupId.serialize() case .callLink(let rootKey): rootKey.deriveRoomId() } } } /// Schedules a peeks if there's no peek scheduled. private func addToPeekQueue(_ peekable: Peekable) { guard peekQueueIdentifiers.insert(peekable.identifier).inserted else { return } peekQueue.append(peekable) peekIfPossible() } /// Peeks `peekAllowance` items from `peekQueue`. /// /// It will often be the case that items added via `addToPeekQueue` are /// peeked immediately. However, if more than 10 items are added, they'll /// queue up until the next 30 second interval. private func peekIfPossible() { guard self.isPeekingEnabled else { return } let peekBatch = self.peekQueue.prefix(peekAllowance) self.peekQueue.removeFirst(peekBatch.count) for peekable in peekBatch { switch peekable { case .groupThread(let groupId): Task { [deps] in await deps.groupCallManager.peekGroupCallAndUpdateThread(forGroupId: groupId, peekTrigger: .localEvent()) } case .callLink(let rootKey): self.callLinkPeekDates[rootKey.deriveRoomId()] = MonotonicDate() Task { [deps] in do { try await Self.peekCallLink(rootKey: rootKey, deps: deps) } catch { Logger.warn("\(error)") } } } } self.peekAllowance -= peekBatch.count } private static nonisolated func peekCallLink(rootKey: CallLinkRootKey, deps: Dependencies) async throws { guard let localIdentifiers = deps.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction else { throw OWSGenericError("Not registered.") } let authCredential = try await deps.callService.authCredentialManager.fetchCallLinkAuthCredential(localIdentifiers: localIdentifiers) let eraId: String? do { eraId = try await deps.callService.callLinkManager.peekCallLink(rootKey: rootKey, authCredential: authCredential) } catch CallLinkManagerImpl.PeekError.expired, CallLinkManagerImpl.PeekError.invalid { eraId = nil } try await deps.db.awaitableWrite { tx in try deps.adHocCallRecordManager.handlePeekResult(eraId: eraId, rootKey: rootKey, tx: tx) } } /// Performs the relevant peek steps when the view appears. private func peekOnAppear() { if viewModelLoader == nil { return } if !viewModelLoader.isEmpty, viewModelLoader.viewModels().compacted().isEmpty { _ = viewModelLoader.viewModel(at: 0, sneakyTransactionDb: self.deps.db) } peekActiveCalls() peekInactiveCallLinks() } /// Schedules periodic operations: replinishing & active call re-peeking. private func schedulePeekTimerIfNeeded() { if self.peekTimer != nil { return } self.peekTimer = Timer.scheduledTimer( withTimeInterval: 30, repeats: true, block: { [weak self] timer in guard let self else { timer.invalidate() return } self.peekAllowance = 10 self.peekQueueIdentifiers = Set(self.peekQueue.map(\.identifier)) guard self.isPeekingEnabled else { timer.invalidate() self.peekTimer = nil return } self.peekActiveCalls() self.peekIfPossible() } ) } private func peekActiveCalls() { for viewModel in viewModelLoader.viewModels() { guard let viewModel else { continue } peekIfActive(viewModel) } } private func peekIfActive(_ viewModel: CallViewModel) { guard viewModel.state == .active else { return } switch viewModel.recipientType { case .individual: break case .groupThread(groupId: let groupId): guard let groupId = try? GroupIdentifier(contents: groupId) else { owsFailDebug("Can't peek group call with invalid group id.") break } addToPeekQueue(.groupThread(groupId: groupId)) case .callLink(let rootKey): addToPeekQueue(.callLink(rootKey: rootKey)) } } private func peekInactiveCallLinks() { for viewModel in viewModelLoader.viewModels() { guard let viewModel, viewModel.state == .inactive else { continue } switch viewModel.recipientType { case .individual, .groupThread: break case .callLink(let rootKey): // Skip any where the link is more than 10 days old. if let timestamp = viewModel.callRecords.first?.callBeganTimestamp, -Date(millisecondsSince1970: timestamp).timeIntervalSinceNow > 10 * .day { continue } // Skip any that have been updated in the past 5 minutes. if let peekDate = callLinkPeekDates[rootKey.deriveRoomId()], MonotonicDate() - peekDate < MonotonicDuration(clampingSeconds: 300) { continue } addToPeekQueue(.callLink(rootKey: rootKey)) } } } // 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 func searchTermDidChange() { reinitializeLoadedViewModels(debounceInterval: Constants.searchDebounceInterval, animated: true) } private func reinitializeLoadedViewModels(debounceInterval: TimeInterval, animated: Bool) { self.reinitializeLoadedViewModelsTask?.cancel() self.reinitializeLoadedViewModelsTask = Task { do { try await Task.sleep(nanoseconds: debounceInterval.clampedNanoseconds) try await self._reinitializeLoadedViewModels(animated: true) } catch { // A new reinitialize call was started, so bail out. } } } // 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 callRecords(oldestId: CallRecord.ID) case callLink(rowId: Int64) } enum Direction { case outgoing case incoming case missed case callLink var label: String { switch self { case .outgoing: return Strings.callDirectionLabelOutgoing case .incoming: return Strings.callDirectionLabelIncoming case .missed: return Strings.callDirectionLabelMissed case .callLink: return CallStrings.callLink } } var symbol: SignalSymbol { switch self { case .outgoing: .arrowUpRight case .incoming, .missed: .arrowDownLeft case .callLink: .link } } } enum Medium { case audio case video case link } 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 or was never active (eg, an upcoming Call Link). case inactive } enum RecipientType { case individual(type: IndividualCallType, contactThread: TSContactThread) case groupThread(groupId: Data) case callLink(CallLinkRootKey) enum IndividualCallType { case audio case video } } let reference: Reference let callRecords: [CallRecord] let title: String let recipientType: RecipientType let direction: Direction let medium: Medium let state: State init( reference: Reference, callRecords: [CallRecord], title: String, recipientType: RecipientType, direction: Direction, medium: Medium, state: State ) { self.reference = reference self.callRecords = callRecords self.title = title self.recipientType = recipientType self.direction = direction self.medium = medium self.state = state } var isMissed: Bool { switch direction { case .outgoing, .incoming, .callLink: 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: if let createCallLinkCell = tableView.dequeueReusableCell( withIdentifier: Self.createCallLinkReuseIdentifier, for: indexPath ) as? CreateCallLinkCell { return createCallLinkCell } return nil case .existingCalls: guard let callCell = tableView.dequeueReusableCell( withIdentifier: Self.callCellReuseIdentifier ) as? CallCell else { return nil } // These loads should be sufficiently fast that doing them here, // synchronously, is fine. self.loadMoreCallsIfNecessary(indexToBeDisplayed: indexPath.row) if let viewModel = viewModelLoader.viewModel(at: indexPath.row, sneakyTransactionDb: deps.db) { callCell.delegate = self callCell.viewModel = viewModel self.peekIfActive(viewModel) return callCell } 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]) snapshot.appendItems([.createCallLink]) snapshot.appendSections([.existingCalls]) snapshot.appendItems(viewModelLoader.viewModelReferences().map { .callViewModelReference($0) }) return snapshot } private func updateSnapshot(updatedReferences: Set, animated: Bool) { var snapshot = getSnapshot() if #available(iOS 18.0, *) { snapshot.reloadItems(updatedReferences.map { .callViewModelReference($0) }) dataSource.apply(snapshot, animatingDifferences: animated) } else { // On iOS 17 and lower, moving & reloading a row at the same time may // result in the wrong row being reloaded. Mitigate this by scheduling // these as two separate operations. dataSource.apply(snapshot, animatingDifferences: animated) if !updatedReferences.isEmpty { snapshot.reloadItems(updatedReferences.map { .callViewModelReference($0) }) dataSource.apply(snapshot, animatingDifferences: animated) } } updateEmptyStateMessage() cancelMultiselectIfEmpty() } /// Reload any rows containing one of the given call record IDs. private func reloadRows(callRecordIds callRecordIdsToReload: [CallRecord.ID]) { if callRecordIdsToReload.isEmpty { return } /// Invalidate the view models, so when the data source reloads the rows, /// it'll reflect the new underlying state for that row. let referencesToReload = viewModelLoader.invalidate( callLinkRowIds: [], callRecordIds: Set(callRecordIdsToReload) ) if referencesToReload.isEmpty { return } 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([.createCallLink, .existingCalls]) dataSource.apply(snapshot) } private func cancelMultiselectIfEmpty() { if tableView.isEditing, viewModelLoader.isEmpty { cancelMultiselect() } } private func updateEmptyStateMessage() { switch (viewModelLoader.isEmpty, searchTerm) { case (true, .some(let searchTerm)) where !searchTerm.isEmpty: noSearchResultsView.text = String( format: Strings.searchNoResultsFoundLabelFormat, arguments: [searchTerm] ) noSearchResultsView.layer.opacity = 1 emptyStateMessageView.layer.opacity = 0 case (true, _): 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) } } } } private extension CallRecordStore { func fetch( callRecordId: CallRecord.ID, tx: DBReadTransaction ) -> CallRecordStore.MaybeDeletedFetchResult { return fetch( callId: callRecordId.callId, conversationId: callRecordId.conversationId, tx: tx ) } } // MARK: - Data Source extension CallsListViewController { fileprivate class DiffableDataSource: UITableViewDiffableDataSource { override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { switch Section(rawValue: indexPath.section) { case .createCallLink: return false case .existingCalls, .none: return true } } } } // MARK: - UITableViewDelegate extension CallsListViewController: UITableViewDelegate { private func viewModelWithSneakyTransaction(at indexPath: IndexPath) -> CallViewModel? { owsPrecondition( indexPath.section == Section.existingCalls.rawValue, "Unexpected section for index path: \(indexPath.section)" ) guard let viewModel = viewModelLoader.viewModel(at: indexPath.row, sneakyTransactionDb: deps.db) else { owsFailBeta("Missing view model for index path. How did this happen?") return nil } return viewModel } func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { switch Section(rawValue: indexPath.section) { case .createCallLink: if tableView.isEditing { return nil } case .existingCalls, .none: break } return indexPath } 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 = viewModelWithSneakyTransaction(at: indexPath) else { return } showCallInfo(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 = viewModelWithSneakyTransaction(at: indexPath) else { return nil } guard let chatThread = goToChatThread(from: viewModel) else { return nil } let goToChatAction = ContextualActionBuilder.makeContextualAction( style: .normal, color: .ows_accentBlue, image: "arrow-square-upright-fill", title: Strings.goToChatActionTitle ) { [weak self] in self?.goToChat(for: chatThread()!) } 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 } let modelReferences = viewModelLoader.modelReferences(at: indexPath.row) let deleteAction = ContextualActionBuilder.makeContextualAction( style: .destructive, color: .ows_accentRed, image: "trash-fill", title: CommonStrings.deleteButton ) { [weak self] in self?.promptToDeleteCallIfNeeded(modelReferences: modelReferences) } return .init(actions: [deleteAction]) } private func longPressActions(forRowAt indexPath: IndexPath) -> [UIAction]? { guard let viewModel = viewModelWithSneakyTransaction(at: indexPath) else { return nil } let modelReferences = viewModelLoader.modelReferences(at: indexPath.row) var actions = [UIAction]() switch viewModel.state { case .active: let joinCallTitle: String let joinCallIconName: String switch viewModel.medium { case .audio: joinCallTitle = Strings.joinVoiceCallActionTitle joinCallIconName = Theme.iconName(.contextMenuVoiceCall) case .video, .link: // [CallLink] TODO: Use "Start Call" instead of "Start Video Call". 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.medium { case .audio: returnToCallIconName = Theme.iconName(.contextMenuVoiceCall) case .video, .link: 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 .inactive: switch viewModel.recipientType { case .individual: let audioCallAction = UIAction( title: Strings.startVoiceCallActionTitle, image: Theme.iconImage(.contextMenuVoiceCall), attributes: [] ) { [weak self] _ in self?.startCall(from: viewModel, withVideo: false) } actions.append(audioCallAction) case .groupThread, .callLink: break } let videoCallAction = UIAction( title: Strings.startVideoCallActionTitle, image: Theme.iconImage(.contextMenuVideoCall), attributes: [] ) { [weak self] _ in self?.startCall(from: viewModel, withVideo: true) } actions.append(videoCallAction) } if let chatThread = goToChatThread(from: viewModel) { let goToChatAction = UIAction( title: Strings.goToChatActionTitle, image: Theme.iconImage(.contextMenuOpenInChat), attributes: [] ) { [weak self] _ in self?.goToChat(for: chatThread()!) } 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, .inactive: let deleteAction = UIAction( title: Strings.deleteCallActionTitle, image: Theme.iconImage(.contextMenuDelete), attributes: .destructive ) { [weak self] _ in self?.promptToDeleteCallIfNeeded(modelReferences: modelReferences) } actions.append(deleteAction) case .participating: break } return actions } } // MARK: - Actions extension CallsListViewController: CallCellDelegate, NewCallViewControllerDelegate { private var callStarterContext: CallStarter.Context { .init( blockingManager: deps.blockingManager, databaseStorage: deps.databaseStorage, callService: deps.callService ) } private func startCall(from viewModel: CallViewModel, withVideo: Bool? = nil) { switch viewModel.recipientType { case let .individual(type, contactThread): CallStarter( contactThread: contactThread, withVideo: withVideo ?? (type == .video), context: self.callStarterContext ).startCall(from: self) case let .groupThread(groupId): owsPrecondition(withVideo != false, "Can't start voice call.") let groupId = try! GroupIdentifier(contents: groupId) CallStarter( groupId: groupId, context: self.callStarterContext ).startCall(from: self) case .callLink(let rootKey): owsPrecondition(withVideo != false, "Can't start voice call.") CallStarter( callLink: rootKey, context: self.callStarterContext ).startCall(from: self) } } private func promptToDeleteMultiple(count: Int, proceedAction: @escaping @MainActor () async -> Void) { OWSActionSheets.showConfirmationAlert( title: String.localizedStringWithFormat(Strings.deleteMultipleTitleFormat, count), message: Strings.deleteMultipleMessage, proceedTitle: Strings.deleteCallActionTitle, proceedStyle: .destructive, proceedAction: { _ in Task { await proceedAction() } }, fromViewController: self ) } private func presentSomeCallLinkDeletionError() { let actionSheet = ActionSheetController(message: Strings.deleteMultipleError) actionSheet.addAction(OWSActionSheets.okayAction) self.presentActionSheet(actionSheet) } private func promptToDeleteCallIfNeeded(modelReferences: ViewModelLoader.ModelReferences) { // If we're the admin for this link, we need to show a warning that other // people won't be able to use it. if isAdmin(forCallLinkRowId: modelReferences.callLinkRowId) { CallLinkDeleter.promptToDelete(fromViewController: self) { [weak self] in do { try await self?.deleteCalls(modelReferenceses: [modelReferences]) self?.presentToast(text: CallLinkDeleter.successText) } catch { Logger.warn("\(error)") self?.presentToast(text: CallLinkDeleter.failureText) } } } else { // Otherwise, we can just delete it. Task { do { try await self.deleteCalls(modelReferenceses: [modelReferences]) } catch { owsFailDebug("\(error)") } } } } private func isAdmin(forCallLinkRowId callLinkRowId: Int64?) -> Bool { return deps.databaseStorage.read { tx in guard let callLinkRowId else { return false } do { let callLinkRecord = try self.deps.callLinkStore.fetch(rowId: callLinkRowId, tx: tx) ?? { throw OWSAssertionError("Couldn't fetch CallLink that must exist.") }() return callLinkRecord.adminPasskey != nil } catch { owsFailDebug("\(error)") return false } } } private func deleteCalls(modelReferenceses: [ViewModelLoader.ModelReferences]) async throws { let callLinksToDelete: [(rootKey: CallLinkRootKey, adminPasskey: Data)] // First, delete everything that's local only. This includes thread-based // calls & any call link calls for which we're not the admin. These // deletions never fail (except for db corruption-level failures). callLinksToDelete = try await deps.databaseStorage.awaitableWrite { tx in var callLinksToDelete = [(rootKey: CallLinkRootKey, adminPasskey: Data)]() var callRecordIdsWithInteractions = [CallRecord.ID]() for modelReferences in modelReferenceses { if let callLinkRowId = modelReferences.callLinkRowId { let callLinkRecord = try self.deps.callLinkStore.fetch(rowId: callLinkRowId, tx: tx) ?? { throw OWSAssertionError("Couldn't fetch CallLink that must exist.") }() if let adminPasskey = callLinkRecord.adminPasskey { callLinksToDelete.append((callLinkRecord.rootKey, adminPasskey)) } else { try self.deleteCallRecords(forCallLinkRowId: callLinkRecord.id, tx: tx) } } else { callRecordIdsWithInteractions.append(contentsOf: modelReferences.callRecordRowIds) } } let callRecordsWithInteractions = callRecordIdsWithInteractions.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: callRecordsWithInteractions, sideEffects: .default(), tx: tx ) return callLinksToDelete } // Then, delete any call links we found for which we're the admin. Each of // these may independently fail. try await deleteCallLinks(callLinksToDelete: callLinksToDelete) } private nonisolated func deleteCallRecords(forCallLinkRowId callLinkRowId: Int64, tx: DBWriteTransaction) throws { let callRecords = try deps.callRecordStore.fetchExisting(conversationId: .callLink(callLinkRowId: callLinkRowId), limit: nil, tx: tx) deps.callRecordDeleteManager.deleteCallRecords(callRecords, sendSyncMessageOnDelete: true, tx: tx) } private func deleteCallLinks(callLinksToDelete: [(rootKey: CallLinkRootKey, adminPasskey: Data)]) async throws { let callLinkStateUpdater = deps.callService.callLinkStateUpdater try await withThrowingTaskGroup(of: Void.self) { taskGroup in for callLinkToDelete in callLinksToDelete { taskGroup.addTask { try await CallLinkDeleter.deleteCallLink( stateUpdater: callLinkStateUpdater, storageServiceManager: SSKEnvironment.shared.storageServiceManagerRef, rootKey: callLinkToDelete.rootKey, adminPasskey: callLinkToDelete.adminPasskey ) } } var anyError: (any Error)? while let result = await taskGroup.nextResult() { switch result { case .success: break case .failure(let error): Logger.warn("Couldn't delete call link: \(error)") anyError = error } } if let anyError { throw anyError } } } private func selectCall(forRowAt indexPath: IndexPath) { startMultiselect() tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) } // MARK: CallCellDelegate fileprivate func joinCall(from viewModel: CallViewModel) { startCall(from: viewModel) } fileprivate func returnToCall(from viewModel: CallViewModel) { guard AppEnvironment.shared.windowManagerRef.hasCall else { return } AppEnvironment.shared.windowManagerRef.returnToCallView() } fileprivate func showCallInfo(from viewModel: CallViewModel) { AssertIsOnMainThread() switch viewModel.recipientType { case .individual(type: _, let thread): showCallInfo(forThread: thread, callRecords: viewModel.callRecords) case .groupThread(let groupId): let thread = deps.db.read { tx in deps.threadStore.fetchGroupThread(groupId: groupId, tx: tx)! } showCallInfo(forThread: thread, callRecords: viewModel.callRecords) case .callLink(let rootKey): showCallInfo(forRootKey: rootKey, callRecords: viewModel.callRecords) } } private func showCallInfo(forThread thread: TSThread, callRecords: [CallRecord]) { let (threadViewModel, isSystemContact) = deps.databaseStorage.read { tx in 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(), callRecords: callRecords ) showCallInfo(viewController: callDetailsView) } private func showCallInfo(forRootKey rootKey: CallLinkRootKey, callRecords: [CallRecord]) { let callLinkRecord = deps.db.read { tx -> CallLinkRecord in do { return try deps.callLinkStore.fetch(roomId: rootKey.deriveRoomId(), tx: tx) ?? { owsFail("Can't fetch CallLinkRecord that must exist.") }() } catch { owsFail("Can't fetch CallLinkRecord: \(error)") } } showCallInfo(viewController: CallLinkViewController.forExisting(callLinkRecord: callLinkRecord, callRecords: callRecords)) } private func showCallInfo(viewController: UIViewController) { viewController.hidesBottomBarWhenPushed = true navigationController?.pushViewController(viewController, animated: true) } // MARK: NewCallViewControllerDelegate private func goToChatThread(from viewModel: CallViewModel) -> (() -> TSThread?)? { switch viewModel.recipientType { case .individual(type: _, let thread): return { thread } case .groupThread(let groupId): return { [deps] in return deps.db.read { tx in deps.threadStore.fetchGroupThread(groupId: groupId, tx: tx) } } case .callLink(_): return nil } } func goToChat(for thread: TSThread) { SignalApp.shared.presentConversationForThread( threadUniqueId: thread.uniqueId, 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.") reinitializeLoadedViewModels(debounceInterval: 0, animated: false) reloadAllRows() } func databaseChangesDidUpdate(databaseChanges: DatabaseChanges) { guard let rowIds = databaseChanges.tableRowIds[CallLinkRecord.databaseTableName] else { return } reloadUpcomingCallLinks() let updatedReferences = viewModelLoader.invalidate(callLinkRowIds: rowIds, callRecordIds: []) updateSnapshot(updatedReferences: updatedReferences, animated: true) } func databaseChangesDidReset() {} } // MARK: - Call cell private extension CallsListViewController { class CallCell: UITableViewCell { weak var delegate: CallCellDelegate? var viewModel: CallViewModel? { didSet { updateContents() } } // MARK: Subviews private lazy var avatarView = ConversationAvatarView( sizeClass: .fortyFour, 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 = .dynamicTypeSubheadline return label }() private func makeStartCallButton(viewModel: CallViewModel) -> UIButton { var config = UIButton.Configuration.gray() config.cornerStyle = .capsule config.background.backgroundInsets = .init(margin: 2) config.baseBackgroundColor = UIColor.Signal.tertiaryFill config.baseForegroundColor = UIColor.Signal.label let icon: ThemeIcon = switch viewModel.medium { case .audio: .buttonVoiceCall case .video, .link: .buttonVideoCall } config.image = Theme.iconImage(icon) return UIButton( configuration: config, primaryAction: UIAction { [weak self] _ in self?.detailsTapped(viewModel: viewModel) } ) } private func makeJoinButton(viewModel: CallViewModel) -> UIButton { var config = UIButton.Configuration.borderedProminent() if #available(iOS 26, *), FeatureFlags.iOS26SDKIsAvailable { #if compiler(>=6.2) config = UIButton.Configuration.prominentGlass() #endif } else { config.cornerStyle = .capsule } let icon: UIImage switch viewModel.medium { case .audio: icon = Theme.iconImage(.phoneFill16) case .video, .link: icon = Theme.iconImage(.videoFill16) } let text: String switch viewModel.state { case .active: text = Strings.joinCallButtonTitle case .participating: text = Strings.returnToCallButtonTitle case .inactive: text = "" } config.title = text config.titleTextAttributesTransformer = .defaultFont(.dynamicTypeSubheadline.bold()) config.image = icon config.imagePadding = 4 let button = UIButton( configuration: config, primaryAction: UIAction { [weak self] _ in self?.detailsTapped(viewModel: viewModel) } ) button.tintColor = UIColor.Signal.green return button } // MARK: Init private let trailingHStack: UIStackView private var trailingButton: UIButton? override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { self.trailingHStack = UIStackView() super.init(style: style, reuseIdentifier: reuseIdentifier) multipleSelectionBackgroundView = UIView(frame: contentView.bounds) let bodyVStack = UIStackView(arrangedSubviews: [ titleLabel, subtitleLabel, ]) bodyVStack.axis = .vertical let leadingHStack = UIStackView(arrangedSubviews: [ avatarView, bodyVStack, ]) leadingHStack.alignment = .center leadingHStack.axis = .horizontal leadingHStack.spacing = 12 trailingHStack.addArrangedSubview(timestampLabel) trailingHStack.axis = .horizontal trailingHStack.spacing = 12 trailingHStack.alignment = .center let outerHStack = UIStackView(arrangedSubviews: [ leadingHStack, UIView(), trailingHStack, ]) outerHStack.axis = .horizontal outerHStack.spacing = 4 contentView.addSubview(outerHStack) outerHStack.autoPinWidthToSuperviewMargins() outerHStack.autoPinHeightToSuperview(withMargin: 14) 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 .inactive: return viewModel.callRecords.first?.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 switch viewModel.recipientType { case .individual(type: _, let thread): configuration.dataSource = .thread(thread) case .groupThread(let groupId): configuration.setGroupIdWithSneakyTransaction(groupId: groupId) case .callLink(let rootKey): configuration.dataSource = .asset(avatar: CommonCallLinksUI.callLinkIcon(rootKey: rootKey), badge: nil) } } let titleText: String = { if viewModel.callRecords.count <= 1 { return viewModel.title } else { return String(format: Strings.coalescedCallsTitleFormat, viewModel.title, "\(viewModel.callRecords.count)") } }() self.titleLabel.text = titleText switch viewModel.direction { case .incoming, .outgoing, .callLink: titleLabel.textColor = Theme.primaryTextColor case .missed: titleLabel.textColor = .ows_accentRed } self.subtitleLabel.attributedText = .composed(of: [ viewModel.direction.symbol.attributedString(for: .subheadline), " ", viewModel.direction.label, ]).styled(with: .font(.dynamicTypeSubheadline)) let button = switch viewModel.state { case .active, .participating: self.makeJoinButton(viewModel: viewModel) case .inactive: self.makeStartCallButton(viewModel: viewModel) } self.trailingButton?.removeFromSuperview() self.trailingButton = button trailingHStack.addArrangedSubview(button) updateDisplayedDateAndScheduleRefresh() } private func applyTheme() { backgroundColor = Theme.backgroundColor selectedBackgroundView?.backgroundColor = Theme.tableCell2SelectedBackgroundColor multipleSelectionBackgroundView?.backgroundColor = Theme.tableCell2SelectedBackgroundColor titleLabel.textColor = Theme.primaryTextColor subtitleLabel.textColor = Theme.snippetColor timestampLabel.textColor = Theme.snippetColor } // MARK: Actions private func detailsTapped(viewModel: CallViewModel) { guard let delegate else { return owsFailDebug("Missing delegate") } switch viewModel.state { case .active, .inactive: delegate.joinCall(from: viewModel) case .participating: delegate.returnToCall(from: viewModel) } } } } private extension CallsListViewController { class CreateCallLinkCell: UITableViewCell { private enum Constants { static let iconDimension: CGFloat = 24 static let spacing: CGFloat = 18 static let hMargin: CGFloat = 26 static let vMargin: CGFloat = 15 } private lazy var iconView: UIImageView = { let imageView = UIImageView(image: UIImage(named: "link")) imageView.tintColor = Theme.primaryIconColor imageView.autoSetDimensions(to: CGSize(square: Constants.iconDimension)) return imageView }() private lazy var label: UILabel = { let label = UILabel() label.font = .dynamicTypeHeadline label.textColor = Theme.primaryTextColor label.numberOfLines = 3 label.lineBreakMode = .byTruncatingTail label.text = OWSLocalizedString( "CREATE_CALL_LINK_LABEL", comment: "Label for button that enables you to make a new call link." ) return label }() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) self.backgroundColor = .Signal.background let stackView = UIStackView(arrangedSubviews: [iconView, label]) stackView.axis = .horizontal stackView.spacing = Constants.spacing stackView.alignment = .center self.contentView.addSubview(stackView) stackView.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets(hMargin: Constants.hMargin, vMargin: Constants.vMargin)) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func prepareForReuse() { iconView.tintColor = Theme.primaryIconColor label.textColor = Theme.primaryTextColor } } }