mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			326 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			326 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Swift
		
	
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
 | 
						|
 | 
						|
import Foundation
 | 
						|
import GRDB
 | 
						|
import DifferenceKit
 | 
						|
import SessionUtilitiesKit
 | 
						|
 | 
						|
/// This type is duplicate in both the database and within the SessionUtil config so should only ever have it's data changes via the
 | 
						|
/// `updateAllAndConfig` function. Updating it elsewhere could result in issues with syncing data between devices
 | 
						|
public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible, CustomStringConvertible, Differentiable {
 | 
						|
    public static var databaseTableName: String { "profile" }
 | 
						|
    internal static let interactionForeignKey = ForeignKey([Columns.id], to: [Interaction.Columns.authorId])
 | 
						|
    internal static let contactForeignKey = ForeignKey([Columns.id], to: [Contact.Columns.id])
 | 
						|
    internal static let groupMemberForeignKey = ForeignKey([Columns.id], to: [GroupMember.Columns.profileId])
 | 
						|
    internal static let contact = hasOne(Contact.self, using: contactForeignKey)
 | 
						|
    public static let groupMembers = hasMany(GroupMember.self, using: groupMemberForeignKey)
 | 
						|
    
 | 
						|
    public typealias Columns = CodingKeys
 | 
						|
    public enum CodingKeys: String, CodingKey, ColumnExpression {
 | 
						|
        case id
 | 
						|
        
 | 
						|
        case name
 | 
						|
        case nickname
 | 
						|
        
 | 
						|
        case profilePictureUrl
 | 
						|
        case profilePictureFileName
 | 
						|
        case profileEncryptionKey
 | 
						|
    }
 | 
						|
 | 
						|
    /// The id for the user that owns the profile (Note: This could be a sessionId, a blindedId or some future variant)
 | 
						|
    public let id: String
 | 
						|
    
 | 
						|
    /// The name of the contact. Use this whenever you need the "real", underlying name of a user (e.g. when sending a message).
 | 
						|
    public let name: String
 | 
						|
    
 | 
						|
    /// A custom name for the profile set by the current user
 | 
						|
    public let nickname: String?
 | 
						|
 | 
						|
    /// The URL from which to fetch the contact's profile picture.
 | 
						|
    public let profilePictureUrl: String?
 | 
						|
 | 
						|
    /// The file name of the contact's profile picture on local storage.
 | 
						|
    public let profilePictureFileName: String?
 | 
						|
 | 
						|
    /// The key with which the profile is encrypted.
 | 
						|
    public let profileEncryptionKey: Data?
 | 
						|
    
 | 
						|
    // MARK: - Initialization
 | 
						|
    
 | 
						|
    public init(
 | 
						|
        id: String,
 | 
						|
        name: String,
 | 
						|
        nickname: String? = nil,
 | 
						|
        profilePictureUrl: String? = nil,
 | 
						|
        profilePictureFileName: String? = nil,
 | 
						|
        profileEncryptionKey: Data? = nil
 | 
						|
    ) {
 | 
						|
        self.id = id
 | 
						|
        self.name = name
 | 
						|
        self.nickname = nickname
 | 
						|
        self.profilePictureUrl = profilePictureUrl
 | 
						|
        self.profilePictureFileName = profilePictureFileName
 | 
						|
        self.profileEncryptionKey = profileEncryptionKey
 | 
						|
    }
 | 
						|
    
 | 
						|
    // MARK: - Description
 | 
						|
    
 | 
						|
    public var description: String {
 | 
						|
        """
 | 
						|
        Profile(
 | 
						|
            name: \(name),
 | 
						|
            profileKey: \(profileEncryptionKey?.description ?? "null"),
 | 
						|
            profilePictureUrl: \(profilePictureUrl ?? "null")
 | 
						|
        )
 | 
						|
        """
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
// MARK: - Codable
 | 
						|
 | 
						|
public extension Profile {
 | 
						|
    init(from decoder: Decoder) throws {
 | 
						|
        let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
 | 
						|
        
 | 
						|
        var profileKey: Data?
 | 
						|
        var profilePictureUrl: String?
 | 
						|
        
 | 
						|
        // If we have both a `profileKey` and a `profilePicture` then the key MUST be valid
 | 
						|
        if
 | 
						|
            let profileKeyData: Data = try? container.decode(Data.self, forKey: .profileEncryptionKey),
 | 
						|
            let profilePictureUrlValue: String = try? container.decode(String.self, forKey: .profilePictureUrl)
 | 
						|
        {
 | 
						|
            profileKey = profileKeyData
 | 
						|
            profilePictureUrl = profilePictureUrlValue
 | 
						|
        }
 | 
						|
        
 | 
						|
        self = Profile(
 | 
						|
            id: try container.decode(String.self, forKey: .id),
 | 
						|
            name: try container.decode(String.self, forKey: .name),
 | 
						|
            nickname: try? container.decode(String.self, forKey: .nickname),
 | 
						|
            profilePictureUrl: profilePictureUrl,
 | 
						|
            profilePictureFileName: try? container.decode(String.self, forKey: .profilePictureFileName),
 | 
						|
            profileEncryptionKey: profileKey
 | 
						|
        )
 | 
						|
    }
 | 
						|
    
 | 
						|
    func encode(to encoder: Encoder) throws {
 | 
						|
        var container: KeyedEncodingContainer<CodingKeys> = encoder.container(keyedBy: CodingKeys.self)
 | 
						|
 | 
						|
        try container.encode(id, forKey: .id)
 | 
						|
        try container.encode(name, forKey: .name)
 | 
						|
        try container.encodeIfPresent(nickname, forKey: .nickname)
 | 
						|
        try container.encodeIfPresent(profilePictureUrl, forKey: .profilePictureUrl)
 | 
						|
        try container.encodeIfPresent(profilePictureFileName, forKey: .profilePictureFileName)
 | 
						|
        try container.encodeIfPresent(profileEncryptionKey, forKey: .profileEncryptionKey)
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
// MARK: - Protobuf
 | 
						|
 | 
						|
public extension Profile {
 | 
						|
    static func fromProto(_ proto: SNProtoDataMessage, id: String) -> Profile? {
 | 
						|
        guard let profileProto = proto.profile, let displayName = profileProto.displayName else { return nil }
 | 
						|
        
 | 
						|
        var profileKey: Data?
 | 
						|
        var profilePictureUrl: String?
 | 
						|
        
 | 
						|
        // If we have both a `profileKey` and a `profilePicture` then the key MUST be valid
 | 
						|
        if let profileKeyData: Data = proto.profileKey, profileProto.profilePicture != nil {
 | 
						|
            profileKey = profileKeyData
 | 
						|
            profilePictureUrl = profileProto.profilePicture
 | 
						|
        }
 | 
						|
        
 | 
						|
        return Profile(
 | 
						|
            id: id,
 | 
						|
            name: displayName,
 | 
						|
            nickname: nil,
 | 
						|
            profilePictureUrl: profilePictureUrl,
 | 
						|
            profilePictureFileName: nil,
 | 
						|
            profileEncryptionKey: profileKey
 | 
						|
        )
 | 
						|
    }
 | 
						|
 | 
						|
    func toProto() -> SNProtoDataMessage? {
 | 
						|
        let dataMessageProto = SNProtoDataMessage.builder()
 | 
						|
        let profileProto = SNProtoLokiProfile.builder()
 | 
						|
        profileProto.setDisplayName(name)
 | 
						|
        
 | 
						|
        if let profileKey: Data = profileEncryptionKey, let profilePictureUrl: String = profilePictureUrl {
 | 
						|
            dataMessageProto.setProfileKey(profileKey)
 | 
						|
            profileProto.setProfilePicture(profilePictureUrl)
 | 
						|
        }
 | 
						|
        
 | 
						|
        do {
 | 
						|
            dataMessageProto.setProfile(try profileProto.build())
 | 
						|
            return try dataMessageProto.build()
 | 
						|
        }
 | 
						|
        catch {
 | 
						|
            SNLog("Couldn't construct profile proto from: \(self).")
 | 
						|
            return nil
 | 
						|
        }
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
// MARK: - GRDB Interactions
 | 
						|
 | 
						|
public extension Profile {
 | 
						|
    static func allContactProfiles(excluding idsToExclude: Set<String> = []) -> QueryInterfaceRequest<Profile> {
 | 
						|
        return Profile
 | 
						|
            .filter(!idsToExclude.contains(Profile.Columns.id))
 | 
						|
            .joining(
 | 
						|
                required: Profile.contact
 | 
						|
                    .filter(Contact.Columns.isApproved == true)
 | 
						|
                    .filter(Contact.Columns.didApproveMe == true)
 | 
						|
            )
 | 
						|
    }
 | 
						|
    
 | 
						|
    static func fetchAllContactProfiles(excluding: Set<String> = [], excludeCurrentUser: Bool = true) -> [Profile] {
 | 
						|
        return Storage.shared
 | 
						|
            .read { db in
 | 
						|
                // Sort the contacts by their displayName value
 | 
						|
                try Profile
 | 
						|
                    .allContactProfiles(
 | 
						|
                        excluding: excluding
 | 
						|
                            .inserting(excludeCurrentUser ? getUserHexEncodedPublicKey(db) : nil)
 | 
						|
                    )
 | 
						|
                    .fetchAll(db)
 | 
						|
                    .sorted(by: { lhs, rhs -> Bool in lhs.displayName() < rhs.displayName() })
 | 
						|
            }
 | 
						|
            .defaulting(to: [])
 | 
						|
    }
 | 
						|
    
 | 
						|
    static func displayName(_ db: Database? = nil, id: ID, threadVariant: SessionThread.Variant = .contact, customFallback: String? = nil) -> String {
 | 
						|
        guard let db: Database = db else {
 | 
						|
            return Storage.shared
 | 
						|
                .read { db in displayName(db, id: id, threadVariant: threadVariant, customFallback: customFallback) }
 | 
						|
                .defaulting(to: (customFallback ?? id))
 | 
						|
        }
 | 
						|
        
 | 
						|
        let existingDisplayName: String? = (try? Profile.fetchOne(db, id: id))?
 | 
						|
            .displayName(for: threadVariant)
 | 
						|
        
 | 
						|
        return (existingDisplayName ?? (customFallback ?? id))
 | 
						|
    }
 | 
						|
    
 | 
						|
    static func displayNameNoFallback(_ db: Database? = nil, id: ID, threadVariant: SessionThread.Variant = .contact) -> String? {
 | 
						|
        guard let db: Database = db else {
 | 
						|
            return Storage.shared.read { db in displayNameNoFallback(db, id: id, threadVariant: threadVariant) }
 | 
						|
        }
 | 
						|
        
 | 
						|
        return (try? Profile.fetchOne(db, id: id))?
 | 
						|
            .displayName(for: threadVariant)
 | 
						|
    }
 | 
						|
    
 | 
						|
    // MARK: - Fetch or Create
 | 
						|
    
 | 
						|
    private static func defaultFor(_ id: String) -> Profile {
 | 
						|
        return Profile(
 | 
						|
            id: id,
 | 
						|
            name: "",
 | 
						|
            nickname: nil,
 | 
						|
            profilePictureUrl: nil,
 | 
						|
            profilePictureFileName: nil,
 | 
						|
            profileEncryptionKey: nil
 | 
						|
        )
 | 
						|
    }
 | 
						|
    
 | 
						|
    /// Fetches or creates a Profile for the current user
 | 
						|
    ///
 | 
						|
    /// **Note:** This method intentionally does **not** save the newly created Profile,
 | 
						|
    /// it will need to be explicitly saved after calling
 | 
						|
    static func fetchOrCreateCurrentUser(_ db: Database? = nil) -> Profile {
 | 
						|
        let userPublicKey: String = getUserHexEncodedPublicKey(db)
 | 
						|
        
 | 
						|
        guard let db: Database = db else {
 | 
						|
            return Storage.shared
 | 
						|
                .read { db in fetchOrCreateCurrentUser(db) }
 | 
						|
                .defaulting(to: defaultFor(userPublicKey))
 | 
						|
        }
 | 
						|
        
 | 
						|
        return (
 | 
						|
            (try? Profile.fetchOne(db, id: userPublicKey)) ??
 | 
						|
            defaultFor(userPublicKey)
 | 
						|
        )
 | 
						|
    }
 | 
						|
    
 | 
						|
    /// Fetches or creates a Profile for the specified user
 | 
						|
    ///
 | 
						|
    /// **Note:** This method intentionally does **not** save the newly created Profile,
 | 
						|
    /// it will need to be explicitly saved after calling
 | 
						|
    static func fetchOrCreate(id: String) -> Profile {
 | 
						|
        let exisingProfile: Profile? = Storage.shared.read { db in
 | 
						|
            try Profile.fetchOne(db, id: id)
 | 
						|
        }
 | 
						|
        
 | 
						|
        return (exisingProfile ?? defaultFor(id))
 | 
						|
    }
 | 
						|
    
 | 
						|
    /// Fetches or creates a Profile for the specified user
 | 
						|
    ///
 | 
						|
    /// **Note:** This method intentionally does **not** save the newly created Profile,
 | 
						|
    /// it will need to be explicitly saved after calling
 | 
						|
    static func fetchOrCreate(_ db: Database, id: String) -> Profile {
 | 
						|
        return (
 | 
						|
            (try? Profile.fetchOne(db, id: id)) ??
 | 
						|
            defaultFor(id)
 | 
						|
        )
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
// MARK: - Convenience
 | 
						|
 | 
						|
public extension Profile {
 | 
						|
    // MARK: - Truncation
 | 
						|
    
 | 
						|
    enum Truncation {
 | 
						|
        case start
 | 
						|
        case middle
 | 
						|
        case end
 | 
						|
    }
 | 
						|
    
 | 
						|
    /// A standardised mechanism for truncating a user id for a given thread
 | 
						|
    static func truncated(id: String, threadVariant: SessionThread.Variant) -> String {
 | 
						|
        return truncated(id: id, truncating: .middle)
 | 
						|
    }
 | 
						|
    
 | 
						|
    /// A standardised mechanism for truncating a user id
 | 
						|
    static func truncated(id: String, truncating: Truncation) -> String {
 | 
						|
        guard id.count > 8 else { return id }
 | 
						|
        
 | 
						|
        switch truncating {
 | 
						|
            case .start: return "...\(id.suffix(8))"
 | 
						|
            case .middle: return "\(id.prefix(4))...\(id.suffix(4))"
 | 
						|
            case .end: return "\(id.prefix(8))..."
 | 
						|
        }
 | 
						|
    }
 | 
						|
    
 | 
						|
    /// The name to display in the UI for a given thread variant
 | 
						|
    func displayName(for threadVariant: SessionThread.Variant = .contact) -> String {
 | 
						|
        return Profile.displayName(for: threadVariant, id: id, name: name, nickname: nickname)
 | 
						|
    }
 | 
						|
    
 | 
						|
    static func displayName(
 | 
						|
        for threadVariant: SessionThread.Variant,
 | 
						|
        id: String,
 | 
						|
        name: String?,
 | 
						|
        nickname: String?,
 | 
						|
        customFallback: String? = nil
 | 
						|
    ) -> String {
 | 
						|
        if let nickname: String = nickname { return nickname }
 | 
						|
        
 | 
						|
        guard let name: String = name, name != id else {
 | 
						|
            return (customFallback ?? Profile.truncated(id: id, threadVariant: threadVariant))
 | 
						|
        }
 | 
						|
        
 | 
						|
        switch threadVariant {
 | 
						|
            case .contact, .legacyGroup, .group: return name
 | 
						|
                
 | 
						|
            case .community:
 | 
						|
                // In open groups, where it's more likely that multiple users have the same name,
 | 
						|
                // we display a bit of the Session ID after a user's display name for added context
 | 
						|
                return "\(name) (\(Profile.truncated(id: id, truncating: .middle)))"
 | 
						|
        }
 | 
						|
    }
 | 
						|
}
 |