1054 lines
43 KiB
Swift
1054 lines
43 KiB
Swift
//
|
|
// Copyright 2021 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import GRDB
|
|
import LibSignalClient
|
|
import UIKit
|
|
public import SignalServiceKit
|
|
|
|
// swiftlint:disable:next class_delegate_protocol
|
|
public protocol ConversationAvatarViewDelegate: UIViewController {
|
|
func didTapBadge()
|
|
|
|
func presentStoryViewController()
|
|
func presentAvatarViewController()
|
|
}
|
|
|
|
public extension ConversationAvatarViewDelegate {
|
|
func didTapAvatar(_ configuration: ConversationAvatarView.Configuration) {
|
|
if configuration.hasStoriesToDisplay {
|
|
let actionSheet = ActionSheetController()
|
|
actionSheet.addAction(OWSActionSheets.cancelAction)
|
|
actionSheet.addAction(.init(
|
|
title: OWSLocalizedString("VIEW_PHOTO", comment: "View the photo of a group or user"),
|
|
handler: { [weak self] _ in
|
|
self?.presentAvatarViewController()
|
|
}
|
|
))
|
|
actionSheet.addAction(.init(
|
|
title: OWSLocalizedString("VIEW_STORY", comment: "View the story of a group or user"),
|
|
handler: { [weak self] _ in
|
|
self?.presentStoryViewController()
|
|
}
|
|
))
|
|
presentActionSheet(actionSheet, animated: true)
|
|
} else {
|
|
presentAvatarViewController()
|
|
}
|
|
}
|
|
}
|
|
|
|
public class ConversationAvatarView: UIView, CVView, PrimaryImageView {
|
|
|
|
public init(
|
|
sizeClass: Configuration.SizeClass = .customDiameter(0),
|
|
localUserDisplayMode: LocalUserDisplayMode,
|
|
badged: Bool = true,
|
|
shape: Configuration.Shape = .circular,
|
|
useAutolayout: Bool = true
|
|
) {
|
|
self.configuration = Configuration(
|
|
sizeClass: sizeClass,
|
|
dataSource: nil,
|
|
localUserDisplayMode: localUserDisplayMode,
|
|
addBadgeIfApplicable: badged,
|
|
shape: shape,
|
|
useAutolayout: useAutolayout)
|
|
|
|
super.init(frame: .zero)
|
|
|
|
addSubview(storyStateView)
|
|
addSubview(avatarView)
|
|
addSubview(badgeView)
|
|
autoresizesSubviews = false
|
|
isUserInteractionEnabled = false
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
// MARK: Configuration API
|
|
|
|
public struct Configuration: Equatable {
|
|
public enum SizeClass: Equatable {
|
|
case twentyFour
|
|
case twentyEight
|
|
case thirtySix
|
|
case forty
|
|
case fortyEight
|
|
case fiftySix
|
|
case sixtyFour
|
|
case eighty
|
|
case eightyEight
|
|
case oneHundredTwelve
|
|
|
|
// Badge sprites may have artifacts from scaling to custom size classes
|
|
// Stick to explicit size classes when you can
|
|
case customDiameter(UInt)
|
|
|
|
public init(avatarDiameter: UInt) {
|
|
switch avatarDiameter {
|
|
case Self.twentyFour.diameter:
|
|
self = .twentyFour
|
|
case Self.twentyEight.diameter:
|
|
self = .twentyEight
|
|
case Self.thirtySix.diameter:
|
|
self = .thirtySix
|
|
case Self.forty.diameter:
|
|
self = .forty
|
|
case Self.fortyEight.diameter:
|
|
self = .fortyEight
|
|
case Self.fiftySix.diameter:
|
|
self = .fiftySix
|
|
case Self.sixtyFour.diameter:
|
|
self = .sixtyFour
|
|
case Self.eighty.diameter:
|
|
self = .eighty
|
|
case Self.eightyEight.diameter:
|
|
self = .eightyEight
|
|
case Self.oneHundredTwelve.diameter:
|
|
self = .oneHundredTwelve
|
|
default:
|
|
self = .customDiameter(avatarDiameter)
|
|
}
|
|
}
|
|
}
|
|
|
|
public enum Shape {
|
|
case rectangular
|
|
case circular
|
|
}
|
|
|
|
/// The preferred size class of the avatar. Used for avatar generation and autolayout (if enabled).
|
|
///
|
|
/// If a predefined size class is used, a badge can optionally be placed by
|
|
/// specifying `addBadgeIfApplicable` or `fallbackBadge`.
|
|
public var sizeClass: SizeClass
|
|
|
|
fileprivate var avatarSizeClass: SizeClass {
|
|
guard hasStoriesToDisplay else { return sizeClass }
|
|
return .customDiameter(sizeClass.diameter - (sizeClass.storyBorderInsets * 2))
|
|
}
|
|
|
|
fileprivate var avatarOffset: CGPoint {
|
|
guard hasStoriesToDisplay else { return .zero }
|
|
return CGPoint(x: Int(sizeClass.storyBorderInsets), y: Int(sizeClass.storyBorderInsets))
|
|
}
|
|
|
|
/// The data provider used to fetch an avatar and badge
|
|
public var dataSource: ConversationAvatarDataSource?
|
|
|
|
public mutating func setGroupIdWithSneakyTransaction(groupId: Data) {
|
|
if dataSource?.groupId == groupId {
|
|
return
|
|
}
|
|
let databaseStorage = SSKEnvironment.shared.databaseStorageRef
|
|
self.dataSource = databaseStorage.read { tx in
|
|
return TSGroupThread.fetch(groupId: groupId, transaction: tx)
|
|
}.map {
|
|
return .thread($0)
|
|
}
|
|
}
|
|
|
|
/// Adjusts how the local user profile avatar is generated (Note to Self or Avatar?)
|
|
public var localUserDisplayMode: LocalUserDisplayMode
|
|
/// Places the user's badge (if they have one) over the avatar. Only supported for predefined size classes
|
|
public var addBadgeIfApplicable: Bool
|
|
/// If the user has no badge, this one will be shown instead.
|
|
/// Useful for previewing badges without them actually being on your profile.
|
|
/// Expects the badge image to already be loaded.
|
|
public var fallbackBadge: ProfileBadge?
|
|
|
|
/// Adjusts the mask of the avatar view
|
|
public var shape: Shape
|
|
/// If set `true`, adds constraints to the view to ensure that it's sized for the provided size class
|
|
/// Otherwise, it's the superview's responsibility to ensure this view is sized appropriately
|
|
public var useAutolayout: Bool
|
|
|
|
// Adopters that'd like to fetch the image synchronously can set this to perform
|
|
// the next model update synchronously if necessary.
|
|
fileprivate var forceSyncUpdate: Bool = false
|
|
public mutating func applyConfigurationSynchronously() {
|
|
forceSyncUpdate = true
|
|
}
|
|
fileprivate mutating func checkForSyncUpdateAndClear() -> Bool {
|
|
// If we don't have a data source then there's no slow-path asset fetching. We can just take the sync update path always
|
|
var shouldUpdateSync = true
|
|
if let dataSource = dataSource, !forceSyncUpdate {
|
|
// if we have data and are not forced to perform a sync call
|
|
// we will perform an async call if we shouldn't use the cache (placeholder shall be shown) or the data is not cached
|
|
shouldUpdateSync = useCachedImages && dataSource.isDataImmediatelyAvailable
|
|
}
|
|
forceSyncUpdate = false
|
|
return shouldUpdateSync
|
|
}
|
|
|
|
fileprivate var useCachedImages = true
|
|
public mutating func usePlaceholderImages() {
|
|
useCachedImages = false
|
|
}
|
|
|
|
public enum StoryConfiguration: Equatable {
|
|
/// Won't show the ring even if there are stories.
|
|
case disabled
|
|
/// Shows the ring based on the provided state; won't observe updates.
|
|
case fixed(StoryContextViewState)
|
|
/// Shows the ring if needed and observes story state changes.
|
|
/// Optional initial value to speed up first load, if not provided state
|
|
/// will be loaded on its own.
|
|
case autoUpdate(state: StoryContextViewState? = nil)
|
|
}
|
|
|
|
public var storyConfiguration: StoryConfiguration = .disabled
|
|
|
|
public var hasStoriesToDisplay: Bool {
|
|
guard StoryManager.areStoriesEnabled else { return false }
|
|
switch storyConfiguration {
|
|
case .disabled: return false
|
|
case let .autoUpdate(state):
|
|
return state?.hasStoriesToDisplay ?? false
|
|
case let .fixed(state):
|
|
return state.hasStoriesToDisplay
|
|
}
|
|
}
|
|
}
|
|
|
|
public private(set) var configuration: Configuration {
|
|
didSet {
|
|
if configuration.dataSource != oldValue.dataSource {
|
|
AssertIsOnMainThread()
|
|
ensureObservers()
|
|
}
|
|
}
|
|
}
|
|
|
|
func updateConfigurationAndSetDirtyIfNecessary(_ newValue: Configuration) {
|
|
let oldValue = configuration
|
|
configuration = newValue
|
|
|
|
// We may need to update our model, layout, or constraints based on the changes to the configuration
|
|
let sizeClassDidChange = configuration.sizeClass != oldValue.sizeClass
|
|
let avatarSizeClassDidChange = configuration.avatarSizeClass != oldValue.avatarSizeClass
|
|
let dataSourceDidChange = configuration.dataSource != oldValue.dataSource
|
|
let localUserDisplayModeDidChange = configuration.localUserDisplayMode != oldValue.localUserDisplayMode
|
|
let shouldShowBadgeDidChange = configuration.addBadgeIfApplicable != oldValue.addBadgeIfApplicable
|
|
let fallbackBadgeDidChange = configuration.fallbackBadge != oldValue.fallbackBadge
|
|
let shapeDidChange = configuration.shape != oldValue.shape
|
|
let autolayoutDidChange = configuration.useAutolayout != oldValue.useAutolayout
|
|
let storyStateDidChange = configuration.storyConfiguration != oldValue.storyConfiguration
|
|
|
|
// Any changes to avatar size or provider will trigger a model update
|
|
let needsModelUpdate: Bool = (
|
|
sizeClassDidChange ||
|
|
avatarSizeClassDidChange ||
|
|
dataSourceDidChange ||
|
|
localUserDisplayModeDidChange ||
|
|
shouldShowBadgeDidChange ||
|
|
fallbackBadgeDidChange
|
|
)
|
|
if needsModelUpdate {
|
|
setNeedsModelUpdate()
|
|
}
|
|
|
|
// If autolayout was toggled, or the size changed while autolayout is enabled we need to update our constraints
|
|
if autolayoutDidChange || (configuration.useAutolayout && (sizeClassDidChange || avatarSizeClassDidChange)) {
|
|
setNeedsUpdateConstraints()
|
|
}
|
|
|
|
if sizeClassDidChange || avatarSizeClassDidChange || shouldShowBadgeDidChange || shapeDidChange || storyStateDidChange {
|
|
setNeedsLayout()
|
|
}
|
|
}
|
|
|
|
// MARK: Configuration updates
|
|
|
|
public func updateWithSneakyTransactionIfNecessary(_ updateBlock: (inout Configuration) -> Void) {
|
|
update(optionalTransaction: nil, updateBlock)
|
|
}
|
|
|
|
/// To reduce the occurrence of unnecessary avatar fetches, updates to the view configuration occur in a closure
|
|
/// Configuration updates will be applied all at once
|
|
public func update(_ transaction: SDSAnyReadTransaction, _ updateBlock: (inout Configuration) -> Void) {
|
|
update(optionalTransaction: transaction, updateBlock)
|
|
}
|
|
|
|
private func update(optionalTransaction transaction: SDSAnyReadTransaction?, _ updateBlock: (inout Configuration) -> Void) {
|
|
AssertIsOnMainThread()
|
|
|
|
let oldConfiguration = configuration
|
|
var mutableConfig = oldConfiguration
|
|
updateBlock(&mutableConfig)
|
|
updateConfigurationAndSetDirtyIfNecessary(mutableConfig)
|
|
updateModelIfNecessary(transaction: transaction)
|
|
}
|
|
|
|
// MARK: Model Updates
|
|
|
|
public func reloadDataIfNecessary() {
|
|
updateModel(transaction: nil)
|
|
}
|
|
|
|
private func updateModel(transaction readTx: SDSAnyReadTransaction?) {
|
|
setNeedsModelUpdate()
|
|
updateModelIfNecessary(transaction: readTx)
|
|
}
|
|
|
|
// If the model has been dirtied, performs an update
|
|
// If an async update is requested, the model is updated immediately with any available cached content
|
|
// followed by enqueueing a full model update on a background thread.
|
|
private func updateModelIfNecessary(transaction readTx: SDSAnyReadTransaction?) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard nextModelGeneration.get() > currentModelGeneration else { return }
|
|
guard let dataSource = configuration.dataSource else {
|
|
updateViewContent(avatarImage: nil, primaryBadgeImage: nil)
|
|
return
|
|
}
|
|
|
|
var avatarImage: UIImage?
|
|
var badgeImage: UIImage?
|
|
let updateSynchronously = configuration.checkForSyncUpdateAndClear()
|
|
if updateSynchronously {
|
|
avatarImage = dataSource.buildImage(configuration: configuration, transaction: readTx)
|
|
badgeImage = dataSource.fetchBadge(configuration: configuration, transaction: readTx)
|
|
} else {
|
|
if configuration.useCachedImages {
|
|
avatarImage = dataSource.fetchCachedImage(configuration: configuration, transaction: readTx)
|
|
badgeImage = dataSource.fetchBadge(configuration: configuration, transaction: readTx)
|
|
} else {
|
|
avatarImage = UIImage(named: Theme.iconName(.profilePlaceholder))
|
|
}
|
|
}
|
|
updateViewContent(avatarImage: avatarImage, primaryBadgeImage: badgeImage)
|
|
if !updateSynchronously {
|
|
enqueueAsyncModelUpdate()
|
|
}
|
|
}
|
|
|
|
private func updateViewContent(avatarImage: UIImage?, primaryBadgeImage: UIImage?) {
|
|
AssertIsOnMainThread()
|
|
|
|
avatarView.image = avatarImage
|
|
badgeView.image = {
|
|
if let primaryBadgeImage = primaryBadgeImage {
|
|
return primaryBadgeImage
|
|
} else if let fallbackAssets = configuration.fallbackBadge?.assets {
|
|
return configuration.sizeClass.fetchImageFromBadgeAssets(fallbackAssets)
|
|
} else {
|
|
return nil
|
|
}
|
|
}()
|
|
|
|
currentModelGeneration = self.nextModelGeneration.get()
|
|
setNeedsLayout()
|
|
}
|
|
|
|
// MARK: - Model Tracking
|
|
|
|
// Invoking `setNeedsModelUpdate()` increments the next model generation
|
|
// Any updates to the model copy the `nextModelGeneration` to the currentModelGeneration
|
|
// For synchronous updates, these happen in lockstep on the main thread
|
|
// For async model updates, this helps with detecting parallel model changes on another thread
|
|
// `nextModelGeneration` can be read on a background thread, so it needs to be atomic.
|
|
// All updates are performed on the main thread.
|
|
private var currentModelGeneration: UInt = 0
|
|
private var nextModelGeneration = AtomicUInt(0, lock: .sharedGlobal)
|
|
@discardableResult
|
|
private func setNeedsModelUpdate() -> UInt { nextModelGeneration.increment() }
|
|
|
|
// Load avatars in _reverse_ order in which they are enqueued.
|
|
// Avatars are enqueued as the user navigates (and not cancelled),
|
|
// so the most recently enqueued avatars are most likely to be
|
|
// visible. To put it another way, we don't cancel loads so
|
|
// the oldest loads are most likely to be unnecessary.
|
|
private static let serialQueue = ReverseDispatchQueue(label: "org.signal.conversation-avatar.loading",
|
|
qos: .userInitiated, autoreleaseFrequency: .workItem)
|
|
|
|
private func enqueueAsyncModelUpdate() {
|
|
AssertIsOnMainThread()
|
|
let generationAtEnqueue = setNeedsModelUpdate()
|
|
let configurationAtEnqueue = configuration
|
|
|
|
Self.serialQueue.async { [weak self] in
|
|
guard let self = self, self.nextModelGeneration.get() == generationAtEnqueue else {
|
|
return
|
|
}
|
|
|
|
let (updatedAvatar, updatedBadge) = SSKEnvironment.shared.databaseStorageRef.read { transaction -> (UIImage?, UIImage?) in
|
|
guard self.nextModelGeneration.get() == generationAtEnqueue else {
|
|
return (nil, nil)
|
|
}
|
|
|
|
let avatarImage = configurationAtEnqueue.dataSource?.buildImage(configuration: configurationAtEnqueue, transaction: transaction)
|
|
let badgeImage = configurationAtEnqueue.dataSource?.fetchBadge(configuration: configurationAtEnqueue, transaction: transaction)
|
|
return (avatarImage, badgeImage)
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
guard self.nextModelGeneration.get() == generationAtEnqueue else {
|
|
return
|
|
}
|
|
self.updateViewContent(avatarImage: updatedAvatar, primaryBadgeImage: updatedBadge)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Subviews and Layout
|
|
|
|
private var storyStateView: UIView = {
|
|
let view = UIView()
|
|
view.backgroundColor = .clear
|
|
return view
|
|
}()
|
|
|
|
private var avatarView: UIImageView = {
|
|
let view = UIImageView()
|
|
view.contentMode = .scaleAspectFill
|
|
view.layer.minificationFilter = .trilinear
|
|
view.layer.magnificationFilter = .trilinear
|
|
return view
|
|
}()
|
|
|
|
private var badgeView: UIImageView = {
|
|
let view = UIImageView()
|
|
view.contentMode = .scaleAspectFit
|
|
view.layer.minificationFilter = .trilinear
|
|
view.layer.magnificationFilter = .trilinear
|
|
return view
|
|
}()
|
|
|
|
private var sizeConstraints: (width: NSLayoutConstraint, height: NSLayoutConstraint)?
|
|
override public func updateConstraints() {
|
|
let targetSize = configuration.sizeClass.size
|
|
|
|
switch (configuration.useAutolayout, sizeConstraints) {
|
|
case (true, let constraints?):
|
|
constraints.width.constant = targetSize.width
|
|
constraints.height.constant = targetSize.height
|
|
case (true, nil):
|
|
sizeConstraints = (width: autoSetDimension(.width, toSize: targetSize.width),
|
|
height: autoSetDimension(.height, toSize: targetSize.height))
|
|
case (false, _):
|
|
if let sizeConstraints = sizeConstraints {
|
|
NSLayoutConstraint.deactivate([sizeConstraints.width, sizeConstraints.height])
|
|
}
|
|
sizeConstraints = nil
|
|
}
|
|
|
|
super.updateConstraints()
|
|
}
|
|
|
|
override public func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
|
|
let storyState: StoryContextViewState?
|
|
switch configuration.storyConfiguration {
|
|
case .disabled:
|
|
storyState = nil
|
|
case .fixed(let state):
|
|
storyState = state
|
|
case .autoUpdate(let state):
|
|
storyState = state
|
|
}
|
|
switch storyState {
|
|
case .none, .noStories:
|
|
storyStateView.isHidden = true
|
|
case .viewed:
|
|
storyStateView.isHidden = false
|
|
storyStateView.layer.borderColor = Theme.isDarkThemeEnabled
|
|
? UIColor.ows_whiteAlpha25.cgColor : UIColor.ows_blackAlpha25.cgColor
|
|
storyStateView.layer.borderWidth = configuration.sizeClass.storyViewedBorderSize
|
|
case .unviewed:
|
|
storyStateView.isHidden = false
|
|
storyStateView.layer.borderColor = UIColor.ows_accentBlue.cgColor
|
|
storyStateView.layer.borderWidth = configuration.sizeClass.storyUnviewedBorderSize
|
|
}
|
|
|
|
storyStateView.frame = CGRect(origin: .zero, size: configuration.sizeClass.size)
|
|
avatarView.frame = CGRect(origin: configuration.avatarOffset, size: configuration.avatarSizeClass.size)
|
|
badgeView.frame = CGRect(origin: configuration.sizeClass.badgeOffset, size: configuration.sizeClass.badgeSize)
|
|
badgeView.isHidden = (badgeView.image == nil)
|
|
|
|
switch configuration.shape {
|
|
case .circular:
|
|
storyStateView.layer.cornerRadius = (storyStateView.bounds.height / 2)
|
|
storyStateView.layer.masksToBounds = true
|
|
avatarView.layer.cornerRadius = (avatarView.bounds.height / 2)
|
|
avatarView.layer.masksToBounds = true
|
|
case .rectangular:
|
|
storyStateView.layer.cornerRadius = 0
|
|
storyStateView.layer.masksToBounds = false
|
|
avatarView.layer.cornerRadius = 0
|
|
avatarView.layer.masksToBounds = false
|
|
}
|
|
}
|
|
|
|
public override var intrinsicContentSize: CGSize { configuration.sizeClass.size }
|
|
|
|
public override func sizeThatFits(_ size: CGSize) -> CGSize { intrinsicContentSize }
|
|
|
|
// MARK: - Controls
|
|
|
|
lazy private var avatarTapGestureRecognizer: UITapGestureRecognizer = {
|
|
let tapGestureRecognizer = UITapGestureRecognizer()
|
|
tapGestureRecognizer.addTarget(self, action: #selector(didTapAvatar(_:)))
|
|
tapGestureRecognizer.numberOfTapsRequired = 1
|
|
return tapGestureRecognizer
|
|
}()
|
|
|
|
lazy private var badgeTapGestureRecognizer: UITapGestureRecognizer = {
|
|
let tapGestureRecognizer = UITapGestureRecognizer()
|
|
tapGestureRecognizer.addTarget(self, action: #selector(didTapBadge(_:)))
|
|
tapGestureRecognizer.numberOfTapsRequired = 1
|
|
return tapGestureRecognizer
|
|
}()
|
|
|
|
public weak var interactionDelegate: (any ConversationAvatarViewDelegate)? {
|
|
didSet {
|
|
if interactionDelegate != nil, oldValue == nil {
|
|
avatarView.addGestureRecognizer(avatarTapGestureRecognizer)
|
|
badgeView.addGestureRecognizer(badgeTapGestureRecognizer)
|
|
avatarView.isUserInteractionEnabled = true
|
|
badgeView.isUserInteractionEnabled = true
|
|
isUserInteractionEnabled = true
|
|
} else if interactionDelegate == nil, oldValue != nil {
|
|
avatarView.removeGestureRecognizer(avatarTapGestureRecognizer)
|
|
badgeView.removeGestureRecognizer(badgeTapGestureRecognizer)
|
|
avatarView.isUserInteractionEnabled = false
|
|
badgeView.isUserInteractionEnabled = false
|
|
isUserInteractionEnabled = false
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc
|
|
private func didTapAvatar(_ sender: UITapGestureRecognizer) {
|
|
guard avatarView.image != nil else { return }
|
|
interactionDelegate?.didTapAvatar(configuration)
|
|
}
|
|
|
|
@objc
|
|
private func didTapBadge(_ sender: UITapGestureRecognizer) {
|
|
guard badgeView.image != nil else { return }
|
|
interactionDelegate?.didTapBadge()
|
|
}
|
|
|
|
// MARK: Notifications
|
|
|
|
private func ensureObservers() {
|
|
// TODO: Badges — Notify on an updated badge asset?
|
|
|
|
NotificationCenter.default.removeObserver(self)
|
|
NotificationCenter.default.addObserver(self,
|
|
selector: #selector(themeDidChange),
|
|
name: .themeDidChange,
|
|
object: nil)
|
|
|
|
guard let dataSource = configuration.dataSource else { return }
|
|
|
|
if dataSource.isContactAvatar {
|
|
if dataSource.contactAddress?.isLocalAddress == true {
|
|
NotificationCenter.default.addObserver(self,
|
|
selector: #selector(localUsersProfileDidChange(notification:)),
|
|
name: UserProfileNotifications.localProfileDidChange,
|
|
object: nil)
|
|
} else {
|
|
NotificationCenter.default.addObserver(self,
|
|
selector: #selector(otherUsersProfileDidChange(notification:)),
|
|
name: UserProfileNotifications.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)
|
|
} else if dataSource.isGroupAvatar {
|
|
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(startObservingStoryChangesIfNeeded),
|
|
name: .storiesEnabledStateDidChange,
|
|
object: nil
|
|
)
|
|
|
|
startObservingStoryChangesIfNeeded()
|
|
}
|
|
|
|
private var storyContextAssociatedDataObservation: DatabaseCancellable?
|
|
|
|
@objc
|
|
private func startObservingStoryChangesIfNeeded() {
|
|
switch configuration.storyConfiguration {
|
|
case .disabled, .fixed:
|
|
stopObservingStoryChanges()
|
|
return
|
|
case .autoUpdate:
|
|
break
|
|
}
|
|
guard let dataSource = configuration.dataSource, StoryManager.areStoriesEnabled else {
|
|
stopObservingStoryChanges()
|
|
return
|
|
}
|
|
|
|
let storyContextAssociatedDataFilterPredicate: SQLSpecificExpressible?
|
|
if
|
|
let contactAddress = dataSource.contactAddress,
|
|
!contactAddress.isLocalAddress,
|
|
let contactAci = contactAddress.serviceId as? Aci
|
|
{
|
|
storyContextAssociatedDataFilterPredicate = Column(StoryContextAssociatedData.columnName(.contactAci)) == contactAci.serviceIdUppercaseString
|
|
} else if let groupId = dataSource.groupId {
|
|
// == not available for data, use single item set contains which is the same thing.
|
|
storyContextAssociatedDataFilterPredicate = Set([groupId]).contains(Column(StoryContextAssociatedData.columnName(.groupId)))
|
|
} else {
|
|
storyContextAssociatedDataFilterPredicate = nil
|
|
}
|
|
|
|
stopObservingStoryChanges()
|
|
if let predicate = storyContextAssociatedDataFilterPredicate {
|
|
storyContextAssociatedDataObservation = ValueObservation.tracking { db -> StoryContextAssociatedData? in
|
|
try StoryContextAssociatedData
|
|
.filter(predicate)
|
|
.fetchOne(db)
|
|
}.start(
|
|
in: SSKEnvironment.shared.databaseStorageRef.grdbStorage.pool,
|
|
onError: { error in
|
|
owsFailDebug("Failed to observe story hidden state: \(error))")
|
|
},
|
|
onChange: { [weak self] (associatedData: StoryContextAssociatedData?) in
|
|
guard self?.configuration.storyConfiguration != .disabled else {
|
|
self?.stopObservingStoryChanges()
|
|
return
|
|
}
|
|
let newStoryState: StoryContextViewState = {
|
|
guard
|
|
StoryManager.areStoriesEnabled,
|
|
associatedData?.hasUnexpiredStories ?? false
|
|
else {
|
|
return .noStories
|
|
}
|
|
return (associatedData?.hasUnviewedStories ?? false) ? .unviewed : .viewed
|
|
}()
|
|
switch self?.configuration.storyConfiguration {
|
|
case .none, .disabled, .fixed:
|
|
self?.stopObservingStoryChanges()
|
|
return
|
|
case .autoUpdate(let currentState):
|
|
if newStoryState != currentState {
|
|
self?.configuration.storyConfiguration = .autoUpdate(state: newStoryState)
|
|
self?.setNeedsLayout()
|
|
}
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
private func stopObservingStoryChanges() {
|
|
storyContextAssociatedDataObservation?.cancel()
|
|
storyContextAssociatedDataObservation = nil
|
|
}
|
|
|
|
@objc
|
|
private func themeDidChange() {
|
|
AssertIsOnMainThread()
|
|
updateModel(transaction: nil)
|
|
}
|
|
|
|
@objc
|
|
private func handleSignalAccountsChanged(notification: Notification) {
|
|
AssertIsOnMainThread()
|
|
|
|
// 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.
|
|
updateModel(transaction: nil)
|
|
}
|
|
|
|
@objc
|
|
private func localUsersProfileDidChange(notification: Notification) {
|
|
AssertIsOnMainThread()
|
|
if let sourceAddress = configuration.dataSource?.contactAddress, sourceAddress.isLocalAddress {
|
|
handleUpdatedAddressNotification(address: sourceAddress)
|
|
}
|
|
}
|
|
|
|
@objc
|
|
private func otherUsersProfileDidChange(notification: Notification) {
|
|
AssertIsOnMainThread()
|
|
guard let changedAddress = notification.userInfo?[UserProfileNotifications.profileAddressKey] as? SignalServiceAddress, changedAddress.isValid else {
|
|
owsFailDebug("changedAddress was unexpectedly nil")
|
|
return
|
|
}
|
|
handleUpdatedAddressNotification(address: changedAddress)
|
|
}
|
|
|
|
@objc
|
|
private func skipContactAvatarBlurDidChange(notification: Notification) {
|
|
AssertIsOnMainThread()
|
|
guard let address = notification.userInfo?[OWSContactsManager.skipContactAvatarBlurAddressKey] as? SignalServiceAddress, address.isValid else {
|
|
owsFailDebug("Missing address.")
|
|
return
|
|
}
|
|
handleUpdatedAddressNotification(address: address)
|
|
}
|
|
|
|
private func handleUpdatedAddressNotification(address: SignalServiceAddress) {
|
|
AssertIsOnMainThread()
|
|
guard let dataSource = configuration.dataSource else { return }
|
|
guard let providerAddress = dataSource.contactAddress else {
|
|
// Should always be set for non-group thread avatar providers
|
|
owsFailDebug("contactAddress was unexpectedly nil")
|
|
return
|
|
}
|
|
|
|
if providerAddress == address {
|
|
updateModel(transaction: nil)
|
|
}
|
|
}
|
|
|
|
@objc
|
|
private func handleGroupAvatarChanged(notification: Notification) {
|
|
AssertIsOnMainThread()
|
|
guard let changedGroupThreadId = notification.userInfo?[TSGroupThread_NotificationKey_UniqueId] as? String else {
|
|
owsFailDebug("groupThreadId was unexpectedly nil")
|
|
return
|
|
}
|
|
handleUpdatedGroupThreadNotification(changedThreadId: changedGroupThreadId)
|
|
}
|
|
|
|
@objc
|
|
private func skipGroupAvatarBlurDidChange(notification: Notification) {
|
|
AssertIsOnMainThread()
|
|
guard let groupThreadId = notification.userInfo?[OWSContactsManager.skipGroupAvatarBlurGroupUniqueIdKey] as? String else {
|
|
owsFailDebug("Missing groupId.")
|
|
return
|
|
}
|
|
handleUpdatedGroupThreadNotification(changedThreadId: groupThreadId)
|
|
}
|
|
|
|
private func handleUpdatedGroupThreadNotification(changedThreadId: String) {
|
|
AssertIsOnMainThread()
|
|
guard let dataSource = configuration.dataSource, dataSource.isGroupAvatar else { return }
|
|
guard let contentThreadId = dataSource.threadId else {
|
|
// Should always be set for non-group thread avatar providers
|
|
owsFailDebug("contactAddress was unexpectedly nil")
|
|
return
|
|
}
|
|
|
|
if contentThreadId == changedThreadId {
|
|
SSKEnvironment.shared.databaseStorageRef.read {
|
|
updateModel(transaction: $0)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - <CVView>
|
|
|
|
public func reset() {
|
|
configuration.dataSource = nil
|
|
reloadDataIfNecessary()
|
|
}
|
|
|
|
// MARK: - <PrimaryImageView>
|
|
|
|
public var primaryImage: UIImage? { avatarView.image }
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public enum ConversationAvatarDataSource: Equatable, CustomStringConvertible {
|
|
case thread(TSThread)
|
|
case address(SignalServiceAddress)
|
|
case asset(avatar: UIImage?, badge: UIImage?)
|
|
|
|
var isContactAvatar: Bool { contactAddress != nil }
|
|
var isGroupAvatar: Bool {
|
|
if case .thread(_ as TSGroupThread) = self {
|
|
return true
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
|
|
var contactAddress: SignalServiceAddress? {
|
|
switch self {
|
|
case .address(let address): return address
|
|
case .thread(let thread as TSContactThread): return thread.contactAddress
|
|
case .thread: return nil
|
|
case .asset: return nil
|
|
}
|
|
}
|
|
|
|
fileprivate var threadId: String? {
|
|
switch self {
|
|
case .thread(let thread): return thread.uniqueId
|
|
case .address, .asset: return nil
|
|
}
|
|
}
|
|
|
|
fileprivate var groupId: Data? {
|
|
switch self {
|
|
case .thread(let thread): return (thread as? TSGroupThread)?.groupId
|
|
case .address, .asset: return nil
|
|
}
|
|
}
|
|
|
|
fileprivate var isDataImmediatelyAvailable: Bool {
|
|
switch self {
|
|
case .thread, .address: return false
|
|
case .asset: return true
|
|
}
|
|
}
|
|
|
|
private func performWithTransaction<T>(_ existingTx: SDSAnyReadTransaction?, _ block: (SDSAnyReadTransaction) -> T) -> T {
|
|
if let transaction = existingTx {
|
|
return block(transaction)
|
|
} else {
|
|
return SSKEnvironment.shared.databaseStorageRef.read { readTx in
|
|
block(readTx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TODO: Badges — Should this be async?
|
|
fileprivate func fetchBadge(configuration: ConversationAvatarView.Configuration, transaction: SDSAnyReadTransaction?) -> UIImage? {
|
|
guard configuration.addBadgeIfApplicable else { return nil }
|
|
guard configuration.sizeClass.badgeDiameter >= 16 else {
|
|
// We never want to show a badge <= 16pts
|
|
Logger.warn("Skipping badge request for badge with diameter of \(configuration.sizeClass.badgeDiameter)")
|
|
return nil
|
|
}
|
|
|
|
let targetAddress: SignalServiceAddress
|
|
switch self {
|
|
case .address(let address):
|
|
targetAddress = address
|
|
case .thread(let contactThread as TSContactThread):
|
|
targetAddress = (contactThread).contactAddress
|
|
case .thread:
|
|
return nil
|
|
case .asset(avatar: _, badge: let badge):
|
|
return badge
|
|
}
|
|
|
|
if targetAddress.isLocalAddress, configuration.localUserDisplayMode == .noteToSelf {
|
|
// We never want to show badges on Note To Self
|
|
return nil
|
|
}
|
|
|
|
let primaryBadge: ProfileBadge? = performWithTransaction(transaction) {
|
|
let userProfile = SSKEnvironment.shared.profileManagerRef.getUserProfile(for: targetAddress, transaction: $0)
|
|
return userProfile?.primaryBadge?.fetchBadgeContent(transaction: $0)
|
|
}
|
|
guard let badgeAssets = primaryBadge?.assets else { return nil }
|
|
return configuration.sizeClass.fetchImageFromBadgeAssets(badgeAssets)
|
|
}
|
|
|
|
fileprivate func buildImage(configuration: ConversationAvatarView.Configuration, transaction: SDSAnyReadTransaction?) -> UIImage? {
|
|
switch self {
|
|
case .thread(let contactThread as TSContactThread):
|
|
return performWithTransaction(transaction) {
|
|
SSKEnvironment.shared.avatarBuilderRef.avatarImage(
|
|
forAddress: contactThread.contactAddress,
|
|
diameterPoints: UInt(configuration.avatarSizeClass.diameter),
|
|
localUserDisplayMode: configuration.localUserDisplayMode,
|
|
transaction: $0)
|
|
}
|
|
|
|
case .address(let address):
|
|
return performWithTransaction(transaction) {
|
|
SSKEnvironment.shared.avatarBuilderRef.avatarImage(
|
|
forAddress: address,
|
|
diameterPoints: UInt(configuration.avatarSizeClass.diameter),
|
|
localUserDisplayMode: configuration.localUserDisplayMode,
|
|
transaction: $0)
|
|
}
|
|
|
|
case .thread(let groupThread as TSGroupThread):
|
|
return performWithTransaction(transaction) {
|
|
SSKEnvironment.shared.avatarBuilderRef.avatarImage(
|
|
forGroupThread: groupThread,
|
|
diameterPoints: UInt(configuration.avatarSizeClass.diameter),
|
|
transaction: $0)
|
|
}
|
|
|
|
case .asset(let avatar, _):
|
|
return avatar
|
|
|
|
case .thread:
|
|
owsFailDebug("Unrecognized thread subclass: \(self)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
fileprivate func fetchCachedImage(configuration: ConversationAvatarView.Configuration, transaction: SDSAnyReadTransaction?) -> UIImage? {
|
|
switch self {
|
|
case .thread(let contactThread as TSContactThread):
|
|
return performWithTransaction(transaction) {
|
|
SSKEnvironment.shared.avatarBuilderRef.precachedAvatarImage(
|
|
forAddress: contactThread.contactAddress,
|
|
diameterPoints: UInt(configuration.avatarSizeClass.diameter),
|
|
localUserDisplayMode: configuration.localUserDisplayMode,
|
|
transaction: $0)
|
|
}
|
|
|
|
case .address(let address):
|
|
return performWithTransaction(transaction) {
|
|
SSKEnvironment.shared.avatarBuilderRef.precachedAvatarImage(
|
|
forAddress: address,
|
|
diameterPoints: UInt(configuration.avatarSizeClass.diameter),
|
|
localUserDisplayMode: configuration.localUserDisplayMode,
|
|
transaction: $0)
|
|
}
|
|
|
|
case .thread(let groupThread as TSGroupThread):
|
|
return performWithTransaction(transaction) {
|
|
SSKEnvironment.shared.avatarBuilderRef.precachedAvatarImage(
|
|
forGroupThread: groupThread,
|
|
diameterPoints: UInt(configuration.avatarSizeClass.diameter),
|
|
transaction: $0)
|
|
}
|
|
|
|
case .asset(let avatar, _):
|
|
return avatar
|
|
|
|
case .thread:
|
|
owsFailDebug("Unrecognized thread subclass: \(self)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
public var description: String {
|
|
switch self {
|
|
case .address(let address): return "[Address: \(address)]"
|
|
case .thread(let thread): return "[Thread \(type(of: thread)):\(thread.uniqueId)]"
|
|
case .asset(let avatar, let badge): return "[AvatarImage: \(String(describing: avatar)), BadgeImage: \(String(describing: badge))]"
|
|
}
|
|
}
|
|
}
|
|
|
|
extension ConversationAvatarView.Configuration.SizeClass {
|
|
// Badge layout is hardcoded. There's no simple rule to precisely place a badge on
|
|
// an arbitrarily sized avatar. Design has provided us with these pre-defined sizes.
|
|
// An avatar outside of these sizes will not support badging
|
|
|
|
public var diameter: UInt {
|
|
switch self {
|
|
case .twentyFour: return 24
|
|
case .twentyEight: return 28
|
|
case .thirtySix: return 36
|
|
case .forty: return 40
|
|
case .fortyEight: return 48
|
|
case .fiftySix: return 56
|
|
case .sixtyFour: return 64
|
|
case .eighty: return 80
|
|
case .eightyEight: return 88
|
|
case .oneHundredTwelve: return 112
|
|
case .customDiameter(let diameter): return diameter
|
|
}
|
|
}
|
|
|
|
var badgeDiameter: CGFloat {
|
|
// Originally, we were only using badge sprites for explicit size classes
|
|
// But it turns out we need these badges displayed in more places than originally thought
|
|
// Now we lerp between the explicit size classes that design has provided for us
|
|
switch diameter {
|
|
case ..<24: return 0
|
|
case 24...36: return 16
|
|
case 36..<40: return CGFloat(diameter).inverseLerp(36, 40).lerp(16, 24)
|
|
case 40...64: return 24
|
|
case 64..<80: return CGFloat(diameter).inverseLerp(64, 80).lerp(24, 36)
|
|
case 80...112: return 36
|
|
case 112...: return (CGFloat(diameter) / 112) * 36
|
|
default: return 0
|
|
}
|
|
}
|
|
|
|
/// The badge offset from its frame origin. Design has specified these points so the badge sits right alongside the circular avatar edge
|
|
var badgeOffset: CGPoint {
|
|
switch self {
|
|
case .twentyFour: return CGPoint(x: 10, y: 12)
|
|
case .twentyEight: return CGPoint(x: 14, y: 16)
|
|
case .thirtySix: return CGPoint(x: 20, y: 23)
|
|
case .forty: return CGPoint(x: 20, y: 22)
|
|
case .fortyEight: return CGPoint(x: 28, y: 30)
|
|
case .fiftySix: return CGPoint(x: 32, y: 38)
|
|
case .sixtyFour: return CGPoint(x: 40, y: 46)
|
|
case .eighty: return CGPoint(x: 44, y: 52)
|
|
case .eightyEight: return CGPoint(x: 49, y: 56)
|
|
case .oneHundredTwelve: return CGPoint(x: 74, y: 80)
|
|
case .customDiameter:
|
|
// Design hand-selected the above offsets for each size class
|
|
// For anything in between, let's just stick the badge at the bottom left of the frame for now
|
|
let avatarFrame = CGRect(origin: .zero, size: size)
|
|
let offsetVector = CGVector(dx: -badgeSize.width, dy: -badgeSize.height)
|
|
return avatarFrame.bottomRight.offsetBy(offsetVector)
|
|
}
|
|
}
|
|
|
|
public func fetchImageFromBadgeAssets(_ badgeAssets: BadgeAssets) -> UIImage? {
|
|
// Never show a badge less than 16 points
|
|
// Otherwise, err on the side of downscaling over upscaling
|
|
switch badgeDiameter {
|
|
case ..<16: return nil
|
|
case 16: return Theme.isDarkThemeEnabled ? badgeAssets.dark16 : badgeAssets.light16
|
|
case 16...24: return Theme.isDarkThemeEnabled ? badgeAssets.dark24 : badgeAssets.light24
|
|
case 24...: return Theme.isDarkThemeEnabled ? badgeAssets.dark36 : badgeAssets.light36
|
|
default: return nil
|
|
}
|
|
}
|
|
|
|
public var size: CGSize { .init(square: CGFloat(diameter)) }
|
|
public var badgeSize: CGSize { .init(square: CGFloat(badgeDiameter)) }
|
|
|
|
public var storyBorderInsets: UInt {
|
|
switch self {
|
|
case .twentyFour: return 4
|
|
case .twentyEight: return 4
|
|
case .thirtySix: return 4
|
|
case .forty: return 4
|
|
case .fortyEight: return 5
|
|
case .fiftySix: return 5
|
|
case .sixtyFour: return 5
|
|
case .eighty: return 5
|
|
case .eightyEight: return 6
|
|
case .oneHundredTwelve: return 6
|
|
case .customDiameter: return UInt(CGFloat(diameter) * 0.09)
|
|
}
|
|
}
|
|
|
|
public var storyUnviewedBorderSize: CGFloat {
|
|
switch self {
|
|
case .twentyFour: return 2
|
|
case .twentyEight: return 2
|
|
case .thirtySix: return 2
|
|
case .forty: return 2
|
|
case .fortyEight: return 2
|
|
case .fiftySix: return 2
|
|
case .sixtyFour: return 2
|
|
case .eighty: return 3
|
|
case .eightyEight: return 3
|
|
case .oneHundredTwelve: return 3
|
|
case .customDiameter: return CGFloat(diameter) * 0.04
|
|
}
|
|
}
|
|
|
|
public var storyViewedBorderSize: CGFloat { storyUnviewedBorderSize / 2 }
|
|
}
|