TM-SGNL-iOS/SignalServiceKit/MessageBackup/Archivers/ChatItem/MessageBackupTSMessageEditHistoryArchiver.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

347 lines
14 KiB
Swift

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
/// Represents an object that can perform archive/restore actions on a single
/// instance, in isolation, of a ``TSMessage`` subclass. The instance may either
/// be the latest or a prior revision in its edit history.
///
/// - SeeAlso
/// ``MessageBackupTSMessageEditHistoryArchiver``
///
/// - Note
/// At the time of writing, implementations exist for ``TSIncomingMessage`` and
/// ``TSOutgoingMessage``, which are the only types that can have an edit
/// history in practice.
protocol MessageBackupTSMessageEditHistoryBuilder<EditHistoryMessageType>: AnyObject {
associatedtype EditHistoryMessageType: TSMessage
typealias Details = MessageBackup.InteractionArchiveDetails
/// Build archive details for the given message.
///
/// - Parameter editRecord
/// If the given message is a prior revision, this should contain the edit
/// record corresponding to that revision.
func buildMessageArchiveDetails(
message: EditHistoryMessageType,
editRecord: EditRecord?,
context: MessageBackup.ChatArchivingContext
) -> MessageBackup.ArchiveInteractionResult<Details>
/// Restore a message from the given chat item.
///
/// - Parameter isPastRevision
/// Whether this chat item is known to be a past revision. If this is true,
/// `hasPastRevisions` will always be `false`.
/// - Parameter hasPastRevisions
/// Whether this chat item has past revisions. If this is true,
/// `isPastRevision` will always be `false`.
func restoreMessage(
_ chatItem: BackupProto_ChatItem,
isPastRevision: Bool,
hasPastRevisions: Bool,
chatThread: MessageBackup.ChatThread,
context: MessageBackup.ChatItemRestoringContext
) -> MessageBackup.RestoreInteractionResult<EditHistoryMessageType>
}
/// An object that can perform archive/restore actions on an instance of a
/// ``TSMessage`` subclass and its entire edit history. This type is primarily
/// responsible for managing the edit history itself, and delegates the "heavy
/// lifting" of performing archive/restore actions on the ``TSMessage``s in the
/// edit history to a ``MessageBackupTSMessageEditHistoryBuilder``.
final class MessageBackupTSMessageEditHistoryArchiver<MessageType: TSMessage>
{
typealias Details = MessageBackup.InteractionArchiveDetails
private typealias ArchiveFrameError = MessageBackup.ArchiveFrameError<MessageBackup.InteractionUniqueId>
private typealias RestoreFrameError = MessageBackup.RestoreFrameError<MessageBackup.ChatItemId>
private let dateProvider: DateProvider
private let editMessageStore: any EditMessageStore
init(
dateProvider: @escaping DateProvider,
editMessageStore: any EditMessageStore
) {
self.dateProvider = dateProvider
self.editMessageStore = editMessageStore
}
// MARK: - Archive
/// Build archive details for the given message, along with its edit
/// history (if it has prior revisions).
///
/// - Important
/// In practice, all messages that _might_ have an edit history are archived
/// by an instance of this type, even if they do not ultimately have any
/// prior revisions.
///
/// - Note
/// When past revision message instances are passed, this method returns a
/// "skippable" result case. Instead, the latest revision and all its past
/// revisions are archived into the same ``Details``, which is a recursive
/// type, when the latest revision is passed.
///
/// - Parameter builder
/// An object responsible for actually building archive details on the
/// passed message, and those in its edit history.
func archiveMessageAndEditHistory<
Builder: MessageBackupTSMessageEditHistoryBuilder<MessageType>
>(
_ message: MessageType,
context: MessageBackup.ChatArchivingContext,
builder: Builder
) -> MessageBackup.ArchiveInteractionResult<Details>
{
var partialErrors = [ArchiveFrameError]()
let shouldArchiveEditHistory: Bool
switch message.editState {
case .pastRevision:
/// This message represents a past revision of a message, which is
/// archived as part of archiving the latest revision. Consequently,
/// we can skip this past revision here.
return .skippableChatUpdate(.pastRevisionOfEditedMessage)
case .none:
shouldArchiveEditHistory = false
case .latestRevisionRead, .latestRevisionUnread:
shouldArchiveEditHistory = true
}
var messageDetails: Details
switch builder.buildMessageArchiveDetails(
message: message,
editRecord: nil,
context: context
).bubbleUp(Details.self, partialErrors: &partialErrors) {
case .continue(let _messageDetails):
messageDetails = _messageDetails
case .bubbleUpError(let errorResult):
return errorResult
}
if shouldArchiveEditHistory {
switch addEditHistoryArchiveDetails(
toLatestRevisionArchiveDetails: &messageDetails,
latestRevisionMessage: message,
context: context,
builder: builder
).bubbleUp(Details.self, partialErrors: &partialErrors) {
case .continue:
break
case .bubbleUpError(let errorResult):
return errorResult
}
}
if partialErrors.isEmpty {
return .success(messageDetails)
} else {
return .partialFailure(messageDetails, partialErrors)
}
}
/// Archive each of the prior revisions of the given latest revision of a
/// message, and add those prior-revision archive details to the given
/// archive details for the latest revision.
private func addEditHistoryArchiveDetails<
Builder: MessageBackupTSMessageEditHistoryBuilder<MessageType>
>(
toLatestRevisionArchiveDetails latestRevisionDetails: inout Details,
latestRevisionMessage: MessageType,
context: MessageBackup.ChatArchivingContext,
builder: Builder
) -> MessageBackup.ArchiveInteractionResult<Void> {
var partialErrors = [ArchiveFrameError]()
guard case .standardMessage = latestRevisionDetails.chatItemType else {
return .success(())
}
/// The edit history, from oldest revision to newest. This ordering
/// matches the expected ordering for `revisions` on a `ChatItem`, but
/// is reverse of what we get from `editMessageStore`.
let editHistory: [(EditRecord, MessageType?)]
do {
editHistory = try editMessageStore.findEditHistory(
for: latestRevisionMessage,
tx: context.tx
).reversed()
} catch {
return .messageFailure([.archiveFrameError(
.editHistoryFailedToFetch,
latestRevisionMessage.uniqueInteractionId
)])
}
for (editRecord, pastRevisionMessage) in editHistory {
guard let pastRevisionMessage else { continue }
/// Build archive details for this past revision, so we can append
/// them to the most recent revision's archive details.
///
/// We'll power through anything less than a `.completeFailure`
/// while restoring a past revision, instead tracking the error,
/// dropping the revision, and moving on.
let pastRevisionDetails: Details
switch builder.buildMessageArchiveDetails(
message: pastRevisionMessage,
editRecord: editRecord,
context: context
) {
case .success(let _pastRevisionDetails):
pastRevisionDetails = _pastRevisionDetails
case .partialFailure(let _pastRevisionDetails, let _partialErrors):
pastRevisionDetails = _pastRevisionDetails
partialErrors.append(contentsOf: _partialErrors)
case .messageFailure(let _partialErrors):
partialErrors.append(contentsOf: _partialErrors)
continue
case .completeFailure(let fatalError):
return .completeFailure(fatalError)
case .skippableChatUpdate:
// This should never happen for an edit revision!
continue
}
/// We're iterating the edit history from oldest to newest, so the
/// past revision details stored on `latestRevisionDetails` will
/// also be ordered oldest to newest.
latestRevisionDetails.addPastRevision(pastRevisionDetails)
}
if partialErrors.isEmpty {
return .success(())
} else {
return .partialFailure((), partialErrors)
}
}
// MARK: - Restore
/// Restore a message from the given chat item, along with its edit history
/// (if it has prior revisions).
///
/// - Note
/// ``BackupProto_ChatItem`` is a recursive type, such that a top-level
/// `ChatItem` represents the latest revision in an edit history and
/// contains sub-`ChatItem`s representing its prior revisions.
///
/// - Parameter builder
/// An object responsible for actually restoring a message from the
/// top-level `ChatItem`, and its contained prior revisions (if any).
func restoreMessageAndEditHistory<
Builder: MessageBackupTSMessageEditHistoryBuilder<MessageType>
>(
_ topLevelChatItem: BackupProto_ChatItem,
chatThread: MessageBackup.ChatThread,
context: MessageBackup.ChatItemRestoringContext,
builder: Builder
) -> MessageBackup.RestoreInteractionResult<Void> {
var partialErrors = [RestoreFrameError]()
guard
let latestRevisionMessage = builder.restoreMessage(
topLevelChatItem,
isPastRevision: false,
hasPastRevisions: topLevelChatItem.revisions.count > 0,
chatThread: chatThread,
context: context
).unwrap(partialErrors: &partialErrors)
else {
return .messageFailure(partialErrors)
}
var earlierRevisionMessages = [MessageType]()
/// `ChatItem.revisions` is ordered oldest -> newest, which aligns with
/// how we want to insert them. Older revisions should be inserted
/// before newer ones.
for revisionChatItem in topLevelChatItem.revisions {
guard
let earlierRevisionMessage = builder.restoreMessage(
revisionChatItem,
isPastRevision: true,
hasPastRevisions: false, // Past revisions can't have their own past revisions!
chatThread: chatThread,
context: context
).unwrap(partialErrors: &partialErrors)
else {
/// This means we won't attempt to restore any later revisions,
/// but we can't be confident they would have restored
/// successfully anyway.
return .messageFailure(partialErrors)
}
earlierRevisionMessages.append(earlierRevisionMessage)
}
for earlierRevisionMessage in earlierRevisionMessages {
guard
let wasRead = earlierRevisionMessage.wasRead()
.unwrap(partialErrors: &partialErrors)
else {
return .messageFailure(partialErrors)
}
let editRecord = EditRecord(
latestRevisionId: latestRevisionMessage.sqliteRowId!,
pastRevisionId: earlierRevisionMessage.sqliteRowId!,
read: wasRead
)
do {
try editMessageStore.insert(editRecord, tx: context.tx)
} catch {
return .partialRestore(
(),
[.restoreFrameError(
.databaseInsertionFailed(error),
topLevelChatItem.id
)] + partialErrors
)
}
}
if partialErrors.isEmpty {
return .success(())
} else {
return .partialRestore((), partialErrors)
}
}
}
// MARK: -
private extension TSMessage {
/// A workaround to generically expose "read status" off `TSMessage`.
///
/// At the time of writing `TSMessage` has four subclasses: `Info`, `Error`,
/// `Incoming`, and `Outgoing`. All of these conform to `OWSReadTracking`
/// excepting `Outgoing`, because all outgoing messages are implicitly read.
///
/// This method exposes that "read status" off `TSMessage`, since
/// `MessageBackupTSMessageEditHistoryArchiver` operates generically on
/// `TSMessage` subclasses and needs to know about read status.
func wasRead() -> MessageBackup.RestoreInteractionResult<Bool> {
if let info = self as? TSInfoMessage {
return .success(info.wasRead)
} else if let error = self as? TSErrorMessage {
return .success(error.wasRead)
} else if let incoming = self as? TSIncomingMessage {
return .success(incoming.wasRead)
} else if self is TSOutgoingMessage {
// Implicitly read!
return .success(true)
}
return .messageFailure([.restoreFrameError(
.developerError(OWSAssertionError("Unexpected TSMessage type instantiated during restore: \(type(of: self))")),
chatItemId
)])
}
}