Signal-iOS/SignalMessaging/Views/ConversationAvatarView.swift

545 lines
20 KiB
Swift

//
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
//
import UIKit
/// Avatar View which updates itself as necessary when the profile, contact, or group picture changes.
@objc
public class ConversationAvatarView: AvatarImageView {
private struct Configuration: Equatable {
let diameter: UInt
let localUserDisplayMode: LocalUserDisplayMode
}
private var configuration: Configuration
private let shouldLoadAsync: Bool
@objc
public var diameter: UInt {
configuration.diameter
}
@objc
public var localUserDisplayMode: LocalUserDisplayMode {
configuration.localUserDisplayMode
}
private var content: ConversationContent? {
didSet {
ensureObservers()
}
}
@objc
public override var intrinsicContentSize: CGSize {
.square(CGFloat(diameter))
}
@objc
public override func sizeThatFits(_ size: CGSize) -> CGSize {
.square(CGFloat(diameter))
}
@objc
public required init(diameter: UInt,
localUserDisplayMode: LocalUserDisplayMode,
shouldLoadAsync: Bool = false) {
self.configuration = Configuration(diameter: diameter,
localUserDisplayMode: localUserDisplayMode)
self.shouldLoadAsync = shouldLoadAsync
super.init(frame: .zero)
setContentHuggingHigh()
setCompressionResistanceHigh()
}
required public init?(coder aDecoder: NSCoder) {
notImplemented()
}
// MARK: -
// To avoid redundant/sneaky transctions, we want to apply all changes to
// this view in a single go. If we need to change the content and/or configuration
// we should make sure those changes call this method just once.
private func configure(content: ConversationContent,
configuration: Configuration,
transaction: SDSAnyReadTransaction) {
let didDiameterChange = self.configuration.diameter != configuration.diameter
let didLocalUserDisplayModeChange = self.configuration.localUserDisplayMode != configuration.localUserDisplayMode
var shouldUpdateImage = self.content != content
self.configuration = configuration
self.content = content
if didDiameterChange {
invalidateIntrinsicContentSize()
setNeedsLayout()
shouldUpdateImage = true
}
if didLocalUserDisplayModeChange {
shouldUpdateImage = true
}
if shouldUpdateImage {
updateImage(transaction: transaction)
}
}
private func configure(content: ConversationContent,
transaction: SDSAnyReadTransaction) {
configure(content: content,
configuration: self.configuration,
transaction: transaction)
}
private func configureWithSneakyTransaction(content: ConversationContent) {
databaseStorage.read { transaction in
configure(content: content, transaction: transaction)
}
}
@objc
public func configureWithSneakyTransaction(thread: TSThread) {
configureWithSneakyTransaction(content: ConversationContent.forThread(thread))
}
@objc
public func configure(thread: TSThread, transaction: SDSAnyReadTransaction) {
configure(content: ConversationContent.forThread(thread), transaction: transaction)
}
@objc
public func configureWithSneakyTransaction(address: SignalServiceAddress) {
databaseStorage.read { transaction in
self.configure(address: address, transaction: transaction)
}
}
@objc
public func configure(address: SignalServiceAddress, transaction: SDSAnyReadTransaction) {
configure(address: address,
diameter: diameter,
localUserDisplayMode: localUserDisplayMode,
transaction: transaction)
}
@objc
public func configure(address: SignalServiceAddress,
diameter: UInt,
localUserDisplayMode: LocalUserDisplayMode,
transaction: SDSAnyReadTransaction) {
configure(content: ConversationContent.forAddress(address, transaction: transaction),
diameter: diameter,
localUserDisplayMode: localUserDisplayMode,
transaction: transaction)
}
@objc
public func configure(thread: TSThread,
diameter: UInt,
localUserDisplayMode: LocalUserDisplayMode,
transaction: SDSAnyReadTransaction) {
configure(content: ConversationContent.forThread(thread),
diameter: diameter,
localUserDisplayMode: localUserDisplayMode,
transaction: transaction)
}
public func configure(content: ConversationContent,
diameter: UInt,
localUserDisplayMode: LocalUserDisplayMode,
transaction: SDSAnyReadTransaction) {
let configuration = Configuration(diameter: diameter, localUserDisplayMode: localUserDisplayMode)
configure(content: content,
configuration: configuration,
transaction: transaction)
}
// MARK: - Notifications
private func ensureObservers() {
NotificationCenter.default.removeObserver(self)
guard let content = content else {
return
}
switch content {
case .contact, .unknownContact:
NotificationCenter.default.addObserver(self,
selector: #selector(otherUsersProfileDidChange(notification:)),
name: .otherUsersProfileDidChange,
object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(handleSignalAccountsChanged(notification:)),
name: .OWSContactsManagerSignalAccountsDidChange,
object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(skipContactAvatarBlurDidChange(notification:)),
name: OWSContactsManager.skipContactAvatarBlurDidChange,
object: nil)
case .group:
NotificationCenter.default.addObserver(self,
selector: #selector(handleGroupAvatarChanged(notification:)),
name: .TSGroupThreadAvatarChanged,
object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(skipGroupAvatarBlurDidChange(notification:)),
name: OWSContactsManager.skipGroupAvatarBlurDidChange,
object: nil)
}
NotificationCenter.default.addObserver(self,
selector: #selector(themeDidChange),
name: .ThemeDidChange,
object: nil)
}
@objc
private func themeDidChange() {
updateImageWithSneakyTransaction()
}
@objc
private func handleSignalAccountsChanged(notification: Notification) {
// PERF: It would be nice if we could do this only if *this* user's SignalAccount changed,
// but currently this is only a course grained notification.
updateImageWithSneakyTransaction()
}
@objc
private func otherUsersProfileDidChange(notification: Notification) {
guard let content = self.content else {
return
}
guard let changedAddress = notification.userInfo?[kNSNotificationKey_ProfileAddress] as? SignalServiceAddress,
changedAddress.isValid else {
owsFailDebug("changedAddress was unexpectedly nil")
return
}
guard let contactAddress = content.contactAddress else {
// shouldn't call this for group threads
owsFailDebug("contactAddress was unexpectedly nil")
return
}
guard contactAddress == changedAddress else {
// not this avatar
return
}
updateImageWithSneakyTransaction()
}
@objc
private func handleGroupAvatarChanged(notification: Notification) {
guard let content = self.content else {
return
}
guard let changedGroupThreadId = notification.userInfo?[TSGroupThread_NotificationKey_UniqueId] as? String else {
owsFailDebug("groupThreadId was unexpectedly nil")
return
}
guard let groupThreadId = content.groupThreadId else {
// shouldn't call this for contact threads
owsFailDebug("groupThreadId was unexpectedly nil")
return
}
guard groupThreadId == changedGroupThreadId else {
// not this avatar
return
}
databaseStorage.read { transaction in
self.content = content.reload(transaction: transaction)
self.updateImage(transaction: transaction)
}
}
@objc
private func skipContactAvatarBlurDidChange(notification: Notification) {
guard let content = self.content else {
return
}
guard let address = notification.userInfo?[OWSContactsManager.skipContactAvatarBlurAddressKey] as? SignalServiceAddress,
address.isValid else {
owsFailDebug("Missing address.")
return
}
guard address == content.contactAddress else {
return
}
updateImageWithSneakyTransaction()
}
@objc
private func skipGroupAvatarBlurDidChange(notification: Notification) {
guard let groupUniqueId = notification.userInfo?[OWSContactsManager.skipGroupAvatarBlurGroupUniqueIdKey] as? String else {
owsFailDebug("Missing groupId.")
return
}
guard groupUniqueId == content?.groupThreadId else {
return
}
updateImageWithSneakyTransaction()
}
// MARK: -
public func updateImageWithSneakyTransaction() {
updateImageAsync()
}
public func updateImage(transaction: SDSAnyReadTransaction) {
if shouldLoadAsync {
updateImageAsync()
} else {
self.image = Self.buildImage(configuration: configuration,
content: content,
transaction: transaction)
}
}
private static let serialQueue = DispatchQueue(label: "org.signal.ConversationAvatarView")
public func updateImageAsync() {
let configuration = self.configuration
guard configuration.diameter > 0,
let content = self.content else {
self.image = nil
return
}
Self.serialQueue.async { [weak self] in
let image: UIImage? = Self.databaseStorage.read { transaction in
Self.buildImage(configuration: configuration, content: content, transaction: transaction)
}
DispatchQueue.main.async {
guard let self = self else {
return
}
guard self.configuration == configuration,
self.content == content else {
// Discard stale loads.
return
}
guard let image = image else {
self.image = nil
return
}
self.image = image
}
}
}
private static func buildImage(configuration: Configuration,
content: ConversationContent?,
transaction: SDSAnyReadTransaction) -> UIImage? {
let diameter = configuration.diameter
guard configuration.diameter > 0,
let content = content else {
return nil
}
guard let image = { () -> UIImage? in
switch content {
case .contact(let contactThread):
return buildContactAvatar(address: contactThread.contactAddress,
conversationColorName: contactThread.conversationColorName,
diameter: diameter,
localUserDisplayMode: configuration.localUserDisplayMode,
transaction: transaction)
case .group(let groupThread):
return buildGroupAvatar(groupThread: groupThread,
diameter: diameter,
transaction: transaction)
case .unknownContact(let contactAddress):
let conversationColorName = TSContactThread.conversationColorName(forContactAddress: contactAddress,
transaction: transaction)
return buildContactAvatar(address: contactAddress,
conversationColorName: conversationColorName,
diameter: diameter,
localUserDisplayMode: configuration.localUserDisplayMode,
transaction: transaction)
}
}() else {
owsFailDebug("Could not build avatar image.")
return nil
}
let screenScale = UIScreen.main.scale
let targetSizePixels = CGFloat(diameter) * screenScale
guard CGFloat(image.pixelWidth) <= targetSizePixels,
CGFloat(image.pixelHeight) <= targetSizePixels else {
let resizedImage = image.resizedImage(toFillPixelSize: .square(targetSizePixels))
return resizedImage
}
return image
}
private static func buildContactAvatar(address: SignalServiceAddress,
conversationColorName: ConversationColorName,
diameter: UInt,
localUserDisplayMode: LocalUserDisplayMode,
transaction: SDSAnyReadTransaction) -> UIImage? {
let builder = OWSContactAvatarBuilder(address: address,
colorName: conversationColorName,
diameter: diameter,
localUserDisplayMode: localUserDisplayMode,
transaction: transaction)
let shouldBlurAvatar = contactsManagerImpl.shouldBlurContactAvatar(address: address,
transaction: transaction)
return buildAvatar(avatarBuilder: builder,
shouldBlurAvatar: shouldBlurAvatar,
transaction: transaction)
}
private static func buildGroupAvatar(groupThread: TSGroupThread,
diameter: UInt,
transaction: SDSAnyReadTransaction) -> UIImage? {
let builder = OWSGroupAvatarBuilder(thread: groupThread,
diameter: diameter)
let shouldBlurAvatar = contactsManagerImpl.shouldBlurGroupAvatar(groupThread: groupThread,
transaction: transaction)
return buildAvatar(avatarBuilder: builder,
shouldBlurAvatar: shouldBlurAvatar,
transaction: transaction)
}
private static func buildAvatar(avatarBuilder: OWSAvatarBuilder,
shouldBlurAvatar: Bool,
transaction: SDSAnyReadTransaction) -> UIImage? {
guard let image = avatarBuilder.build(with: transaction) else {
owsFailDebug("Could not build contact avatar.")
return nil
}
if shouldBlurAvatar {
return contactsManagerImpl.blurAvatar(image)
} else {
return image
}
}
@objc
public func reset() {
self.content = nil
self.image = nil
}
}
// MARK: -
// Represents a real or potential conversation.
public enum ConversationContent: Equatable, Dependencies {
case contact(contactThread: TSContactThread)
case group(groupThread: TSGroupThread)
case unknownContact(contactAddress: SignalServiceAddress)
static func forThread(_ thread: TSThread) -> ConversationContent {
if let contactThread = thread as? TSContactThread {
return .contact(contactThread: contactThread)
} else if let groupThread = thread as? TSGroupThread {
return .group(groupThread: groupThread)
} else {
owsFail("Invalid thread.")
}
}
static func forAddress(_ address: SignalServiceAddress,
transaction: SDSAnyReadTransaction) -> ConversationContent {
if let contactThread = TSContactThread.getWithContactAddress(address,
transaction: transaction) {
return .contact(contactThread: contactThread)
} else {
return .unknownContact(contactAddress: address)
}
}
public var contactAddress: SignalServiceAddress? {
switch self {
case .contact(let contactThread):
return contactThread.contactAddress
case .group:
return nil
case .unknownContact(let contactAddress):
return contactAddress
}
}
var groupThreadId: String? {
switch self {
case .contact:
return nil
case .group(let groupThread):
return groupThread.uniqueId
case .unknownContact:
return nil
}
}
var thread: TSThread? {
switch self {
case .contact(let contactThread):
return contactThread
case .group(let groupThread):
return groupThread
case .unknownContact:
return nil
}
}
func reloadWithSneakyTransaction() -> ConversationContent {
databaseStorage.read { transaction in
reload(transaction: transaction)
}
}
func reload(transaction: SDSAnyReadTransaction) -> ConversationContent {
if let contactAddress = self.contactAddress {
return Self.forAddress(contactAddress, transaction: transaction)
} else {
guard let thread = self.thread else {
return self
}
guard let latestThread = TSThread.anyFetch(uniqueId: thread.uniqueId,
transaction: transaction) else {
owsFailDebug("Missing thread.")
return self
}
return ConversationContent.forThread(latestThread)
}
}
// MARK: - Equatable
public static func == (lhs: ConversationContent, rhs: ConversationContent) -> Bool {
switch lhs {
case .contact(let contactThreadLhs):
switch rhs {
case .contact(let contactThreadRhs):
return contactThreadLhs.contactAddress == contactThreadRhs.contactAddress
default:
return false
}
case .group(let groupThreadLhs):
switch rhs {
case .group(let groupThreadRhs):
return groupThreadLhs.groupId == groupThreadRhs.groupId
default:
return false
}
case .unknownContact(let contactAddressLhs):
switch rhs {
case .unknownContact(let contactAddressRhs):
return contactAddressLhs == contactAddressRhs
default:
return false
}
}
}
}