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

746 lines
30 KiB
Swift
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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: SDSAnyReadTransaction) -> [EmojiWithSkinTones] {
let recentEmojiStrings = keyValueStore.getStringArray(EmojiPickerCollectionView.recentEmojiKey, transaction: tx.asV2Read) ?? []
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
public var searchText: String? {
didSet {
searchWithText(searchText)
}
}
private var emojiSearchResults: [EmojiWithSkinTones] = []
private var emojiSearchLocalization: String?
private var emojiSearchIndex: [String: [String]]?
public var isSearching: Bool {
if let searchText = searchText, !searchText.isEmpty {
return true
}
return false
}
lazy var tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(dismissSkinTonePicker))
private let forceDarkTheme: Bool
init(
message: TSMessage?,
forceDarkTheme: Bool = false
) {
self.forceDarkTheme = forceDarkTheme
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.unwrapGrdbRead)
} 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 = (Theme.isDarkThemeEnabled || self.forceDarkTheme) ? .ows_gray80 : .ows_white
let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress))
panGestureRecognizer.require(toFail: longPressGesture)
addGestureRecognizer(longPressGesture)
addGestureRecognizer(tapGestureRecognizer)
tapGestureRecognizer.delegate = self
NotificationCenter.default.addObserver(self, selector: #selector(emojiSearchManifestUpdated), name: EmojiSearchIndex.EmojiSearchManifestFetchedNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(emojiSearchIndexUpdated), name: EmojiSearchIndex.EmojiSearchIndexFetchedNotification, object: nil)
EmojiSearchIndex.updateManifestIfNeeded()
emojiSearchLocalization = EmojiSearchIndex.searchIndexLocalizationForLocale(NSLocale.current.identifier)
if let emojiSearchLocalization = emojiSearchLocalization {
emojiSearchIndex = EmojiSearchIndex.emojiSearchIndex(for: emojiSearchLocalization, shouldFetch: true)
}
}
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: SDSAnyWriteTransaction) {
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.setObject(
newRecentEmoji.map { $0.rawValue },
key: EmojiPickerCollectionView.recentEmojiKey,
transaction: transaction.asV2Write
)
}
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
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 {
partialResult.anchoredMatches.append(emoji)
return
} else {
unanchoredMatch = true
// Don't break here to continue to check for anchored matches
}
}
}
if unanchoredMatch {
partialResult.unanchoredMatches.append(emoji)
}
}
return result.anchoredMatches + result.unanchoredMatches
}
@objc
private func emojiSearchManifestUpdated(notification: Notification) {
emojiSearchLocalization = EmojiSearchIndex.searchIndexLocalizationForLocale(NSLocale.current.identifier)
if let emojiSearchLocalization = emojiSearchLocalization {
emojiSearchIndex = EmojiSearchIndex.emojiSearchIndex(for: emojiSearchLocalization, shouldFetch: false)
}
}
@objc
private func emojiSearchIndexUpdated(notification: Notification) {
if let emojiSearchLocalization = emojiSearchLocalization {
emojiSearchIndex = EmojiSearchIndex.emojiSearchIndex(for: emojiSearchLocalization, shouldFetch: false)
}
}
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 = self else { return }
if let emoji = 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(forceDarkTheme: self.forceDarkTheme)
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()
private override init(frame: CGRect) {
super.init(frame: frame)
layoutMargins = UIEdgeInsets(
top: 16,
leading: EmojiPickerCollectionView.margins,
bottom: 6,
trailing: EmojiPickerCollectionView.margins
)
label.font = UIFont.dynamicTypeFootnoteClamped.semibold()
addSubview(label)
label.autoPinEdgesToSuperviewMargins()
label.setCompressionResistanceHigh()
}
convenience init(forceDarkTheme: Bool = false) {
self.init(frame: .zero)
label.textColor = forceDarkTheme ? Theme.darkThemeSecondaryTextAndIconColor : Theme.secondaryTextAndIconColor
}
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
}
}
// URL handling
private class EmojiSearchIndex: NSObject {
public static let EmojiSearchManifestFetchedNotification = Notification.Name("EmojiSearchManifestFetchedNotification")
public static let EmojiSearchIndexFetchedNotification = Notification.Name("EmojiSearchIndexFetchedNotification")
private static let emojiSearchIndexKVS = KeyValueStore(collection: "EmojiSearchIndexKeyValueStore")
private static let emojiSearchIndexVersionKey = "emojiSearchIndexVersionKey"
private static let emojiSearchIndexAvailableLocalizationsKey = "emojiSearchIndexAvailableLocalizationsKey"
private static let remoteManifestURL = URL(string: "/dynamic/android/emoji/search/manifest.json")!
private static let remoteSearchFormat = "/static/android/emoji/search/%d/%@.json"
public class func updateManifestIfNeeded() {
var searchIndexVersion: Int = 0
var searchIndexLocalizations: [String] = []
(searchIndexVersion, searchIndexLocalizations) = SSKEnvironment.shared.databaseStorageRef.read { transaction in
let version = self.emojiSearchIndexKVS.getInt(emojiSearchIndexVersionKey, transaction: transaction.asV2Read) ?? 0
let locs = self.emojiSearchIndexKVS.getStringArray(emojiSearchIndexAvailableLocalizationsKey, transaction: transaction.asV2Read) ?? []
return (version, locs)
}
let urlSession = SSKEnvironment.shared.signalServiceRef.urlSessionForUpdates()
Promise.wrapAsync {
return try await urlSession.performRequest(self.remoteManifestURL.absoluteString, method: .get)
}.done { response in
guard response.responseStatusCode == 200 else {
throw OWSAssertionError("Bad response code for emoji manifest fetch")
}
guard let json = response.responseBodyJson as? [String: Any] else {
throw OWSAssertionError("Unable to generate JSON for emoji manifest from response body.")
}
guard let parser = ParamParser(responseObject: json) else {
throw OWSAssertionError("Unable to parse emoji manifest from response body.")
}
let remoteVersion: Int = try parser.required(key: "version")
let remoteLocalizations: [String] = try parser.required(key: "languages")
if remoteVersion != searchIndexVersion {
Logger.info("[Emoji Search] Invalidating search index, old version \(searchIndexVersion), new version \(remoteVersion)")
self.invalidateSearchIndex(newVersion: remoteVersion, localizationsToInvalidate: searchIndexLocalizations, newLocalizations: remoteLocalizations)
let localization = self.searchIndexLocalizationForLocale(NSLocale.current.identifier, searchIndexManifest: remoteLocalizations)
if let localization = localization {
self.fetchEmojiSearchIndex(for: localization, version: remoteVersion)
}
NotificationCenter.default.postNotificationNameAsync(self.EmojiSearchManifestFetchedNotification, object: nil)
}
}.catch { error in
owsFailDebug("Failed to download manifest \(error)")
}
}
public static func searchIndexLocalizationForLocale(_ locale: String, searchIndexManifest: [String]? = nil) -> String? {
var manifest = searchIndexManifest
if manifest == nil {
manifest = SSKEnvironment.shared.databaseStorageRef.read { transaction in
return self.emojiSearchIndexKVS.getStringArray(emojiSearchIndexAvailableLocalizationsKey, transaction: transaction.asV2Read) ?? []
}
}
guard let manifest = manifest else {
Logger.info("[Emoji Search] Manifest not yet downloaded")
return nil
}
// We have a specific locale for this
if manifest.contains(locale) {
return locale
}
// Look for a generic top level
let localizationComponents = locale.components(separatedBy: "_")
if localizationComponents.count > 1, let firstComponent = localizationComponents.first {
if manifest.contains(firstComponent) {
return firstComponent
}
}
return nil
}
public static func emojiSearchIndex(for localization: String, shouldFetch: Bool) -> [String: [String]]? {
let index = SSKEnvironment.shared.databaseStorageRef.read { transaction in
return self.emojiSearchIndexKVS.getObject(
localization,
ofClasses: [NSDictionary.self, NSArray.self, NSString.self],
transaction: transaction.asV2Read
) as? [String: [String]]
}
if shouldFetch && index == nil {
Logger.debug("Kicking off fetch for localization \(localization)")
self.fetchEmojiSearchIndex(for: localization)
}
return index
}
private static func invalidateSearchIndex(newVersion: Int, localizationsToInvalidate: [String], newLocalizations: [String]) {
SSKEnvironment.shared.databaseStorageRef.write { transaction in
for localization in localizationsToInvalidate {
self.emojiSearchIndexKVS.removeValue(forKey: localization, transaction: transaction.asV2Write)
}
self.emojiSearchIndexKVS.setObject(newLocalizations, key: emojiSearchIndexAvailableLocalizationsKey, transaction: transaction.asV2Write)
self.emojiSearchIndexKVS.setInt(newVersion, key: emojiSearchIndexVersionKey, transaction: transaction.asV2Write)
}
}
private static func fetchEmojiSearchIndex(for localization: String, version: Int? = nil) {
var searchIndexVersion = version
if searchIndexVersion == nil {
searchIndexVersion = SSKEnvironment.shared.databaseStorageRef.read { transaction in
return self.emojiSearchIndexKVS.getInt(emojiSearchIndexVersionKey, transaction: transaction.asV2Read) ?? 0
}
}
guard let searchIndexVersion = searchIndexVersion else {
owsFailDebug("No local emoji index version, manifest must be updated first")
return
}
let urlSession = SSKEnvironment.shared.signalServiceRef.urlSessionForUpdates()
let request = String(format: remoteSearchFormat, searchIndexVersion, localization)
Promise.wrapAsync {
return try await urlSession.performRequest(request, method: .get)
}.done { response in
guard response.responseStatusCode == 200 else {
throw OWSAssertionError("Bad response code for emoji index fetch")
}
guard let json = response.responseBodyJson as? [[String: Any]] else {
throw OWSAssertionError("Unable to generate JSON for emoji index from response body.")
}
let index = self.buildSearchIndexMap(for: json)
SSKEnvironment.shared.databaseStorageRef.write { transaction in
self.emojiSearchIndexKVS.setObject(index, key: localization, transaction: transaction.asV2Write)
}
NotificationCenter.default.postNotificationNameAsync(self.EmojiSearchIndexFetchedNotification, object: nil)
}.catch { error in
owsFailDebug("Failed to download manifest \(error)")
}
}
private static func buildSearchIndexMap(for responseArray: [[String: Any]]) -> [String: [String]] {
var index: [String: [String]] = [:]
for response in responseArray {
let emoji: String? = response["emoji"] as? String
let tags: [String]? = response["tags"] as? [String]
if let emoji = emoji, let tags = tags {
index[emoji] = tags
}
}
return index
}
}