342 lines
14 KiB
Swift
342 lines
14 KiB
Swift
//
|
|
// Copyright 2023 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import Foundation
|
|
public import LibSignalClient
|
|
|
|
public protocol GroupUpdateInfoMessageInserter {
|
|
func insertGroupUpdateInfoMessageForNewGroup(
|
|
localIdentifiers: LocalIdentifiers,
|
|
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
|
|
groupThread: TSGroupThread,
|
|
groupModel: TSGroupModel,
|
|
disappearingMessageToken: DisappearingMessageToken,
|
|
groupUpdateSource: GroupUpdateSource,
|
|
transaction: DBWriteTransaction,
|
|
)
|
|
|
|
func insertGroupUpdateInfoMessage(
|
|
localIdentifiers: LocalIdentifiers,
|
|
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
|
|
groupThread: TSGroupThread,
|
|
oldGroupModel: TSGroupModel,
|
|
newGroupModel: TSGroupModel,
|
|
oldDisappearingMessageToken: DisappearingMessageToken,
|
|
newDisappearingMessageToken: DisappearingMessageToken,
|
|
newlyLearnedPniToAciAssociations: [Pni: Aci],
|
|
groupUpdateSource: GroupUpdateSource,
|
|
transaction: DBWriteTransaction,
|
|
)
|
|
}
|
|
|
|
class GroupUpdateInfoMessageInserterImpl: GroupUpdateInfoMessageInserter {
|
|
private let dateProvider: DateProvider
|
|
private let groupUpdateItemBuilder: GroupUpdateItemBuilder
|
|
private let notificationPresenter: any NotificationPresenter
|
|
|
|
init(
|
|
dateProvider: @escaping DateProvider,
|
|
groupUpdateItemBuilder: GroupUpdateItemBuilder,
|
|
notificationPresenter: any NotificationPresenter,
|
|
) {
|
|
self.dateProvider = dateProvider
|
|
self.groupUpdateItemBuilder = groupUpdateItemBuilder
|
|
self.notificationPresenter = notificationPresenter
|
|
}
|
|
|
|
func insertGroupUpdateInfoMessageForNewGroup(
|
|
localIdentifiers: LocalIdentifiers,
|
|
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
|
|
groupThread: TSGroupThread,
|
|
groupModel: TSGroupModel,
|
|
disappearingMessageToken: DisappearingMessageToken,
|
|
groupUpdateSource: GroupUpdateSource,
|
|
transaction tx: DBWriteTransaction,
|
|
) {
|
|
_insertGroupUpdateInfoMessage(
|
|
localIdentifiers: localIdentifiers,
|
|
spamReportingMetadata: spamReportingMetadata,
|
|
groupThread: groupThread,
|
|
oldGroupModel: nil,
|
|
newGroupModel: groupModel,
|
|
oldDisappearingMessageToken: nil,
|
|
newDisappearingMessageToken: disappearingMessageToken,
|
|
newlyLearnedPniToAciAssociations: [:],
|
|
groupUpdateSource: groupUpdateSource,
|
|
transaction: tx,
|
|
)
|
|
}
|
|
|
|
func insertGroupUpdateInfoMessage(
|
|
localIdentifiers: LocalIdentifiers,
|
|
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
|
|
groupThread: TSGroupThread,
|
|
oldGroupModel: TSGroupModel,
|
|
newGroupModel: TSGroupModel,
|
|
oldDisappearingMessageToken: DisappearingMessageToken,
|
|
newDisappearingMessageToken: DisappearingMessageToken,
|
|
newlyLearnedPniToAciAssociations: [Pni: Aci],
|
|
groupUpdateSource: GroupUpdateSource,
|
|
transaction tx: DBWriteTransaction,
|
|
) {
|
|
_insertGroupUpdateInfoMessage(
|
|
localIdentifiers: localIdentifiers,
|
|
spamReportingMetadata: spamReportingMetadata,
|
|
groupThread: groupThread,
|
|
oldGroupModel: oldGroupModel,
|
|
newGroupModel: newGroupModel,
|
|
oldDisappearingMessageToken: oldDisappearingMessageToken,
|
|
newDisappearingMessageToken: newDisappearingMessageToken,
|
|
newlyLearnedPniToAciAssociations: newlyLearnedPniToAciAssociations,
|
|
groupUpdateSource: groupUpdateSource,
|
|
transaction: tx,
|
|
)
|
|
}
|
|
|
|
private func _insertGroupUpdateInfoMessage(
|
|
localIdentifiers: LocalIdentifiers,
|
|
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
|
|
groupThread: TSGroupThread,
|
|
oldGroupModel: TSGroupModel?,
|
|
newGroupModel: TSGroupModel,
|
|
oldDisappearingMessageToken: DisappearingMessageToken?,
|
|
newDisappearingMessageToken: DisappearingMessageToken,
|
|
newlyLearnedPniToAciAssociations: [Pni: Aci],
|
|
groupUpdateSource: GroupUpdateSource,
|
|
transaction tx: DBWriteTransaction,
|
|
) {
|
|
let updateItemsForNewMessage: [TSInfoMessage.PersistableGroupUpdateItem]
|
|
|
|
if
|
|
let oldGroupModel,
|
|
let invitedPniPromotions: InvitedPnisPromotionToFullMemberAcis = .from(
|
|
oldGroupMembership: oldGroupModel.groupMembership,
|
|
newGroupMembership: newGroupModel.groupMembership,
|
|
newlyLearnedPniToAciAssociations: newlyLearnedPniToAciAssociations,
|
|
)
|
|
{
|
|
/// We can't accurately detect PNI -> ACI promotions via the group
|
|
/// model approach we'll take below, so we need to check for it in a
|
|
/// one-off fashion here before going into that flow.
|
|
updateItemsForNewMessage = invitedPniPromotions.promotions.map { pni, aci in
|
|
return .invitedPniPromotedToFullMemberAci(
|
|
newMember: aci.codableUuid,
|
|
inviter: oldGroupModel.groupMembership.addedByAci(
|
|
forInvitedMember: .init(pni),
|
|
)?.codableUuid,
|
|
)
|
|
}
|
|
} else {
|
|
let persistibleGroupUpdateItems: [TSInfoMessage.PersistableGroupUpdateItem] = {
|
|
if let oldGroupModel {
|
|
return groupUpdateItemBuilder.precomputedUpdateItemsByDiffingModels(
|
|
oldGroupModel: oldGroupModel,
|
|
newGroupModel: newGroupModel,
|
|
oldDisappearingMessageToken: oldDisappearingMessageToken,
|
|
newDisappearingMessageToken: newDisappearingMessageToken,
|
|
localIdentifiers: localIdentifiers,
|
|
groupUpdateSource: groupUpdateSource,
|
|
tx: tx,
|
|
)
|
|
} else {
|
|
return groupUpdateItemBuilder.precomputedUpdateItemsForNewGroup(
|
|
newGroupModel: newGroupModel,
|
|
newDisappearingMessageToken: newDisappearingMessageToken,
|
|
localIdentifiers: localIdentifiers,
|
|
groupUpdateSource: groupUpdateSource,
|
|
tx: tx,
|
|
)
|
|
}
|
|
}()
|
|
|
|
let possiblyCollapsibleMembershipChange: PossiblyCollapsibleMembershipChange? = {
|
|
if
|
|
persistibleGroupUpdateItems.count == 1,
|
|
case let .otherUserRequestedToJoin(requesterAci) = persistibleGroupUpdateItems.first!
|
|
{
|
|
return .newJoinRequestFromSingleUser(requestingAci: requesterAci.wrappedValue)
|
|
} else if
|
|
persistibleGroupUpdateItems.count == 1,
|
|
case let .otherUserRequestCanceledByOtherUser(requesterAci) = persistibleGroupUpdateItems.first!
|
|
{
|
|
return .canceledJoinRequestFromSingleUser(cancelingAci: requesterAci.wrappedValue)
|
|
}
|
|
|
|
return nil
|
|
}()
|
|
|
|
if
|
|
let possiblyCollapsibleMembershipChange,
|
|
let collapseResult = handlePossiblyCollapsibleMembershipChange(
|
|
possiblyCollapsibleMembershipChange: possiblyCollapsibleMembershipChange,
|
|
localIdentifiers: localIdentifiers,
|
|
groupThread: groupThread,
|
|
newGroupModel: newGroupModel,
|
|
transaction: tx,
|
|
)
|
|
{
|
|
switch collapseResult {
|
|
case .updatesCollapsedIntoExistingMessage:
|
|
// If we collapsed this update into an existing info
|
|
// message, we should bail out before doing anything with a
|
|
// new info message.
|
|
return
|
|
case let .updateItemForNewMessage(persistableGroupUpdateItem):
|
|
updateItemsForNewMessage = [persistableGroupUpdateItem]
|
|
}
|
|
} else {
|
|
updateItemsForNewMessage = persistibleGroupUpdateItems
|
|
}
|
|
}
|
|
|
|
/// This is true because the list of group update items we
|
|
/// compute above will never be empty. Even if we get a strange group
|
|
/// update that somehow doesn't produce a diff, we'll get back a list
|
|
/// with a single "generic group update" item in it.
|
|
owsPrecondition(!updateItemsForNewMessage.isEmpty)
|
|
let timestamp = dateProvider().ows_millisecondsSince1970
|
|
let insertedMessages: [(
|
|
updateItem: TSInfoMessage.PersistableGroupUpdateItem,
|
|
message: TSInfoMessage,
|
|
)] = updateItemsForNewMessage.map { item in
|
|
let message: TSInfoMessage = .makeForGroupUpdate(
|
|
timestamp: timestamp,
|
|
spamReportingMetadata: spamReportingMetadata,
|
|
groupThread: groupThread,
|
|
updateItems: [item],
|
|
)
|
|
message.anyInsert(transaction: tx)
|
|
return (item, message)
|
|
}
|
|
|
|
let wasLocalUserRequestingMember = oldGroupModel?.groupMembership.isLocalUserRequestingMember ?? false
|
|
let isLocalUserRequestingMember = newGroupModel.groupMembership.isLocalUserRequestingMember
|
|
|
|
var isTerminatedGroup = false
|
|
|
|
let isLocalUserUpdate: Bool
|
|
switch groupUpdateSource {
|
|
case .localUser:
|
|
isLocalUserUpdate = true
|
|
default:
|
|
isLocalUserUpdate = false
|
|
}
|
|
if isLocalUserUpdate || (!wasLocalUserRequestingMember && isLocalUserRequestingMember) {
|
|
let now = NSDate.ows_millisecondTimeStamp()
|
|
insertedMessages.map(\.message).forEach { message in
|
|
message.markAsRead(
|
|
atTimestamp: now,
|
|
thread: groupThread,
|
|
circumstance: .onThisDevice,
|
|
shouldClearNotifications: true,
|
|
transaction: tx,
|
|
)
|
|
}
|
|
} else {
|
|
// Notify when the local user is added or invited to a group.
|
|
for groupJoinMessage in insertedMessages.compactMap({ item, message -> TSInfoMessage? in
|
|
switch item {
|
|
case
|
|
.localUserWasInvitedByLocalUser,
|
|
.localUserWasInvitedByOtherUser,
|
|
.localUserWasInvitedByUnknownUser,
|
|
.localUserAddedByLocalUser,
|
|
.localUserAddedByOtherUser,
|
|
.localUserAddedByUnknownUser,
|
|
.localUserInvitedAfterMigration,
|
|
.localUserJoined,
|
|
.localUserJoinedViaInviteLink:
|
|
return message
|
|
default:
|
|
return nil
|
|
}
|
|
}) {
|
|
notificationPresenter.notifyUser(
|
|
forTSMessage: groupJoinMessage,
|
|
thread: groupThread,
|
|
wantsSound: true,
|
|
transaction: tx,
|
|
)
|
|
}
|
|
|
|
// Notify when the group ends.
|
|
for groupTerminateMessage in insertedMessages.compactMap({ item, message -> TSInfoMessage? in
|
|
switch item {
|
|
case
|
|
.groupTerminatedByLocalUser,
|
|
.groupTerminatedByOtherUser,
|
|
.groupTerminatedByUnknownUser:
|
|
return message
|
|
default:
|
|
return nil
|
|
}
|
|
}) {
|
|
notificationPresenter.notifyUser(
|
|
forTSMessage: groupTerminateMessage,
|
|
thread: groupThread,
|
|
wantsSound: true,
|
|
transaction: tx,
|
|
)
|
|
isTerminatedGroup = true
|
|
}
|
|
}
|
|
|
|
// Delete intents for terminated group.
|
|
if isTerminatedGroup {
|
|
DependenciesBridge.shared.threadSoftDeleteManager.removeIntentsForTerminatedGroup(threadUniqueId: groupThread.uniqueId)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
/// Represents a group change that consists exclusively of invited PNIs being
|
|
/// promoted to a full-member ACI.
|
|
///
|
|
/// When a user is invited to a group by PNI and accept, their ACI joins the
|
|
/// group as a full member. To a ``TSGroupModel`` diff that looks like "someone
|
|
/// declined an invite and someone entirely unrelated joined the group", because
|
|
/// PNI:ACI association isn't tracked in the group model.
|
|
///
|
|
/// Consequently, we check for this in a one-off fashion here.
|
|
private struct InvitedPnisPromotionToFullMemberAcis {
|
|
let promotions: [(pni: Pni, aci: Aci)]
|
|
|
|
private init(promotions: [(pni: Pni, aci: Aci)]) {
|
|
self.promotions = promotions
|
|
}
|
|
|
|
static func from(
|
|
oldGroupMembership: GroupMembership,
|
|
newGroupMembership: GroupMembership,
|
|
newlyLearnedPniToAciAssociations: [Pni: Aci],
|
|
) -> InvitedPnisPromotionToFullMemberAcis? {
|
|
let membersDiff: Set<ServiceId> = newGroupMembership.allMembersOfAnyKindServiceIds
|
|
.symmetricDifference(oldGroupMembership.allMembersOfAnyKindServiceIds)
|
|
|
|
var remainingMembers = membersDiff
|
|
var promotions: [(pni: Pni, aci: Aci)] = []
|
|
|
|
for possiblyInvitedPni in membersDiff.compactMap({ $0 as? Pni }) {
|
|
if
|
|
oldGroupMembership.isInvitedMember(possiblyInvitedPni),
|
|
let fullMemberAci = newlyLearnedPniToAciAssociations[possiblyInvitedPni],
|
|
newGroupMembership.isFullMember(fullMemberAci)
|
|
{
|
|
remainingMembers.remove(possiblyInvitedPni)
|
|
remainingMembers.remove(fullMemberAci)
|
|
|
|
promotions.append((pni: possiblyInvitedPni, aci: fullMemberAci))
|
|
}
|
|
}
|
|
|
|
if remainingMembers.isEmpty, !promotions.isEmpty {
|
|
return InvitedPnisPromotionToFullMemberAcis(promotions: promotions)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|