// // 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 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 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() 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) } } } }