Path: blob/main/SignalServiceKit/Groups/GroupUpdateInfoMessageInserter+FoldIntoExistingMessage.swift
1 views
//
// 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: DBWriteTransaction,
) -> 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: DBWriteTransaction,
) -> 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: DBWriteTransaction,
) -> 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)
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
}
}
private static func mostRecentVisibleInteractionsAsInfoMessages(
forGroupThread groupThread: TSGroupThread,
withTransaction transaction: DBReadTransaction,
) -> (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: DBReadTransaction,
) -> 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
}
}
}