// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. import Foundation import GRDB import SessionUtil import SessionSnodeKit import SessionUtilitiesKit // MARK: - Group Info Handling internal extension SessionUtil { static let columnsRelatedToGroupMembers: [ColumnExpression] = [ GroupMember.Columns.role, GroupMember.Columns.roleStatus ] // MARK: - Incoming Changes static func handleGroupMembersUpdate( _ db: Database, in config: Config?, groupSessionId: SessionId, serverTimestampMs: Int64, using dependencies: Dependencies ) throws { guard config.needsDump else { return } guard case .object(let conf) = config else { throw SessionUtilError.invalidConfigObject } // Get the two member sets let updatedMembers: Set = try extractMembers( from: conf, groupSessionId: groupSessionId, serverTimestampMs: serverTimestampMs ) let existingMembers: Set = (try? GroupMember .filter(GroupMember.Columns.groupId == groupSessionId.hexString) .fetchSet(db)) .defaulting(to: []) let updatedStandardMemberIds: Set = updatedMembers .filter { $0.role == .standard } .map { $0.profileId } .asSet() let updatedAdminMemberIds: Set = updatedMembers .filter { $0.role == .admin } .map { $0.profileId } .asSet() // Add in any new members and remove any removed members try updatedMembers .subtracting(existingMembers) .forEach { try $0.save(db) } try GroupMember .filter(GroupMember.Columns.groupId == groupSessionId.hexString) .filter( ( GroupMember.Columns.role == GroupMember.Role.standard && !updatedStandardMemberIds.contains(GroupMember.Columns.profileId) ) || ( GroupMember.Columns.role == GroupMember.Role.admin && !updatedAdminMemberIds.contains(GroupMember.Columns.profileId) ) ) .deleteAll(db) } } // MARK: - Outgoing Changes internal extension SessionUtil { static func getMembers( groupSessionId: SessionId, using dependencies: Dependencies ) throws -> Set { return try dependencies[cache: .sessionUtil] .config(for: .groupMembers, sessionId: groupSessionId) .wrappedValue .map { config in guard case .object(let conf) = config else { throw SessionUtilError.invalidConfigObject } return try extractMembers( from: conf, groupSessionId: groupSessionId, serverTimestampMs: SnodeAPI.currentOffsetTimestampMs(using: dependencies) ) } ?? { throw SessionUtilError.failedToRetrieveConfigData }() } static func addMembers( _ db: Database, groupSessionId: SessionId, members: [(id: String, profile: Profile?)], using dependencies: Dependencies ) throws { try SessionUtil.performAndPushChange( db, for: .groupMembers, sessionId: groupSessionId, using: dependencies ) { config in guard case .object(let conf) = config else { throw SessionUtilError.invalidConfigObject } try members.forEach { memberId, profile in var profilePic: user_profile_pic = user_profile_pic() if let picUrl: String = profile?.profilePictureUrl, let picKey: Data = profile?.profileEncryptionKey { profilePic.url = picUrl.toLibSession() profilePic.key = picKey.toLibSession() } var error: SessionUtilError? try CExceptionHelper.performSafely { var member: config_group_member = config_group_member() guard groups_members_get_or_construct(conf, &member, memberId.toLibSession()) else { error = .getOrConstructFailedUnexpectedly return } member.name = ((profile?.name ?? "").toLibSession() ?? member.name) member.profile_pic = profilePic member.invited = 1 groups_members_set(conf, &member) } if let error: SessionUtilError = error { SNLog("[SessionUtil] Failed to add member to group: \(groupSessionId)") throw error } } } } static func updateMemberStatus( _ db: Database, groupSessionId: SessionId, memberId: String, role: GroupMember.Role, status: GroupMember.RoleStatus, using dependencies: Dependencies ) throws { try SessionUtil.performAndPushChange( db, for: .groupMembers, sessionId: groupSessionId, using: dependencies ) { config in guard case .object(let conf) = config else { throw SessionUtilError.invalidConfigObject } // Only update members if they already exist in the group var groupMember: config_group_member = config_group_member() guard groups_members_get(conf, &groupMember, memberId.toLibSession()) else { return } switch role { case .standard: groupMember.invited = Int32(status.rawValue) case .admin: groupMember.promoted = Int32(status.rawValue) default: break } groups_members_set(conf, &groupMember) } } static func removeMembers( _ db: Database, groupSessionId: SessionId, memberIds: Set, using dependencies: Dependencies ) throws { try SessionUtil.performAndPushChange( db, for: .groupMembers, sessionId: groupSessionId, using: dependencies ) { config in guard case .object(let conf) = config else { throw SessionUtilError.invalidConfigObject } memberIds.forEach { groups_members_erase(conf, $0.toLibSession()) } } } static func updatingGroupMembers( _ db: Database, _ updated: [T], using dependencies: Dependencies ) throws -> [T] { guard let updatedMembers: [GroupMember] = updated as? [GroupMember] else { throw StorageError.generic } // Exclude legacy groups as they aren't managed via SessionUtil let targetMembers: [GroupMember] = updatedMembers .filter { (try? SessionId(from: $0.groupId))?.prefix == .group } // If we only updated the current user contact then no need to continue guard !targetMembers.isEmpty, let groupId: SessionId = targetMembers.first.map({ try? SessionId(from: $0.groupId) }), groupId.prefix == .group else { return updated } // Loop through each of the groups and update their settings try targetMembers.forEach { member in try SessionUtil.performAndPushChange( db, for: .groupMembers, sessionId: groupId, using: dependencies ) { config in guard case .object(let conf) = config else { throw SessionUtilError.invalidConfigObject } // Only update members if they already exist in the group var groupMember: config_group_member = config_group_member() guard groups_members_get(conf, &groupMember, member.profileId.toLibSession()) else { return } // Update the role and status to match switch member.role { case .admin: groupMember.admin = true groupMember.invited = 0 groupMember.promoted = Int32(member.roleStatus.rawValue) default: groupMember.admin = false groupMember.invited = Int32(member.roleStatus.rawValue) groupMember.promoted = 0 } groups_members_set(conf, &groupMember) } } return updated } } // MARK: - MemberData private struct MemberData { let memberId: String let profile: Profile? let admin: Bool let invited: Int32 let promoted: Int32 } // MARK: - Convenience private extension SessionUtil { static func extractMembers( from conf: UnsafeMutablePointer?, groupSessionId: SessionId, serverTimestampMs: Int64 ) throws -> Set { var infiniteLoopGuard: Int = 0 var result: [MemberData] = [] var member: config_group_member = config_group_member() let membersIterator: UnsafeMutablePointer = groups_members_iterator_new(conf) while !groups_members_iterator_done(membersIterator, &member) { try SessionUtil.checkLoopLimitReached(&infiniteLoopGuard, for: .groupMembers) let memberId: String = String(cString: withUnsafeBytes(of: member.session_id) { [UInt8]($0) } .map { CChar($0) } .nullTerminated() ) let profilePictureUrl: String? = String(libSessionVal: member.profile_pic.url, nullIfEmpty: true) let profileResult: Profile = Profile( id: memberId, name: String(libSessionVal: member.name), lastNameUpdate: (TimeInterval(serverTimestampMs) / 1000), nickname: nil, profilePictureUrl: profilePictureUrl, profileEncryptionKey: (profilePictureUrl == nil ? nil : Data( libSessionVal: member.profile_pic.key, count: ProfileManager.avatarAES256KeyByteLength ) ), lastProfilePictureUpdate: (TimeInterval(serverTimestampMs) / 1000), lastBlocksCommunityMessageRequests: nil ) result.append( MemberData( memberId: memberId, profile: profileResult, admin: member.admin, invited: member.invited, promoted: member.promoted ) ) groups_members_iterator_advance(membersIterator) } groups_members_iterator_free(membersIterator) // Need to free the iterator return result .map { data in GroupMember( groupId: groupSessionId.hexString, profileId: data.memberId, role: (data.admin || (data.promoted > 0) ? .admin : .standard), roleStatus: { switch (data.invited, data.promoted, data.admin) { case (2, _, _), (_, 2, false): return .failed // Explicitly failed case (1..., _, _), (_, 1..., false): return .pending // Pending if not accepted default: return .accepted // Otherwise it's accepted } }(), isHidden: false ) } .asSet() } }