diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 68c1ca8a5..c7953fdac 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -134,6 +134,9 @@ 7B1581E4271FC59D00848B49 /* CallModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1581E3271FC59C00848B49 /* CallModal.swift */; }; 7B1581E6271FD2A100848B49 /* VideoPreviewVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1581E5271FD2A100848B49 /* VideoPreviewVC.swift */; }; 7B1581E827210ECC00848B49 /* RenderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1581E727210ECC00848B49 /* RenderView.swift */; }; + 7B1B52D828580C6D006069F2 /* EmojiPickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1B52D728580C6D006069F2 /* EmojiPickerSheet.swift */; }; + 7B1B52DF28580D51006069F2 /* EmojiPickerCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1B52DB28580D50006069F2 /* EmojiPickerCollectionView.swift */; }; + 7B1B52E028580D51006069F2 /* EmojiSkinTonePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1B52DC28580D50006069F2 /* EmojiSkinTonePicker.swift */; }; 7B1D74AA27BCC16E0030B423 /* NSENotificationPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1D74A927BCC16E0030B423 /* NSENotificationPresenter.swift */; }; 7B1D74AC27BDE7510030B423 /* Promise+Timeout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1D74AB27BDE7510030B423 /* Promise+Timeout.swift */; }; 7B1D74B027C365960030B423 /* Timer+MainThread.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1D74AF27C365960030B423 /* Timer+MainThread.swift */; }; @@ -1138,6 +1141,9 @@ 7B1581E3271FC59C00848B49 /* CallModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallModal.swift; sourceTree = ""; }; 7B1581E5271FD2A100848B49 /* VideoPreviewVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPreviewVC.swift; sourceTree = ""; }; 7B1581E727210ECC00848B49 /* RenderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderView.swift; sourceTree = ""; }; + 7B1B52D728580C6D006069F2 /* EmojiPickerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerSheet.swift; sourceTree = ""; }; + 7B1B52DB28580D50006069F2 /* EmojiPickerCollectionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmojiPickerCollectionView.swift; sourceTree = ""; }; + 7B1B52DC28580D50006069F2 /* EmojiSkinTonePicker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmojiSkinTonePicker.swift; sourceTree = ""; }; 7B1D74A927BCC16E0030B423 /* NSENotificationPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSENotificationPresenter.swift; sourceTree = ""; }; 7B1D74AB27BDE7510030B423 /* Promise+Timeout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Promise+Timeout.swift"; sourceTree = ""; }; 7B1D74AF27C365960030B423 /* Timer+MainThread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Timer+MainThread.swift"; sourceTree = ""; }; @@ -2100,6 +2106,9 @@ 7B1B52BD2851ADE1006069F2 /* Emoji Picker */ = { isa = PBXGroup; children = ( + 7B1B52DB28580D50006069F2 /* EmojiPickerCollectionView.swift */, + 7B1B52DC28580D50006069F2 /* EmojiSkinTonePicker.swift */, + 7B1B52D728580C6D006069F2 /* EmojiPickerSheet.swift */, ); path = "Emoji Picker"; sourceTree = ""; @@ -4970,7 +4979,9 @@ 3496955E219B605E00DCFE74 /* PhotoLibrary.swift in Sources */, C3A76A8D25DB83F90074CB90 /* PermissionMissingModal.swift in Sources */, 340FC8A9204DAC8D007AEB0F /* NotificationSettingsOptionsViewController.m in Sources */, + 7B1B52E028580D51006069F2 /* EmojiSkinTonePicker.swift in Sources */, B849789625D4A2F500D0D0B3 /* LinkPreviewView.swift in Sources */, + 7B1B52DF28580D51006069F2 /* EmojiPickerCollectionView.swift in Sources */, C3D0972B2510499C00F6E3E4 /* BackgroundPoller.swift in Sources */, C3548F0624456447009433A8 /* PNModeVC.swift in Sources */, B80A579F23DFF1F300876683 /* NewClosedGroupVC.swift in Sources */, @@ -5095,6 +5106,7 @@ B894D0752339EDCF00B4D94D /* NukeDataModal.swift in Sources */, 7B93D06D27CF175800811CB6 /* MessageRequestsCell.swift in Sources */, 7B93D07727CF1A8A00811CB6 /* MockDataGenerator.swift in Sources */, + 7B1B52D828580C6D006069F2 /* EmojiPickerSheet.swift in Sources */, 7B4C75CD26BB92060000AC89 /* DeletedMessageView.swift in Sources */, B897621C25D201F7004F83B2 /* ScrollToBottomButton.swift in Sources */, 346B66311F4E29B200E5122F /* CropScaleImageViewController.swift in Sources */, diff --git a/Session/Conversations/Context Menu/ContextMenuVC+EmojiReactsView.swift b/Session/Conversations/Context Menu/ContextMenuVC+EmojiReactsView.swift index 8b90c0812..031c216e2 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+EmojiReactsView.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+EmojiReactsView.swift @@ -89,7 +89,7 @@ extension ContextMenuVC { // MARK: Interaction @objc private func handleTap() { dismiss() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: { [weak self] in self?.work() }) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 8b74dd596..b16e8871b 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -51,8 +51,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc let call = SessionCall(for: contactSessionID, uuid: UUID().uuidString.lowercased(), mode: .offer, outgoing: true) let callVC = CallVC(for: call) callVC.conversationVC = self - self.inputAccessoryView?.isHidden = true - self.inputAccessoryView?.alpha = 0 + hideInputAccessoryView() present(callVC, animated: true, completion: nil) } else { let callPermissionRequestModal = CallPermissionRequestModal() @@ -497,6 +496,11 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc self.oldText = newText } + func hideInputAccessoryView() { + self.inputAccessoryView?.isHidden = true + self.inputAccessoryView?.alpha = 0 + } + func showInputAccessoryView() { UIView.animate(withDuration: 0.25, animations: { self.inputAccessoryView?.isHidden = false @@ -742,8 +746,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc } alertVC.addAction(cancelAction) - self.inputAccessoryView?.isHidden = true - self.inputAccessoryView?.alpha = 0 + hideInputAccessoryView() self.presentAlert(alertVC) } else { deleteLocally(viewItem) @@ -878,10 +881,18 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc } func showFullEmojiKeyboard(_ viewItem: ConversationViewItem) { - // TODO: to be implemented - - print("Ryan Test: showFullEmojiKeyboard") - + hideInputAccessoryView() + let emojiPicker = EmojiPickerSheet( + completionHandler: { emoji in + if let emoji = emoji { + self.react(viewItem, with: emoji.rawValue) + } + }, + dismissHandler: { + self.showInputAccessoryView() + }) + emojiPicker.modalPresentationStyle = .overFullScreen + present(emojiPicker, animated: true, completion: nil) } func contextMenuDismissed() { diff --git a/Session/Conversations/Emoji Picker/EmojiPickerCollectionView.swift b/Session/Conversations/Emoji Picker/EmojiPickerCollectionView.swift new file mode 100644 index 000000000..68bd7061b --- /dev/null +++ b/Session/Conversations/Emoji Picker/EmojiPickerCollectionView.swift @@ -0,0 +1,395 @@ + +protocol EmojiPickerCollectionViewDelegate: AnyObject { + func emojiPicker(_ emojiPicker: EmojiPickerCollectionView, didSelectEmoji emoji: EmojiWithSkinTones) + func emojiPickerWillBeginDragging(_ emojiPicker: EmojiPickerCollectionView) +} + +class EmojiPickerCollectionView: UICollectionView { + let layout: UICollectionViewFlowLayout + + private static let emojiPickerCollection = "EmojiPickerCollection" + private static let recentEmojiKey = "recentEmoji" + + weak var pickerDelegate: EmojiPickerCollectionViewDelegate? + + private var recentEmoji: [EmojiWithSkinTones] = [] + var hasRecentEmoji: Bool { !recentEmoji.isEmpty } + + private var allSendableEmojiByCategory: [Emoji.Category: [EmojiWithSkinTones]] = [:] + private lazy var allSendableEmoji: [EmojiWithSkinTones] = { + return Array(allSendableEmojiByCategory.values).flatMap({$0}) + }() + + static let emojiWidth: CGFloat = 38 + static let margins: CGFloat = 16 + static let minimumSpacing: CGFloat = 10 + + public var searchText: String? { + didSet { + searchWithText(searchText) + } + } + + private var emojiSearchResults: [EmojiWithSkinTones] = [] + + public var isSearching: Bool { + if let searchText = searchText, searchText.count != 0 { + return true + } + + return false + } + + lazy var tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(dismissSkinTonePicker)) + + init() { + layout = UICollectionViewFlowLayout() + layout.itemSize = CGSize(width: Self.emojiWidth, height: Self.emojiWidth) + layout.minimumInteritemSpacing = EmojiPickerCollectionView.minimumSpacing + layout.sectionInset = UIEdgeInsets(top: 0, leading: EmojiPickerCollectionView.margins, bottom: 0, trailing: EmojiPickerCollectionView.margins) + + super.init(frame: .zero, collectionViewLayout: layout) + + delegate = self + dataSource = self + + register(EmojiCell.self, forCellWithReuseIdentifier: EmojiCell.reuseIdentifier) + register( + EmojiSectionHeader.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: EmojiSectionHeader.reuseIdentifier + ) + + backgroundColor = isDarkMode ? .ows_gray90 : .ows_white + + let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress)) + panGestureRecognizer.require(toFail: longPressGesture) + addGestureRecognizer(longPressGesture) + + addGestureRecognizer(tapGestureRecognizer) + tapGestureRecognizer.delegate = self + + Storage.read { transaction in + let rawRecentEmoji = transaction.object( + forKey: EmojiPickerCollectionView.recentEmojiKey, + inCollection: EmojiPickerCollectionView.emojiPickerCollection + ) as? [String] ?? [] + + self.recentEmoji = rawRecentEmoji.compactMap { EmojiWithSkinTones(rawValue: $0) } + + // Some emoji have two different code points but identical appearances. Let's remove them! + // If we normalize to a different emoji than the one currently in our array, we want to drop + // the non-normalized variant if the normalized variant already exists. Otherwise, map to the + // normalized variant. + for (idx, emoji) in self.recentEmoji.enumerated().reversed() { + if !emoji.isNormalized { + if self.recentEmoji.contains(emoji.normalized) { + self.recentEmoji.remove(at: idx) + } else { + self.recentEmoji[idx] = emoji.normalized + } + } + } + + self.allSendableEmojiByCategory = Emoji.allSendableEmojiByCategoryWithPreferredSkinTones(transaction: transaction) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // This is not an exact calculation, but is simple and works for our purposes. + var numberOfColumns: Int { Int((self.width()) / (EmojiPickerCollectionView.emojiWidth + EmojiPickerCollectionView.minimumSpacing)) } + + // At max, we show 3 rows of recent emoji + private var maxRecentEmoji: Int { numberOfColumns * 3 } + private var categoryIndexOffset: Int { hasRecentEmoji ? 1 : 0} + + func emojiForSection(_ section: Int) -> [EmojiWithSkinTones] { + guard section > 0 || !hasRecentEmoji else { return Array(recentEmoji[0.. EmojiWithSkinTones? { + return isSearching ? emojiSearchResults[safe: indexPath.row] : emojiForSection(indexPath.section)[safe: indexPath.row] + } + + func nameForSection(_ section: Int) -> String? { + guard section > 0 || !hasRecentEmoji else { + return NSLocalizedString("EMOJI_CATEGORY_RECENTS_NAME", + comment: "The name for the emoji category 'Recents'") + } + + guard let category = Emoji.Category.allCases[safe: section - categoryIndexOffset] else { + owsFailDebug("Unexpectedly missing category for section \(section)") + return nil + } + + return category.localizedName + } + + func recordRecentEmoji(_ emoji: EmojiWithSkinTones, transaction: YapDatabaseReadWriteTransaction) { + guard recentEmoji.first != emoji else { return } + guard emoji.isNormalized else { + recordRecentEmoji(emoji.normalized, transaction: transaction) + return + } + + var newRecentEmoji = recentEmoji + + // Remove any existing entries for this emoji + newRecentEmoji.removeAll { emoji == $0 } + // Insert the selected emoji at the start of the list + newRecentEmoji.insert(emoji, at: 0) + // Truncate the recent emoji list to a maximum of 50 stored + newRecentEmoji = Array(newRecentEmoji[0.. Bool { + if gestureRecognizer == tapGestureRecognizer { + return currentSkinTonePicker != nil + } + + return true + } +} + +extension EmojiPickerCollectionView: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let emoji = emojiForIndexPath(indexPath) else { + return owsFailDebug("Missing emoji for indexPath \(indexPath)") + } + + Storage.write { transaction in + self.recordRecentEmoji(emoji, transaction: transaction) + } + + pickerDelegate?.emojiPicker(self, didSelectEmoji: emoji) + } +} + +extension EmojiPickerCollectionView: UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return isSearching ? emojiSearchResults.count : emojiForSection(section).count + } + + func numberOfSections(in collectionView: UICollectionView) -> Int { + return isSearching ? 1 : Emoji.Category.allCases.count + categoryIndexOffset + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = dequeueReusableCell(withReuseIdentifier: EmojiCell.reuseIdentifier, for: indexPath) + + guard let emojiCell = cell as? EmojiCell else { + owsFailDebug("unexpected cell type") + return cell + } + + guard let emoji = emojiForIndexPath(indexPath) else { + owsFailDebug("unexpected indexPath") + return cell + } + + emojiCell.configure(emoji: emoji) + + return cell + } + + func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { + + let supplementaryView = dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: EmojiSectionHeader.reuseIdentifier, + for: indexPath + ) + + guard let sectionHeader = supplementaryView as? EmojiSectionHeader else { + owsFailDebug("unexpected supplementary view type") + return supplementaryView + } + + sectionHeader.label.text = nameForSection(indexPath.section) + + return sectionHeader + } +} + +extension EmojiPickerCollectionView: UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + referenceSizeForHeaderInSection section: Int) -> CGSize { + guard !isSearching else { + return CGSize.zero + } + + let measureCell = EmojiSectionHeader() + measureCell.label.text = nameForSection(section) + return measureCell.sizeThatFits(CGSize(width: self.width(), height: .greatestFiniteMagnitude)) + } +} + +private class EmojiCell: UICollectionViewCell { + static let reuseIdentifier = "EmojiCell" + + let emojiLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + + backgroundColor = .clear + + emojiLabel.font = .boldSystemFont(ofSize: 32) + contentView.addSubview(emojiLabel) + emojiLabel.autoPinEdgesToSuperviewEdges() + + // For whatever reason, some emoji glyphs occasionally have different typographic widths on certain devices + // e.g. 👩‍🦰: 36x38.19, 👱‍♀️: 40x38. (See: commit message for more info) + // To workaround this, we can clip the label instead of truncating. It appears to only clip the additional + // typographic space. In either case, it's better than truncating and seeing an ellipsis. + emojiLabel.lineBreakMode = .byClipping + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(emoji: EmojiWithSkinTones) { + emojiLabel.text = emoji.rawValue + } +} + +private class EmojiSectionHeader: UICollectionReusableView { + static let reuseIdentifier = "EmojiSectionHeader" + + let label = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + + layoutMargins = UIEdgeInsets( + top: 16, + leading: EmojiPickerCollectionView.margins, + bottom: 6, + trailing: EmojiPickerCollectionView.margins + ) + + label.font = .systemFont(ofSize: Values.smallFontSize) + label.textColor = Colors.text + addSubview(label) + label.autoPinEdgesToSuperviewMargins() + label.setCompressionResistanceHigh() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func sizeThatFits(_ size: CGSize) -> CGSize { + var labelSize = label.sizeThatFits(size) + labelSize.width += layoutMargins.left + layoutMargins.right + labelSize.height += layoutMargins.top + layoutMargins.bottom + return labelSize + } +} + +fileprivate extension EmojiWithSkinTones { + + var normalized: EmojiWithSkinTones { + switch (baseEmoji, skinTones) { + case (let base, nil) where base.normalized != base: + return EmojiWithSkinTones(baseEmoji: base.normalized) + default: + return self + } + } + + var isNormalized: Bool { self == normalized } + +} diff --git a/Session/Conversations/Emoji Picker/EmojiPickerSheet.swift b/Session/Conversations/Emoji Picker/EmojiPickerSheet.swift new file mode 100644 index 000000000..64a692d48 --- /dev/null +++ b/Session/Conversations/Emoji Picker/EmojiPickerSheet.swift @@ -0,0 +1,128 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +class EmojiPickerSheet: BaseVC { + let completionHandler: (EmojiWithSkinTones?) -> Void + let dismissHandler: () -> Void + + // MARK: Components + + private lazy var contentView: UIView = { + let result = UIView() + let line = UIView() + line.set(.height, to: 0.5) + line.backgroundColor = Colors.border.withAlphaComponent(0.5) + result.addSubview(line) + line.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing, UIView.VerticalEdge.top ], to: result) + result.backgroundColor = Colors.modalBackground + return result + }() + + private let collectionView = EmojiPickerCollectionView() + + private lazy var searchBar: SearchBar = { + let result = SearchBar() + result.tintColor = Colors.text + result.backgroundColor = isDarkMode ? .ows_gray90 : .ows_white + result.delegate = self + return result + }() + + // MARK: Lifecycle + + init(completionHandler: @escaping (EmojiWithSkinTones?) -> Void, dismissHandler: @escaping () -> Void) { + self.completionHandler = completionHandler + self.dismissHandler = dismissHandler + super.init(nibName: nil, bundle: nil) + } + + public required init() { + fatalError("init() has not been implemented") + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func viewDidLoad() { + super.viewDidLoad() + setUpViewHierarchy() + } + + private func setUpViewHierarchy() { + view.addSubview(contentView) + contentView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing, UIView.VerticalEdge.bottom ], to: view) + contentView.set(.height, to: 440) + populateContentView() + } + + private func populateContentView() { + let topStackView = UIStackView() + topStackView.axis = .horizontal + topStackView.isLayoutMarginsRelativeArrangement = true + topStackView.spacing = 8 + + topStackView.addArrangedSubview(searchBar) + + contentView.addSubview(topStackView) + + topStackView.autoPinWidthToSuperview() + topStackView.autoPinEdge(toSuperviewEdge: .top) + + contentView.addSubview(collectionView) + collectionView.autoPinEdge(.top, to: .bottom, of: searchBar) + collectionView.autoPinEdge(.bottom, to: .bottom, of: contentView) + collectionView.autoPinWidthToSuperview() + collectionView.pickerDelegate = self + collectionView.alwaysBounceVertical = true + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + coordinator.animate(alongsideTransition: { _ in + self.collectionView.reloadData() + }, completion: nil) + } + + public override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + // Ensure the scrollView's layout has completed + // as we're about to use its bounds to calculate + // the masking view and contentOffset. + contentView.layoutIfNeeded() + } + + // MARK: Interaction + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + let touch = touches.first! + let location = touch.location(in: view) + if contentView.frame.contains(location) { + super.touchesBegan(touches, with: event) + } else { + close() + } + } + + @objc func close() { + dismiss(animated: true, completion: dismissHandler) + } +} + +extension EmojiPickerSheet: EmojiPickerCollectionViewDelegate { + func emojiPickerWillBeginDragging(_ emojiPicker: EmojiPickerCollectionView) { + searchBar.resignFirstResponder() + } + + func emojiPicker(_ emojiPicker: EmojiPickerCollectionView, didSelectEmoji emoji: EmojiWithSkinTones) { + completionHandler(emoji) + dismiss(animated: true, completion: dismissHandler) + } +} + +extension EmojiPickerSheet: UISearchBarDelegate { + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + collectionView.searchText = searchText + } +} + diff --git a/Session/Conversations/Emoji Picker/EmojiSkinTonePicker.swift b/Session/Conversations/Emoji Picker/EmojiSkinTonePicker.swift new file mode 100644 index 000000000..a6efa3361 --- /dev/null +++ b/Session/Conversations/Emoji Picker/EmojiSkinTonePicker.swift @@ -0,0 +1,305 @@ + +import Foundation + +class EmojiSkinTonePicker: UIView { + let emoji: Emoji + let preferredSkinTonePermutation: [Emoji.SkinTone]? + let completion: (EmojiWithSkinTones?) -> Void + + private let referenceOverlay = UIView() + private let containerView = UIView() + + class func present( + referenceView: UIView, + emoji: EmojiWithSkinTones, + completion: @escaping (EmojiWithSkinTones?) -> Void + ) -> EmojiSkinTonePicker? { + guard emoji.baseEmoji.hasSkinTones else { return nil } + + UIImpactFeedbackGenerator(style: .light).impactOccurred() + + let picker = EmojiSkinTonePicker(emoji: emoji, completion: completion) + + guard let superview = referenceView.superview else { + owsFailDebug("reference is missing superview") + return nil + } + + superview.addSubview(picker) + + picker.referenceOverlay.autoMatch(.width, to: .width, of: referenceView) + picker.referenceOverlay.autoMatch(.height, to: .height, of: referenceView, withOffset: 30) + picker.referenceOverlay.autoPinEdge(.leading, to: .leading, of: referenceView) + + let leadingConstraint = picker.autoPinEdge(toSuperviewEdge: .leading) + + picker.layoutIfNeeded() + + let halfWidth = picker.width() / 2 + let margin: CGFloat = 8 + + if (halfWidth + margin) > referenceView.center.x { + leadingConstraint.constant = margin + } else if (halfWidth + margin) > (superview.width() - referenceView.center.x) { + leadingConstraint.constant = superview.width() - picker.width() - margin + } else { + leadingConstraint.constant = referenceView.center.x - halfWidth + } + + let distanceFromTop = referenceView.frame.minY - superview.bounds.minY + if distanceFromTop > picker.containerView.height() { + picker.containerView.autoPinEdge(toSuperviewEdge: .top) + picker.referenceOverlay.autoPinEdge(.top, to: .bottom, of: picker.containerView, withOffset: -20) + picker.referenceOverlay.autoPinEdge(toSuperviewEdge: .bottom) + picker.autoPinEdge(.bottom, to: .bottom, of: referenceView) + } else { + picker.containerView.autoPinEdge(toSuperviewEdge: .bottom) + picker.referenceOverlay.autoPinEdge(.bottom, to: .top, of: picker.containerView, withOffset: 20) + picker.referenceOverlay.autoPinEdge(toSuperviewEdge: .top) + picker.autoPinEdge(.top, to: .top, of: referenceView) + } + + picker.alpha = 0 + UIView.animate(withDuration: 0.12) { picker.alpha = 1 } + + return picker + } + + func dismiss() { + UIView.animate(withDuration: 0.12, animations: { self.alpha = 0 }) { _ in + self.removeFromSuperview() + } + } + + func didChangeLongPress(_ sender: UILongPressGestureRecognizer) { + guard let singleSelectionButtons = singleSelectionButtons else { return } + + if referenceOverlay.frame.contains(sender.location(in: self)) { + singleSelectionButtons.forEach { $0.isSelected = false } + } else { + let point = sender.location(in: containerView) + let previouslySelectedButton = singleSelectionButtons.first { $0.isSelected } + singleSelectionButtons.forEach { $0.isSelected = $0.frame.insetBy(dx: -3, dy: -80).contains(point) } + let selectedButton = singleSelectionButtons.first { $0.isSelected } + + if let selectedButton = selectedButton, selectedButton != previouslySelectedButton { + SelectionHapticFeedback().selectionChanged() + } + } + } + + func didEndLongPress(_ sender: UILongPressGestureRecognizer) { + guard let singleSelectionButtons = singleSelectionButtons else { return } + + let point = sender.location(in: containerView) + if referenceOverlay.frame.contains(sender.location(in: self)) { + // Do nothing. + } else if let selectedButton = singleSelectionButtons.first(where: { + $0.frame.insetBy(dx: -3, dy: -80).contains(point) + }) { + selectedButton.sendActions(for: .touchUpInside) + } else { + dismiss() + } + } + + init(emoji: EmojiWithSkinTones, completion: @escaping (EmojiWithSkinTones?) -> Void) { + owsAssertDebug(emoji.baseEmoji.hasSkinTones) + + self.emoji = emoji.baseEmoji + self.preferredSkinTonePermutation = emoji.skinTones + self.completion = completion + + super.init(frame: .zero) + + layer.shadowOffset = .zero + layer.shadowOpacity = 0.25 + layer.shadowRadius = 4 + + referenceOverlay.backgroundColor = isDarkMode ? .ows_gray75 : .ows_white + referenceOverlay.layer.cornerRadius = 9 + addSubview(referenceOverlay) + + containerView.layoutMargins = UIEdgeInsets(top: 9, leading: 16, bottom: 9, trailing: 16) + containerView.backgroundColor = isDarkMode ? .ows_gray75 : .ows_white + containerView.layer.cornerRadius = 11 + addSubview(containerView) + containerView.autoPinWidthToSuperview() + containerView.setCompressionResistanceHigh() + + if emoji.baseEmoji.allowsMultipleSkinTones { + prepareForMultipleSkinTones() + } else { + prepareForSingleSkinTone() + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Single Skin Tone + + private lazy var yellowEmoji = EmojiWithSkinTones(baseEmoji: emoji, skinTones: nil) + private lazy var yellowButton = button(for: yellowEmoji) { [weak self] emojiWithSkinTone in + self?.completion(emojiWithSkinTone) + } + + private var singleSelectionButtons: [UIButton]? + private func prepareForSingleSkinTone() { + let hStack = UIStackView() + hStack.axis = .horizontal + hStack.spacing = 8 + containerView.addSubview(hStack) + hStack.autoPinEdgesToSuperviewMargins() + + hStack.addArrangedSubview(yellowButton) + + hStack.addArrangedSubview(.spacer(withWidth: 2)) + + let divider = UIView() + divider.autoSetDimension(.width, toSize: 1) + divider.backgroundColor = isDarkMode ? .ows_gray75 : .ows_gray05 + hStack.addArrangedSubview(divider) + + hStack.addArrangedSubview(.spacer(withWidth: 2)) + + let skinToneButtons = self.skinToneButtons(for: emoji) { [weak self] emojiWithSkinTone in + self?.completion(emojiWithSkinTone) + } + + singleSelectionButtons = skinToneButtons.map { $0.button } + singleSelectionButtons?.forEach { hStack.addArrangedSubview($0) } + singleSelectionButtons?.append(yellowButton) + } + + // MARK: - Multiple Skin Tones + + private lazy var skinToneComponentEmoji: [Emoji] = { + guard let skinToneComponentEmoji = emoji.skinToneComponentEmoji else { + owsFailDebug("missing skin tone component emoji \(emoji)") + return [] + } + return skinToneComponentEmoji + }() + + private var buttonsPerComponentEmojiIndex = [Int: [(Emoji.SkinTone, UIButton)]]() + private lazy var skinToneButton = button(for: EmojiWithSkinTones( + baseEmoji: emoji, + skinTones: .init(repeating: .medium, count: skinToneComponentEmoji.count) + )) { [weak self] _ in + guard let self = self else { return } + guard self.selectedSkinTones.count == self.skinToneComponentEmoji.count else { return } + self.completion(EmojiWithSkinTones(baseEmoji: self.emoji, skinTones: self.selectedSkinTones)) + } + + private var selectedSkinTones = [Emoji.SkinTone]() { + didSet { + if selectedSkinTones.count == skinToneComponentEmoji.count { + skinToneButton.setTitle( + EmojiWithSkinTones( + baseEmoji: emoji, + skinTones: selectedSkinTones + ).rawValue, + for: .normal + ) + skinToneButton.isEnabled = true + skinToneButton.alpha = 1 + } else { + skinToneButton.setTitle( + EmojiWithSkinTones( + baseEmoji: emoji, + skinTones: [.medium] + ).rawValue, + for: .normal + ) + skinToneButton.isEnabled = false + skinToneButton.alpha = 0.2 + } + } + } + + private var skinTonePerComponentEmojiIndex = [Int: Emoji.SkinTone]() { + didSet { + var selectedSkinTones = [Emoji.SkinTone]() + for idx in skinToneComponentEmoji.indices { + for (skinTone, button) in buttonsPerComponentEmojiIndex[idx] ?? [] { + if skinTonePerComponentEmojiIndex[idx] == skinTone { + selectedSkinTones.append(skinTone) + button.isSelected = true + } else { + button.isSelected = false + } + } + } + self.selectedSkinTones = selectedSkinTones + } + } + + private func prepareForMultipleSkinTones() { + let vStack = UIStackView() + vStack.axis = .vertical + vStack.spacing = 6 + containerView.addSubview(vStack) + vStack.autoPinEdgesToSuperviewMargins() + + for (idx, emoji) in skinToneComponentEmoji.enumerated() { + let skinToneButtons = self.skinToneButtons(for: emoji) { [weak self] emojiWithSkinTone in + self?.skinTonePerComponentEmojiIndex[idx] = emojiWithSkinTone.skinTones?.first + } + buttonsPerComponentEmojiIndex[idx] = skinToneButtons + + let hStack = UIStackView(arrangedSubviews: skinToneButtons.map { $0.button }) + hStack.axis = .horizontal + hStack.spacing = 6 + vStack.addArrangedSubview(hStack) + + skinTonePerComponentEmojiIndex[idx] = preferredSkinTonePermutation?[safe: idx] + + // If there's only one preferred skin tone, all the component emoji use it. + if preferredSkinTonePermutation?.count == 1 { + skinTonePerComponentEmojiIndex[idx] = preferredSkinTonePermutation?.first + } else { + skinTonePerComponentEmojiIndex[idx] = preferredSkinTonePermutation?[safe: idx] + } + } + + let divider = UIView() + divider.autoSetDimension(.height, toSize: 1) + divider.backgroundColor = isDarkMode ? .ows_gray75 : .ows_gray05 + vStack.addArrangedSubview(divider) + + let leftSpacer = UIView.hStretchingSpacer() + let middleSpacer = UIView.hStretchingSpacer() + let rightSpacer = UIView.hStretchingSpacer() + + let hStack = UIStackView(arrangedSubviews: [leftSpacer, yellowButton, middleSpacer, skinToneButton, rightSpacer]) + hStack.axis = .horizontal + vStack.addArrangedSubview(hStack) + + leftSpacer.autoMatch(.width, to: .width, of: rightSpacer) + middleSpacer.autoMatch(.width, to: .width, of: rightSpacer) + } + + // MARK: - Button Helpers + + func skinToneButtons(for emoji: Emoji, handler: @escaping (EmojiWithSkinTones) -> Void) -> [(skinTone: Emoji.SkinTone, button: UIButton)] { + var buttons = [(Emoji.SkinTone, UIButton)]() + for skinTone in Emoji.SkinTone.allCases { + let emojiWithSkinTone = EmojiWithSkinTones(baseEmoji: emoji, skinTones: [skinTone]) + buttons.append((skinTone, button(for: emojiWithSkinTone, handler: handler))) + } + return buttons + } + + func button(for emoji: EmojiWithSkinTones, handler: @escaping (EmojiWithSkinTones) -> Void) -> UIButton { + let button = OWSButton { handler(emoji) } + button.titleLabel?.font = .boldSystemFont(ofSize: 32) + button.setTitle(emoji.rawValue, for: .normal) + button.setBackgroundImage(UIImage(color: isDarkMode ? .ows_gray60 : .ows_gray25), for: .selected) + button.layer.cornerRadius = 6 + button.clipsToBounds = true + button.autoSetDimensions(to: CGSize(width: 38, height: 38)) + return button + } +} diff --git a/Session/Meta/Translations/en.lproj/Localizable.strings b/Session/Meta/Translations/en.lproj/Localizable.strings index 62c842bb6..cae2d7c49 100644 --- a/Session/Meta/Translations/en.lproj/Localizable.strings +++ b/Session/Meta/Translations/en.lproj/Localizable.strings @@ -651,3 +651,21 @@ "modal_call_permission_request_explanation" = "You can enable the 'Voice and video calls' permission in the Privacy Settings."; "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; +/* The name for the emoji category 'Activities' */ +"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities"; +/* The name for the emoji category 'Animals & Nature' */ +"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature"; +/* The name for the emoji category 'Flags' */ +"EMOJI_CATEGORY_FLAGS_NAME" = "Flags"; +/* The name for the emoji category 'Food & Drink' */ +"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink"; +/* The name for the emoji category 'Objects' */ +"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects"; +/* The name for the emoji category 'Recents' */ +"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used"; +/* The name for the emoji category 'Smileys & People' */ +"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People"; +/* The name for the emoji category 'Symbols' */ +"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols"; +/* The name for the emoji category 'Travel & Places' */ +"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";