TM-SGNL-iOS/SignalServiceKit/Groups/GroupUpdateInfoMessageInserter+FoldIntoExistingMessage.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

459 lines
17 KiB
Swift

//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
public import LibSignalClient
extension GroupUpdateInfoMessageInserterImpl {
/// Represents updates to a group's membership that may be possible to
/// collapse into existing info messages.
enum PossiblyCollapsibleMembershipChange {
case newJoinRequestFromSingleUser(requestingAci: Aci)
case canceledJoinRequestFromSingleUser(cancelingAci: Aci)
}
/// Represents the result of collapsing updates into existing messages.
enum CollapsibleMembershipChangeResult {
/// Membership changes were collapsed into an existing, now-updated,
/// info message.
case updatesCollapsedIntoExistingMessage
/// Update messages pertaining to the membership change are available
/// and a new info message should be inserted containing them. Existing
/// messages may have been updated while computing these updates.
case updateItemForNewMessage(TSInfoMessage.PersistableGroupUpdateItem)
}
func handlePossiblyCollapsibleMembershipChange(
possiblyCollapsibleMembershipChange: PossiblyCollapsibleMembershipChange,
localIdentifiers: LocalIdentifiers,
groupThread: TSGroupThread,
newGroupModel: TSGroupModel,
transaction: SDSAnyWriteTransaction
) -> CollapsibleMembershipChangeResult? {
guard
let (mostRecentInfoMsg, secondMostRecentInfoMsgMaybe) =
Self.mostRecentVisibleInteractionsAsInfoMessages(
forGroupThread: groupThread,
withTransaction: transaction
)
else {
return nil
}
switch possiblyCollapsibleMembershipChange {
case .newJoinRequestFromSingleUser(let requestingAci):
/// By requesting and canceling over and over, a user who is not in
/// a group would be able to fill the group's chat history with info
/// messages detailing their actions. To address that, we'll
/// "collapse" a request/cancel event into a preexisting info
/// message, if appropriate, rather than letting the events pile up.
guard localIdentifiers.aci != requestingAci else {
return nil
}
return maybeUpdate(
mostRecentInfoMsg: mostRecentInfoMsg,
withNewJoinRequestFrom: requestingAci,
localIdentifiers: localIdentifiers,
transaction: transaction
)
case .canceledJoinRequestFromSingleUser(let cancelingAci):
/// See the comment above for why we care about this case.
guard localIdentifiers.aci != cancelingAci else {
return nil
}
return maybeUpdate(
mostRecentInfoMsg: mostRecentInfoMsg,
andSecondMostRecentInfoMsg: secondMostRecentInfoMsgMaybe,
withCanceledJoinRequestFrom: cancelingAci,
newGroupModel: newGroupModel,
localIdentifiers: localIdentifiers,
transaction: transaction
)
}
}
private func maybeUpdate(
mostRecentInfoMsg: TSInfoMessage,
withNewJoinRequestFrom requestingAci: Aci,
localIdentifiers: LocalIdentifiers,
transaction: SDSAnyWriteTransaction
) -> CollapsibleMembershipChangeResult? {
// For a new join request we always want a new info message. However,
// if the new request matches collapsed request/cancel events on the
// most recent message we should make a note on the new message that
// it is no longer the tail of the sequence.
//
// Note that the new message might get collapsed further (into the
// most recent message) in the future.
let mostRecentUpdateItem: TSInfoMessage.PersistableGroupUpdateItem?
switch mostRecentInfoMsg.groupUpdateMetadata(localIdentifiers: localIdentifiers) {
case .precomputed(let precomputedItems):
mostRecentUpdateItem = precomputedItems.asSingleUpdateItem
case .modelDiff, .legacyRawString, .newGroup, .nonGroupUpdate:
return nil
}
guard
let mostRecentUpdateItem,
case let .sequenceOfInviteLinkRequestAndCancels(requester, count, isTail) = mostRecentUpdateItem,
requestingAci == requester.wrappedValue
else {
return nil
}
owsAssertDebug(isTail)
mostRecentInfoMsg.setSingleUpdateItem(
singleUpdateItem: .sequenceOfInviteLinkRequestAndCancels(
requester: requestingAci.codableUuid,
count: count,
isTail: false
)
)
mostRecentInfoMsg.anyUpsert(transaction: transaction)
return .updateItemForNewMessage(
.sequenceOfInviteLinkRequestAndCancels(
requester: requestingAci.codableUuid,
count: 0,
isTail: true
)
)
}
private func maybeUpdate(
mostRecentInfoMsg: TSInfoMessage,
andSecondMostRecentInfoMsg secondMostRecentInfoMsg: TSInfoMessage?,
withCanceledJoinRequestFrom cancelingAci: Aci,
newGroupModel: TSGroupModel,
localIdentifiers: LocalIdentifiers,
transaction: SDSAnyWriteTransaction
) -> CollapsibleMembershipChangeResult? {
// If the most recent message represents the join request that's being
// canceled, we want to collapse into it.
//
// Further, if the second-most-recent message represents already-
// collapsed join/cancel events from the same address, we can simply
// increment that message's collapse counter and delete the most recent
// message.
if let (mostRecentInfoMsgJoiner, count) = mostRecentInfoMsg.representsSequenceOfRequestsAndCancelsWithAdditionalRequestToJoin(
localIdentifiers: localIdentifiers
) {
guard mostRecentInfoMsgJoiner == cancelingAci else {
return nil
}
// collapse into the single most recent message; it already represents
// a sequence of requests + cancels and one more request.
mostRecentInfoMsg.setSingleUpdateItem(
singleUpdateItem: .sequenceOfInviteLinkRequestAndCancels(
requester: cancelingAci.codableUuid,
count: count + 1,
isTail: true
)
)
mostRecentInfoMsg.anyUpsert(transaction: transaction)
return .updatesCollapsedIntoExistingMessage
}
guard
let mostRecentInfoMsgJoiner = mostRecentInfoMsg.representsCollapsibleSingleRequestToJoin(
localIdentifiers: localIdentifiers,
tx: transaction
),
cancelingAci == mostRecentInfoMsgJoiner
else {
return nil
}
if
let secondMostRecentInfoMsg,
let (requester, count) = secondMostRecentInfoMsg
.representsSingleSequenceOfRequestsAndCancels(
localIdentifiers: localIdentifiers
),
cancelingAci == requester
{
DependenciesBridge.shared.interactionDeleteManager
.delete(mostRecentInfoMsg, sideEffects: .default(), tx: transaction.asV2Write)
secondMostRecentInfoMsg.setSingleUpdateItem(
singleUpdateItem: .sequenceOfInviteLinkRequestAndCancels(
requester: cancelingAci.codableUuid,
count: count + 1,
isTail: true
)
)
secondMostRecentInfoMsg.anyUpsert(transaction: transaction)
return .updatesCollapsedIntoExistingMessage
} else {
mostRecentInfoMsg.setSingleUpdateItem(
singleUpdateItem: .sequenceOfInviteLinkRequestAndCancels(
requester: cancelingAci.codableUuid,
count: 1,
isTail: true
)
)
mostRecentInfoMsg.anyUpsert(transaction: transaction)
return .updatesCollapsedIntoExistingMessage
}
}
/// See ``GroupUpdateInfoMessageInserterBackupHelper``.
public static func collapseFromBackupIfNeeded(
updates: inout [TSInfoMessage.PersistableGroupUpdateItem],
localIdentifiers: LocalIdentifiers,
groupThread: TSGroupThread,
transaction: SDSAnyWriteTransaction
) {
if
updates.count == 2,
let (sequenceRequestor, count) = updates[0]
.representsSequenceOfRequestsAndCancels(),
let singleRequestor = updates[1]
.representsCollapsibleSingleRequestToJoin(),
sequenceRequestor == singleRequestor
{
// These updates are collapsible in isolation.
// Mark the first one as not the tail; thats the only change
// we need to make to "collapse" this one.
updates[0] =
.sequenceOfInviteLinkRequestAndCancels(
requester: sequenceRequestor.codableUuid,
count: count,
isTail: false
)
return
}
guard
updates.count == 1,
let requestingAci = updates[0].representsCollapsibleSingleRequestToJoin()
else {
// No change needed.
return
}
// This latest message is collapsible; try and collapse it.
guard let (mostRecentInfoMsg, _) =
mostRecentVisibleInteractionsAsInfoMessages(
forGroupThread: groupThread,
withTransaction: transaction
)
else {
return
}
guard let (mostRecentMsgAci, count) = mostRecentInfoMsg
.representsSingleSequenceOfRequestsAndCancels(
localIdentifiers: localIdentifiers
),
requestingAci == mostRecentMsgAci
else {
return
}
mostRecentInfoMsg.anyUpdateInfoMessage(
transaction: transaction,
block: {
$0.setSingleUpdateItem(
singleUpdateItem: .sequenceOfInviteLinkRequestAndCancels(
requester: mostRecentMsgAci.codableUuid,
// Count stays the same.
count: count,
// Its not the tail because of the subsequent request.
isTail: false
)
)
}
)
// No need to change the updates in the new info message.
return
}
private static func mostRecentVisibleInteractionsAsInfoMessages(
forGroupThread groupThread: TSGroupThread,
withTransaction transaction: SDSAnyReadTransaction
) -> (first: TSInfoMessage, second: TSInfoMessage?)? {
var mostRecentVisibleInteraction: TSInteraction?
var secondMostRecentVisibleInteraction: TSInteraction?
do {
try InteractionFinder(threadUniqueId: groupThread.uniqueId)
.enumerateInteractionsForConversationView(
rowIdFilter: .newest,
tx: transaction
) { interaction -> Bool in
if mostRecentVisibleInteraction == nil {
mostRecentVisibleInteraction = interaction
} else if secondMostRecentVisibleInteraction == nil {
secondMostRecentVisibleInteraction = interaction
return false
}
return true
}
} catch let error {
Logger.warn("Failed to get most recent interactions for thread: \(error.localizedDescription)")
return nil
}
guard let mostRecentInfoMessage = mostRecentVisibleInteraction as? TSInfoMessage else {
return nil
}
guard let secondMostRecentInfoMessage = secondMostRecentVisibleInteraction as? TSInfoMessage else {
return (mostRecentInfoMessage, nil)
}
return (mostRecentInfoMessage, secondMostRecentInfoMessage)
}
}
// MARK: TSInfoMessage extension
public extension TSInfoMessage.PersistableGroupUpdateItemsWrapper {
var asSingleUpdateItem: TSInfoMessage.PersistableGroupUpdateItem? {
guard updateItems.count == 1 else {
return nil
}
return updateItems.first
}
}
private extension TSInfoMessage {
func setSingleUpdateItem(singleUpdateItem: PersistableGroupUpdateItem) {
setGroupUpdateItemsWrapper(PersistableGroupUpdateItemsWrapper([singleUpdateItem]))
}
func representsCollapsibleSingleRequestToJoin(
localIdentifiers: LocalIdentifiers,
tx: SDSAnyReadTransaction
) -> Aci? {
switch groupUpdateMetadata(localIdentifiers: localIdentifiers) {
case .newGroup, .nonGroupUpdate, .legacyRawString:
return nil
case .precomputed(let precomputedItems):
return precomputedItems
.asSingleUpdateItem?
.representsCollapsibleSingleRequestToJoin()
case .modelDiff:
// In the case of a model diff, convert to a persistable item first, and see if
// that persistable item is a single request to join. The persistable item
// generation logic already has logic to determine this case; no need to
// replicate it here.
guard
let groupUpdateItems = computedGroupUpdateItems(
localIdentifiers: localIdentifiers,
tx: tx
),
groupUpdateItems.count == 1
else {
return nil
}
return groupUpdateItems.first?.representsCollapsibleSingleRequestToJoin()
}
}
func representsSingleSequenceOfRequestsAndCancels(
localIdentifiers: LocalIdentifiers
) -> (Aci, count: UInt)? {
switch self.groupUpdateMetadata(localIdentifiers: localIdentifiers) {
case .modelDiff, .legacyRawString, .newGroup, .nonGroupUpdate:
// This is a phenomenon exclusive to precomputed cases.
return nil
case .precomputed(let precomputedItems):
return precomputedItems
.asSingleUpdateItem?
.representsSequenceOfRequestsAndCancels()
}
}
/// It is possible (e.g. when restoring from a desktop-generated backup) to have a single info message
/// containing both a ``sequenceOfInviteLinkRequestAndCancels`` and a single request to
/// join that happens right after.
/// If this is the latest message, and we get a new cancel, we want to collapse everything down to
/// a single ``sequenceOfInviteLinkRequestAndCancels`` with an incremented count.
func representsSequenceOfRequestsAndCancelsWithAdditionalRequestToJoin(
localIdentifiers: LocalIdentifiers
) -> (Aci, count: UInt)? {
switch groupUpdateMetadata(localIdentifiers: localIdentifiers) {
case .newGroup, .nonGroupUpdate, .legacyRawString, .modelDiff:
// This is a phenomenon exclusive to precomputed cases.
return nil
case .precomputed(let precomputedItems):
let precomputedItems = precomputedItems.updateItems
guard precomputedItems.count == 2 else {
return nil
}
let firstItemRequester: Aci
let firstItemCount: UInt
let firstMessageIsTail: Bool
switch precomputedItems[0] {
case let .sequenceOfInviteLinkRequestAndCancels(requester, count, isTail):
firstItemRequester = requester.wrappedValue
firstItemCount = count
firstMessageIsTail = isTail
default:
return nil
}
guard let secondItemRequester = precomputedItems[1]
.representsCollapsibleSingleRequestToJoin()
else {
return nil
}
guard firstItemRequester == secondItemRequester else {
return nil
}
owsAssertDebug(
!firstMessageIsTail,
"Should not be tail when there is a subsequent request!"
)
return (firstItemRequester, firstItemCount)
}
}
}
public extension TSInfoMessage.PersistableGroupUpdateItem {
func representsSequenceOfRequestsAndCancels() -> (Aci, count: UInt)? {
guard
case let .sequenceOfInviteLinkRequestAndCancels(requester, count, _) = self
else {
return nil
}
return (requester.wrappedValue, count)
}
func representsCollapsibleSingleRequestToJoin() -> Aci? {
switch self {
case let .sequenceOfInviteLinkRequestAndCancels(requester, count, isTail):
guard isTail, count == 0 else {
return nil
}
return requester.wrappedValue
case .localUserRequestedToJoin:
// Just calling out that we don't collapse the local user's requests.
return nil
case let .otherUserRequestedToJoin(requester):
return requester.wrappedValue
default:
return nil
}
}
}