Path: blob/main/Signal/Calls/UserInterface/CallsListViewController.swift
1 views
//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import LibSignalClient
import SignalRingRTC
import SignalServiceKit
import SignalUI
// MARK: - CallCellDelegate
private protocol CallCellDelegate: AnyObject {
func joinCall(from viewModel: CallsListViewController.CallViewModel)
func returnToCall(from viewModel: CallsListViewController.CallViewModel)
func presentToast(toastText: String)
}
// MARK: - CallsListViewController
class CallsListViewController: OWSViewController, HomeTabViewController, CallServiceStateObserver, GroupCallObserver {
private typealias Snapshot = NSDiffableDataSourceSnapshot<Section, RowIdentifier>
private enum Constants {
/// The maximum number of search results to match.
static let maxSearchResults: Int = 100
/// An interval to wait after the search term changes before actually
/// issuing a search.
static let searchDebounceInterval: TimeInterval = 0.1
}
// MARK: - Dependencies
private struct Dependencies {
let 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()
view.backgroundColor = .Signal.background
updateBarButtonItems()
OWSTableViewController2.removeBackButtonText(viewController: self)
if #available(iOS 26, *) {
toolbarDeleteButton.image = UIImage(resource: .trash)
self.toolbarItems = [.flexibleSpace(), toolbarDeleteButton]
}
tableView.delegate = self
tableView.allowsSelectionDuringEditing = true
tableView.allowsMultipleSelectionDuringEditing = true
tableView.separatorStyle = .none
tableView.contentInset = .zero
tableView.backgroundColor = .Signal.background
tableView.register(CreateCallLinkCell.self, forCellReuseIdentifier: Self.createCallLinkReuseIdentifier)
tableView.register(CallCell.self, forCellReuseIdentifier: Self.callCellReuseIdentifier)
tableView.dataSource = dataSource
view.addSubview(tableView)
tableView.autoPinHeight(toHeightOf: view)
tableViewHorizontalEdgeConstraints = [
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
view.trailingAnchor.constraint(equalTo: tableView.trailingAnchor),
]
NSLayoutConstraint.activate(tableViewHorizontalEdgeConstraints)
updateTableViewPaddingIfNeeded()
view.addSubview(emptyStateMessageView)
emptyStateMessageView.autoCenterInSuperview()
view.addSubview(noSearchResultsView)
noSearchResultsView.autoPinWidthToSuperviewMargins()
noSearchResultsView.autoPinEdge(toSuperviewMargin: .top, withInset: 80)
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 traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
updateTableViewPaddingIfNeeded()
}
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
}
}
// 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, *) {
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, *) {
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<Void, any Error>?
/// 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<Int64>()
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: tx,
)
},
callViewModelForUpcomingCallLink: { callLinkRowId, tx in
return Self.callViewModel(
forCallRecords: [],
upcomingCallLinkRowId: callLinkRowId,
deps: capturedDeps,
tx: tx,
)
},
fetchCallRecordBlock: { callRecordId, tx -> CallRecord? in
return capturedDeps.callRecordStore.fetch(
callRecordId: callRecordId,
tx: 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<Data>()
/// 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 nonisolated static func peekCallLink(rootKey: CallLinkRootKey, deps: Dependencies) async throws {
let registeredState = try deps.tsAccountManager.registeredStateWithMaybeSneakyTransaction()
let authCredential = try await deps.callService.authCredentialManager.fetchCallLinkAuthCredential(
localIdentifiers: registeredState.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)
/// Set to `true` when call list is displayed in split view controller's "sidebar" on iOS 26 and later.
/// Setting this to `true` would add an extra padding on both sides of the table view.
/// This value is also passed down to table view cells that make their own layout choices based on the value.
private var useSidebarCallListCellAppearance = false {
didSet {
guard oldValue != useSidebarCallListCellAppearance else { return }
tableViewHorizontalEdgeConstraints.forEach {
$0.constant = useSidebarCallListCellAppearance ? 18 : 0
}
tableView.reloadData()
}
}
private var tableViewHorizontalEdgeConstraints: [NSLayoutConstraint] = []
/// iOS 26+: checks if this VC is displayed in the collapsed split view controller and updates `useSidebarCallListCellAppearance` accordingly.
/// Does nothing on prior iOS versions.
private func updateTableViewPaddingIfNeeded() {
guard #available(iOS 26, *) else { return }
if let splitViewController, !splitViewController.isCollapsed {
useSidebarCallListCellAppearance = true
} else {
useSidebarCallListCellAppearance = false
}
}
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:
guard
let createCallLinkCell = tableView.dequeueReusableCell(
withIdentifier: Self.createCallLinkReuseIdentifier,
for: indexPath,
) as? CreateCallLinkCell else { return nil }
createCallLinkCell.useSidebarAppearance = useSidebarCallListCellAppearance
return createCallLinkCell
case .existingCalls:
guard
let callCell = tableView.dequeueReusableCell(
withIdentifier: Self.callCellReuseIdentifier,
for: indexPath,
) as? CallCell else { return nil }
callCell.useSidebarAppearance = useSidebarCallListCellAppearance
// These loads should be sufficiently fast that doing them here,
// synchronously, is fine.
loadMoreCallsIfNecessary(indexToBeDisplayed: indexPath.row)
if let viewModel = viewModelLoader.viewModel(at: indexPath.row, sneakyTransactionDb: deps.db) {
callCell.delegate = self
callCell.viewModel = viewModel
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<CallViewModel.Reference>, 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.nonPluralLocalizedStringWithFormat(
Strings.searchNoResultsFoundLabelFormat,
searchTerm,
)
noSearchResultsView.alpha = 1
emptyStateMessageView.alpha = 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.alpha = 0
emptyStateMessageView.alpha = 1
case (_, _):
// Hide empty state message
noSearchResultsView.alpha = 0
emptyStateMessageView.alpha = 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<Section, RowIdentifier> {
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(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: .arrowSquareUprightFill,
title: Strings.goToChatActionTitle,
) { [weak self] completion in
self?.goToChat(for: chatThread()!)
completion(true)
}
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: .trashFill,
title: CommonStrings.deleteButton,
) { [weak self] completion in
self?.promptToDeleteCallIfNeeded(modelReferences: modelReferences)
completion(true)
}
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) {
AppEnvironment.shared.windowManagerRef.returnToCallView()
}
fileprivate func presentToast(toastText: String) {
presentToast(text: toastText)
}
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,
memberLabelCoordinator: nil,
)
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()
}
}
/// If set to `true` background in `selected` state would have rounded corners.
var useSidebarAppearance = false
// MARK: Subviews
private lazy var avatarView = ConversationAvatarView(
sizeClass: .fortyFour,
localUserDisplayMode: .asUser,
)
private lazy var titleLabel: UILabel = {
let label = UILabel()
label.font = .dynamicTypeHeadline
label.textColor = .Signal.label
return label
}()
private lazy var subtitleLabel: UILabel = {
let label = UILabel()
label.textColor = .Signal.secondaryLabel
return label
}()
private lazy var timestampLabel: UILabel = {
let label = UILabel()
label.font = .dynamicTypeSubheadline
label.textColor = .Signal.secondaryLabel
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)
let button = UIButton(
configuration: config,
primaryAction: UIAction { [weak self] _ in
self?.detailsTapped(viewModel: viewModel)
},
)
button.setCompressionResistanceHorizontalHigh()
return button
}
private func makeJoinButton(viewModel: CallViewModel) -> UIButton {
var config = UIButton.Configuration.borderedProminent()
if #available(iOS 26, *) {
config = UIButton.Configuration.prominentGlass()
} 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?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
tintColor = .Signal.accent
automaticallyUpdatesBackgroundConfiguration = false
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)
}
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() {
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.nonPluralLocalizedStringWithFormat(Strings.coalescedCallsTitleFormat, viewModel.title, "\(viewModel.callRecords.count)")
}
}()
titleLabel.text = titleText
switch viewModel.direction {
case .incoming, .outgoing, .callLink:
titleLabel.textColor = .Signal.label
case .missed:
titleLabel.textColor = .Signal.red
}
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:
makeJoinButton(viewModel: viewModel)
case .inactive:
makeStartCallButton(viewModel: viewModel)
}
trailingButton?.removeFromSuperview()
trailingButton = button
trailingHStack.addArrangedSubview(button)
updateDisplayedDateAndScheduleRefresh()
}
override func updateConfiguration(using state: UICellConfigurationState) {
var configuration = UIBackgroundConfiguration.clear()
if state.isSelected || state.isHighlighted {
configuration.backgroundColor = Theme.tableCell2SelectedBackgroundColor
if useSidebarAppearance {
configuration.cornerRadius = 36
}
} else {
configuration.backgroundColor = .Signal.background
}
backgroundConfiguration = configuration
}
// MARK: Actions
private enum canJoinCallResult {
case failedGroupTerminated
case failedNotMemberOfGroup
case success
}
private func canJoinCall(viewModel: CallViewModel) -> canJoinCallResult {
let db = DependenciesBridge.shared.db
switch viewModel.recipientType {
case .groupThread(groupId: let groupId):
guard
let groupThread: TSGroupThread = db.read(block: { tx in
return try? TSGroupThread.fetch(forGroupId: GroupIdentifier(contents: groupId), tx: tx)
})
else {
owsFailDebug("unable to fetch groupThread")
return .success
}
if groupThread.isTerminatedGroup {
return .failedGroupTerminated
}
if !groupThread.isLocalUserFullMemberOfThread {
return .failedNotMemberOfGroup
}
return .success
case .callLink, .individual:
return .success
}
}
private func detailsTapped(viewModel: CallViewModel) {
guard let delegate else {
return owsFailDebug("Missing delegate")
}
let canJoinCall = canJoinCall(viewModel: viewModel)
switch canJoinCall {
case .failedGroupTerminated:
delegate.presentToast(
toastText: OWSLocalizedString(
"END_GROUP_ACTION_ERROR",
comment: "Description for error sheet that says the user can no longer take this action because the group has ended.",
),
)
return
case .failedNotMemberOfGroup:
delegate.presentToast(
toastText: OWSLocalizedString(
"GROUP_CALL_NOT_A_MEMBER",
comment: "Text indicating you can't take this action because you're not a member of the group",
),
)
return
case .success:
break
}
switch viewModel.state {
case .active, .inactive:
delegate.joinCall(from: viewModel)
case .participating:
delegate.returnToCall(from: viewModel)
}
}
}
}
private extension CallsListViewController {
class CreateCallLinkCell: UITableViewCell {
/// If set to `true` background in `selected` state would have rounded corners.
var useSidebarAppearance = false
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 = .Signal.label
imageView.autoSetDimensions(to: CGSize(square: Constants.iconDimension))
return imageView
}()
private lazy var label: UILabel = {
let label = UILabel()
label.font = .dynamicTypeHeadline
label.textColor = .Signal.label
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)
automaticallyUpdatesBackgroundConfiguration = false
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 updateConfiguration(using state: UICellConfigurationState) {
var configuration = UIBackgroundConfiguration.clear()
if state.isSelected || state.isHighlighted {
configuration.backgroundColor = Theme.tableCell2SelectedBackgroundColor
if useSidebarAppearance {
configuration.cornerRadius = 36
}
} else {
configuration.backgroundColor = .Signal.background
}
backgroundConfiguration = configuration
}
}
}