mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
0c09f2bfc5
Updated the document download UI Minor font tweaks to match settings more closely Added profile data to the MessageRequestResponse Fixed the broken tests
394 lines
14 KiB
Swift
394 lines
14 KiB
Swift
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import Foundation
|
|
import GRDB
|
|
import DifferenceKit
|
|
import SignalCoreKit
|
|
import SessionUtilitiesKit
|
|
|
|
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: OWSAES256Key?
|
|
|
|
// MARK: - Initialization
|
|
|
|
public init(
|
|
id: String,
|
|
name: String,
|
|
nickname: String? = nil,
|
|
profilePictureUrl: String? = nil,
|
|
profilePictureFileName: String? = nil,
|
|
profileEncryptionKey: OWSAES256Key? = 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?.keyData.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: OWSAES256Key?
|
|
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)
|
|
{
|
|
guard let validProfileKey: OWSAES256Key = OWSAES256Key(data: profileKeyData) else {
|
|
owsFailDebug("Failed to make profile key for key data")
|
|
throw StorageError.decodingFailed
|
|
}
|
|
|
|
profileKey = validProfileKey
|
|
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.encode(nickname, forKey: .nickname)
|
|
try container.encode(profilePictureUrl, forKey: .profilePictureUrl)
|
|
try container.encode(profilePictureFileName, forKey: .profilePictureFileName)
|
|
try container.encode(profileEncryptionKey?.keyData, 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: OWSAES256Key?
|
|
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 {
|
|
guard let validProfileKey: OWSAES256Key = OWSAES256Key(data: profileKeyData) else {
|
|
owsFailDebug("Failed to make profile key for key data")
|
|
return nil
|
|
}
|
|
|
|
profileKey = validProfileKey
|
|
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: OWSAES256Key = profileEncryptionKey, let profilePictureUrl: String = profilePictureUrl {
|
|
dataMessageProto.setProfileKey(profileKey.keyData)
|
|
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: - Mutation
|
|
|
|
public extension Profile {
|
|
func with(
|
|
name: String? = nil,
|
|
profilePictureUrl: Updatable<String?> = .existing,
|
|
profilePictureFileName: Updatable<String?> = .existing,
|
|
profileEncryptionKey: Updatable<OWSAES256Key> = .existing
|
|
) -> Profile {
|
|
return Profile(
|
|
id: id,
|
|
name: (name ?? self.name),
|
|
nickname: self.nickname,
|
|
profilePictureUrl: (profilePictureUrl ?? self.profilePictureUrl),
|
|
profilePictureFileName: (profilePictureFileName ?? self.profilePictureFileName),
|
|
profileEncryptionKey: (profileEncryptionKey ?? self.profileEncryptionKey)
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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() -> Profile {
|
|
var userPublicKey: String = ""
|
|
|
|
let exisingProfile: Profile? = Storage.shared.read { db in
|
|
userPublicKey = getUserHexEncodedPublicKey(db)
|
|
|
|
return try Profile.fetchOne(db, id: userPublicKey)
|
|
}
|
|
|
|
return (exisingProfile ?? defaultFor(userPublicKey))
|
|
}
|
|
|
|
/// 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) -> Profile {
|
|
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
|
|
|
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, .closedGroup: return name
|
|
|
|
case .openGroup:
|
|
// 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)))"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Objective-C Support
|
|
|
|
// FIXME: Remove when possible
|
|
|
|
@objc(SMKProfile)
|
|
public class SMKProfile: NSObject {
|
|
@objc public static func displayName(id: String) -> String {
|
|
return Profile.displayName(id: id)
|
|
}
|
|
|
|
@objc public static func displayName(id: String, customFallback: String) -> String {
|
|
return Profile.displayName(id: id, customFallback: customFallback)
|
|
}
|
|
|
|
@objc(displayNameAfterSavingNickname:forProfileId:)
|
|
public static func displayNameAfterSaving(nickname: String?, for profileId: String) -> String {
|
|
return Storage.shared.write { db in
|
|
let profile: Profile = Profile.fetchOrCreate(id: profileId)
|
|
let targetNickname: String? = ((nickname ?? "").count > 0 ? nickname : nil)
|
|
|
|
try Profile
|
|
.filter(id: profile.id)
|
|
.updateAll(db, Profile.Columns.nickname.set(to: targetNickname))
|
|
|
|
return (targetNickname ?? profile.name)
|
|
}
|
|
.defaulting(to: "")
|
|
}
|
|
}
|