session-ios/SessionMessagingKit/Database/Models/Profile.swift
Morgan Pretty 3c07a2d044 Added linting for the localized strings, updated the quote & mention behaviour for the current user
Added a script and build step to error if we have localised a string in code bug don't have an entry in the localisable files
Added the logic and UI to replace the current users public key (or blinded key) with 'You' in mentions and quotes
Cleaned up some duplicate & missing localised strings
Fixed a bug where new closed groups weren't getting setup locally correctly
Updated the id truncating behaviour to always truncate from the middle
2022-07-15 18:15:28 +10:00

389 lines
14 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SignalCoreKit
import SessionUtilitiesKit
public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible, CustomStringConvertible {
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 = SNProtoDataMessageLokiProfile.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 fetchAllContactProfiles(excluding: Set<String> = [], excludeCurrentUser: Bool = true) -> [Profile] {
return Storage.shared
.read { db in
let idsToExclude: Set<String> = excluding
.inserting(excludeCurrentUser ? getUserHexEncodedPublicKey(db) : nil)
// Sort the contacts by their displayName value
return try Profile
.filter(!idsToExclude.contains(Profile.Columns.id))
.joining(
required: Profile.contact
.filter(Contact.Columns.isApproved == true)
.filter(Contact.Columns.didApproveMe == true)
)
.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: id,
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 = .contact) -> String {
return truncated(id: id, truncating: .middle)
}
/// A standardised mechanism for truncating a user id
static func truncated(id: String, truncating: Truncation = .middle) -> 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: "")
}
}