TM-SGNL-iOS/Signal/ConversationView/Reactions/ReactionsDetailSheet.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

362 lines
14 KiB
Swift

//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
import SignalUI
class ReactionsDetailSheet: InteractiveSheetViewController {
let messageId: String
private var reactionState: InteractionReactionState
private let reactionFinder: ReactionFinder
let stackView = UIStackView()
let emojiCountsCollectionView = EmojiCountsCollectionView()
override var interactiveScrollViews: [UIScrollView] { emojiReactorsViews }
private var emojiCounts: [InteractionReactionState.EmojiCount] {
reactionState.emojiCounts
}
private var allEmoji: [Emoji] {
return emojiCounts.compactMap { Emoji($0.emoji) }
}
init(reactionState: InteractionReactionState, message: TSMessage) {
self.reactionState = reactionState
self.messageId = message.uniqueId
self.reactionFinder = ReactionFinder(uniqueMessageId: message.uniqueId)
super.init()
self.animationsShouldBeInterruptible = true
}
// MARK: -
override public func viewDidLoad() {
super.viewDidLoad()
stackView.axis = .vertical
stackView.spacing = 0
contentView.addSubview(stackView)
stackView.autoPinEdgesToSuperviewEdges()
// Prepare top view with emoji counts
stackView.addArrangedSubview(emojiCountsCollectionView)
buildEmojiCountItems()
// Prepare paging between emoji reactors
setupPaging()
// Select the "all" reaction page by setting selected emoji to nil
setSelectedEmoji(nil)
}
private var hasPreparedInitialLayout = false
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
// Once we have a frame defined, we need to update the
// page constraints. If we don't do this, the contentOffset
// gets reset after the pagingScrollView layout occurs.
guard !hasPreparedInitialLayout else { return }
hasPreparedInitialLayout = true
emojiPagingScrollView.superview?.layoutIfNeeded()
updatePageConstraints(ignoreScrollingState: true)
}
// MARK: -
func setReactionState(_ reactionState: InteractionReactionState, transaction: SDSAnyReadTransaction) {
self.reactionState = reactionState
buildEmojiCountItems()
// If the currently selected emoji still exists, keep it selected.
// Otherwise, select the "all" page by setting selected emoji to nil.
let newSelectedEmoji: Emoji?
if let selectedEmoji = selectedEmoji, allEmoji.contains(selectedEmoji) {
newSelectedEmoji = selectedEmoji
} else {
newSelectedEmoji = nil
}
setSelectedEmoji(newSelectedEmoji, transaction: transaction)
}
func buildEmojiCountItems() {
let allReactionsItem = EmojiItem(emoji: nil, count: emojiCounts.lazy.map { $0.count }.reduce(0, +)) { [weak self] in
self?.setSelectedEmoji(nil)
}
emojiCountsCollectionView.items = [allReactionsItem] + emojiCounts.map { (emojiCount) in
EmojiItem(emoji: emojiCount.emoji,
count: emojiCount.count) { [weak self] in
self?.setSelectedEmoji(Emoji(emojiCount.emoji))
}
}
}
// MARK: - Emoji Selection
private var selectedEmoji: Emoji?
func setSelectedEmoji(_ emoji: Emoji?) {
SSKEnvironment.shared.databaseStorageRef.read { self.setSelectedEmoji(emoji, transaction: $0) }
}
func setSelectedEmoji(_ emoji: Emoji?, transaction: SDSAnyReadTransaction) {
let oldValue = selectedEmoji
selectedEmoji = emoji
selectedEmojiChanged(oldSelectedEmoji: oldValue, transaction: transaction)
}
// MARK: - Paging
/// This array always includes three reactors views, where the indices represent:
/// 0 - Previous Page
/// 1 - Current Page
/// 2 - Next Page
private lazy var emojiReactorsViews = [
EmojiReactorsTableView(),
EmojiReactorsTableView(),
EmojiReactorsTableView()
]
private var emojiReactorsViewConstraints = [NSLayoutConstraint]()
private var currentPageReactorsView: EmojiReactorsTableView {
return emojiReactorsViews[1]
}
private var nextPageReactorsView: EmojiReactorsTableView {
return emojiReactorsViews[2]
}
private var previousPageReactorsView: EmojiReactorsTableView {
return emojiReactorsViews[0]
}
private let emojiPagingScrollView = UIScrollView()
private var nextPageEmoji: Emoji? {
// If we don't have an emoji defined, the first emoji is always up next
guard let emoji = selectedEmoji else { return allEmoji.first }
// If we don't have an index, or we're at the end of the array, "all" is up next
guard let index = allEmoji.firstIndex(of: emoji), index < (allEmoji.count - 1) else { return nil }
// Otherwise, use the next emoji in the array
return allEmoji[index + 1]
}
private var previousPageEmoji: Emoji? {
// If we don't have an emoji defined, the last emoji is always previous
guard let emoji = selectedEmoji else { return allEmoji.last }
// If we don't have an index, or we're at the start of the array, "all" is previous
guard let index = allEmoji.firstIndex(of: emoji), index > 0 else { return nil }
// Otherwise, use the previous emoji in the array
return allEmoji[index - 1]
}
private var pageWidth: CGFloat { return min(CurrentAppContext().frame.width, maxWidth) }
private var numberOfPages: CGFloat { return CGFloat(emojiReactorsViews.count) }
// These thresholds indicate the offset at which we update the next / previous page.
// They're not exactly half way through the transition, to avoid us continuously
// bouncing back and forth between pages.
private var previousPageThreshold: CGFloat { return pageWidth * 0.45 }
private var nextPageThreshold: CGFloat { return pageWidth + previousPageThreshold }
private func setupPaging() {
emojiPagingScrollView.isPagingEnabled = true
emojiPagingScrollView.showsHorizontalScrollIndicator = false
emojiPagingScrollView.isDirectionalLockEnabled = true
emojiPagingScrollView.delegate = self
stackView.addArrangedSubview(emojiPagingScrollView)
emojiPagingScrollView.autoPinEdge(toSuperviewSafeArea: .left)
emojiPagingScrollView.autoPinEdge(toSuperviewSafeArea: .right)
let reactorsPagesContainer = UIView()
emojiPagingScrollView.addSubview(reactorsPagesContainer)
reactorsPagesContainer.autoPinEdgesToSuperviewEdges()
reactorsPagesContainer.autoMatch(.height, to: .height, of: emojiPagingScrollView)
reactorsPagesContainer.autoMatch(.width, to: .width, of: emojiPagingScrollView, withMultiplier: numberOfPages)
for (index, reactorsView) in emojiReactorsViews.enumerated() {
reactorsView.isDirectionalLockEnabled = true
// We want the current page on top, to prevent weird
// animations when we initially calculate our frame.
if reactorsView == currentPageReactorsView {
reactorsPagesContainer.addSubview(reactorsView)
} else {
reactorsPagesContainer.insertSubview(reactorsView, at: 0)
}
reactorsView.autoMatch(.width, to: .width, of: emojiPagingScrollView)
reactorsView.autoMatch(.height, to: .height, of: emojiPagingScrollView)
reactorsView.autoPinEdge(toSuperviewEdge: .top)
reactorsView.autoPinEdge(toSuperviewEdge: .bottom)
emojiReactorsViewConstraints.append(
reactorsView.autoPinEdge(toSuperviewEdge: .left, withInset: CGFloat(index) * pageWidth)
)
}
}
private func reactions(for emoji: Emoji?, transaction: SDSAnyReadTransaction) -> [OWSReaction] {
guard let emoji = emoji else {
return reactionFinder.allReactions(transaction: transaction.unwrapGrdbRead)
}
guard let reactions = reactionState.reactionsByEmoji[emoji] else {
owsFailDebug("missing reactions for emoji \(emoji)")
return []
}
return reactions
}
private func selectedEmojiChanged(oldSelectedEmoji: Emoji?, transaction: SDSAnyReadTransaction) {
AssertIsOnMainThread()
// We're paging backwards!
if oldSelectedEmoji == nextPageEmoji, oldSelectedEmoji != selectedEmoji {
// The previous page becomes the current page and the current page becomes
// the next page. We have to load the new previous.
emojiReactorsViews.insert(emojiReactorsViews.removeLast(), at: 0)
emojiReactorsViewConstraints.insert(emojiReactorsViewConstraints.removeLast(), at: 0)
let previousPageReactions = reactions(for: previousPageEmoji, transaction: transaction)
previousPageReactorsView.configure(for: previousPageReactions, transaction: transaction)
// We're paging forwards!
} else if oldSelectedEmoji == previousPageEmoji, oldSelectedEmoji != selectedEmoji {
// The next page becomes the current page and the current page becomes
// the previous page. We have to load the new next.
emojiReactorsViews.append(emojiReactorsViews.removeFirst())
emojiReactorsViewConstraints.append(emojiReactorsViewConstraints.removeFirst())
let nextPageReactions = reactions(for: nextPageEmoji, transaction: transaction)
nextPageReactorsView.configure(for: nextPageReactions, transaction: transaction)
// We didn't get here through paging, stuff probably changed. Reload all the things.
} else {
let currentPageReactions = reactions(for: selectedEmoji, transaction: transaction)
currentPageReactorsView.configure(for: currentPageReactions, transaction: transaction)
let previousPageReactions = reactions(for: previousPageEmoji, transaction: transaction)
previousPageReactorsView.configure(for: previousPageReactions, transaction: transaction)
let nextPageReactions = reactions(for: nextPageEmoji, transaction: transaction)
nextPageReactorsView.configure(for: nextPageReactions, transaction: transaction)
}
updatePageConstraints()
// Update selection on the counts view to reflect our new selected emoji
if let selectedEmoji = selectedEmoji, let index = allEmoji.firstIndex(of: selectedEmoji) {
emojiCountsCollectionView.setSelectedIndex(index + 1)
} else {
emojiCountsCollectionView.setSelectedIndex(0)
}
}
private func updatePageConstraints(ignoreScrollingState: Bool = false) {
// Setup the collection views in their page positions
for (index, constraint) in emojiReactorsViewConstraints.enumerated() {
constraint.constant = CGFloat(index) * pageWidth
}
// Scrolling backwards
if !ignoreScrollingState && emojiPagingScrollView.contentOffset.x <= previousPageThreshold {
emojiPagingScrollView.contentOffset.x += pageWidth
// Scrolling forward
} else if !ignoreScrollingState && emojiPagingScrollView.contentOffset.x >= nextPageThreshold {
emojiPagingScrollView.contentOffset.x -= pageWidth
// Not moving forward or back, just scroll back to center so we can go forward and back again
} else {
emojiPagingScrollView.contentOffset.x = pageWidth
}
}
// MARK: - Scroll state management
/// Indicates that the user stopped actively scrolling, but
/// we still haven't reached their final destination.
private var isWaitingForDeceleration = false
/// Indicates that the user started scrolling and we've yet
/// to reach their final destination.
private var isUserScrolling = false
/// Indicates that we're currently changing pages due to a
/// user initiated scroll action.
private var isScrollingChange = false
private func userStartedScrolling() {
isWaitingForDeceleration = false
isUserScrolling = true
}
private func userStoppedScrolling(waitingForDeceleration: Bool = false) {
guard isUserScrolling else { return }
if waitingForDeceleration {
isWaitingForDeceleration = true
} else {
isWaitingForDeceleration = false
isUserScrolling = false
}
}
private func checkForPageChange() {
// Ignore any page changes unless the user is triggering them.
guard isUserScrolling else { return }
isScrollingChange = true
let offsetX = emojiPagingScrollView.contentOffset.x
// Scrolled left a page
if offsetX <= previousPageThreshold {
setSelectedEmoji(previousPageEmoji)
// Scrolled right a page
} else if offsetX >= nextPageThreshold {
setSelectedEmoji(nextPageEmoji)
}
isScrollingChange = false
}
}
// MARK: -
extension ReactionsDetailSheet: UIScrollViewDelegate {
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
checkForPageChange()
}
public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
userStartedScrolling()
}
public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
userStoppedScrolling(waitingForDeceleration: decelerate)
}
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
userStoppedScrolling()
}
}