946 lines
30 KiB
Swift
946 lines
30 KiB
Swift
//
|
|
// Copyright 2020 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import Foundation
|
|
public import LibSignalClient
|
|
|
|
// MARK: - GroupMemberState
|
|
|
|
private enum GroupMemberState: Equatable {
|
|
case fullMember(
|
|
role: TSGroupMemberRole,
|
|
didJoinFromInviteLink: Bool,
|
|
didJoinFromAcceptedJoinRequest: Bool
|
|
)
|
|
case invited(role: TSGroupMemberRole, addedByAci: Aci)
|
|
case requesting
|
|
|
|
var role: TSGroupMemberRole {
|
|
switch self {
|
|
case .fullMember(let role, _, _):
|
|
return role
|
|
case .invited(let role, _):
|
|
return role
|
|
case .requesting:
|
|
return .`normal`
|
|
}
|
|
}
|
|
|
|
var isAdministrator: Bool {
|
|
role == .administrator
|
|
}
|
|
|
|
var isFullMember: Bool {
|
|
switch self {
|
|
case .fullMember: return true
|
|
default: return false
|
|
}
|
|
}
|
|
|
|
var isInvited: Bool {
|
|
switch self {
|
|
case .invited: return true
|
|
default: return false
|
|
}
|
|
}
|
|
|
|
var isRequesting: Bool {
|
|
switch self {
|
|
case .requesting: return true
|
|
default: return false
|
|
}
|
|
}
|
|
}
|
|
|
|
extension GroupMemberState: Codable {
|
|
private enum TypeKey: UInt, Codable {
|
|
case fullMember = 0
|
|
case invited = 1
|
|
case requesting = 2
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case typeKey
|
|
case role
|
|
case addedByAci = "addedByUuid"
|
|
case didJoinFromInviteLink
|
|
case didJoinFromAcceptedJoinRequest
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
|
|
let typeKey = try container.decode(TypeKey.self, forKey: .typeKey)
|
|
switch typeKey {
|
|
case .fullMember:
|
|
let role = try container.decode(TSGroupMemberRole.self, forKey: .role)
|
|
let didJoinFromInviteLink = try container.decodeIfPresent(Bool.self, forKey: .didJoinFromInviteLink) ?? false
|
|
let didJoinFromAcceptedJoinRequest = try container.decodeIfPresent(
|
|
Bool.self,
|
|
forKey: .didJoinFromAcceptedJoinRequest
|
|
) ?? false
|
|
self = .fullMember(
|
|
role: role,
|
|
didJoinFromInviteLink: didJoinFromInviteLink,
|
|
didJoinFromAcceptedJoinRequest: didJoinFromAcceptedJoinRequest
|
|
)
|
|
case .invited:
|
|
let role = try container.decode(TSGroupMemberRole.self, forKey: .role)
|
|
let addedByAci = try container.decode(UUID.self, forKey: .addedByAci)
|
|
self = .invited(role: role, addedByAci: Aci(fromUUID: addedByAci))
|
|
case .requesting:
|
|
self = .requesting
|
|
}
|
|
}
|
|
|
|
func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
|
|
switch self {
|
|
case .fullMember(let role, let didJoinFromInviteLink, let didJoinFromAcceptedJoinRequest):
|
|
try container.encode(TypeKey.fullMember, forKey: .typeKey)
|
|
try container.encode(role, forKey: .role)
|
|
try container.encode(didJoinFromInviteLink, forKey: .didJoinFromInviteLink)
|
|
try container.encode(
|
|
didJoinFromAcceptedJoinRequest,
|
|
forKey: .didJoinFromAcceptedJoinRequest
|
|
)
|
|
case .invited(let role, let addedByAci):
|
|
try container.encode(TypeKey.invited, forKey: .typeKey)
|
|
try container.encode(role, forKey: .role)
|
|
try container.encode(addedByAci.rawUUID, forKey: .addedByAci)
|
|
case .requesting:
|
|
try container.encode(TypeKey.requesting, forKey: .typeKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension GroupMemberState: CustomStringConvertible {
|
|
public var description: String {
|
|
switch self {
|
|
case .fullMember: return ".fullMember"
|
|
case .invited: return ".invited"
|
|
case .requesting: return ".requesting"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
@objc
|
|
public class GroupMembership: MTLModel {
|
|
|
|
// MARK: Types
|
|
|
|
public typealias BannedAtTimestampMillis = UInt64
|
|
public typealias BannedMembersMap = [Aci: BannedAtTimestampMillis]
|
|
|
|
fileprivate typealias MemberStateMap = [SignalServiceAddress: GroupMemberState]
|
|
fileprivate typealias InvalidInviteMap = [Data: InvalidInviteModel]
|
|
|
|
private typealias LegacyMemberStateMap = [SignalServiceAddress: LegacyMemberState]
|
|
|
|
// MARK: Init
|
|
|
|
fileprivate var memberStates: MemberStateMap
|
|
public fileprivate(set) var bannedMembers: BannedMembersMap
|
|
private var invalidInviteMap: InvalidInviteMap
|
|
|
|
public var invalidInviteUserIds: [Data] {
|
|
return Array(invalidInviteMap.keys)
|
|
}
|
|
|
|
@objc
|
|
public override init() {
|
|
self.memberStates = [:]
|
|
self.bannedMembers = [:]
|
|
self.invalidInviteMap = [:]
|
|
|
|
super.init()
|
|
}
|
|
|
|
@objc
|
|
required public init?(coder aDecoder: NSCoder) {
|
|
if let invalidInviteMap = aDecoder.decodeObject(forKey: Self.invalidInviteMapKey) as? InvalidInviteMap {
|
|
self.invalidInviteMap = invalidInviteMap
|
|
} else {
|
|
// invalidInviteMap is optional.
|
|
self.invalidInviteMap = [:]
|
|
}
|
|
|
|
if let memberStatesData = aDecoder.decodeObject(forKey: Self.memberStatesKey) as? Data {
|
|
let decoder = JSONDecoder()
|
|
do {
|
|
self.memberStates = try decoder.decode(MemberStateMap.self, from: memberStatesData)
|
|
} catch {
|
|
owsFailDebug("Could not decode member states: \(error)")
|
|
return nil
|
|
}
|
|
} else if let legacyMemberStateMap = aDecoder.decodeObject(forKey: Self.legacyMemberStatesKey) as? LegacyMemberStateMap {
|
|
self.memberStates = Self.convertLegacyMemberStateMap(legacyMemberStateMap)
|
|
} else {
|
|
owsFailDebug("Could not decode legacy member states.")
|
|
return nil
|
|
}
|
|
|
|
if let bannedMembers = aDecoder.decodeObject(forKey: Self.bannedMembersKey) as? [UUID: BannedAtTimestampMillis] {
|
|
self.bannedMembers = bannedMembers.mapKeys(injectiveTransform: { Aci(fromUUID: $0) })
|
|
} else {
|
|
// TODO: (Group Abuse) we should debug assert here eventually.
|
|
// However, while clients are learning about banned members this is
|
|
// a normal path to hit.
|
|
self.bannedMembers = [:]
|
|
}
|
|
|
|
super.init()
|
|
}
|
|
|
|
private static var memberStatesKey: String { "memberStates" }
|
|
private static var legacyMemberStatesKey: String { "memberStateMap" }
|
|
private static var bannedMembersKey: String { "bannedMembers" }
|
|
private static var invalidInviteMapKey: String { "invalidInviteMap" }
|
|
|
|
public override func encode(with aCoder: NSCoder) {
|
|
let encoder = JSONEncoder()
|
|
do {
|
|
let memberStatesData = try encoder.encode(self.memberStates)
|
|
aCoder.encode(memberStatesData, forKey: Self.memberStatesKey)
|
|
} catch {
|
|
owsFailDebug("Error: \(error)")
|
|
}
|
|
|
|
aCoder.encode(bannedMembers.mapKeys(injectiveTransform: { $0.rawUUID }), forKey: Self.bannedMembersKey)
|
|
aCoder.encode(invalidInviteMap, forKey: Self.invalidInviteMapKey)
|
|
}
|
|
|
|
@objc
|
|
public required init(dictionary dictionaryValue: [String: Any]!) throws {
|
|
fatalError("init(dictionary:) has not been implemented")
|
|
}
|
|
|
|
fileprivate init(
|
|
memberStates: MemberStateMap,
|
|
bannedMembers: BannedMembersMap,
|
|
invalidInviteMap: InvalidInviteMap
|
|
) {
|
|
self.memberStates = memberStates
|
|
self.bannedMembers = bannedMembers
|
|
self.invalidInviteMap = invalidInviteMap
|
|
|
|
super.init()
|
|
}
|
|
|
|
@objc
|
|
init(v1Members: [SignalServiceAddress]) {
|
|
var builder = Builder()
|
|
builder.addFullMembers(Set(v1Members), role: .normal)
|
|
self.memberStates = builder.memberStates
|
|
self.bannedMembers = [:]
|
|
self.invalidInviteMap = [:]
|
|
|
|
super.init()
|
|
}
|
|
|
|
#if TESTABLE_BUILD
|
|
/// Construction for tests is functionally equivalent to construction of a
|
|
/// group membership for a legacy, V1 group model.
|
|
convenience init(membersForTest: [SignalServiceAddress]) {
|
|
self.init(v1Members: membersForTest)
|
|
}
|
|
#endif
|
|
|
|
// MARK: - Equality
|
|
|
|
@objc
|
|
public override func isEqual(_ object: Any!) -> Bool {
|
|
guard let other = object as? GroupMembership else {
|
|
return false
|
|
}
|
|
|
|
guard Self.memberStates(
|
|
self.memberStates,
|
|
areEqualTo: other.memberStates
|
|
) else {
|
|
return false
|
|
}
|
|
|
|
guard self.bannedMembers == other.bannedMembers else {
|
|
return false
|
|
}
|
|
|
|
let invalidlyInvitedUserIdsSet = Set(invalidInviteUserIds)
|
|
let otherInvalidlyInvitedUserIdsSet = Set(other.invalidInviteUserIds)
|
|
return invalidlyInvitedUserIdsSet == otherInvalidlyInvitedUserIdsSet
|
|
}
|
|
|
|
/// When comparing member states, ignore the ``didJoinFromInviteLink`` and
|
|
/// ``didJoinFromAcceptedJoinRequest`` fields.
|
|
/// These fields are not stored as part of memberships in group snapshots from
|
|
/// the service, and are only computed when a member joins a group and we add
|
|
/// them locally. If our local membership differs from a group snapshot's
|
|
/// only in these fields, we want to consider them equal to avoid clobbering our local state.
|
|
private static func memberStates(
|
|
_ memberStates: MemberStateMap,
|
|
areEqualTo otherMemberStates: MemberStateMap
|
|
) -> Bool {
|
|
|
|
func hardcodeDidJoinViaInviteLink(for groupMemberState: GroupMemberState) -> GroupMemberState {
|
|
switch groupMemberState {
|
|
case .fullMember(let role, _, _):
|
|
return .fullMember(
|
|
role: role,
|
|
didJoinFromInviteLink: false,
|
|
didJoinFromAcceptedJoinRequest: false
|
|
)
|
|
default:
|
|
return groupMemberState
|
|
}
|
|
}
|
|
|
|
guard memberStates.count == otherMemberStates.count else {
|
|
return false
|
|
}
|
|
|
|
return memberStates.allSatisfy { (key, value) -> Bool in
|
|
guard let otherValue = otherMemberStates[key] else { return false }
|
|
return hardcodeDidJoinViaInviteLink(for: value) == hardcodeDidJoinViaInviteLink(for: otherValue)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private static func convertLegacyMemberStateMap(_ legacyMemberStateMap: LegacyMemberStateMap) -> MemberStateMap {
|
|
var result = MemberStateMap()
|
|
for (address, legacyMemberState) in legacyMemberStateMap {
|
|
let memberState: GroupMemberState
|
|
if legacyMemberState.isPending {
|
|
if let addedByUuid = legacyMemberState.addedByUuid {
|
|
memberState = .invited(role: legacyMemberState.role, addedByAci: Aci(fromUUID: addedByUuid))
|
|
} else {
|
|
owsFailDebug("Missing addedByUuid.")
|
|
continue
|
|
}
|
|
} else {
|
|
memberState = .fullMember(
|
|
role: legacyMemberState.role,
|
|
didJoinFromInviteLink: false,
|
|
didJoinFromAcceptedJoinRequest: false
|
|
)
|
|
}
|
|
result[address] = memberState
|
|
}
|
|
return result
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
@objc
|
|
public static var empty: GroupMembership {
|
|
return Builder().build()
|
|
}
|
|
|
|
@objc
|
|
public static func normalize(_ addresses: [SignalServiceAddress]) -> [SignalServiceAddress] {
|
|
return Array(Set(addresses))
|
|
.sorted(by: { (l, r) in l.compare(r) == .orderedAscending })
|
|
}
|
|
|
|
public var asBuilder: Builder {
|
|
return Builder(
|
|
memberStates: memberStates,
|
|
bannedMembers: bannedMembers,
|
|
invalidInviteMap: invalidInviteMap
|
|
)
|
|
}
|
|
|
|
public override var debugDescription: String {
|
|
var result = "[\n"
|
|
for address in GroupMembership.normalize(Array(allMembersOfAnyKind)) {
|
|
guard let memberState = memberStates[address] else {
|
|
owsFailDebug("Missing memberState.")
|
|
continue
|
|
}
|
|
result += "\(address), memberType: \(memberState)\n"
|
|
}
|
|
for (aci, bannedAtTimestamp) in bannedMembers {
|
|
result += "Banned: \(aci), at \(bannedAtTimestamp)\n"
|
|
}
|
|
result += "]"
|
|
return result
|
|
}
|
|
}
|
|
|
|
// MARK: - Accessors
|
|
|
|
public extension GroupMembership {
|
|
|
|
var fullMemberAdministrators: Set<SignalServiceAddress> {
|
|
return Set(memberStates.lazy.filter { $0.value.isAdministrator && $0.value.isFullMember }.map { $0.key })
|
|
}
|
|
|
|
var fullMembers: Set<SignalServiceAddress> {
|
|
return Set(memberStates.lazy.filter { $0.value.isFullMember }.map { $0.key })
|
|
}
|
|
|
|
var invitedMembers: Set<SignalServiceAddress> {
|
|
return Set(memberStates.lazy.filter { $0.value.isInvited }.map { $0.key })
|
|
}
|
|
|
|
var requestingMembers: Set<SignalServiceAddress> {
|
|
return Set(memberStates.lazy.filter { $0.value.isRequesting }.map { $0.key })
|
|
}
|
|
|
|
var fullOrInvitedMembers: Set<SignalServiceAddress> {
|
|
return Set(memberStates.lazy.filter {
|
|
$0.value.isFullMember || $0.value.isInvited
|
|
}.map { $0.key })
|
|
}
|
|
|
|
var invitedOrRequestMembers: Set<SignalServiceAddress> {
|
|
return Set(memberStates.lazy.filter {
|
|
$0.value.isInvited || $0.value.isRequesting
|
|
}.map { $0.key })
|
|
}
|
|
|
|
var allMembersOfAnyKind: Set<SignalServiceAddress> {
|
|
return Set(memberStates.keys)
|
|
}
|
|
|
|
var allMembersOfAnyKindServiceIds: Set<ServiceId> {
|
|
return Set(memberStates.keys.lazy.compactMap { $0.serviceId })
|
|
}
|
|
}
|
|
|
|
public extension GroupMembership {
|
|
|
|
func role(for serviceId: ServiceId) -> TSGroupMemberRole? {
|
|
return role(for: SignalServiceAddress(serviceId))
|
|
}
|
|
|
|
func role(for address: SignalServiceAddress) -> TSGroupMemberRole? {
|
|
guard let memberState = memberStates[address] else {
|
|
return nil
|
|
}
|
|
return memberState.role
|
|
}
|
|
|
|
func isFullOrInvitedAdministrator(_ address: SignalServiceAddress) -> Bool {
|
|
guard let memberState = memberStates[address] else {
|
|
return false
|
|
}
|
|
switch memberState {
|
|
case .fullMember(let role, _, _):
|
|
return role == .administrator
|
|
case .invited(let role, _):
|
|
return role == .administrator
|
|
case .requesting:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isFullOrInvitedAdministrator(_ serviceId: ServiceId) -> Bool {
|
|
return isFullOrInvitedAdministrator(SignalServiceAddress(serviceId))
|
|
}
|
|
|
|
@objc
|
|
func isFullMemberAndAdministrator(_ address: SignalServiceAddress) -> Bool {
|
|
guard let memberState = memberStates[address] else {
|
|
return false
|
|
}
|
|
return memberState.isAdministrator && memberState.isFullMember
|
|
}
|
|
|
|
func isFullMemberAndAdministrator(_ serviceId: ServiceId) -> Bool {
|
|
return isFullMemberAndAdministrator(SignalServiceAddress(serviceId))
|
|
}
|
|
|
|
@objc
|
|
func isFullMember(_ address: SignalServiceAddress) -> Bool {
|
|
guard let memberState = memberStates[address] else {
|
|
return false
|
|
}
|
|
return memberState.isFullMember
|
|
}
|
|
|
|
func isFullMember(_ serviceId: ServiceId) -> Bool {
|
|
return isFullMember(SignalServiceAddress(serviceId))
|
|
}
|
|
|
|
@objc
|
|
func isInvitedMember(_ address: SignalServiceAddress) -> Bool {
|
|
guard let memberState = memberStates[address] else {
|
|
return false
|
|
}
|
|
return memberState.isInvited
|
|
}
|
|
|
|
func isInvitedMember(_ serviceId: ServiceId) -> Bool {
|
|
return isInvitedMember(SignalServiceAddress(serviceId))
|
|
}
|
|
|
|
func isRequestingMember(_ address: SignalServiceAddress) -> Bool {
|
|
guard let memberState = memberStates[address] else {
|
|
return false
|
|
}
|
|
return memberState.isRequesting
|
|
}
|
|
|
|
func isRequestingMember(_ serviceId: ServiceId) -> Bool {
|
|
return isRequestingMember(SignalServiceAddress(serviceId))
|
|
}
|
|
|
|
func isMemberOfAnyKind(_ address: SignalServiceAddress) -> Bool {
|
|
return memberStates[address] != nil
|
|
}
|
|
|
|
func isMemberOfAnyKind(_ serviceId: ServiceId) -> Bool {
|
|
return isMemberOfAnyKind(SignalServiceAddress(serviceId))
|
|
}
|
|
|
|
func isBannedMember(_ aci: Aci) -> Bool {
|
|
return bannedMembers[aci] != nil
|
|
}
|
|
|
|
func hasInvalidInvite(forUserId userId: Data) -> Bool {
|
|
return invalidInviteMap[userId] != nil
|
|
}
|
|
|
|
/// This method should only be called on invited members.
|
|
func addedByAci(forInvitedMember address: SignalServiceAddress) -> Aci? {
|
|
guard let memberState = memberStates[address] else {
|
|
return nil
|
|
}
|
|
switch memberState {
|
|
case .invited(_, let addedByAci):
|
|
return addedByAci
|
|
default:
|
|
owsFailDebug("Not a pending profile key member.")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func addedByAci(forInvitedMember serviceId: ServiceId) -> Aci? {
|
|
return addedByAci(forInvitedMember: SignalServiceAddress(serviceId))
|
|
}
|
|
|
|
/// This method should only be called for full members.
|
|
func didJoinFromInviteLink(forFullMember address: SignalServiceAddress) -> Bool {
|
|
guard let memberState = memberStates[address] else {
|
|
owsFailDebug("Missing member: \(address)")
|
|
return false
|
|
}
|
|
switch memberState {
|
|
case .fullMember(_, let didJoinFromInviteLink, _):
|
|
return didJoinFromInviteLink
|
|
default:
|
|
owsFailDebug("Not a full member.")
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// this method should only be called for full members.
|
|
func didJoinFromAcceptedJoinRequest(forFullMember address: SignalServiceAddress) -> Bool {
|
|
guard let memberState = memberStates[address] else {
|
|
owsFailDebug("Missing member: \(address)")
|
|
return false
|
|
}
|
|
switch memberState {
|
|
case .fullMember(_, _, let didJoinFromAcceptedJoinRequest):
|
|
return didJoinFromAcceptedJoinRequest
|
|
default:
|
|
owsFailDebug("Not a full member.")
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// Is this user's profile key exposed to the group?
|
|
func hasProfileKeyInGroup(serviceId: ServiceId) -> Bool {
|
|
guard let memberState = memberStates[SignalServiceAddress(serviceId)] else {
|
|
return false
|
|
}
|
|
|
|
switch memberState {
|
|
case .fullMember, .requesting:
|
|
return true
|
|
case .invited:
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// Can this user view the profile keys in the group?
|
|
func canViewProfileKeys(serviceId: ServiceId) -> Bool {
|
|
guard let memberState = memberStates[SignalServiceAddress(serviceId)] else {
|
|
return false
|
|
}
|
|
|
|
switch memberState {
|
|
case .fullMember, .invited:
|
|
return true
|
|
case .requesting:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Builder
|
|
|
|
public extension GroupMembership {
|
|
struct Builder {
|
|
fileprivate var memberStates = MemberStateMap()
|
|
private var bannedMembers = BannedMembersMap()
|
|
private var invalidInviteMap = InvalidInviteMap()
|
|
|
|
public init() {}
|
|
|
|
fileprivate init(
|
|
memberStates: MemberStateMap,
|
|
bannedMembers: BannedMembersMap,
|
|
invalidInviteMap: InvalidInviteMap
|
|
) {
|
|
self.memberStates = memberStates
|
|
self.bannedMembers = bannedMembers
|
|
self.invalidInviteMap = invalidInviteMap
|
|
}
|
|
|
|
// MARK: Member states
|
|
|
|
public mutating func remove(_ serviceId: ServiceId) {
|
|
remove(SignalServiceAddress(serviceId))
|
|
}
|
|
|
|
public mutating func remove(_ address: SignalServiceAddress) {
|
|
remove([address])
|
|
}
|
|
|
|
public mutating func remove(_ addresses: Set<SignalServiceAddress>) {
|
|
for address in addresses {
|
|
memberStates.removeValue(forKey: address)
|
|
}
|
|
}
|
|
|
|
public mutating func addFullMember(
|
|
_ aci: Aci,
|
|
role: TSGroupMemberRole,
|
|
didJoinFromInviteLink: Bool = false,
|
|
didJoinFromAcceptedJoinRequest: Bool = false
|
|
) {
|
|
addFullMember(
|
|
SignalServiceAddress(aci),
|
|
role: role,
|
|
didJoinFromInviteLink: didJoinFromInviteLink,
|
|
didJoinFromAcceptedJoinRequest: didJoinFromAcceptedJoinRequest
|
|
)
|
|
}
|
|
|
|
public mutating func addFullMember(
|
|
_ address: SignalServiceAddress,
|
|
role: TSGroupMemberRole,
|
|
didJoinFromInviteLink: Bool = false,
|
|
didJoinFromAcceptedJoinRequest: Bool = false
|
|
) {
|
|
addFullMembers(
|
|
[address],
|
|
role: role,
|
|
didJoinFromInviteLink: didJoinFromInviteLink,
|
|
didJoinFromAcceptedJoinRequest: didJoinFromAcceptedJoinRequest
|
|
)
|
|
}
|
|
|
|
public mutating func addFullMembers(
|
|
_ addresses: Set<SignalServiceAddress>,
|
|
role: TSGroupMemberRole,
|
|
didJoinFromInviteLink: Bool = false,
|
|
didJoinFromAcceptedJoinRequest: Bool = false
|
|
) {
|
|
// Dupe is not necessarily an error; you might know of the UUID
|
|
// mapping for a user that another group member doesn't know about.
|
|
addMembers(
|
|
addresses,
|
|
withState: .fullMember(
|
|
role: role,
|
|
didJoinFromInviteLink: didJoinFromInviteLink,
|
|
didJoinFromAcceptedJoinRequest: didJoinFromAcceptedJoinRequest
|
|
),
|
|
failOnDupe: false
|
|
)
|
|
}
|
|
|
|
public mutating func addInvitedMember(_ serviceId: ServiceId, role: TSGroupMemberRole, addedByAci: Aci) {
|
|
addInvitedMember(SignalServiceAddress(serviceId), role: role, addedByAci: addedByAci)
|
|
}
|
|
|
|
public mutating func addInvitedMember(
|
|
_ address: SignalServiceAddress,
|
|
role: TSGroupMemberRole,
|
|
addedByAci: Aci
|
|
) {
|
|
addInvitedMembers([address], role: role, addedByAci: addedByAci)
|
|
}
|
|
|
|
public mutating func addInvitedMembers(
|
|
_ addresses: Set<SignalServiceAddress>,
|
|
role: TSGroupMemberRole,
|
|
addedByAci: Aci
|
|
) {
|
|
addMembers(addresses, withState: .invited(role: role, addedByAci: addedByAci))
|
|
}
|
|
|
|
public mutating func addRequestingMember(_ aci: Aci) {
|
|
addRequestingMember(SignalServiceAddress(aci))
|
|
}
|
|
|
|
public mutating func addRequestingMember(_ address: SignalServiceAddress) {
|
|
addRequestingMembers([address])
|
|
}
|
|
|
|
public mutating func addRequestingMembers(_ addresses: Set<SignalServiceAddress>) {
|
|
addMembers(addresses, withState: .requesting)
|
|
}
|
|
|
|
private mutating func addMembers(
|
|
_ addresses: Set<SignalServiceAddress>,
|
|
withState memberState: GroupMemberState,
|
|
failOnDupe: Bool = true
|
|
) {
|
|
for address in addresses {
|
|
guard memberStates[address] == nil else {
|
|
let errorMessage = "Duplicate address."
|
|
if failOnDupe {
|
|
owsFailDebug(errorMessage)
|
|
} else {
|
|
Logger.warn(errorMessage)
|
|
}
|
|
continue
|
|
}
|
|
|
|
memberStates[address] = memberState
|
|
}
|
|
}
|
|
|
|
public func hasMemberOfAnyKind(_ address: SignalServiceAddress) -> Bool {
|
|
nil != memberStates[address]
|
|
}
|
|
|
|
// MARK: Banned members
|
|
|
|
public mutating func addBannedMember(_ aci: Aci, bannedAtTimestamp: BannedAtTimestampMillis) {
|
|
guard bannedMembers[aci] == nil else {
|
|
owsFailDebug("Duplicate banned member!")
|
|
return
|
|
}
|
|
|
|
bannedMembers[aci] = bannedAtTimestamp
|
|
}
|
|
|
|
public mutating func removeBannedMember(_ aci: Aci) {
|
|
guard bannedMembers[aci] != nil else {
|
|
owsFailDebug("Removing not-currently-banned member!")
|
|
return
|
|
}
|
|
|
|
bannedMembers.removeValue(forKey: aci)
|
|
}
|
|
|
|
// MARK: Invalid invites
|
|
|
|
public mutating func addInvalidInvite(userId: Data, addedByUserId: Data) {
|
|
invalidInviteMap[userId] = InvalidInviteModel(userId: userId, addedByUserId: addedByUserId)
|
|
}
|
|
|
|
public mutating func removeInvalidInvite(userId: Data) {
|
|
invalidInviteMap.removeValue(forKey: userId)
|
|
}
|
|
|
|
public func hasInvalidInvite(userId: Data) -> Bool {
|
|
nil != invalidInviteMap[userId]
|
|
}
|
|
|
|
// MARK: Build
|
|
|
|
public func build() -> GroupMembership {
|
|
owsAssertDebug(Set(bannedMembers.keys.lazy.map { SignalServiceAddress($0) })
|
|
.isDisjoint(with: Set(memberStates.keys)))
|
|
|
|
// TODO: Why is this here? Uggh.
|
|
let memberStates = self.memberStates.filter {
|
|
$0.key.phoneNumber != OWSUserProfile.Constants.localProfilePhoneNumber
|
|
}
|
|
|
|
return GroupMembership(
|
|
memberStates: memberStates,
|
|
bannedMembers: bannedMembers,
|
|
invalidInviteMap: invalidInviteMap
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Local user accessors
|
|
|
|
public extension GroupMembership {
|
|
/// The local PNI, if it is present and an invited member.
|
|
///
|
|
/// - Note
|
|
/// PNIs can only be invited members. Further note that profile keys are
|
|
/// required for full and requesting members, and PNIs have no associated
|
|
/// profile or profile key.
|
|
private func localPniAsInvitedMember(localIdentifiers: LocalIdentifiers) -> Pni? {
|
|
if let localPni = localIdentifiers.pni, isInvitedMember(localPni) {
|
|
return localPni
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
@objc
|
|
var isLocalUserMemberOfAnyKind: Bool {
|
|
guard let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction else {
|
|
return false
|
|
}
|
|
|
|
if isMemberOfAnyKind(localIdentifiers.aciAddress) {
|
|
return true
|
|
}
|
|
|
|
return localPniAsInvitedMember(localIdentifiers: localIdentifiers) != nil
|
|
}
|
|
|
|
@objc
|
|
var isLocalUserFullMember: Bool {
|
|
guard let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aci else {
|
|
return false
|
|
}
|
|
|
|
return isFullMember(localAci)
|
|
}
|
|
|
|
/// The ID at which the local user is invited, if at all.
|
|
///
|
|
/// Checks membership for the local ACI first. If none is available, falls
|
|
/// back to checking membership for the local PNI.
|
|
func localUserInvitedAtServiceId(localIdentifiers: LocalIdentifiers) -> ServiceId? {
|
|
if isMemberOfAnyKind(localIdentifiers.aci) {
|
|
// If our ACI is any kind of member, return that membership rather
|
|
// than falling back to the PNI.
|
|
if isInvitedMember(localIdentifiers.aci) {
|
|
return localIdentifiers.aci
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
return localPniAsInvitedMember(localIdentifiers: localIdentifiers)
|
|
}
|
|
|
|
/// Whether the local user is an invited member.
|
|
///
|
|
/// Checks membership for the local ACI first. If none is available, falls
|
|
/// back to checking membership for the local PNI.
|
|
var isLocalUserInvitedMember: Bool {
|
|
guard let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction else {
|
|
return false
|
|
}
|
|
|
|
return localUserInvitedAtServiceId(localIdentifiers: localIdentifiers) != nil
|
|
}
|
|
|
|
var isLocalUserRequestingMember: Bool {
|
|
guard let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aci else {
|
|
return false
|
|
}
|
|
|
|
return isRequestingMember(localAci)
|
|
}
|
|
|
|
@objc
|
|
var isLocalUserFullOrInvitedMember: Bool {
|
|
return isLocalUserFullMember || isLocalUserInvitedMember
|
|
}
|
|
|
|
var isLocalUserFullMemberAndAdministrator: Bool {
|
|
guard let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aci else {
|
|
return false
|
|
}
|
|
|
|
return isFullMemberAndAdministrator(localAci)
|
|
}
|
|
}
|
|
|
|
// MARK: - InvalidInviteModel
|
|
|
|
@objc(GroupMembershipInvalidInviteModel)
|
|
private class InvalidInviteModel: MTLModel {
|
|
@objc
|
|
var userId: Data?
|
|
|
|
@objc
|
|
var addedByUserId: Data?
|
|
|
|
init(userId: Data?, addedByUserId: Data? = nil) {
|
|
self.userId = userId
|
|
self.addedByUserId = addedByUserId
|
|
super.init()
|
|
}
|
|
|
|
@objc
|
|
public override init() {
|
|
super.init()
|
|
}
|
|
|
|
@objc
|
|
required public init?(coder aDecoder: NSCoder) {
|
|
super.init(coder: aDecoder)
|
|
}
|
|
|
|
@objc
|
|
public required init(dictionary dictionaryValue: [String: Any]!) throws {
|
|
try super.init(dictionary: dictionaryValue)
|
|
}
|
|
}
|
|
|
|
// MARK: - LegacyMemberState
|
|
|
|
@objc(_TtCC16SignalServiceKit15GroupMembership11MemberState)
|
|
private class LegacyMemberState: MTLModel {
|
|
@objc
|
|
var role: TSGroupMemberRole = .normal
|
|
|
|
@objc
|
|
var isPending: Bool = false
|
|
|
|
// Only applies for pending members.
|
|
@objc
|
|
var addedByUuid: UUID?
|
|
|
|
@objc
|
|
public override init() {
|
|
super.init()
|
|
}
|
|
|
|
init(role: TSGroupMemberRole,
|
|
isPending: Bool,
|
|
addedByUuid: UUID? = nil) {
|
|
self.role = role
|
|
self.isPending = isPending
|
|
self.addedByUuid = addedByUuid
|
|
|
|
super.init()
|
|
}
|
|
|
|
@objc
|
|
required public init?(coder aDecoder: NSCoder) {
|
|
super.init(coder: aDecoder)
|
|
}
|
|
|
|
@objc
|
|
public required init(dictionary dictionaryValue: [String: Any]!) throws {
|
|
try super.init(dictionary: dictionaryValue)
|
|
}
|
|
|
|
@objc
|
|
public var isAdministrator: Bool {
|
|
return role == .administrator
|
|
}
|
|
}
|