Frequently used emoji

This commit is contained in:
Justin Mazzocchi 2021-01-15 16:58:10 -08:00
parent 65ba491a4b
commit b2ff1d0a0b
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
10 changed files with 151 additions and 44 deletions

View file

@ -143,6 +143,13 @@ extension ContentDatabase {
t.column("category", .text) t.column("category", .text)
} }
try db.create(table: "emojiUse") { t in
t.column("emoji", .text).primaryKey(onConflict: .replace)
t.column("system", .boolean).notNull()
t.column("lastUse", .datetime).notNull()
t.column("count", .integer).notNull()
}
try db.create(table: "conversationRecord") { t in try db.create(table: "conversationRecord") { t in
t.column("id", .text).primaryKey(onConflict: .replace) t.column("id", .text).primaryKey(onConflict: .replace)
t.column("unread", .boolean).notNull() t.column("unread", .boolean).notNull()

View file

@ -394,6 +394,19 @@ public extension ContentDatabase {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func updateUse(emoji: String, system: Bool) -> AnyPublisher<Never, Error> {
databaseWriter.writePublisher {
let count = try Int.fetchOne(
$0,
EmojiUse.filter(EmojiUse.Columns.system == system && EmojiUse.Columns.emoji == emoji)
.select(EmojiUse.Columns.count))
try EmojiUse(emoji: emoji, system: system, lastUse: Date(), count: (count ?? 0) + 1).save($0)
}
.ignoreOutput()
.eraseToAnyPublisher()
}
func timelinePublisher(_ timeline: Timeline) -> AnyPublisher<[[CollectionItem]], Error> { func timelinePublisher(_ timeline: Timeline) -> AnyPublisher<[[CollectionItem]], Error> {
ValueObservation.tracking( ValueObservation.tracking(
TimelineItemsInfo.request(TimelineRecord.filter(TimelineRecord.Columns.id == timeline.id)).fetchOne) TimelineItemsInfo.request(TimelineRecord.filter(TimelineRecord.Columns.id == timeline.id)).fetchOne)
@ -514,6 +527,11 @@ public extension ContentDatabase {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func emojiUses(limit: Int) -> AnyPublisher<[EmojiUse], Error> {
databaseWriter.readPublisher(value: EmojiUse.all().order(EmojiUse.Columns.count.desc).limit(limit).fetchAll)
.eraseToAnyPublisher()
}
func lastReadId(_ markerTimeline: Marker.Timeline) -> String? { func lastReadId(_ markerTimeline: Marker.Timeline) -> String? {
try? databaseWriter.read { try? databaseWriter.read {
try String.fetchOne( try String.fetchOne(

View file

@ -0,0 +1,20 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import GRDB
public struct EmojiUse: ContentDatabaseRecord, Hashable {
public let emoji: String
public let system: Bool
public let lastUse: Date
public let count: Int
}
extension EmojiUse {
enum Columns {
static let emoji = Column(CodingKeys.emoji)
static let system = Column(CodingKeys.system)
static let lastUse = Column(CodingKeys.lastUse)
static let count = Column(CodingKeys.count)
}
}

View file

@ -0,0 +1,5 @@
// Copyright © 2021 Metabolist. All rights reserved.
import DB
public typealias EmojiUse = DB.EmojiUse

View file

@ -3,9 +3,9 @@
import Foundation import Foundation
import Mastodon import Mastodon
public enum PickerEmoji: Hashable { public indirect enum PickerEmoji: Hashable {
case custom(Emoji) case custom(Emoji, inFrequentlyUsed: Bool)
case system(SystemEmoji) case system(SystemEmoji, inFrequentlyUsed: Bool)
} }
public extension PickerEmoji { public extension PickerEmoji {
@ -15,6 +15,42 @@ public extension PickerEmoji {
case customNamed(String) case customNamed(String)
case systemGroup(SystemEmoji.Group) case systemGroup(SystemEmoji.Group)
} }
var name: String {
switch self {
case let .custom(emoji, _):
return emoji.shortcode
case let .system(emoji, _):
return emoji.emoji
}
}
var system: Bool {
switch self {
case .system:
return true
default:
return false
}
}
var escaped: String {
switch self {
case let .custom(emoji, _):
return ":\(emoji.shortcode):"
case let .system(emoji, _):
return emoji.emoji
}
}
var inFrequentlyUsed: Self {
switch self {
case let .custom(emoji, _):
return .custom(emoji, inFrequentlyUsed: true)
case let .system(emoji, _):
return .system(emoji, inFrequentlyUsed: true)
}
}
} }
extension PickerEmoji.Category: Comparable { extension PickerEmoji.Category: Comparable {

View file

@ -35,9 +35,9 @@ public extension EmojiPickerService {
} }
if typed[category] == nil { if typed[category] == nil {
typed[category] = [.custom(emoji)] typed[category] = [.custom(emoji, inFrequentlyUsed: false)]
} else { } else {
typed[category]?.append(.custom(emoji)) typed[category]?.append(.custom(emoji, inFrequentlyUsed: false))
} }
} }
@ -71,7 +71,11 @@ public extension EmojiPickerService {
typed[.systemGroup(group)] = emoji typed[.systemGroup(group)] = emoji
.filter { !($0.version > Self.maxEmojiVersion) } .filter { !($0.version > Self.maxEmojiVersion) }
.map { PickerEmoji.system($0.withMaxVersionForSkinToneVariations(Self.maxEmojiVersion)) } .map {
PickerEmoji.system(
$0.withMaxVersionForSkinToneVariations(Self.maxEmojiVersion),
inFrequentlyUsed: false)
}
} }
return promise(.success(typed)) return promise(.success(typed))
@ -116,6 +120,14 @@ public extension EmojiPickerService {
} }
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func emojiUses(limit: Int) -> AnyPublisher<[EmojiUse], Error> {
contentDatabase.emojiUses(limit: limit)
}
func updateUse(emoji: PickerEmoji) -> AnyPublisher<Never, Error> {
contentDatabase.updateUse(emoji: emoji.name, system: emoji.system)
}
} }
private extension EmojiPickerService { private extension EmojiPickerService {

View file

@ -197,20 +197,22 @@ extension EmojiPickerViewController: UICollectionViewDelegate {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return } guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
select(emoji: applyingDefaultSkinTone(emoji: item)) select(emoji: applyingDefaultSkinTone(emoji: item))
viewModel.updateUse(emoji: item)
} }
func collectionView(_ collectionView: UICollectionView, func collectionView(_ collectionView: UICollectionView,
contextMenuConfigurationForItemAt indexPath: IndexPath, contextMenuConfigurationForItemAt indexPath: IndexPath,
point: CGPoint) -> UIContextMenuConfiguration? { point: CGPoint) -> UIContextMenuConfiguration? {
guard let item = dataSource.itemIdentifier(for: indexPath), guard let item = dataSource.itemIdentifier(for: indexPath),
case let .system(emoji) = item, case let .system(emoji, inFrequentlyUsed) = item,
!emoji.skinToneVariations.isEmpty !emoji.skinToneVariations.isEmpty
else { return nil } else { return nil }
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in
UIMenu(children: ([emoji] + emoji.skinToneVariations).map { skinToneVariation in UIMenu(children: ([emoji] + emoji.skinToneVariations).map { skinToneVariation in
UIAction(title: skinToneVariation.emoji) { [weak self] _ in UIAction(title: skinToneVariation.emoji) { [weak self] _ in
self?.select(emoji: .system(skinToneVariation)) self?.select(emoji: .system(skinToneVariation, inFrequentlyUsed: inFrequentlyUsed))
self?.viewModel.updateUse(emoji: item)
} }
}) })
} }
@ -235,9 +237,9 @@ private extension EmojiPickerViewController {
} }
func applyingDefaultSkinTone(emoji: PickerEmoji) -> PickerEmoji { func applyingDefaultSkinTone(emoji: PickerEmoji) -> PickerEmoji {
if case let .system(systemEmoji) = emoji, if case let .system(systemEmoji, inFrequentlyUsed) = emoji,
let defaultEmojiSkinTone = viewModel.identification.appPreferences.defaultEmojiSkinTone { let defaultEmojiSkinTone = viewModel.identification.appPreferences.defaultEmojiSkinTone {
return .system(systemEmoji.applying(skinTone: defaultEmojiSkinTone)) return .system(systemEmoji.applying(skinTone: defaultEmojiSkinTone), inFrequentlyUsed: inFrequentlyUsed)
} else { } else {
return emoji return emoji
} }

View file

@ -368,17 +368,8 @@ private extension NewStatusViewController {
let emojiPickerController = EmojiPickerViewController(viewModel: emojiPickerViewModel) { let emojiPickerController = EmojiPickerViewController(viewModel: emojiPickerViewModel) {
guard let textInput = fromView as? UITextInput else { return } guard let textInput = fromView as? UITextInput else { return }
let emojiString: String
switch $0 {
case let .custom(emoji):
emojiString = ":\(emoji.shortcode):"
case let .system(emoji):
emojiString = emoji.emoji
}
if let selectedTextRange = textInput.selectedTextRange { if let selectedTextRange = textInput.selectedTextRange {
textInput.replace(selectedTextRange, withText: emojiString.appending(" ")) textInput.replace(selectedTextRange, withText: $0.escaped.appending(" "))
} }
} dismissAction: { } dismissAction: {
fromView.becomeFirstResponder() fromView.becomeFirstResponder()

View file

@ -15,6 +15,7 @@ final public class EmojiPickerViewModel: ObservableObject {
private let emojiPickerService: EmojiPickerService private let emojiPickerService: EmojiPickerService
@Published private var customEmoji = [PickerEmoji.Category: [PickerEmoji]]() @Published private var customEmoji = [PickerEmoji.Category: [PickerEmoji]]()
@Published private var systemEmoji = [PickerEmoji.Category: [PickerEmoji]]() @Published private var systemEmoji = [PickerEmoji.Category: [PickerEmoji]]()
@Published private var emojiUses = [EmojiUse]()
@Published private var systemEmojiAnnotationsAndTags = [String: String]() @Published private var systemEmojiAnnotationsAndTags = [String: String]()
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@ -32,41 +33,42 @@ final public class EmojiPickerViewModel: ObservableObject {
.assignErrorsToAlertItem(to: \.alertItem, on: self) .assignErrorsToAlertItem(to: \.alertItem, on: self)
.assign(to: &$systemEmoji) .assign(to: &$systemEmoji)
emojiPickerService.emojiUses(limit: Self.frequentlyUsedLimit)
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.print()
.assign(to: &$emojiUses)
$customEmoji.dropFirst().combineLatest( $customEmoji.dropFirst().combineLatest(
$systemEmoji.dropFirst(), $systemEmoji.dropFirst(),
$query, $query,
$locale.combineLatest($systemEmojiAnnotationsAndTags)) // Combine API limits to 4 params $locale.combineLatest($systemEmojiAnnotationsAndTags, $emojiUses.dropFirst()))
.map { .map {
let (customEmoji, systemEmoji, query, (locale, systemEmojiAnnotationsAndTags)) = $0 let (customEmoji, systemEmoji, query, (locale, systemEmojiAnnotationsAndTags, emojiUses)) = $0
var emojis = customEmoji.merging(systemEmoji) { $1 }
var queriedCustomEmoji = customEmoji
var queriedSystemEmoji = systemEmoji
if !query.isEmpty { if !query.isEmpty {
queriedCustomEmoji = queriedCustomEmoji.mapValues {
$0.filter {
guard case let .custom(emoji) = $0 else { return false }
return emoji.shortcode.matches(query: query, locale: locale)
}
}
queriedCustomEmoji = queriedCustomEmoji.filter { !$0.value.isEmpty }
let matchingSystemEmojis = Set(systemEmojiAnnotationsAndTags.filter { let matchingSystemEmojis = Set(systemEmojiAnnotationsAndTags.filter {
$0.key.matches(query: query, locale: locale) $0.key.matches(query: query, locale: locale)
}.values) }.values)
queriedSystemEmoji = queriedSystemEmoji.mapValues { emojis = emojis.mapValues {
$0.filter { $0.filter {
guard case let .system(emoji) = $0 else { return false } if $0.system {
return matchingSystemEmojis.contains($0.name)
return matchingSystemEmojis.contains(emoji.emoji) } else {
return $0.name.matches(query: query, locale: locale)
}
} }
} }
queriedSystemEmoji = queriedSystemEmoji.filter { !$0.value.isEmpty }
} }
return queriedSystemEmoji.merging(queriedCustomEmoji) { $1 } emojis[.frequentlyUsed] = emojiUses.compactMap { use in
emojis.values.reduce([], +)
.first { use.system == $0.system && use.emoji == $0.name }
.map(\.inFrequentlyUsed)
}
return emojis.filter { !$0.value.isEmpty }
} }
.assign(to: &$emoji) .assign(to: &$emoji)
@ -76,6 +78,19 @@ final public class EmojiPickerViewModel: ObservableObject {
} }
} }
public extension EmojiPickerViewModel {
func updateUse(emoji: PickerEmoji) {
emojiPickerService.updateUse(emoji: emoji)
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { _ in }
.store(in: &cancellables)
}
}
private extension EmojiPickerViewModel {
static let frequentlyUsedLimit = 12
}
private extension String { private extension String {
func matches(query: String, locale: Locale) -> Bool { func matches(query: String, locale: Locale) -> Bool {
lowercased(with: locale) lowercased(with: locale)

View file

@ -67,17 +67,18 @@ private extension EmojiView {
} }
func applyEmojiConfiguration() { func applyEmojiConfiguration() {
switch emojiConfiguration.emoji { imageView.isHidden = emojiConfiguration.emoji.system
case let .custom(emoji):
if case let .custom(emoji, _) = emojiConfiguration.emoji {
imageView.isHidden = false imageView.isHidden = false
emojiLabel.isHidden = true emojiLabel.isHidden = true
imageView.kf.setImage(with: emoji.url) imageView.kf.setImage(with: emoji.url)
case let .system(emoji): } else {
imageView.isHidden = true imageView.isHidden = true
emojiLabel.isHidden = false emojiLabel.isHidden = false
emojiLabel.text = emoji.emoji emojiLabel.text = emojiConfiguration.emoji.name
} }
} }
} }