WIP: Emoji picker keyboard

This commit is contained in:
ryanzhao 2022-06-10 16:51:37 +10:00
parent a3aaef7f78
commit 48ad72b942
10 changed files with 18357 additions and 1 deletions

641
Scripts/EmojiGenerator.swift Executable file
View File

@ -0,0 +1,641 @@
#!/usr/bin/env xcrun --sdk macosx swift
import Foundation
// OWSAssertionError but for this script
enum EmojiError: Error {
case assertion(String)
init(_ string: String) {
self = .assertion(string)
}
}
// MARK: - Remote Model
// These definitions are kept fairly lightweight since we don't control their format
// All processing of remote data is done by converting RemoteModel items to EmojiModel items
enum RemoteModel {
struct EmojiItem: Codable {
let name: String
let shortName: String
let unified: String
let sortOrder: UInt
let category: EmojiCategory
let skinVariations: [String: SkinVariation]?
}
struct SkinVariation: Codable {
let unified: String
}
enum EmojiCategory: String, Codable, Equatable {
case smileys = "Smileys & Emotion"
case people = "People & Body"
// This category is not provided in the data set, but is actually
// a merger of the categories of `smileys` and `people`
case smileysAndPeople = "Smileys & People"
case animals = "Animals & Nature"
case food = "Food & Drink"
case activities = "Activities"
case travel = "Travel & Places"
case objects = "Objects"
case symbols = "Symbols"
case flags = "Flags"
case components = "Component"
}
static func fetchEmojiData() throws -> Data {
// let remoteSourceUrl = URL(string: "https://unicodey.com/emoji-data/emoji.json")!
// This URL has been unavailable the past couple of weeks. If you're seeing failures here, try this other one:
let remoteSourceUrl = URL(string: "https://raw.githubusercontent.com/iamcal/emoji-data/master/emoji.json")!
return try Data(contentsOf: remoteSourceUrl)
}
}
// MARK: - Local Model
struct EmojiModel {
let definitions: [EmojiDefinition]
struct EmojiDefinition {
let category: RemoteModel.EmojiCategory
let rawName: String
let enumName: String
let variants: [Emoji]
var baseEmoji: Character { variants[0].base }
struct Emoji: Comparable {
let emojiChar: Character
let base: Character
let skintoneSequence: SkinToneSequence
static func <(lhs: Self, rhs: Self) -> Bool {
for (leftElement, rightElement) in zip(lhs.skintoneSequence, rhs.skintoneSequence) {
if leftElement.sortId != rightElement.sortId {
return leftElement.sortId < rightElement.sortId
}
}
if lhs.skintoneSequence.count != rhs.skintoneSequence.count {
return lhs.skintoneSequence.count < rhs.skintoneSequence.count
} else {
return false
}
}
}
init(parsingRemoteItem remoteItem: RemoteModel.EmojiItem) throws {
category = remoteItem.category
rawName = remoteItem.name
enumName = Self.parseEnumNameFromRemoteItem(remoteItem)
let baseEmojiChar = try Self.codePointsToCharacter(Self.parseCodePointString(remoteItem.unified))
let baseEmoji = Emoji(emojiChar: baseEmojiChar, base: baseEmojiChar, skintoneSequence: .none)
let toneVariants: [Emoji]
if let skinVariations = remoteItem.skinVariations {
toneVariants = try skinVariations.map { key, value in
let modifier = SkinTone.sequence(from: Self.parseCodePointString(key))
let parsedEmoji = try Self.codePointsToCharacter(Self.parseCodePointString(value.unified))
return Emoji(emojiChar: parsedEmoji, base: baseEmojiChar, skintoneSequence: modifier)
}.sorted()
} else {
toneVariants = []
}
variants = [baseEmoji] + toneVariants
try postInitValidation()
}
func postInitValidation() throws {
guard variants.count > 0 else {
throw EmojiError("Expecting at least one variant")
}
guard variants.allSatisfy({ $0.base == baseEmoji }) else {
// All emoji variants must have a common base emoji
throw EmojiError("Inconsistent base emoji: \(baseEmoji)")
}
let hasMultipleComponents = variants.first(where: { $0.skintoneSequence.count > 1 }) != nil
if hasMultipleComponents, skinToneComponents == nil {
// If you hit this, this means a new emoji was added where a skintone modifier sequence specifies multiple
// skin tones for multiple emoji components: e.g. 👫 -> 🧍+🧍
// These are defined in `skinToneComponents`. You'll need to add a new case.
throw EmojiError("\(baseEmoji):\(enumName) definition has variants with multiple skintone modifiers but no component emojis defined")
}
}
static func parseEnumNameFromRemoteItem(_ item: RemoteModel.EmojiItem) -> String {
// some names don't play nice with swift, so we special case them
switch item.shortName {
case "+1": return "plusOne"
case "-1": return "negativeOne"
case "8ball": return "eightBall"
case "repeat": return "`repeat`"
case "100": return "oneHundred"
case "1234": return "oneTwoThreeFour"
case "couplekiss": return "personKissPerson"
case "couple_with_heart": return "personHeartPerson"
default:
let uppperCamelCase = item.shortName
.replacingOccurrences(of: "-", with: " ")
.replacingOccurrences(of: "_", with: " ")
.titlecase
.replacingOccurrences(of: " ", with: "")
return uppperCamelCase.first!.lowercased() + uppperCamelCase.dropFirst()
}
}
var skinToneComponents: String? {
// There's no great way to do this except manually. Some emoji have multiple skin tones.
// In the picker, we need to use one emoji to represent each person. For now, we manually
// specify this. Hopefully, in the future, the data set will contain this information.
switch enumName {
case "peopleHoldingHands": return "[.standingPerson, .standingPerson]"
case "twoWomenHoldingHands": return "[.womanStanding, .womanStanding]"
case "manAndWomanHoldingHands": return "[.womanStanding, .manStanding]"
case "twoMenHoldingHands": return "[.manStanding, .manStanding]"
case "personKissPerson": return "[.adult, .adult]"
case "womanKissMan": return "[.woman, .man]"
case "manKissMan": return "[.man, .man]"
case "womanKissWoman": return "[.woman, .woman]"
case "personHeartPerson": return "[.adult, .adult]"
case "womanHeartMan": return "[.woman, .man]"
case "manHeartMan": return "[.man, .man]"
case "womanHeartWoman": return "[.woman, .woman]"
case "handshake": return "[.rightwardsHand, .leftwardsHand]"
default:
return nil
}
}
var isNormalized: Bool { enumName == normalizedEnumName }
var normalizedEnumName: String {
switch enumName {
// flagUm (US Minor Outlying Islands) looks identical to the
// US flag. We don't present it as a sendable reaction option
// This matches the iOS keyboard behavior.
case "flagUm": return "us"
default: return enumName
}
}
static func parseCodePointString(_ pointString: String) -> [UnicodeScalar] {
return pointString
.components(separatedBy: "-")
.map { Int($0, radix: 16)! }
.map { UnicodeScalar($0)! }
}
static func codePointsToCharacter(_ codepoints: [UnicodeScalar]) throws -> Character {
let result = codepoints.map { String($0) }.joined()
if result.count != 1 {
throw EmojiError("Invalid number of chars for codepoint sequence: \(codepoints)")
}
return result.first!
}
}
init(rawJSONData jsonData: Data) throws {
let jsonDecoder = JSONDecoder()
jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
definitions = try jsonDecoder
.decode([RemoteModel.EmojiItem].self, from: jsonData)
.sorted { $0.sortOrder < $1.sortOrder }
.map { try EmojiDefinition(parsingRemoteItem: $0) }
}
typealias SkinToneSequence = [EmojiModel.SkinTone]
enum SkinTone: UnicodeScalar, CaseIterable, Equatable {
case light = "🏻"
case mediumLight = "🏼"
case medium = "🏽"
case mediumDark = "🏾"
case dark = "🏿"
var sortId: Int { return SkinTone.allCases.firstIndex(of: self)! }
static func sequence(from codepoints: [UnicodeScalar]) -> SkinToneSequence {
codepoints
.map { SkinTone(rawValue: $0)! }
.reduce(into: [SkinTone]()) { result, skinTone in
guard !result.contains(skinTone) else { return }
result.append(skinTone)
}
}
}
}
extension EmojiModel.SkinToneSequence {
static var none: EmojiModel.SkinToneSequence = []
}
// MARK: - File Writers
extension EmojiGenerator {
static func writePrimaryFile(from emojiModel: EmojiModel) {
// Main enum: Create a string enum defining our enumNames equal to the baseEmoji string
// e.g. case grinning = "😀"
writeBlock(fileName: "Emoji.swift") { fileHandle in
fileHandle.writeLine("// swiftlint:disable all")
fileHandle.writeLine("")
fileHandle.writeLine("/// A sorted representation of all available emoji")
fileHandle.writeLine("enum Emoji: String, CaseIterable, Equatable {")
fileHandle.indent {
emojiModel.definitions.forEach {
fileHandle.writeLine("case \($0.enumName) = \"\($0.baseEmoji)\"")
}
}
fileHandle.writeLine("}")
fileHandle.writeLine("// swiftlint:disable all")
}
}
static func writeStringConversionsFile(from emojiModel: EmojiModel) {
// Inline helpers:
var firstItem = true
func conditionalCheckForEmojiItem(_ item: EmojiModel.EmojiDefinition.Emoji) -> String {
let isFirst = (firstItem == true)
firstItem = false
let prefix = isFirst ? "" : "} else "
let suffix = "if rawValue == \"\(item.emojiChar)\" {"
return prefix + suffix
}
func conversionForEmojiItem(_ item: EmojiModel.EmojiDefinition.Emoji, definition: EmojiModel.EmojiDefinition) -> String {
let skinToneString: String
if item.skintoneSequence.isEmpty {
skinToneString = "nil"
} else {
skinToneString = "[\(item.skintoneSequence.map { ".\($0)" }.joined(separator: ", "))]"
}
return "self.init(baseEmoji: .\(definition.enumName), skinTones: \(skinToneString))"
}
// Conversion from String: Creates an initializer mapping a single character emoji string to an EmojiWithSkinTones
// e.g.
// if rawValue == "😀" { self.init(baseEmoji: .grinning, skinTones: nil) }
// else if rawValue == "🦻🏻" { self.init(baseEmoji: .earWithHearingAid, skinTones: [.light])
writeBlock(fileName: "EmojiWithSkinTones+String.swift") { fileHandle in
fileHandle.writeLine("extension EmojiWithSkinTones {")
fileHandle.indent {
fileHandle.writeLine("init?(rawValue: String) {")
fileHandle.indent {
fileHandle.writeLine("guard rawValue.isSingleEmoji else { return nil }")
emojiModel.definitions.forEach { definition in
definition.variants.forEach { emoji in
fileHandle.writeLine(conditionalCheckForEmojiItem(emoji))
fileHandle.indent {
fileHandle.writeLine(conversionForEmojiItem(emoji, definition: definition))
}
}
}
fileHandle.writeLine("} else {")
fileHandle.indent {
fileHandle.writeLine("return nil")
}
fileHandle.writeLine("}")
}
fileHandle.writeLine("}")
}
fileHandle.writeLine("}")
}
}
static func writeSkinToneLookupFile(from emojiModel: EmojiModel) {
writeBlock(fileName: "Emoji+SkinTones.swift") { fileHandle in
fileHandle.writeLine("extension Emoji {")
fileHandle.indent {
// SkinTone enum
fileHandle.writeLine("enum SkinTone: String, CaseIterable, Equatable {")
fileHandle.indent {
for skinTone in EmojiModel.SkinTone.allCases {
fileHandle.writeLine("case \(skinTone) = \"\(skinTone.rawValue)\"")
}
}
fileHandle.writeLine("}")
fileHandle.writeLine("")
// skin tone helpers
fileHandle.writeLine("var hasSkinTones: Bool { return emojiPerSkinTonePermutation != nil }")
fileHandle.writeLine("var allowsMultipleSkinTones: Bool { return hasSkinTones && skinToneComponentEmoji != nil }")
fileHandle.writeLine("")
// Start skinToneComponentEmoji
fileHandle.writeLine("var skinToneComponentEmoji: [Emoji]? {")
fileHandle.indent {
fileHandle.writeLine("switch self {")
emojiModel.definitions.forEach { emojiDef in
if let components = emojiDef.skinToneComponents {
fileHandle.writeLine("case .\(emojiDef.enumName): return \(components)")
}
}
fileHandle.writeLine("default: return nil")
fileHandle.writeLine("}")
}
fileHandle.writeLine("}")
fileHandle.writeLine("")
// Start emojiPerSkinTonePermutation
fileHandle.writeLine("var emojiPerSkinTonePermutation: [[SkinTone]: String]? {")
fileHandle.indent {
fileHandle.writeLine("switch self {")
emojiModel.definitions.forEach { emojiDef in
let skintoneVariants = emojiDef.variants.filter({ $0.skintoneSequence != .none})
if skintoneVariants.isEmpty {
// None of our variants have a skintone, nothing to do
return
}
fileHandle.writeLine("case .\(emojiDef.enumName):")
fileHandle.indent {
fileHandle.writeLine("return [")
fileHandle.indent {
skintoneVariants.forEach {
let skintoneSequenceKey = $0.skintoneSequence.map({ ".\($0)" }).joined(separator: ", ")
fileHandle.writeLine("[\(skintoneSequenceKey)]: \"\($0.emojiChar)\",")
}
}
fileHandle.writeLine("]")
}
}
fileHandle.writeLine("default: return nil")
fileHandle.writeLine("}")
}
fileHandle.writeLine("}")
}
fileHandle.writeLine("}")
}
}
static func writeCategoryLookupFile(from emojiModel: EmojiModel) {
let outputCategories: [RemoteModel.EmojiCategory] = [
.smileysAndPeople,
.animals,
.food,
.activities,
.travel,
.objects,
.symbols,
.flags
]
writeBlock(fileName: "Emoji+Category.swift") { fileHandle in
fileHandle.writeLine("extension Emoji {")
fileHandle.indent {
// Category enum
fileHandle.writeLine("enum Category: String, CaseIterable, Equatable {")
fileHandle.indent {
// Declare cases
for category in outputCategories {
fileHandle.writeLine("case \(category) = \"\(category.rawValue)\"")
}
fileHandle.writeLine("")
// Localized name for category
fileHandle.writeLine("var localizedName: String {")
fileHandle.indent {
fileHandle.writeLine("switch self {")
for category in outputCategories {
fileHandle.writeLine("case .\(category):")
fileHandle.indent {
let stringKey = "EMOJI_CATEGORY_\("\(category)".uppercased())_NAME"
let stringComment = "The name for the emoji category '\(category.rawValue)'"
fileHandle.writeLine("return NSLocalizedString(\"\(stringKey)\", comment: \"\(stringComment)\")")
}
}
fileHandle.writeLine("}")
}
fileHandle.writeLine("}")
fileHandle.writeLine("")
// Emoji lookup per category
fileHandle.writeLine("var normalizedEmoji: [Emoji] {")
fileHandle.indent {
fileHandle.writeLine("switch self {")
let normalizedEmojiPerCategory: [RemoteModel.EmojiCategory: [EmojiModel.EmojiDefinition]]
normalizedEmojiPerCategory = emojiModel.definitions.reduce(into: [:]) { result, emojiDef in
if emojiDef.isNormalized {
var categoryList = result[emojiDef.category] ?? []
categoryList.append(emojiDef)
result[emojiDef.category] = categoryList
}
}
for category in outputCategories {
let emoji: [EmojiModel.EmojiDefinition] = {
switch category {
case .smileysAndPeople:
// Merge smileys & people. It's important we initially bucket these separately,
// because we want the emojis to be sorted smileys followed by people
return normalizedEmojiPerCategory[.smileys]! + normalizedEmojiPerCategory[.people]!
default:
return normalizedEmojiPerCategory[category]!
}
}()
fileHandle.writeLine("case .\(category):")
fileHandle.indent {
fileHandle.writeLine("return [")
fileHandle.indent {
emoji.compactMap { $0.enumName }.forEach { name in
fileHandle.writeLine(".\(name),")
}
}
fileHandle.writeLine("]")
}
}
fileHandle.writeLine("}")
}
fileHandle.writeLine("}")
}
fileHandle.writeLine("}")
fileHandle.writeLine("")
// Category lookup per emoji
fileHandle.writeLine("var category: Category {")
fileHandle.indent {
fileHandle.writeLine("switch self {")
for emojiDef in emojiModel.definitions {
let category = [.smileys, .people].contains(emojiDef.category) ? .smileysAndPeople : emojiDef.category
if category != .components {
fileHandle.writeLine("case .\(emojiDef.enumName): return .\(category)")
}
}
// Write a default case, because this enum is too long for the compiler to validate it's exhaustive
fileHandle.writeLine("default: fatalError(\"Unexpected case \\(self)\")")
fileHandle.writeLine("}")
}
fileHandle.writeLine("}")
fileHandle.writeLine("")
// Normalized variant mapping
fileHandle.writeLine("var isNormalized: Bool { normalized == self }")
fileHandle.writeLine("var normalized: Emoji {")
fileHandle.indent {
fileHandle.writeLine("switch self {")
emojiModel.definitions.filter { !$0.isNormalized }.forEach {
fileHandle.writeLine("case .\($0.enumName): return .\($0.normalizedEnumName)")
}
fileHandle.writeLine("default: return self")
fileHandle.writeLine("}")
}
fileHandle.writeLine("}")
}
fileHandle.writeLine("}")
}
}
static func writeNameLookupFile(from emojiModel: EmojiModel) {
// Name lookup: Create a computed property mapping an Emoji enum element to the raw Emoji name string
// e.g. case .grinning: return "GRINNING FACE"
writeBlock(fileName: "Emoji+Name.swift") { fileHandle in
fileHandle.writeLine("extension Emoji {")
fileHandle.indent {
fileHandle.writeLine("var name: String {")
fileHandle.indent {
fileHandle.writeLine("switch self {")
emojiModel.definitions.forEach {
fileHandle.writeLine("case .\($0.enumName): return \"\($0.rawName)\"")
}
fileHandle.writeLine("}")
}
fileHandle.writeLine("}")
}
fileHandle.writeLine("}")
}
}
}
// MARK: - File I/O Helpers
class WriteHandle {
static let emojiDirectory = URL(
fileURLWithPath: "../Session/Emoji",
isDirectory: true,
relativeTo: EmojiGenerator.pathToFolderContainingThisScript!)
let handle: FileHandle
var indentDepth: Int = 0
var hasBeenClosed = false
func indent(_ block: () -> Void) {
indentDepth += 1
block()
indentDepth -= 1
}
func writeLine(_ body: String) {
let spaces = indentDepth * 4
let prefix = String(repeating: " ", count: spaces)
let suffix = "\n"
let line = prefix + body + suffix
handle.write(line.data(using: .utf8)!)
}
init(fileName: String) {
// Create directory if necessary
if !FileManager.default.fileExists(atPath: Self.emojiDirectory.path) {
try! FileManager.default.createDirectory(at: Self.emojiDirectory, withIntermediateDirectories: true, attributes: nil)
}
// Delete old file and create anew
let url = URL(fileURLWithPath: fileName, relativeTo: Self.emojiDirectory)
if FileManager.default.fileExists(atPath: url.path) {
try! FileManager.default.removeItem(at: url)
}
FileManager.default.createFile(atPath: url.path, contents: nil, attributes: nil)
handle = try! FileHandle(forWritingTo: url)
}
deinit {
precondition(hasBeenClosed, "File handle still open at de-init")
}
func close() {
handle.closeFile()
hasBeenClosed = true
}
}
extension EmojiGenerator {
static func writeBlock(fileName: String, block: (WriteHandle) -> Void) {
let fileHandle = WriteHandle(fileName: fileName)
defer { fileHandle.close() }
fileHandle.writeLine("")
fileHandle.writeLine("// This file is generated by EmojiGenerator.swift, do not manually edit it.")
fileHandle.writeLine("")
block(fileHandle)
}
// from http://stackoverflow.com/a/31480534/255489
static var pathToFolderContainingThisScript: URL? = {
let cwd = FileManager.default.currentDirectoryPath
let script = CommandLine.arguments[0]
if script.hasPrefix("/") { // absolute
let path = (script as NSString).deletingLastPathComponent
return URL(fileURLWithPath: path)
} else { // relative
let urlCwd = URL(fileURLWithPath: cwd)
if let urlPath = URL(string: script, relativeTo: urlCwd) {
let path = (urlPath.path as NSString).deletingLastPathComponent
return URL(fileURLWithPath: path)
}
}
return nil
}()
}
// MARK: - Misc
extension String {
var titlecase: String {
components(separatedBy: " ")
.map { $0.first!.uppercased() + $0.dropFirst().lowercased() }
.joined(separator: " ")
}
}
// MARK: - Lifecycle
class EmojiGenerator {
static func run() throws {
let remoteData = try RemoteModel.fetchEmojiData()
let model = try EmojiModel(rawJSONData: remoteData)
writePrimaryFile(from: model)
writeStringConversionsFile(from: model)
writeSkinToneLookupFile(from: model)
writeCategoryLookupFile(from: model)
writeNameLookupFile(from: model)
}
}
do {
try EmojiGenerator.run()
} catch {
print("Failed to generate emoji data: \(error)")
let errorCode = (error as? CustomNSError)?.errorCode ?? -1
exit(Int32(errorCode))
}

View File

@ -158,6 +158,13 @@
7B93D07327CF19C800811CB6 /* MessageRequestsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93D07227CF19C800811CB6 /* MessageRequestsMigration.swift */; };
7B93D07727CF1A8A00811CB6 /* MockDataGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93D07527CF1A8900811CB6 /* MockDataGenerator.swift */; };
7B9F71C928470667006DFE7B /* ReactionListSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9F71C828470667006DFE7B /* ReactionListSheet.swift */; };
7B9F71D02852EEE2006DFE7B /* Emoji+Category.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9F71CB2852EEE2006DFE7B /* Emoji+Category.swift */; };
7B9F71D12852EEE2006DFE7B /* EmojiWithSkinTones+String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9F71CC2852EEE2006DFE7B /* EmojiWithSkinTones+String.swift */; };
7B9F71D22852EEE2006DFE7B /* Emoji+SkinTones.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9F71CD2852EEE2006DFE7B /* Emoji+SkinTones.swift */; };
7B9F71D32852EEE2006DFE7B /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9F71CE2852EEE2006DFE7B /* Emoji.swift */; };
7B9F71D42852EEE2006DFE7B /* Emoji+Name.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9F71CF2852EEE2006DFE7B /* Emoji+Name.swift */; };
7B9F71D72853100A006DFE7B /* Emoji+Available.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9F71D528531009006DFE7B /* Emoji+Available.swift */; };
7B9F71D82853100A006DFE7B /* EmojiWithSkinTones.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9F71D628531009006DFE7B /* EmojiWithSkinTones.swift */; };
7BA68909272A27BE00EFC32F /* SessionCall.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BA68908272A27BE00EFC32F /* SessionCall.swift */; };
7BA6890D27325CCC00EFC32F /* SessionCallManager+CXCallController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BA6890C27325CCC00EFC32F /* SessionCallManager+CXCallController.swift */; };
7BA6890F27325CE300EFC32F /* SessionCallManager+CXProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BA6890E27325CE300EFC32F /* SessionCallManager+CXProvider.swift */; };
@ -1155,6 +1162,13 @@
7B93D07227CF19C800811CB6 /* MessageRequestsMigration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRequestsMigration.swift; sourceTree = "<group>"; };
7B93D07527CF1A8900811CB6 /* MockDataGenerator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockDataGenerator.swift; sourceTree = "<group>"; };
7B9F71C828470667006DFE7B /* ReactionListSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionListSheet.swift; sourceTree = "<group>"; };
7B9F71CB2852EEE2006DFE7B /* Emoji+Category.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Emoji+Category.swift"; sourceTree = "<group>"; };
7B9F71CC2852EEE2006DFE7B /* EmojiWithSkinTones+String.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "EmojiWithSkinTones+String.swift"; sourceTree = "<group>"; };
7B9F71CD2852EEE2006DFE7B /* Emoji+SkinTones.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Emoji+SkinTones.swift"; sourceTree = "<group>"; };
7B9F71CE2852EEE2006DFE7B /* Emoji.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = "<group>"; };
7B9F71CF2852EEE2006DFE7B /* Emoji+Name.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Emoji+Name.swift"; sourceTree = "<group>"; };
7B9F71D528531009006DFE7B /* Emoji+Available.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Emoji+Available.swift"; sourceTree = "<group>"; };
7B9F71D628531009006DFE7B /* EmojiWithSkinTones.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmojiWithSkinTones.swift; sourceTree = "<group>"; };
7BA68908272A27BE00EFC32F /* SessionCall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionCall.swift; sourceTree = "<group>"; };
7BA6890C27325CCC00EFC32F /* SessionCallManager+CXCallController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionCallManager+CXCallController.swift"; sourceTree = "<group>"; };
7BA6890E27325CE300EFC32F /* SessionCallManager+CXProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionCallManager+CXProvider.swift"; sourceTree = "<group>"; };
@ -2127,6 +2141,20 @@
path = Views;
sourceTree = "<group>";
};
7B9F71CA2852EEE2006DFE7B /* Emoji */ = {
isa = PBXGroup;
children = (
7B9F71D528531009006DFE7B /* Emoji+Available.swift */,
7B9F71D628531009006DFE7B /* EmojiWithSkinTones.swift */,
7B9F71CB2852EEE2006DFE7B /* Emoji+Category.swift */,
7B9F71CC2852EEE2006DFE7B /* EmojiWithSkinTones+String.swift */,
7B9F71CD2852EEE2006DFE7B /* Emoji+SkinTones.swift */,
7B9F71CE2852EEE2006DFE7B /* Emoji.swift */,
7B9F71CF2852EEE2006DFE7B /* Emoji+Name.swift */,
);
path = Emoji;
sourceTree = "<group>";
};
7BA68907272A279900EFC32F /* Call Management */ = {
isa = PBXGroup;
children = (
@ -3665,6 +3693,7 @@
D221A093169C9E5E00537ABF /* Session */ = {
isa = PBXGroup;
children = (
7B9F71CA2852EEE2006DFE7B /* Emoji */,
C3F0A58F255C8E3D007BE2A3 /* Meta */,
B8B558ED26C4B55F00693325 /* Calls */,
C360969C25AD18BA008B62B2 /* Closed Groups */,
@ -4917,6 +4946,7 @@
EF764C351DB67CC5000D9A87 /* UIViewController+Permissions.m in Sources */,
45CD81EF1DC030E7004C9430 /* SyncPushTokensJob.swift in Sources */,
B83524A525C3BA4B0089A44F /* InfoMessageCell.swift in Sources */,
7B9F71D82853100A006DFE7B /* EmojiWithSkinTones.swift in Sources */,
B84A89BC25DE328A0040017D /* ProfilePictureVC.swift in Sources */,
34386A54207D271D009F5D9C /* NeverClearView.swift in Sources */,
451166C01FD86B98000739BA /* AccountManager.swift in Sources */,
@ -4928,6 +4958,7 @@
340FC8AA204DAC8D007AEB0F /* NotificationSettingsViewController.m in Sources */,
7BAF54CE27ACCEEC003D12F8 /* Storage+RecentSearchResults.swift in Sources */,
4C090A1B210FD9C7001FD7F9 /* HapticFeedback.swift in Sources */,
7B9F71D12852EEE2006DFE7B /* EmojiWithSkinTones+String.swift in Sources */,
3496744F2076ACD000080B5F /* LongTextViewController.swift in Sources */,
34F308A21ECB469700BB7697 /* OWSBezierPathView.m in Sources */,
B886B4A92398BA1500211ABE /* QRCode.swift in Sources */,
@ -4996,6 +5027,7 @@
45F32C222057297A00A300D5 /* MediaDetailViewController.m in Sources */,
B82149C125D605C6009C0F2A /* InfoBanner.swift in Sources */,
C3DAB3242480CB2B00725F25 /* SRCopyableLabel.swift in Sources */,
7B9F71D02852EEE2006DFE7B /* Emoji+Category.swift in Sources */,
7BAADFCC27B0EF23007BCF92 /* CallVideoView.swift in Sources */,
B8CCF63F23975CFB0091D419 /* JoinOpenGroupVC.swift in Sources */,
34ABC0E421DD20C500ED9469 /* ConversationMessageMapping.swift in Sources */,
@ -5017,14 +5049,17 @@
B8041A9525C8FA1D003C2166 /* MediaLoaderView.swift in Sources */,
45F32C232057297A00A300D5 /* MediaPageViewController.swift in Sources */,
34D2CCDA2062E7D000CB1A14 /* OWSScreenLockUI.m in Sources */,
7B9F71D42852EEE2006DFE7B /* Emoji+Name.swift in Sources */,
4CA46F4C219CCC630038ABDE /* CaptionView.swift in Sources */,
C328253025CA55370062D0A7 /* ContextMenuWindow.swift in Sources */,
340FC8B7204DAC8D007AEB0F /* OWSConversationSettingsViewController.m in Sources */,
7B1581E4271FC59D00848B49 /* CallModal.swift in Sources */,
34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */,
B84664F5235022F30083A1CD /* MentionUtilities.swift in Sources */,
7B9F71D72853100A006DFE7B /* Emoji+Available.swift in Sources */,
34D1F0C01F8EC1760066283D /* MessageRecipientStatusUtils.swift in Sources */,
7B7CB189270430D20079FF93 /* CallMessageView.swift in Sources */,
7B9F71D32852EEE2006DFE7B /* Emoji.swift in Sources */,
C328250F25CA06020062D0A7 /* VoiceMessageView.swift in Sources */,
B82B4090239DD75000A248E7 /* RestoreVC.swift in Sources */,
3488F9362191CC4000E524CC /* MediaView.swift in Sources */,
@ -5047,6 +5082,7 @@
C31D1DE9252172D4005D4DA8 /* ContactUtilities.swift in Sources */,
4521C3C01F59F3BA00B4C582 /* TextFieldHelper.swift in Sources */,
340FC8AC204DAC8D007AEB0F /* PrivacySettingsTableViewController.m in Sources */,
7B9F71D22852EEE2006DFE7B /* Emoji+SkinTones.swift in Sources */,
7B7CB18E270D066F0079FF93 /* IncomingCallBanner.swift in Sources */,
B8569AE325CBB19A00DBA3DB /* DocumentView.swift in Sources */,
7BFD1A8A2745C4F000FB91B9 /* Permissions.swift in Sources */,

View File

@ -0,0 +1,111 @@
import Foundation
extension Emoji {
private static let availableCache: Atomic<[Emoji:Bool]> = Atomic([:])
private static let iosVersionKey = "iosVersion"
private static let cacheUrl = URL(fileURLWithPath: OWSFileSystem.appSharedDataDirectoryPath())
.appendingPathComponent("Library")
.appendingPathComponent("Caches")
.appendingPathComponent("emoji.plist")
static func warmAvailableCache() {
owsAssertDebug(!Thread.isMainThread)
guard CurrentAppContext().isMainAppAndActive else { return }
var availableCache = [Emoji: Bool]()
var uncachedEmoji = [Emoji]()
let iosVersion = UIDevice.current.systemVersion
// Use an NSMutableDictionary for built-in plist serialization and heterogeneous values.
var availableMap = NSMutableDictionary()
do {
availableMap = try NSMutableDictionary(contentsOf: Self.cacheUrl, error: ())
} catch {
Logger.info("Re-building emoji availability cache. Cache could not be loaded. \(error)")
uncachedEmoji = Emoji.allCases
}
let lastIosVersion = availableMap[iosVersionKey] as? String
if lastIosVersion == iosVersion {
Logger.debug("Loading emoji availability cache (expect \(Emoji.allCases.count) items, found \(availableMap.count - 1)).")
for emoji in Emoji.allCases {
if let available = availableMap[emoji.rawValue] as? Bool {
availableCache[emoji] = available
} else {
Logger.warn("Emoji unexpectedly missing from cache: \(emoji).")
uncachedEmoji.append(emoji)
}
}
} else if uncachedEmoji.isEmpty {
Logger.info("Re-building emoji availability cache. iOS version upgraded from \(lastIosVersion ?? "(none)") -> \(iosVersion)")
uncachedEmoji = Emoji.allCases
}
if !uncachedEmoji.isEmpty {
Logger.info("Checking emoji availability for \(uncachedEmoji.count) uncached emoji")
uncachedEmoji.forEach {
let available = isEmojiAvailable($0)
availableMap[$0.rawValue] = available
availableCache[$0] = available
}
availableMap[iosVersionKey] = iosVersion
do {
// Use FileManager.createDirectory directly because OWSFileSystem.ensureDirectoryExists
// can modify the protection, and this is a system-managed directory.
try FileManager.default.createDirectory(at: Self.cacheUrl.deletingLastPathComponent(),
withIntermediateDirectories: true)
try availableMap.write(to: Self.cacheUrl)
} catch {
Logger.warn("Failed to save emoji availability cache; it will be recomputed next time! \(error)")
}
}
Logger.info("Warmed emoji availability cache with \(availableCache.lazy.filter { $0.value }.count) available emoji for iOS \(iosVersion)")
Self.availableCache.mutate{ $0 = availableCache }
}
private static func isEmojiAvailable(_ emoji: Emoji) -> Bool {
return emoji.rawValue.isUnicodeStringAvailable
}
/// Indicates whether the given emoji is available on this iOS
/// version. We cache the availability in memory.
var available: Bool {
guard let available = Self.availableCache.wrappedValue[self] else {
let available = Self.isEmojiAvailable(self)
Self.availableCache.mutate{ $0[self] = available }
return available
}
return available
}
}
private extension String {
/// A known undefined unicode character for comparison
private static let unknownUnicodeStringPng = "\u{1fff}".unicodeStringPngRepresentation
// Based on https://stackoverflow.com/a/41393387
// Check if an emoji is available on the current iOS version
// by verifying its image is different than the "unknown"
// reference image
var isUnicodeStringAvailable: Bool {
guard self.isSingleEmoji else { return false }
return String.unknownUnicodeStringPng != unicodeStringPngRepresentation
}
var unicodeStringPngRepresentation: Data? {
let attributes = [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 8)]
let size = (self as NSString).size(withAttributes: attributes)
UIGraphicsBeginImageContext(size)
defer { UIGraphicsEndImageContext() }
(self as NSString).draw(at: CGPoint(x: 0, y: 0), withAttributes: attributes)
guard let unicodeImage = UIGraphicsGetImageFromCurrentImageContext() else { return nil }
return unicodeImage.pngData()
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1863
Session/Emoji/Emoji.swift Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,73 @@
//
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
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 = skinTones {
return baseEmoji.emojiPerSkinTonePermutation?[skinTones] ?? baseEmoji.rawValue
} else {
return baseEmoji.rawValue
}
}
}
extension Emoji {
private static let keyValueStore = SDSKeyValueStore(collection: "Emoji+PreferredSkinTonePermutation")
static func allSendableEmojiByCategoryWithPreferredSkinTones(transaction: YapDatabaseReadTransaction) -> [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: YapDatabaseReadTransaction) -> EmojiWithSkinTones {
guard let rawSkinTones = Self.keyValueStore.getObject(forKey: rawValue, transaction: transaction) as? [String] else {
return EmojiWithSkinTones(baseEmoji: self, skinTones: nil)
}
return EmojiWithSkinTones(baseEmoji: self, skinTones: rawSkinTones.compactMap { SkinTone(rawValue: $0) })
}
func setPreferredSkinTones(_ preferredSkinTonePermutation: [SkinTone]?, transaction: YapDatabaseReadWriteTransaction) {
if let preferredSkinTonePermutation = preferredSkinTonePermutation {
Self.keyValueStore.setObject(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
}
}

View File

@ -131,7 +131,7 @@ extension String {
return CTLineGetGlyphCount(line)
}
var isSingleEmoji: Bool {
public var isSingleEmoji: Bool {
return glyphCount == 1 && containsEmoji
}