Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/Emoji/EmojiPickerCollectionView.swift
1 views
//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import SignalServiceKit
import SignalUI

enum EmojiPickerSection {
    case messageEmoji
    case recentEmoji
    case emojiCategory(categoryIndex: Int)
}

protocol EmojiPickerCollectionViewDelegate: AnyObject {
    func emojiPicker(_ emojiPicker: EmojiPickerCollectionView, didSelectEmoji emoji: EmojiWithSkinTones)
    func emojiPicker(_ emojiPicker: EmojiPickerCollectionView, didScrollToSection section: EmojiPickerSection)
    func emojiPickerWillBeginDragging(_ emojiPicker: EmojiPickerCollectionView)

}

class EmojiPickerCollectionView: UICollectionView {
    let layout: UICollectionViewFlowLayout

    private static let keyValueStore = KeyValueStore(collection: "EmojiPickerCollectionView")
    private static let recentEmojiKey = "recentEmoji"

    /// Reads the stored recent emoji and removes duplicates using `removingNonNormalizedDuplicates`.
    static func getRecentEmoji(tx: DBReadTransaction) -> [EmojiWithSkinTones] {
        let recentEmojiStrings = keyValueStore.getStringArray(EmojiPickerCollectionView.recentEmojiKey, transaction: tx) ?? []

        return recentEmojiStrings
            .compactMap(EmojiWithSkinTones.init(rawValue:))
            .removingNonNormalizedDuplicates()
    }

    weak var pickerDelegate: EmojiPickerCollectionViewDelegate?

    // The emoji already applied to the message
    private let messageEmoji: [EmojiWithSkinTones]
    var hasMessageEmoji: Bool { !messageEmoji.isEmpty }

    private let recentEmoji: [EmojiWithSkinTones]
    var hasRecentEmoji: Bool { !recentEmoji.isEmpty }

    private let 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

    var searchText: String? {
        didSet {
            searchWithText(searchText)
        }
    }

    private var emojiSearchResults: [EmojiWithSkinTones] = []
    private var emojiSearchLocalization: String?
    private var emojiSearchIndex: [String: [String]]?

    var isSearching: Bool {
        if let searchText, !searchText.isEmpty {
            return true
        }

        return false
    }

    lazy var tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(dismissSkinTonePicker))

    init(message: TSMessage?) {
        layout = UICollectionViewFlowLayout()
        layout.itemSize = CGSize(square: EmojiPickerCollectionView.emojiWidth)
        layout.minimumInteritemSpacing = EmojiPickerCollectionView.minimumSpacing
        layout.sectionInset = UIEdgeInsets(top: 0, leading: EmojiPickerCollectionView.margins, bottom: 0, trailing: EmojiPickerCollectionView.margins)

        let messageReacts: [OWSReaction]
        (messageReacts, recentEmoji, allSendableEmojiByCategory) = SSKEnvironment.shared.databaseStorageRef.read { transaction in
            let messageReacts: [OWSReaction]
            if let message {
                messageReacts = ReactionFinder(uniqueMessageId: message.uniqueId).allReactions(transaction: transaction)
            } else {
                messageReacts = []
            }

            let recentEmoji = EmojiPickerCollectionView.getRecentEmoji(tx: transaction)

            let allSendableEmojiByCategory = Emoji.allSendableEmojiByCategoryWithPreferredSkinTones(
                transaction: transaction,
            )

            return (messageReacts, recentEmoji, allSendableEmojiByCategory)
        }
        // Remove duplicates while preserving order.
        var messageEmojiSet = Set<EmojiWithSkinTones>()
        var dedupedEmoji = [EmojiWithSkinTones]()
        for react in messageReacts {
            guard let emoji = EmojiWithSkinTones(rawValue: react.emoji) else {
                continue
            }
            guard !messageEmojiSet.contains(emoji.normalized) else {
                continue
            }
            messageEmojiSet.insert(emoji.normalized)
            dedupedEmoji.append(emoji)
        }
        self.messageEmoji = dedupedEmoji

        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 = nil

        let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress))
        panGestureRecognizer.require(toFail: longPressGesture)
        addGestureRecognizer(longPressGesture)

        addGestureRecognizer(tapGestureRecognizer)
        tapGestureRecognizer.delegate = self

        loadEmojiSearchIfNeeded()
    }

    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(width / (EmojiPickerCollectionView.emojiWidth + EmojiPickerCollectionView.minimumSpacing)) }

    // At max, we show 3 rows of recent emoji
    private var maxRecentEmoji: Int { numberOfColumns * 3 }

    private func section(raw: Int) -> EmojiPickerSection {
        switch (hasMessageEmoji, hasRecentEmoji) {
        case (true, true):
            // Message emoji, then recents, then categories.
            switch raw {
            case 0: return .messageEmoji
            case 1: return .recentEmoji
            default: return .emojiCategory(categoryIndex: raw - 2)
            }
        case (true, false):
            // Message emoji and then categories
            switch raw {
            case 0: return .messageEmoji
            default: return .emojiCategory(categoryIndex: raw - 1)
            }
        case (false, true):
            // Recents and then categories
            switch raw {
            case 0: return .recentEmoji
            default: return .emojiCategory(categoryIndex: raw - 1)
            }
        case (false, false):
            return .emojiCategory(categoryIndex: raw)
        }
    }

    private func rawSection(from section: EmojiPickerSection) -> Int {
        switch (hasMessageEmoji, hasRecentEmoji) {
        case (true, true):
            // Message emoji, then recents, then categories.
            switch section {
            case .messageEmoji: return 0
            case .recentEmoji: return 1
            case .emojiCategory(let categoryIndex): return categoryIndex + 2
            }
        case (true, false):
            // Message emoji and then categories
            switch section {
            case .messageEmoji: return 0
            case .recentEmoji: return 0
            case .emojiCategory(let categoryIndex): return categoryIndex + 1
            }
        case (false, true):
            // Recents and then categories
            switch section {
            case .messageEmoji: return 0
            case .recentEmoji: return 0
            case .emojiCategory(let categoryIndex): return categoryIndex + 1
            }
        case (false, false):
            switch section {
            case .messageEmoji: return 0
            case .recentEmoji: return 0
            case .emojiCategory(let categoryIndex): return categoryIndex
            }
        }
    }

    func emojiForSection(_ section: Int) -> [EmojiWithSkinTones] {
        switch self.section(raw: section) {
        case .messageEmoji:
            return messageEmoji
        case .recentEmoji:
            return Array(recentEmoji[0..<min(maxRecentEmoji, recentEmoji.count)])
        case .emojiCategory(let categoryIndex):
            guard let category = Emoji.Category.allCases[safe: categoryIndex] 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? {
        switch self.section(raw: section) {
        case .messageEmoji:
            return OWSLocalizedString(
                "EMOJI_CATEGORY_ON_MESSAGE_NAME",
                comment: "The name for the emoji section for emojis already used on the message",
            )
        case .recentEmoji:
            return OWSLocalizedString(
                "EMOJI_CATEGORY_RECENTS_NAME",
                comment: "The name for the emoji category 'Recents'",
            )
        case .emojiCategory(let categoryIndex):
            guard let category = Emoji.Category.allCases[safe: categoryIndex] else {
                owsFailDebug("Unexpectedly missing category for section \(section)")
                return nil
            }

            return category.localizedName
        }
    }

    func recordRecentEmoji(_ emoji: EmojiWithSkinTones, transaction: DBWriteTransaction) {
        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)])

        EmojiPickerCollectionView.keyValueStore.setStringArray(
            newRecentEmoji.map { $0.rawValue },
            key: EmojiPickerCollectionView.recentEmojiKey,
            transaction: transaction,
        )
    }

    var lowestVisibleSection = 0
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        currentSkinTonePicker?.dismiss()
        currentSkinTonePicker = nil

        let newLowestVisibleSection = indexPathsForVisibleItems.reduce(into: Set<Int>()) { $0.insert($1.section) }.min() ?? 0

        guard scrollingToSection == nil || newLowestVisibleSection == self.rawSection(from: scrollingToSection!) else { return }

        scrollingToSection = nil

        if lowestVisibleSection != newLowestVisibleSection {
            pickerDelegate?.emojiPicker(self, didScrollToSection: self.section(raw: newLowestVisibleSection))
            lowestVisibleSection = newLowestVisibleSection
        }
    }

    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        pickerDelegate?.emojiPickerWillBeginDragging(self)
    }

    // MARK: - Search

    private var hasLoadedEmojiSearch = false

    private func loadEmojiSearchIfNeeded() {
        if hasLoadedEmojiSearch {
            return
        }
        hasLoadedEmojiSearch = true
        loadEmojiSearch()
        if emojiSearchIndex == nil, emojiSearchLocalization != nil {
            Task {
                try await EmojiSearchIndex.updateManifest()
                self.loadEmojiSearch()
            }
        }
    }

    private func loadEmojiSearch() {
        let databaseStorage = SSKEnvironment.shared.databaseStorageRef
        databaseStorage.read { tx in
            self.emojiSearchLocalization = EmojiSearchIndex.searchIndexLocalization(
                forLocale: NSLocale.current.identifier,
                manifestLocalizations: EmojiSearchIndex.availableLocalizations(tx: tx) ?? [],
            )
            self.emojiSearchIndex = self.emojiSearchLocalization.flatMap {
                return EmojiSearchIndex.emojiSearchIndex(forLocalization: $0, tx: tx)
            }
        }
    }

    private func searchWithText(_ searchText: String?) {
        emojiSearchResults = searchResults(searchText)
        reloadData()
    }

    private func searchResults(_ searchText: String?) -> [EmojiWithSkinTones] {
        guard let searchText = searchText?.stripped else {
            return []
        }

        if
            searchText.count == 1,
            let searchEmoji = EmojiWithSkinTones(rawValue: searchText)
        {
            return [searchEmoji]
        }

        // Anchored matches are emoji that have a term that starts with the
        // search text. Unanchored matches are emoji that have a term that
        // contains the search text elsewhere.
        let initialResult = (anchoredMatches: [EmojiWithSkinTones](), unanchoredMatches: [EmojiWithSkinTones]())
        let result = allSendableEmoji.reduce(into: initialResult) { partialResult, emoji in
            let terms = emojiSearchIndex?[emoji.baseEmoji.rawValue] ?? [emoji.baseEmoji.name]

            var unanchoredMatch = false
            for term in terms {
                if let range = term.range(of: searchText, options: [.caseInsensitive]) {
                    if range.lowerBound == term.startIndex {
                        // Anchored match
                        if range.upperBound == term.endIndex {
                            // Exact match. Put very first
                            partialResult.anchoredMatches.insert(emoji, at: 0)
                        } else {
                            partialResult.anchoredMatches.append(emoji)
                        }
                        return
                    }
                    unanchoredMatch = true
                    // Don't break here to continue to check for anchored matches
                }
            }

            if unanchoredMatch {
                partialResult.unanchoredMatches.append(emoji)
            }
        }

        return result.anchoredMatches + result.unanchoredMatches
    }

    var scrollingToSection: EmojiPickerSection?
    func scrollToSectionHeader(_ section: EmojiPickerSection, animated: Bool) {
        guard
            let attributes = layoutAttributesForSupplementaryElement(
                ofKind: UICollectionView.elementKindSectionHeader,
                at: IndexPath(item: 0, section: self.rawSection(from: section)),
            ) else { return }
        scrollingToSection = section
        setContentOffset(CGPoint(x: 0, y: attributes.frame.minY - contentInset.top), animated: animated)
    }

    private weak var currentSkinTonePicker: EmojiSkinTonePicker?

    @objc
    private 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 else { return }

                if let emoji {
                    SSKEnvironment.shared.databaseStorageRef.asyncWrite { 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
    private 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)")
        }

        SSKEnvironment.shared.databaseStorageRef.asyncWrite { transaction in
            self.recordRecentEmoji(emoji, transaction: transaction)
            emoji.baseEmoji.setPreferredSkinTones(emoji.skinTones, 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 {
        guard !isSearching else { return 1 }
        var numSections = 0
        if hasMessageEmoji { numSections += 1 }
        if hasRecentEmoji { numSections += 1 }
        numSections += Emoji.Category.allCases.count
        return numSections
    }

    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: 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 private init(frame: CGRect) {
        super.init(frame: frame)

        layoutMargins = UIEdgeInsets(
            top: 16,
            leading: EmojiPickerCollectionView.margins,
            bottom: 6,
            trailing: EmojiPickerCollectionView.margins,
        )

        label.font = UIFont.dynamicTypeFootnoteClamped.semibold()
        label.textColor = UIColor.Signal.secondaryLabel
        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
    }
}

enum EmojiSearchIndex {
    private static let emojiSearchIndexKVS = KeyValueStore(collection: "EmojiSearchIndexKeyValueStore")
    private static let emojiSearchIndexVersionKey = "emojiSearchIndexVersionKey"
    private static let emojiSearchIndexAvailableLocalizationsKey = "emojiSearchIndexAvailableLocalizationsKey"

    static func updateManifest() async throws {
        let databaseStorage = SSKEnvironment.shared.databaseStorageRef
        let signalService = SSKEnvironment.shared.signalServiceRef

        let urlSession = signalService.urlSessionForUpdates()
        let response = try await urlSession.performRequest("/dynamic/android/emoji/search/manifest.json", method: .get)
        guard response.responseStatusCode == 200 else {
            throw response.asError()
        }
        let manifest = try JSONDecoder().decode(Manifest.self, from: response.responseBodyData ?? Data())

        await databaseStorage.awaitableWrite { tx in
            let localVersion = self.emojiSearchIndexKVS.getInt(emojiSearchIndexVersionKey, transaction: tx)
            if manifest.version != localVersion {
                Logger.info("invalidating search index (old version: \(localVersion as Optional), new version: \(manifest.version))")
                self.resetSearchIndex(
                    newVersion: manifest.version,
                    newLocalizations: manifest.languages,
                    tx: tx,
                )
            }
        }

        let localization = self.searchIndexLocalization(
            forLocale: NSLocale.current.identifier,
            manifestLocalizations: manifest.languages,
        )
        if let localization {
            try await self.fetchEmojiSearchIndex(forLocalization: localization, version: manifest.version)
        }
    }

    struct Manifest: Decodable {
        var version: Int
        var languages: [String]
    }

    fileprivate static func searchIndexLocalization(forLocale locale: String, manifestLocalizations: [String]) -> String? {
        // We have a specific locale for this
        if manifestLocalizations.contains(locale) {
            return locale
        }

        // Look for a generic top level
        let localizationComponents = locale.components(separatedBy: "_")
        if localizationComponents.count > 1, let firstComponent = localizationComponents.first {
            if manifestLocalizations.contains(firstComponent) {
                return firstComponent
            }
        }

        return nil
    }

    fileprivate static func emojiSearchIndex(forLocalization localization: String, tx: DBReadTransaction) -> [String: [String]]? {
        return self.emojiSearchIndexKVS.getObject(
            localization,
            ofClasses: [NSDictionary.self, NSArray.self, NSString.self],
            transaction: tx,
        ) as? [String: [String]]
    }

    private static func resetSearchIndex(
        newVersion: Int,
        newLocalizations: [String],
        tx: DBWriteTransaction,
    ) {
        emojiSearchIndexKVS.removeAll(transaction: tx)
        emojiSearchIndexKVS.setStringArray(newLocalizations, key: self.emojiSearchIndexAvailableLocalizationsKey, transaction: tx)
        emojiSearchIndexKVS.setInt(newVersion, key: self.emojiSearchIndexVersionKey, transaction: tx)
    }

    fileprivate static func availableLocalizations(tx: DBReadTransaction) -> [String]? {
        return self.emojiSearchIndexKVS.getStringArray(self.emojiSearchIndexAvailableLocalizationsKey, transaction: tx)
    }

    fileprivate static func fetchEmojiSearchIndex(forLocalization localization: String, version: Int) async throws {
        let databaseStorage = SSKEnvironment.shared.databaseStorageRef
        let signalService = SSKEnvironment.shared.signalServiceRef

        let urlSession = signalService.urlSessionForUpdates()
        let response = try await urlSession.performRequest(
            "/static/android/emoji/search/\(version)/\(localization).json",
            method: .get,
        )
        guard response.responseStatusCode == 200 else {
            throw response.asError()
        }
        var searchIndex = [String: [String]]()
        for emojiTags in try JSONDecoder().decode([EmojiTags].self, from: response.responseBodyData ?? Data()) {
            searchIndex[emojiTags.emoji] = emojiTags.tags
        }

        await databaseStorage.awaitableWrite { tx in
            self.emojiSearchIndexKVS.setObject(searchIndex as [NSString: [NSString]] as NSDictionary, key: localization, transaction: tx)
        }
    }

    struct EmojiTags: Decodable {
        var emoji: String
        var tags: [String]
    }
}