625 lines
28 KiB
Swift
625 lines
28 KiB
Swift
//
|
|
// Copyright 2024 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import SignalServiceKit
|
|
|
|
extension CallsListViewController {
|
|
/// Responsible for loading call links & call records from disk.
|
|
///
|
|
/// (For the purposes of this type, a "call history item" is one or more
|
|
/// ``CallRecord``s displayed as a single row in the UI. It might be a
|
|
/// single ``CallRecord`` or multiple that have been coalesced. These may be
|
|
/// individual calls, group calls, or call link calls. An "upcoming call
|
|
/// link" is a call link that's never been used. A "call list item" refers
|
|
/// to either of these.)
|
|
///
|
|
/// This types loads call list items from disk & exposes an API that
|
|
/// contains everything it has loaded. Internally, it performs caching &
|
|
/// batching of the larger ``CallViewModel``s to reduce memory usage.
|
|
/// However, these details are hidden from its public API.
|
|
///
|
|
/// (In other words, callers should assume ``CallViewModel``s are available
|
|
/// for every element in ``viewModelReferences()`` because this type handles
|
|
/// batching & cache management behind the scenes.)
|
|
///
|
|
/// When the on-disk values are changed, callers should invoke the
|
|
/// appropriate methods on this type to fetch/update/remove/apply the
|
|
/// changes. When the user scrolls to the bottom of the list, callers should
|
|
/// request additional items.
|
|
struct ViewModelLoader {
|
|
typealias CallViewModelForUpcomingCallLink = (
|
|
_ callLinkRowId: Int64,
|
|
_ tx: DBReadTransaction
|
|
) -> CallViewModel
|
|
|
|
typealias CallViewModelForCallRecords = (
|
|
_ callRecords: [CallRecord],
|
|
_ tx: DBReadTransaction
|
|
) -> CallViewModel
|
|
|
|
typealias FetchCallRecordBlock = (
|
|
_ callRecordId: CallRecord.ID,
|
|
_ tx: DBReadTransaction
|
|
) -> CallRecord?
|
|
|
|
enum LoadDirection {
|
|
case older
|
|
case newer
|
|
}
|
|
|
|
private let callLinkStore: any CallLinkRecordStore
|
|
private let callRecordLoader: CallRecordLoader
|
|
private let callViewModelForCallRecords: CallViewModelForCallRecords
|
|
private let callViewModelForUpcomingCallLink: CallViewModelForUpcomingCallLink
|
|
private let fetchCallRecordBlock: FetchCallRecordBlock
|
|
private let shouldFetchUpcomingCallLinks: Bool
|
|
private let viewModelPageSize: Int
|
|
private let maxCoalescedCallsInOneViewModel: Int
|
|
|
|
init(
|
|
callLinkStore: any CallLinkRecordStore,
|
|
callRecordLoader: CallRecordLoader,
|
|
callViewModelForCallRecords: @escaping CallViewModelForCallRecords,
|
|
callViewModelForUpcomingCallLink: @escaping CallViewModelForUpcomingCallLink,
|
|
fetchCallRecordBlock: @escaping FetchCallRecordBlock,
|
|
shouldFetchUpcomingCallLinks: Bool,
|
|
viewModelPageSize: Int = 50,
|
|
maxCoalescedCallsInOneViewModel: Int = 50
|
|
) {
|
|
self.callLinkStore = callLinkStore
|
|
self.callRecordLoader = callRecordLoader
|
|
self.callViewModelForCallRecords = callViewModelForCallRecords
|
|
self.callViewModelForUpcomingCallLink = callViewModelForUpcomingCallLink
|
|
self.fetchCallRecordBlock = fetchCallRecordBlock
|
|
self.shouldFetchUpcomingCallLinks = shouldFetchUpcomingCallLinks
|
|
self.viewModelPageSize = viewModelPageSize
|
|
self.maxCoalescedCallsInOneViewModel = maxCoalescedCallsInOneViewModel
|
|
|
|
self.callHistoryItemReferences = []
|
|
self.upcomingCallLinkReferences = []
|
|
}
|
|
|
|
// MARK: - References
|
|
|
|
private enum Reference {
|
|
case upcomingCallLink(UpcomingCallLinkReference)
|
|
case callHistoryItem(CallHistoryItemReference)
|
|
}
|
|
|
|
private struct UpcomingCallLinkReference {
|
|
let callLinkRowId: Int64
|
|
|
|
var viewModel: CallViewModel?
|
|
var viewModelReference: CallViewModel.Reference { .callLink(rowId: callLinkRowId) }
|
|
}
|
|
|
|
private struct CallHistoryItemReference {
|
|
let callRecordIds: NonEmptyArray<CallRecord.ID>
|
|
let callLinkRowId: Int64?
|
|
|
|
init(callRecordIds: NonEmptyArray<CallRecord.ID>, callLinkRowId: Int64?) {
|
|
self.callRecordIds = callRecordIds
|
|
self.callLinkRowId = callLinkRowId
|
|
}
|
|
|
|
var viewModel: CallViewModel?
|
|
var viewModelReference: CallViewModel.Reference {
|
|
if let callLinkRowId {
|
|
return .callLink(rowId: callLinkRowId)
|
|
} else {
|
|
return .callRecords(oldestId: callRecordIds.last)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var upcomingCallLinkReferences: [UpcomingCallLinkReference]
|
|
|
|
/// The range of `callBeganTimestamp`s that have been loaded. If nil,
|
|
/// nothing has been fetched yet (or there wasn't anything to fetch).
|
|
private var callHistoryItemTimestampRange: ClosedRange<UInt64>?
|
|
|
|
/// All the "call history items" we've loaded so far. This is generally
|
|
/// equivalent to allCallItems.prefix(…).
|
|
private var callHistoryItemReferences: [CallHistoryItemReference]
|
|
|
|
var isEmpty: Bool { upcomingCallLinkReferences.isEmpty && callHistoryItemReferences.isEmpty }
|
|
|
|
var totalCount: Int { upcomingCallLinkReferences.count + callHistoryItemReferences.count }
|
|
|
|
/// All the references known by this type.
|
|
///
|
|
/// This value should be used as a the data source for a table view.
|
|
func viewModelReferences() -> [CallViewModel.Reference] {
|
|
return upcomingCallLinkReferences.map(\.viewModelReference) + callHistoryItemReferences.map(\.viewModelReference)
|
|
}
|
|
|
|
func viewModelReference(at index: Int) -> CallViewModel.Reference {
|
|
switch reference(at: index) {
|
|
case .upcomingCallLink(let ref): return ref.viewModelReference
|
|
case .callHistoryItem(let ref): return ref.viewModelReference
|
|
}
|
|
}
|
|
|
|
/// Stores ROWIDs for the database rows backing a call list item.
|
|
struct ModelReferences {
|
|
var callLinkRowId: Int64?
|
|
var callRecordRowIds: [CallRecord.ID]
|
|
}
|
|
|
|
func modelReferences(at index: Int) -> ModelReferences {
|
|
switch reference(at: index) {
|
|
case .upcomingCallLink(let ref): return ModelReferences(callLinkRowId: ref.callLinkRowId, callRecordRowIds: [])
|
|
case .callHistoryItem(let ref): return ModelReferences(callLinkRowId: ref.callLinkRowId, callRecordRowIds: ref.callRecordIds.rawValue)
|
|
}
|
|
}
|
|
|
|
private func reference(at index: Int) -> Reference {
|
|
var internalIndex = index
|
|
if internalIndex < upcomingCallLinkReferences.count {
|
|
return .upcomingCallLink(upcomingCallLinkReferences[internalIndex])
|
|
}
|
|
internalIndex -= upcomingCallLinkReferences.count
|
|
if internalIndex < callHistoryItemReferences.count {
|
|
return .callHistoryItem(callHistoryItemReferences[internalIndex])
|
|
}
|
|
owsFail("Must provide valid index.")
|
|
}
|
|
|
|
// MARK: - View Models
|
|
|
|
func viewModels() -> [CallViewModel?] {
|
|
return upcomingCallLinkReferences.map(\.viewModel) + callHistoryItemReferences.map(\.viewModel)
|
|
}
|
|
|
|
/// Returns the view model at `index`.
|
|
///
|
|
/// This fetches a new batch from the disk if `index` isn't yet loaded.
|
|
mutating func viewModel(at index: Int, sneakyTransactionDb: any DB) -> CallViewModel? {
|
|
if let alreadyFetched = _viewModel(at: index) {
|
|
return alreadyFetched
|
|
}
|
|
sneakyTransactionDb.read { tx in self.loadUntilCached(at: index, tx: tx) }
|
|
return _viewModel(at: index)
|
|
}
|
|
|
|
private func _viewModel(at index: Int) -> CallViewModel? {
|
|
var internalIndex = index
|
|
if internalIndex < upcomingCallLinkReferences.count {
|
|
return upcomingCallLinkReferences[internalIndex].viewModel
|
|
}
|
|
internalIndex -= upcomingCallLinkReferences.count
|
|
if internalIndex < callHistoryItemReferences.count {
|
|
return callHistoryItemReferences[internalIndex].viewModel
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Load more
|
|
|
|
/// Loads a batch of view models surrounding `index`.
|
|
///
|
|
/// This method is safe to call for any `index` less than `totalCount` or
|
|
/// `viewModelReferences().count`.
|
|
private mutating func loadUntilCached(at index: Int, tx: DBReadTransaction) {
|
|
// Ensure the page that contains `index` has been loaded.
|
|
let pageIndex = index / viewModelPageSize
|
|
let pageRange = (pageIndex * viewModelPageSize)..<((pageIndex + 1) * viewModelPageSize)
|
|
for index in pageRange {
|
|
self.loadViewModel(at: index, tx: tx)
|
|
}
|
|
|
|
// Throw away cached view models if we have way too many.
|
|
let adjacentRange = (
|
|
max(0, pageRange.lowerBound - 2 * viewModelPageSize)
|
|
..< min(totalCount, pageRange.upperBound + 2 * viewModelPageSize)
|
|
)
|
|
for internalIndex in upcomingCallLinkReferences.indices {
|
|
if adjacentRange.contains(internalIndex) {
|
|
continue
|
|
}
|
|
upcomingCallLinkReferences[internalIndex].viewModel = nil
|
|
}
|
|
for internalIndex in callHistoryItemReferences.indices {
|
|
if adjacentRange.contains(internalIndex + upcomingCallLinkReferences.count) {
|
|
continue
|
|
}
|
|
callHistoryItemReferences[internalIndex].viewModel = nil
|
|
}
|
|
}
|
|
|
|
mutating func reloadUpcomingCallLinkReferences(tx: DBReadTransaction) {
|
|
guard shouldFetchUpcomingCallLinks else {
|
|
return
|
|
}
|
|
let upcomingCallLinks: [CallLinkRecord]
|
|
do {
|
|
upcomingCallLinks = try callLinkStore.fetchUpcoming(earlierThan: nil, limit: 2048, tx: tx)
|
|
} catch {
|
|
Logger.warn("Couldn't fetch call links to show on the calls tab: \(error)")
|
|
return
|
|
}
|
|
self.upcomingCallLinkReferences = upcomingCallLinks.map {
|
|
return UpcomingCallLinkReference(callLinkRowId: $0.id)
|
|
}
|
|
_ = self.pruneDuplicateAdHocCalls()
|
|
}
|
|
|
|
/// Load a page of call history items in the requested direction.
|
|
///
|
|
/// - Returns
|
|
/// True if the owner of this type should schedule a reload (ie changes were
|
|
/// made to call history items). It also returns references for any rows
|
|
/// that were modified (ie inserting a new coalesced record or replacing a
|
|
/// call link).
|
|
mutating func loadCallHistoryItemReferences(
|
|
direction loadDirection: LoadDirection,
|
|
tx: DBReadTransaction
|
|
) -> (Bool, Set<CallViewModel.Reference>) {
|
|
var fetchResult: [NonEmptyArray<CallRecord>]
|
|
let fetchDirection: LoadDirection
|
|
switch (loadDirection, callHistoryItemTimestampRange) {
|
|
case (.older, _), (.newer, nil):
|
|
/// If we're asked to load newer calls, but we don't have any calls loaded
|
|
/// yet, do an "older" load since they're gonna be equivalent.
|
|
|
|
fetchDirection = .older
|
|
fetchResult = loadOlderCallHistoryItemReferences(
|
|
olderThan: callHistoryItemTimestampRange?.lowerBound,
|
|
maxCount: viewModelPageSize,
|
|
tx: tx
|
|
)
|
|
case (.newer, let callHistoryItemTimestampRange?):
|
|
fetchDirection = .newer
|
|
fetchResult = loadNewerCallHistoryItemReferences(
|
|
newerThan: callHistoryItemTimestampRange.upperBound,
|
|
tx: tx
|
|
).map { NonEmptyArray(singleElement: $0) }
|
|
}
|
|
|
|
guard let newestGroup = fetchResult.first, let oldestGroup = fetchResult.last else {
|
|
return (false, [])
|
|
}
|
|
|
|
var modifiedReferences = Set<CallViewModel.Reference>()
|
|
|
|
// Expand `callHistoryItemTimestampRange` so that the next fetch elides existing items.
|
|
let newestFetchedTimestamp: UInt64 = newestGroup.first.callBeganTimestamp
|
|
let oldestFetchedTimestamp: UInt64 = oldestGroup.last.callBeganTimestamp
|
|
|
|
if let callHistoryItemTimestampRange {
|
|
self.callHistoryItemTimestampRange = (
|
|
min(callHistoryItemTimestampRange.lowerBound, oldestFetchedTimestamp)
|
|
... max(callHistoryItemTimestampRange.upperBound, newestFetchedTimestamp)
|
|
)
|
|
} else {
|
|
self.callHistoryItemTimestampRange = oldestFetchedTimestamp...newestFetchedTimestamp
|
|
}
|
|
|
|
// Special case: If we fetched newer records, and if the oldest one we
|
|
// fetched can be merged with what we already have, do so. This handles the
|
|
// common case of making a call while scrolled near the top of the Calls
|
|
// Tab. If this code is removed, the app will behave properly, but calls
|
|
// won't be coalesced until the a new loader is created.
|
|
//
|
|
// | "new" | "old" |
|
|
// | fetchResult | callHistoryItemReferences |
|
|
// | . | . | . |[N]|[X Y]| . . . . | . | . | . . |
|
|
//
|
|
// oldestGroupOfNewCallRecords: [N]
|
|
// (Note: Even though it's an array, there's always just one element.)
|
|
// oldestGroupOfNewCallRecords.first: N
|
|
// newestOldReference & newestGroupOfOldCallRecords: [X Y]
|
|
// oldestNewestOldCallRecord: Y
|
|
// (How to interpret:
|
|
// - "old" -> callHistoryItemReferences
|
|
// - "newest" -> first element, equivalent to the first coalesced row
|
|
// - "oldest" -> last element in that row, equivalent to its oldest call record)
|
|
//
|
|
// We then compare "N" against "Y" to see if these can be coalesced.
|
|
if
|
|
fetchDirection == .newer,
|
|
let oldestGroupOfNewCallRecords = fetchResult.last,
|
|
let newestOldReference = callHistoryItemReferences.first,
|
|
let newestGroupOfOldCallRecords = newestOldReference.viewModel?.callRecords,
|
|
let oldestNewestOldCallRecord = newestGroupOfOldCallRecords.last,
|
|
oldestGroupOfNewCallRecords.first.isValidCoalescingAnchor(for: oldestNewestOldCallRecord),
|
|
(oldestGroupOfNewCallRecords.rawValue.count + newestGroupOfOldCallRecords.count) <= maxCoalescedCallsInOneViewModel
|
|
{
|
|
let combinedGroupOfCallRecords = oldestGroupOfNewCallRecords + newestGroupOfOldCallRecords
|
|
let reference = CallHistoryItemReference(
|
|
callRecordIds: combinedGroupOfCallRecords.map(\.id),
|
|
callLinkRowId: newestOldReference.callLinkRowId
|
|
)
|
|
callHistoryItemReferences[0] = reference
|
|
modifiedReferences.insert(reference.viewModelReference)
|
|
fetchResult = fetchResult.dropLast()
|
|
}
|
|
|
|
let fetchedCallHistoryItemReferences = fetchResult.map { callRecord in
|
|
return CallHistoryItemReference(
|
|
callRecordIds: callRecord.map(\.id),
|
|
callLinkRowId: { () -> Int64? in
|
|
switch callRecord.first.conversationId {
|
|
case .thread(threadRowId: _):
|
|
return nil
|
|
case .callLink(let callLinkRowId):
|
|
return callLinkRowId
|
|
}
|
|
}()
|
|
)
|
|
}
|
|
|
|
switch fetchDirection {
|
|
case .older:
|
|
// swiftlint:disable shorthand_operator
|
|
self.callHistoryItemReferences = self.callHistoryItemReferences + fetchedCallHistoryItemReferences
|
|
// swiftlint:enable shorthand_operator
|
|
case .newer:
|
|
self.callHistoryItemReferences = fetchedCallHistoryItemReferences + self.callHistoryItemReferences
|
|
}
|
|
modifiedReferences.formUnion(self.pruneDuplicateAdHocCalls())
|
|
|
|
return (true, modifiedReferences)
|
|
}
|
|
|
|
/// Removes duplicate occurrences of call links in the rendered rows.
|
|
///
|
|
/// If there are multiple ``CallRecord``s for a call link, the most recent
|
|
/// one will be kept. If there are ``CallRecord``s and upcoming call links,
|
|
/// the ``CallRecord`` will be kept.
|
|
mutating func pruneDuplicateAdHocCalls() -> Set<CallViewModel.Reference> {
|
|
var modifiedReferences = Set<CallViewModel.Reference>()
|
|
// Filter to show each call link only once.
|
|
var visitedIds = Set<Int64>()
|
|
self.callHistoryItemReferences.removeAll(where: {
|
|
if let callLinkRowId = $0.callLinkRowId, !visitedIds.insert(callLinkRowId).inserted {
|
|
modifiedReferences.insert($0.viewModelReference)
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
// Give precedence to historical calls rather than upcoming calls. In the
|
|
// data layer, this can't happen, but reloading links & call records
|
|
// happens separately, so there may be temporary overlap in the UI layer.
|
|
self.upcomingCallLinkReferences.removeAll(where: {
|
|
if visitedIds.contains($0.callLinkRowId) {
|
|
modifiedReferences.insert($0.viewModelReference)
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
return modifiedReferences
|
|
}
|
|
|
|
// MARK: - Rehydration
|
|
|
|
/// "Hydrate" (ie load) the view model at `index`.
|
|
///
|
|
/// This is a no-op if it's already loaded.
|
|
private mutating func loadViewModel(at index: Int, tx: DBReadTransaction) {
|
|
var internalIndex = index
|
|
if internalIndex < upcomingCallLinkReferences.count {
|
|
let reference = upcomingCallLinkReferences[internalIndex]
|
|
if reference.viewModel == nil {
|
|
upcomingCallLinkReferences[internalIndex].viewModel = callViewModelForUpcomingCallLink(reference.callLinkRowId, tx)
|
|
}
|
|
return
|
|
}
|
|
internalIndex -= upcomingCallLinkReferences.count
|
|
if internalIndex < callHistoryItemReferences.count {
|
|
let reference = callHistoryItemReferences[internalIndex]
|
|
if reference.viewModel == nil {
|
|
let callRecords = reference.callRecordIds.map {
|
|
guard let callRecord = fetchCallRecordBlock($0, tx) else {
|
|
owsFail("Missing call record for existing reference!")
|
|
}
|
|
return callRecord
|
|
}
|
|
callHistoryItemReferences[internalIndex].viewModel = callViewModelForCallRecords(callRecords.rawValue, tx)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
// MARK: - Newly-Fetched References
|
|
|
|
/// Loads older call history items.
|
|
///
|
|
/// - Returns
|
|
/// Call history items computed by merging ``CallRecord``s. At most
|
|
/// `maxCount` items will be returned, but more than `maxCount`
|
|
/// ``CallRecord``s may be fetched due to coalescing.
|
|
private func loadOlderCallHistoryItemReferences(
|
|
olderThan oldestCallTimestamp: UInt64?,
|
|
maxCount: Int,
|
|
tx: DBReadTransaction
|
|
) -> [NonEmptyArray<CallRecord>] {
|
|
let newCallRecordsCursor: CallRecordCursor = callRecordLoader.loadCallRecords(
|
|
loadDirection: .olderThan(oldestCallTimestamp: oldestCallTimestamp),
|
|
tx: tx
|
|
)
|
|
|
|
// Group call records that will be shown together in the UI.
|
|
var callRecordsForNextGroup = [CallRecord]()
|
|
|
|
var results = [NonEmptyArray<CallRecord>]()
|
|
while let nextCallRecord = try? newCallRecordsCursor.next() {
|
|
if let anchorCallRecord = callRecordsForNextGroup.first {
|
|
let canCoalesce: Bool = (
|
|
callRecordsForNextGroup.count < maxCoalescedCallsInOneViewModel
|
|
&& anchorCallRecord.isValidCoalescingAnchor(for: nextCallRecord)
|
|
)
|
|
if !canCoalesce {
|
|
results.append(NonEmptyArray(callRecordsForNextGroup)!)
|
|
callRecordsForNextGroup = []
|
|
if results.count >= maxCount {
|
|
// Bail when we reach the limit.
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
callRecordsForNextGroup.append(nextCallRecord)
|
|
}
|
|
if let finalGroup = NonEmptyArray(callRecordsForNextGroup) {
|
|
results.append(finalGroup)
|
|
}
|
|
return results
|
|
}
|
|
|
|
/// Loads newer call history items.
|
|
///
|
|
/// - Note
|
|
/// Unlike ``loadOlderCallHistoryItemReferences()``, this method doesn't
|
|
/// coalesce its results.
|
|
///
|
|
/// In general usage, this type expects to start empty and monotonically
|
|
/// load older calls; finding a brand-new newer call implies the call was
|
|
/// inserted after our initial load. Finding a single brand-new newer call
|
|
/// is expected in the case where a new call starts, but it is unexpected to
|
|
/// find multiple brand-new newer calls at once.
|
|
///
|
|
/// Consequently, ``loadCallHistoryItemReferences()`` will sometimes
|
|
/// coalesce a single brand-new newer call into already-fetched value to
|
|
/// support the "new call" scenario. However, if multiple brand-new newer
|
|
/// calls are found, the others won't be coalesced.
|
|
///
|
|
/// Users are unlikely to encounter this limitation in practice, and the
|
|
/// effect is simply that calls that should have coalesced are not until the
|
|
/// next time we load-from-empty. In return, this code is simpler.
|
|
private func loadNewerCallHistoryItemReferences(
|
|
newerThan newestCallTimestamp: UInt64,
|
|
tx: DBReadTransaction
|
|
) -> [CallRecord] {
|
|
let newCallRecordsCursor: CallRecordCursor = callRecordLoader.loadCallRecords(
|
|
loadDirection: .newerThan(newestCallTimestamp: newestCallTimestamp),
|
|
tx: tx
|
|
)
|
|
|
|
// The call records we get back from our cursor will be ordered ascending,
|
|
// but we need them to be sorted descending.
|
|
//
|
|
// We'll reverse them here to make things easier on our callers.
|
|
return (try? newCallRecordsCursor.drain().reversed()) ?? []
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
/// Drops any calls with the given IDs from set of loaded objects.
|
|
///
|
|
/// - Important
|
|
/// ``viewModelReferences()``'s result may change after calling this method.
|
|
mutating func dropCalls(
|
|
matching callRecordIdsToDrop: [CallRecord.ID],
|
|
tx: DBReadTransaction
|
|
) {
|
|
let callRecordIdsToDrop = Set(callRecordIdsToDrop)
|
|
|
|
// Remove any IDs that were dropped from the references.
|
|
var callHistoryItemIndicesToRemove = IndexSet()
|
|
for internalIndex in callHistoryItemReferences.indices {
|
|
let reference = callHistoryItemReferences[internalIndex]
|
|
guard reference.callRecordIds.rawValue.contains(where: { callRecordIdsToDrop.contains($0) }) else {
|
|
continue
|
|
}
|
|
if let callRecordIds = NonEmptyArray(reference.callRecordIds.rawValue.filter({ !callRecordIdsToDrop.contains($0) })) {
|
|
callHistoryItemReferences[internalIndex] = CallHistoryItemReference(
|
|
callRecordIds: callRecordIds,
|
|
callLinkRowId: reference.callLinkRowId
|
|
)
|
|
} else {
|
|
callHistoryItemIndicesToRemove.insert(internalIndex)
|
|
}
|
|
}
|
|
callHistoryItemReferences.remove(atOffsets: callHistoryItemIndicesToRemove)
|
|
}
|
|
|
|
/// Invalidates view models containing any of the given IDs.
|
|
///
|
|
/// - Returns
|
|
/// References for any view models that were invalidated.
|
|
mutating func invalidate(callLinkRowIds: Set<Int64>, callRecordIds: Set<CallRecord.ID>) -> Set<CallViewModel.Reference> {
|
|
var invalidatedViewModelReferences = Set<CallViewModel.Reference>()
|
|
|
|
for internalIndex in upcomingCallLinkReferences.indices {
|
|
let reference = upcomingCallLinkReferences[internalIndex]
|
|
if callLinkRowIds.contains(reference.callLinkRowId) {
|
|
upcomingCallLinkReferences[internalIndex].viewModel = nil
|
|
invalidatedViewModelReferences.insert(reference.viewModelReference)
|
|
}
|
|
}
|
|
for internalIndex in callHistoryItemReferences.indices {
|
|
let reference = callHistoryItemReferences[internalIndex]
|
|
let hasMatch = { () -> Bool in
|
|
if let callLinkRowId = reference.callLinkRowId, callLinkRowIds.contains(callLinkRowId) {
|
|
return true
|
|
}
|
|
return reference.callRecordIds.rawValue.contains(where: { callRecordIds.contains($0) })
|
|
}()
|
|
if hasMatch {
|
|
callHistoryItemReferences[internalIndex].viewModel = nil
|
|
invalidatedViewModelReferences.insert(reference.viewModelReference)
|
|
}
|
|
}
|
|
|
|
return invalidatedViewModelReferences
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private extension CallRecord {
|
|
private enum Constants {
|
|
/// A time interval representing a window within which two call records
|
|
/// can be coalesced together.
|
|
static let coalescingTimeWindow: TimeInterval = 4 * kHourInterval
|
|
}
|
|
|
|
/// Whether the given call record can be coalesced under this call record.
|
|
func isValidCoalescingAnchor(for otherCallRecord: CallRecord) -> Bool {
|
|
switch (conversationId, otherCallRecord.conversationId) {
|
|
case (.thread(let threadRowId), .thread(let otherThreadRowId)) where threadRowId == otherThreadRowId:
|
|
break
|
|
case (.thread(_), .thread(_)):
|
|
return false
|
|
case (.callLink(_), _), (_, .callLink(_)):
|
|
// Call links are never coalesced.
|
|
return false
|
|
}
|
|
return (
|
|
callDirection == otherCallRecord.callDirection
|
|
&& callStatus.isMissedCall == otherCallRecord.callStatus.isMissedCall
|
|
&& callBeganDate.addingTimeInterval(-Constants.coalescingTimeWindow) < otherCallRecord.callBeganDate
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct NonEmptyArray<Element> {
|
|
let rawValue: [Element]
|
|
|
|
init?(_ rawValue: [Element]) {
|
|
if rawValue.isEmpty {
|
|
return nil
|
|
}
|
|
self.rawValue = rawValue
|
|
}
|
|
|
|
init(singleElement: Element) {
|
|
self.rawValue = [singleElement]
|
|
}
|
|
|
|
var first: Element { self.rawValue.first! }
|
|
|
|
var last: Element { self.rawValue.last! }
|
|
|
|
func map<T, E>(_ transform: (Element) throws(E) -> T) throws(E) -> NonEmptyArray<T> where E: Error {
|
|
return NonEmptyArray<T>(try self.rawValue.map(transform))!
|
|
}
|
|
|
|
static func + (lhs: Self, rhs: [Element]) -> Self {
|
|
return Self(lhs.rawValue + rhs)!
|
|
}
|
|
}
|