Path: blob/main/SignalServiceKit/Messages/Edit/EditMessageWrapper.swift
1 views
//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
public import LibSignalClient
/// Wrapper that preserves the type information of the message
/// being targeted for editing. This wrapper prevents a lot of unecessary
/// casting from TSMessage back into the specific message types. In
/// most cases TSMessage is fine to pass around, but for certain situations
/// having the TSMessage -> TSMessageBuilder relationship defined is
/// useful, and for things like outgoing edits, preserving this information
/// is necessary.
public protocol EditMessageWrapper {
associatedtype MessageType: TSMessage
associatedtype MessageBuilderType: TSMessageBuilder
var message: MessageType { get }
var wasRead: Bool { get }
/// Clones this message into a new builder, applying the given edits and
/// zeroing-out any attachment-related fields.
func cloneAsBuilderWithoutAttachments(
applying: MessageEdits,
isLatestRevision: Bool,
attachmentContentValidator: AttachmentContentValidator,
tx: DBWriteTransaction,
) -> MessageBuilderType
static func build(
_ builder: MessageBuilderType,
tx: DBReadTransaction,
) -> MessageType
func updateMessageCopy(
newMessageCopy: MessageType,
tx: DBWriteTransaction,
)
}
// MARK: -
public struct IncomingEditMessageWrapper: EditMessageWrapper {
public let message: TSIncomingMessage
public let thread: TSThread
public let authorAci: Aci?
/// Read state is .. complicated when it comes to edit revisions.
///
/// For the latest revision of a message, the `TSInteraction/read` property
/// is accurate.
///
/// However, the `TSInteraction` for all past revisions has `read` set to
/// `true`, and "whether the user has read that edit" is tracked separately
/// on `EditRecord`.
///
/// This is for two reasons:
///
/// 1. There are many queries that filter on `TSInteraction/read`, and by
/// automatically excluding prior revisions from those queries we make them
/// simpler; for example, queries pertaining to unread count, or whether a
/// thread contains an unread mention of the local user.
///
/// 2. Interactions are marked "read" by tracking the latest interaction to
/// become visible, and marking all interactions before it (by SQL insertion
/// order) as read. Old edit revisions are not visible in the UI, and are
/// inserted as *newer* interactions than the latest revision; this makes it
/// complicated to correctly mark those interactions as read.
///
/// ---
///
/// Note that we should only ever be targeting a latest revision for edits.
public var wasRead: Bool {
switch message.editState {
case .none, .latestRevisionRead, .latestRevisionUnread:
return message.wasRead
case .pastRevision:
// We shouldn't ever be targeting a past revision for an edit. If we
// were, though, assume it was unread since it can't be seen in the
// conversation view.
owsFailDebug("Edit target was unexpectedly past revision!")
return false
}
}
public func cloneAsBuilderWithoutAttachments(
applying edits: MessageEdits,
isLatestRevision: Bool,
attachmentContentValidator: AttachmentContentValidator,
tx: DBWriteTransaction,
) -> TSIncomingMessageBuilder {
let editState: TSEditState = {
if isLatestRevision {
switch message.editState {
case .none:
return message.wasRead ? .latestRevisionRead : .latestRevisionUnread
case .latestRevisionRead, .latestRevisionUnread:
return message.editState
case .pastRevision:
owsFailDebug("Latest revision message unexpectedly had .pastRevision edit state!")
return message.editState
}
} else {
return .pastRevision
}
}()
let messageBody: ValidatedInlineMessageBody?
switch edits.body {
case .keep:
messageBody = message.body.map {
attachmentContentValidator.truncatedMessageBodyForInlining(
MessageBody(text: $0, ranges: message.bodyRanges ?? .empty),
tx: tx,
)
}
case .change(let body):
messageBody = body
}
let timestamp = edits.timestamp.unwrapChange(orKeepValue: message.timestamp)
let receivedAtTimestamp = edits.receivedAtTimestamp.unwrapChange(orKeepValue: message.receivedAtTimestamp)
let serverTimestamp = edits.serverTimestamp.unwrapChange(orKeepValue: message.serverTimestamp?.uint64Value ?? 0)
let serverDeliveryTimestamp = edits.serverDeliveryTimestamp.unwrapChange(orKeepValue: message.serverDeliveryTimestamp)
let serverGuid = edits.serverGuid.unwrapChange(orKeepValue: message.serverGuid)
if message.isPoll {
owsFailDebug("Poll messages should not be editable")
}
/// Copies the wrapped message's fields with edited fields overridden as
/// appropriate. Attachment-related properties are zeroed-out, and
/// handled later by ``EditManagerAttachments/reconcileAttachments``.
return TSIncomingMessageBuilder(
thread: thread,
timestamp: timestamp,
receivedAtTimestamp: receivedAtTimestamp,
authorAci: authorAci,
authorE164: nil,
messageBody: messageBody,
editState: editState,
// Prior revisions don't expire (timer=0); instead they
// are cascade-deleted when the latest revision expires.
expiresInSeconds: isLatestRevision ? message.expiresInSeconds : 0,
expireTimerVersion: isLatestRevision ? message.expireTimerVersion?.uint32Value : nil,
expireStartedAt: message.expireStartedAt,
read: isLatestRevision ? false : true,
serverTimestamp: serverTimestamp,
serverDeliveryTimestamp: serverDeliveryTimestamp,
serverGuid: serverGuid,
wasReceivedByUD: message.wasReceivedByUD,
isSmsMessageRestoredFromBackup: message.isSmsMessageRestoredFromBackup,
isViewOnceMessage: message.isViewOnceMessage,
isViewOnceComplete: message.isViewOnceComplete,
wasRemotelyDeleted: message.wasRemotelyDeleted,
storyAuthorAci: message.storyAuthorAci?.wrappedAciValue,
storyTimestamp: message.storyTimestamp?.uint64Value,
storyReactionEmoji: message.storyReactionEmoji,
quotedMessage: nil,
contactShare: nil,
linkPreview: nil,
messageSticker: nil,
giftBadge: message.giftBadge,
paymentNotification: nil,
isPoll: false,
)
}
public static func build(
_ builder: TSIncomingMessageBuilder,
tx: DBReadTransaction,
) -> TSIncomingMessage {
return builder.build()
}
public func updateMessageCopy(
newMessageCopy: TSIncomingMessage,
tx: DBWriteTransaction,
) {}
}
// MARK: -
public struct OutgoingEditMessageWrapper: EditMessageWrapper {
public let message: TSOutgoingMessage
public let thread: TSThread
public init(
message: TSOutgoingMessage,
thread: TSThread,
) {
self.message = message
self.thread = thread
}
/// Outgoing messages are always read.
public var wasRead: Bool { true }
public func cloneAsBuilderWithoutAttachments(
applying edits: MessageEdits,
isLatestRevision: Bool,
attachmentContentValidator: AttachmentContentValidator,
tx: DBWriteTransaction,
) -> TSOutgoingMessageBuilder {
let messageBody: ValidatedInlineMessageBody?
switch edits.body {
case .keep:
messageBody = message.body.map {
attachmentContentValidator.truncatedMessageBodyForInlining(
MessageBody(text: $0, ranges: message.bodyRanges ?? .empty),
tx: tx,
)
}
case .change(let body):
messageBody = body
}
let timestamp = edits.timestamp.unwrapChange(orKeepValue: message.timestamp)
let receivedAtTimestamp = edits.receivedAtTimestamp.unwrapChange(orKeepValue: message.receivedAtTimestamp)
if message.isPoll {
owsFailDebug("Poll messages should not be editable")
}
/// Copies the wrapped message's fields with edited fields overridden as
/// appropriate. Attachment-related properties are zeroed-out, and
/// handled later by ``EditManagerAttachments/reconcileAttachments``.
return TSOutgoingMessageBuilder(
thread: thread,
timestamp: timestamp,
receivedAtTimestamp: receivedAtTimestamp,
messageBody: messageBody,
// Outgoing messages are implicitly read.
editState: isLatestRevision ? .latestRevisionRead : .pastRevision,
// Prior revisions don't expire (timer=0); instead they
// are cascade-deleted when the latest revision expires.
expiresInSeconds: isLatestRevision ? message.expiresInSeconds : 0,
expireTimerVersion: isLatestRevision ? message.expireTimerVersion?.uint32Value : 0,
expireStartedAt: message.expireStartedAt,
isVoiceMessage: message.isVoiceMessage,
isSmsMessageRestoredFromBackup: message.isSmsMessageRestoredFromBackup,
isViewOnceMessage: message.isViewOnceMessage,
isViewOnceComplete: message.isViewOnceComplete,
wasRemotelyDeleted: message.wasRemotelyDeleted,
wasNotCreatedLocally: message.wasNotCreatedLocally,
groupChangeProtoData: message.changeActionsProtoData,
storyAuthorAci: message.storyAuthorAci?.wrappedAciValue,
storyTimestamp: message.storyTimestamp?.uint64Value,
storyReactionEmoji: message.storyReactionEmoji,
quotedMessage: nil,
contactShare: nil,
linkPreview: nil,
messageSticker: nil,
giftBadge: message.giftBadge,
isPoll: false,
)
}
public static func build(
_ builder: TSOutgoingMessageBuilder,
tx: DBReadTransaction,
) -> TSOutgoingMessage {
return builder.build(transaction: tx)
}
public func updateMessageCopy(
newMessageCopy: TSOutgoingMessage,
tx: DBWriteTransaction,
) {
// Need to copy over the recipient address from the old message
// This is needed when procesing sync messages.
newMessageCopy.updateWithRecipientAddressStates(
message.recipientAddressStates,
tx: tx,
)
}
}