Signal-iOS/Signal/Calls/UserInterface/CallsListViewController.swift
Igor Solomennikov 46445edfe7
Use modern UIButton configuration for Approve / Reject group join request buttons.
Made avatar and button a bit larger too.
2026-05-27 14:57:59 -07:00

2659 lines
100 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import 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: 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 = 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 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
}
return deps.callLinkStore.fetch(rowId: callLinkRowId, tx: tx)
.owsFailUnwrap("FOREIGN KEYs mean this must exist.")
}()
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
}
await deps.db.awaitableWrite { tx in
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.noCallsTitleLabel.styled(with: .font(.dynamicTypeTitle3.semibold())),
"\n",
Strings.noCallsSubtitleLabel.styled(with: .font(.dynamicTypeBody)),
]
case .missed:
return [
Strings.noMissedCallsTitleLabel.styled(with: .font(.dynamicTypeTitle3.semibold())),
"\n",
Strings.noMissedCallsSubtitleLabel.styled(with: .font(.dynamicTypeBody)),
]
}
}())
.styled(
with: .color(.Signal.secondaryLabel),
)
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
}
let callLinkRecord = self.deps.callLinkStore.fetch(rowId: callLinkRowId, tx: tx)
.owsFailUnwrap("FOREIGN KEYs mean this must exist.")
return callLinkRecord.adminPasskey != nil
}
}
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 = 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 = self.deps.callLinkStore.fetch(rowId: callLinkRowId, tx: tx)
.owsFailUnwrap("FOREIGN KEYs mean this must exist.")
if let adminPasskey = callLinkRecord.adminPasskey {
callLinksToDelete.append((callLinkRecord.rootKey, adminPasskey))
} else {
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) {
let callRecords = 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
return deps.callLinkStore.fetch(roomId: rootKey.deriveRoomId(), tx: tx)
.owsFailUnwrap("FOREIGN KEYs mean this must exist.")
}
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 {
let icon: ThemeIcon = switch viewModel.medium {
case .audio:
.buttonVoiceCall
case .video, .link:
.buttonVideoCall
}
let button = UIButton(
configuration: .roundGray(image: Theme.iconImage(icon)),
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
}
}
}