// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit import DifferenceKit import SessionUIKit import SessionMessagingKit import SignalUtilitiesKit final class ReactionListSheet: BaseVC { public struct ReactionSummary: Hashable, Differentiable { let emoji: EmojiWithSkinTones let number: Int let isSelected: Bool var description: String { return "\(emoji.rawValue) · \(number)" } } private let interactionId: Int64 private let onDismiss: (() -> ())? private var messageViewModel: MessageViewModel = MessageViewModel() private var reactionSummaries: [ReactionSummary] = [] private var selectedReactionUserList: [MessageViewModel.ReactionInfo] = [] private var lastSelectedReactionIndex: Int = 0 public var delegate: ReactionDelegate? // MARK: - UI private lazy var contentView: UIView = { let result: UIView = UIView() result.themeBackgroundColor = .backgroundSecondary let line: UIView = UIView() line.themeBackgroundColor = .borderSeparator result.addSubview(line) line.set(.height, to: Values.separatorThickness) line.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing, UIView.VerticalEdge.top ], to: result) return result }() private lazy var layout: UICollectionViewFlowLayout = { let result: UICollectionViewFlowLayout = UICollectionViewFlowLayout() result.scrollDirection = .horizontal result.sectionInset = UIEdgeInsets( top: 0, leading: Values.smallSpacing, bottom: 0, trailing: Values.smallSpacing ) result.minimumLineSpacing = Values.smallSpacing result.minimumInteritemSpacing = Values.smallSpacing result.estimatedItemSize = UICollectionViewFlowLayout.automaticSize return result }() private lazy var reactionContainer: UICollectionView = { let result: UICollectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: layout) result.register(view: Cell.self) result.set(.height, to: 48) result.themeBackgroundColor = .clear result.isScrollEnabled = true result.showsHorizontalScrollIndicator = false result.dataSource = self result.delegate = self return result }() private lazy var detailInfoLabel: UILabel = { let result: UILabel = UILabel() result.font = .systemFont(ofSize: Values.mediumFontSize) result.themeTextColor = .textSecondary result.set(.height, to: 32) return result }() private lazy var clearAllButton: SessionButton = { let result: SessionButton = SessionButton(style: .destructiveBorderless, size: .small) result.translatesAutoresizingMaskIntoConstraints = false result.setTitle("MESSAGE_REQUESTS_CLEAR_ALL".localized(), for: .normal) result.addTarget(self, action: #selector(clearAllTapped), for: .touchUpInside) result.isHidden = true return result }() private lazy var userListView: UITableView = { let result: UITableView = UITableView() result.dataSource = self result.delegate = self result.register(view: SessionCell.self) result.register(view: FooterCell.self) result.separatorStyle = .none result.themeBackgroundColor = .clear result.showsVerticalScrollIndicator = false return result }() // MARK: - Lifecycle init(for interactionId: Int64, onDismiss: (() -> ())? = nil) { self.interactionId = interactionId self.onDismiss = onDismiss super.init(nibName: nil, bundle: nil) } override init(nibName: String?, bundle: Bundle?) { preconditionFailure("Use init(for:) instead.") } required init?(coder: NSCoder) { preconditionFailure("Use init(for:) instead.") } override func viewDidLoad() { super.viewDidLoad() view.themeBackgroundColor = .clear let swipeGestureRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(close)) swipeGestureRecognizer.direction = .down view.addGestureRecognizer(swipeGestureRecognizer) setUpViewHierarchy() } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() reactionContainer.scrollToItem( at: IndexPath(item: lastSelectedReactionIndex, section: 0), at: .centeredHorizontally, animated: false ) } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) self.onDismiss?() } private func setUpViewHierarchy() { view.addSubview(contentView) contentView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing, UIView.VerticalEdge.bottom ], to: view) // Emoji collectionView height + seleted emoji detail height + 5 × user cell height + footer cell height + bottom safe area inset let contentViewHeight: CGFloat = 100 + 5 * 65 + 45 + (UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? 0) contentView.set(.height, to: contentViewHeight) populateContentView() } private func populateContentView() { // Reactions container contentView.addSubview(reactionContainer) reactionContainer.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: contentView) reactionContainer.pin(.top, to: .top, of: contentView, withInset: Values.verySmallSpacing) // Seperator let seperator = UIView() seperator.themeBackgroundColor = .borderSeparator seperator.set(.height, to: 0.5) contentView.addSubview(seperator) seperator.pin(.leading, to: .leading, of: contentView, withInset: Values.smallSpacing) seperator.pin(.trailing, to: .trailing, of: contentView, withInset: -Values.smallSpacing) seperator.pin(.top, to: .bottom, of: reactionContainer, withInset: Values.verySmallSpacing) // Detail info & clear all let stackView = UIStackView(arrangedSubviews: [ detailInfoLabel, clearAllButton ]) contentView.addSubview(stackView) stackView.pin(.top, to: .bottom, of: seperator, withInset: Values.smallSpacing) stackView.pin(.leading, to: .leading, of: contentView, withInset: Values.mediumSpacing) stackView.pin(.trailing, to: .trailing, of: contentView, withInset: -Values.mediumSpacing) // Line let line = UIView() line.set(.height, to: 0.5) line.themeBackgroundColor = .borderSeparator contentView.addSubview(line) line.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: contentView) line.pin(.top, to: .bottom, of: stackView, withInset: Values.smallSpacing) // Reactor list contentView.addSubview(userListView) userListView.pin([ UIView.HorizontalEdge.trailing, UIView.HorizontalEdge.leading, UIView.VerticalEdge.bottom ], to: contentView) userListView.pin(.top, to: .bottom, of: line, withInset: 0) } // MARK: - Content public func handleInteractionUpdates( _ allMessages: [MessageViewModel], selectedReaction: EmojiWithSkinTones? = nil, updatedReactionIndex: Int? = nil, initialLoad: Bool = false, shouldShowClearAllButton: Bool = false ) { guard let cellViewModel: MessageViewModel = allMessages.first(where: { $0.id == self.interactionId }) else { return } // If we have no more reactions (eg. the user removed the last one) then closed the list sheet guard cellViewModel.reactionInfo?.isEmpty == false else { close() return } // Generated the updated data let updatedReactionInfo: OrderedDictionary = (cellViewModel.reactionInfo ?? []) .reduce(into: OrderedDictionary()) { result, reactionInfo in guard let emoji: EmojiWithSkinTones = EmojiWithSkinTones(rawValue: reactionInfo.reaction.emoji) else { return } guard var updatedValue: [MessageViewModel.ReactionInfo] = result.value(forKey: emoji) else { result.append(key: emoji, value: [reactionInfo]) return } if reactionInfo.reaction.authorId == cellViewModel.currentUserPublicKey { updatedValue.insert(reactionInfo, at: 0) } else { updatedValue.append(reactionInfo) } result.replace(key: emoji, value: updatedValue) } let oldSelectedReactionIndex: Int = self.lastSelectedReactionIndex let updatedSelectedReactionIndex: Int = updatedReactionIndex .defaulting( to: { // If we explicitly provided a 'selectedReaction' value then try to use that if selectedReaction != nil, let targetIndex: Int = updatedReactionInfo.orderedKeys.firstIndex(where: { $0 == selectedReaction }) { return targetIndex } // Otherwise try to maintain the index of the currently selected index guard !self.reactionSummaries.isEmpty, let emoji: EmojiWithSkinTones = self.reactionSummaries[safe: oldSelectedReactionIndex]?.emoji, let targetIndex: Int = updatedReactionInfo.orderedKeys.firstIndex(of: emoji) else { return 0 } return targetIndex }() ) let updatedSummaries: [ReactionSummary] = updatedReactionInfo .orderedKeys .enumerated() .map { index, emoji in ReactionSummary( emoji: emoji, number: updatedReactionInfo.value(forKey: emoji) .defaulting(to: []) .map { Int($0.reaction.count) } .reduce(0, +), isSelected: (index == updatedSelectedReactionIndex) ) } // Update the general UI self.detailInfoLabel.text = updatedSummaries[safe: updatedSelectedReactionIndex]?.description // Update general properties self.messageViewModel = cellViewModel self.lastSelectedReactionIndex = updatedSelectedReactionIndex // Ensure the first load or a load when returning from a child screen runs without animations (if // we don't do this the cells will animate in from a frame of CGRect.zero or have a buggy transition) guard !initialLoad else { self.reactionSummaries = updatedSummaries self.selectedReactionUserList = updatedReactionInfo .orderedKeys[safe: updatedSelectedReactionIndex] .map { updatedReactionInfo.value(forKey: $0) } .defaulting(to: []) // Update clear all button visibility self.clearAllButton.isHidden = !shouldShowClearAllButton UIView.performWithoutAnimation { self.reactionContainer.reloadData() self.userListView.reloadData() } return } // Update the collection view content let collectionViewChangeset: StagedChangeset<[ReactionSummary]> = StagedChangeset( source: self.reactionSummaries, target: updatedSummaries ) // If there are changes then we want to reload both the collection and table views self.reactionContainer.reload( using: collectionViewChangeset, interrupt: { $0.changeCount > 1 } ) { [weak self] updatedData in self?.reactionSummaries = updatedData } // If we changed the selected index then no need to reload the changes guard oldSelectedReactionIndex == updatedSelectedReactionIndex && self.reactionSummaries[safe: oldSelectedReactionIndex]?.emoji == updatedSummaries[safe: updatedSelectedReactionIndex]?.emoji else { self.selectedReactionUserList = updatedReactionInfo .orderedKeys[safe: updatedSelectedReactionIndex] .map { updatedReactionInfo.value(forKey: $0) } .defaulting(to: []) self.userListView.reloadData() return } let tableChangeset: StagedChangeset<[MessageViewModel.ReactionInfo]> = StagedChangeset( source: self.selectedReactionUserList, target: updatedReactionInfo .orderedKeys[safe: updatedSelectedReactionIndex] .map { updatedReactionInfo.value(forKey: $0) } .defaulting(to: []) ) self.userListView.reload( using: tableChangeset, deleteSectionsAnimation: .none, insertSectionsAnimation: .none, reloadSectionsAnimation: .none, deleteRowsAnimation: .none, insertRowsAnimation: .none, reloadRowsAnimation: .none, interrupt: { [weak self] changeset in /// This is the case where there were 6 reactors in total and locally we only have 5 including current user, /// and current user remove the reaction. There would be 4 reactors locally and we need to show more /// reactors cell at this moment. After update from sogs, we'll get the all 5 reactors and update the table /// with 5 reactors and not showing the more reactors cell. changeset.elementInserted.count == 1 && self?.selectedReactionUserList.count == 4 || /// This is the case where there were 5 reactors without current user, and current user reacted. Before we got /// the update from sogs, we'll have 6 reactors locally and not showing the more reactors cell. After the update, /// we'll need to update the table and show 5 reactors with the more reactors cell. changeset.elementDeleted.count == 1 && self?.selectedReactionUserList.count == 6 || /// To many changes to make changeset.changeCount > 100 } ) { [weak self] updatedData in self?.selectedReactionUserList = updatedData } } // MARK: - Interaction override func touchesBegan(_ touches: Set, with event: UIEvent?) { guard let touch: UITouch = touches.first, contentView.frame.contains(touch.location(in: view)) else { close() return } super.touchesBegan(touches, with: event) } @objc func close() { dismiss(animated: true, completion: nil) } @objc private func clearAllTapped() { guard let selectedReaction: EmojiWithSkinTones = self.reactionSummaries.first(where: { $0.isSelected })?.emoji else { return } delegate?.removeAllReactions(messageViewModel, for: selectedReaction.rawValue) } } // MARK: - UICollectionView extension ReactionListSheet: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { // MARK: Data Source func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return self.reactionSummaries.count } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell: Cell = collectionView.dequeue(type: Cell.self, for: indexPath) let summary: ReactionSummary = self.reactionSummaries[indexPath.item] cell.update( with: summary.emoji.rawValue, count: summary.number, isCurrentSelection: summary.isSelected ) return cell } // MARK: Interaction func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { self.handleInteractionUpdates([messageViewModel], updatedReactionIndex: indexPath.item) } } // MARK: - UITableViewDelegate & UITableViewDataSource extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { let moreReactorCount = self.reactionSummaries[lastSelectedReactionIndex].number - self.selectedReactionUserList.count return moreReactorCount > 0 ? self.selectedReactionUserList.count + 1 : self.selectedReactionUserList.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { guard indexPath.row < self.selectedReactionUserList.count else { let moreReactorCount = self.reactionSummaries[lastSelectedReactionIndex].number - self.selectedReactionUserList.count let footerCell: FooterCell = tableView.dequeue(type: FooterCell.self, for: indexPath) footerCell.update( moreReactorCount: moreReactorCount, emoji: self.reactionSummaries[lastSelectedReactionIndex].emoji.rawValue ) footerCell.selectionStyle = .none return footerCell } let cell: SessionCell = tableView.dequeue(type: SessionCell.self, for: indexPath) let cellViewModel: MessageViewModel.ReactionInfo = self.selectedReactionUserList[indexPath.row] let authorId: String = cellViewModel.reaction.authorId cell.update( with: SessionCell.Info( id: cellViewModel, position: Position.with(indexPath.row, count: self.selectedReactionUserList.count), leftAccessory: .profile(id: authorId, profile: cellViewModel.profile), title: ( cellViewModel.profile?.displayName() ?? Profile.truncated( id: authorId, threadVariant: self.messageViewModel.threadVariant ) ), rightAccessory: (authorId != self.messageViewModel.currentUserPublicKey ? nil : .icon( UIImage(named: "X")? .withRenderingMode(.alwaysTemplate), size: .fit ) ), styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge), isEnabled: (authorId == self.messageViewModel.currentUserPublicKey) ) ) return cell } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) guard indexPath.row < self.selectedReactionUserList.count else { return } let cellViewModel: MessageViewModel.ReactionInfo = self.selectedReactionUserList[indexPath.row] guard let selectedReaction: EmojiWithSkinTones = self.reactionSummaries .first(where: { $0.isSelected })? .emoji, selectedReaction.rawValue == cellViewModel.reaction.emoji, cellViewModel.reaction.authorId == self.messageViewModel.currentUserPublicKey else { return } delegate?.removeReact(self.messageViewModel, for: selectedReaction) } } // MARK: - Cell extension ReactionListSheet { fileprivate final class Cell: UICollectionViewCell { // MARK: - UI private static var contentViewHeight: CGFloat = 32 private static var contentViewCornerRadius: CGFloat { contentViewHeight / 2 } private lazy var snContentView: UIView = { let result = UIView() result.themeBackgroundColor = .messageBubble_incomingBackground result.layer.cornerRadius = Cell.contentViewCornerRadius result.layer.borderWidth = 1 // Intentionally 1pt (instead of 'Values.separatorThickness') result.set(.height, to: Cell.contentViewHeight) return result }() private lazy var emojiLabel: UILabel = { let result: UILabel = UILabel() result.font = .systemFont(ofSize: Values.mediumFontSize) return result }() private lazy var numberLabel: UILabel = { let result: UILabel = UILabel() result.font = .systemFont(ofSize: Values.mediumFontSize) result.themeTextColor = .textPrimary return result }() // MARK: - Initialization override init(frame: CGRect) { super.init(frame: frame) setUpViewHierarchy() } required init?(coder: NSCoder) { super.init(coder: coder) setUpViewHierarchy() } private func setUpViewHierarchy() { addSubview(snContentView) let stackView = UIStackView(arrangedSubviews: [ emojiLabel, numberLabel ]) stackView.axis = .horizontal stackView.alignment = .center let spacing = Values.smallSpacing + 2 stackView.spacing = spacing stackView.layoutMargins = UIEdgeInsets(top: 0, left: spacing, bottom: 0, right: spacing) stackView.isLayoutMarginsRelativeArrangement = true snContentView.addSubview(stackView) stackView.pin(to: snContentView) snContentView.pin(to: self) } // MARK: - Content fileprivate func update( with emoji: String, count: Int, isCurrentSelection: Bool ) { emojiLabel.text = emoji numberLabel.text = (count < 1000 ? "\(count)" : String(format: "%.1fk", Float(count) / 1000) ) snContentView.themeBorderColor = (isCurrentSelection ? .primary : .clear) } } fileprivate final class FooterCell: UITableViewCell { private lazy var label: UILabel = { let result: UILabel = UILabel() result.font = .systemFont(ofSize: Values.smallFontSize) result.themeTextColor = .textSecondary result.textAlignment = .center return result }() // MARK: - Initialization override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) setUpViewHierarchy() } required init?(coder: NSCoder) { super.init(coder: coder) setUpViewHierarchy() } private func setUpViewHierarchy() { // Background color themeBackgroundColor = .backgroundSecondary contentView.addSubview(label) label.pin(to: contentView) label.set(.height, to: 45) } func update(moreReactorCount: Int, emoji: String) { label.text = (moreReactorCount == 1 ? String(format: "EMOJI_REACTS_MORE_REACTORS_ONE".localized(), "\(emoji)") : String(format: "EMOJI_REACTS_MORE_REACTORS_MUTIPLE".localized(), "\(moreReactorCount)" ,"\(emoji)") ) } } } // MARK: - Delegate protocol ReactionDelegate: AnyObject { func react(_ cellViewModel: MessageViewModel, with emoji: EmojiWithSkinTones) func removeReact(_ cellViewModel: MessageViewModel, for emoji: EmojiWithSkinTones) func removeAllReactions(_ cellViewModel: MessageViewModel, for emoji: String) }