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

import SignalServiceKit

public struct EmojiWithSkinTones: Hashable {
    let baseEmoji: Emoji
    let skinTones: [Emoji.SkinTone]?

    init(baseEmoji: Emoji, skinTones: [Emoji.SkinTone]? = nil) {
        self.baseEmoji = baseEmoji

        // Deduplicate skin tones, while preserving order. This allows for
        // multi-skin tone emoji, where if you have for example the permutation
        // [.dark, .dark], it is consolidated to just [.dark], to be initialized
        // with either variant and result in the correct emoji.
        self.skinTones = skinTones?.reduce(into: [Emoji.SkinTone]()) { result, skinTone in
            guard !result.contains(skinTone) else { return }
            result.append(skinTone)
        }
    }

    var rawValue: String {
        if let skinTones {
            return baseEmoji.emojiPerSkinTonePermutation?[skinTones] ?? baseEmoji.rawValue
        } else {
            return baseEmoji.rawValue
        }
    }
}

extension EmojiWithSkinTones {
    init?(rawValue: String) {
        guard rawValue.isSingleEmoji else { return nil }
        if let result = Self.emojiToSkinToneComponents(emoji: rawValue) {
            self.init(baseEmoji: result.0, skinTones: result.1)
        } else if let emoji = Emoji(rawValue: rawValue) {
            self.init(baseEmoji: emoji, skinTones: nil)
        } else { return nil }
    }
}

extension Emoji {
    private static let keyValueStore = KeyValueStore(collection: "Emoji+PreferredSkinTonePermutation")

    static func allSendableEmojiByCategoryWithPreferredSkinTones(transaction: DBReadTransaction) -> [Category: [EmojiWithSkinTones]] {
        return Category.allCases.reduce(into: [Category: [EmojiWithSkinTones]]()) { result, category in
            result[category] = category.normalizedEmoji.filter { $0.available }.map { $0.withPreferredSkinTones(transaction: transaction) }
        }
    }

    func withPreferredSkinTones(transaction: DBReadTransaction) -> EmojiWithSkinTones {
        let rawSkinTones = Self.keyValueStore.getStringArray(rawValue, transaction: transaction)
        return EmojiWithSkinTones(baseEmoji: self, skinTones: rawSkinTones?.compactMap { SkinTone(rawValue: $0) })
    }

    func setPreferredSkinTones(_ preferredSkinTonePermutation: [SkinTone]?, transaction: DBWriteTransaction) {
        if let preferredSkinTonePermutation {
            Self.keyValueStore.setStringArray(
                preferredSkinTonePermutation.map { $0.rawValue },
                key: rawValue,
                transaction: transaction,
            )
        } else {
            Self.keyValueStore.removeValue(forKey: rawValue, transaction: transaction)
        }
    }

    init?(_ string: String) {
        guard let emojiWithSkinTonePermutation = EmojiWithSkinTones(rawValue: string) else { return nil }
        self = emojiWithSkinTonePermutation.baseEmoji
    }
}

// MARK: -

extension String {
    // This is slightly more accurate than String.isSingleEmoji,
    // but slower.
    //
    // * This will reject "lone modifiers".
    // * This will reject certain edge cases such as 🌈️.
    var isSingleEmojiUsingEmojiWithSkinTones: Bool {
        EmojiWithSkinTones(rawValue: self) != nil
    }
}

// MARK: - Normalization

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 }

}

extension Array where Element == EmojiWithSkinTones {
    /// Removes non-normalized emoji when normalized variants are present.
    ///
    /// 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.
    mutating func removeNonNormalizedDuplicates() {
        for (idx, emoji) in self.enumerated().reversed() {
            if !emoji.isNormalized {
                if self.contains(emoji.normalized) {
                    self.remove(at: idx)
                } else {
                    self[idx] = emoji.normalized
                }
            }
        }
    }

    /// Returns a new array removing non-normalized emoji when normalized variants are present.
    ///
    /// 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.
    func removingNonNormalizedDuplicates() -> Self {
        var newArray = self
        newArray.removeNonNormalizedDuplicates()
        return newArray
    }
}