feat: emoji picker view
This commit is contained in:
parent
5f4758d36a
commit
913939616e
|
@ -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 = "<group>"; };
|
||||
7B1581E5271FD2A100848B49 /* VideoPreviewVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPreviewVC.swift; sourceTree = "<group>"; };
|
||||
7B1581E727210ECC00848B49 /* RenderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderView.swift; sourceTree = "<group>"; };
|
||||
7B1B52D728580C6D006069F2 /* EmojiPickerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerSheet.swift; sourceTree = "<group>"; };
|
||||
7B1B52DB28580D50006069F2 /* EmojiPickerCollectionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmojiPickerCollectionView.swift; sourceTree = "<group>"; };
|
||||
7B1B52DC28580D50006069F2 /* EmojiSkinTonePicker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmojiSkinTonePicker.swift; sourceTree = "<group>"; };
|
||||
7B1D74A927BCC16E0030B423 /* NSENotificationPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSENotificationPresenter.swift; sourceTree = "<group>"; };
|
||||
7B1D74AB27BDE7510030B423 /* Promise+Timeout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Promise+Timeout.swift"; sourceTree = "<group>"; };
|
||||
7B1D74AF27C365960030B423 /* Timer+MainThread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Timer+MainThread.swift"; sourceTree = "<group>"; };
|
||||
|
@ -2100,6 +2106,9 @@
|
|||
7B1B52BD2851ADE1006069F2 /* Emoji Picker */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7B1B52DB28580D50006069F2 /* EmojiPickerCollectionView.swift */,
|
||||
7B1B52DC28580D50006069F2 /* EmojiSkinTonePicker.swift */,
|
||||
7B1B52D728580C6D006069F2 /* EmojiPickerSheet.swift */,
|
||||
);
|
||||
path = "Emoji Picker";
|
||||
sourceTree = "<group>";
|
||||
|
@ -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 */,
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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..<min(maxRecentEmoji, recentEmoji.count)]) }
|
||||
|
||||
guard let category = Emoji.Category.allCases[safe: section - categoryIndexOffset] else {
|
||||
owsFailDebug("Unexpectedly missing category for section \(section)")
|
||||
return []
|
||||
}
|
||||
|
||||
guard let categoryEmoji = allSendableEmojiByCategory[category] else {
|
||||
owsFailDebug("Unexpectedly missing emoji for category \(category)")
|
||||
return []
|
||||
}
|
||||
|
||||
return categoryEmoji
|
||||
}
|
||||
|
||||
func emojiForIndexPath(_ indexPath: IndexPath) -> 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..<min(50, newRecentEmoji.count)])
|
||||
|
||||
transaction.setObject(
|
||||
newRecentEmoji.map { $0.rawValue },
|
||||
forKey: EmojiPickerCollectionView.recentEmojiKey,
|
||||
inCollection: EmojiPickerCollectionView.emojiPickerCollection
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Search
|
||||
|
||||
func searchWithText(_ searchText: String?) {
|
||||
if let searchText = searchText {
|
||||
emojiSearchResults = allSendableEmoji.filter { emoji in
|
||||
return emoji.baseEmoji.name.range(of: searchText, options: [.caseInsensitive, .anchored]) != nil
|
||||
}
|
||||
} else {
|
||||
emojiSearchResults = []
|
||||
}
|
||||
|
||||
reloadData()
|
||||
}
|
||||
|
||||
var scrollingToSection: Int?
|
||||
func scrollToSectionHeader(_ section: Int, animated: Bool) {
|
||||
guard let attributes = layoutAttributesForSupplementaryElement(
|
||||
ofKind: UICollectionView.elementKindSectionHeader,
|
||||
at: IndexPath(item: 0, section: section)
|
||||
) else { return }
|
||||
scrollingToSection = section
|
||||
setContentOffset(CGPoint(x: 0, y: (attributes.frame.minY - contentInset.top)), animated: animated)
|
||||
}
|
||||
|
||||
private weak var currentSkinTonePicker: EmojiSkinTonePicker?
|
||||
|
||||
@objc
|
||||
func handleLongPress(sender: UILongPressGestureRecognizer) {
|
||||
|
||||
switch sender.state {
|
||||
case .began:
|
||||
let point = sender.location(in: self)
|
||||
guard let indexPath = indexPathForItem(at: point) else { return }
|
||||
guard let emoji = emojiForIndexPath(indexPath) else { return }
|
||||
guard let cell = cellForItem(at: indexPath) else { return }
|
||||
|
||||
currentSkinTonePicker?.dismiss()
|
||||
currentSkinTonePicker = EmojiSkinTonePicker.present(referenceView: cell, emoji: emoji) { [weak self] emoji in
|
||||
guard let self = self else { return }
|
||||
|
||||
if let emoji = emoji {
|
||||
Storage.write { transaction in
|
||||
self.recordRecentEmoji(emoji, transaction: transaction)
|
||||
emoji.baseEmoji.setPreferredSkinTones(emoji.skinTones, transaction: transaction)
|
||||
}
|
||||
|
||||
self.pickerDelegate?.emojiPicker(self, didSelectEmoji: emoji)
|
||||
}
|
||||
|
||||
self.currentSkinTonePicker?.dismiss()
|
||||
self.currentSkinTonePicker = nil
|
||||
}
|
||||
case .changed:
|
||||
currentSkinTonePicker?.didChangeLongPress(sender)
|
||||
case .ended:
|
||||
currentSkinTonePicker?.didEndLongPress(sender)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
func dismissSkinTonePicker() {
|
||||
currentSkinTonePicker?.dismiss()
|
||||
currentSkinTonePicker = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension EmojiPickerCollectionView: UIGestureRecognizerDelegate {
|
||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> 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 }
|
||||
|
||||
}
|
|
@ -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<UITouch>, 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
|
|
Loading…
Reference in New Issue