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)
}
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
t.column("id", .text).primaryKey(onConflict: .replace)
t.column("unread", .boolean).notNull()

View File

@ -394,6 +394,19 @@ public extension ContentDatabase {
.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> {
ValueObservation.tracking(
TimelineItemsInfo.request(TimelineRecord.filter(TimelineRecord.Columns.id == timeline.id)).fetchOne)
@ -514,6 +527,11 @@ public extension ContentDatabase {
.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? {
try? databaseWriter.read {
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 Mastodon
public enum PickerEmoji: Hashable {
case custom(Emoji)
case system(SystemEmoji)
public indirect enum PickerEmoji: Hashable {
case custom(Emoji, inFrequentlyUsed: Bool)
case system(SystemEmoji, inFrequentlyUsed: Bool)
}
public extension PickerEmoji {
@ -15,6 +15,42 @@ public extension PickerEmoji {
case customNamed(String)
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 {

View File

@ -35,9 +35,9 @@ public extension EmojiPickerService {
}
if typed[category] == nil {
typed[category] = [.custom(emoji)]
typed[category] = [.custom(emoji, inFrequentlyUsed: false)]
} else {
typed[category]?.append(.custom(emoji))
typed[category]?.append(.custom(emoji, inFrequentlyUsed: false))
}
}
@ -71,7 +71,11 @@ public extension EmojiPickerService {
typed[.systemGroup(group)] = emoji
.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))
@ -116,6 +120,14 @@ public extension EmojiPickerService {
}
.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 {

View File

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

View File

@ -368,17 +368,8 @@ private extension NewStatusViewController {
let emojiPickerController = EmojiPickerViewController(viewModel: emojiPickerViewModel) {
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 {
textInput.replace(selectedTextRange, withText: emojiString.appending(" "))
textInput.replace(selectedTextRange, withText: $0.escaped.appending(" "))
}
} dismissAction: {
fromView.becomeFirstResponder()

View File

@ -15,6 +15,7 @@ final public class EmojiPickerViewModel: ObservableObject {
private let emojiPickerService: EmojiPickerService
@Published private var customEmoji = [PickerEmoji.Category: [PickerEmoji]]()
@Published private var systemEmoji = [PickerEmoji.Category: [PickerEmoji]]()
@Published private var emojiUses = [EmojiUse]()
@Published private var systemEmojiAnnotationsAndTags = [String: String]()
private var cancellables = Set<AnyCancellable>()
@ -32,41 +33,42 @@ final public class EmojiPickerViewModel: ObservableObject {
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.assign(to: &$systemEmoji)
emojiPickerService.emojiUses(limit: Self.frequentlyUsedLimit)
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.print()
.assign(to: &$emojiUses)
$customEmoji.dropFirst().combineLatest(
$systemEmoji.dropFirst(),
$query,
$locale.combineLatest($systemEmojiAnnotationsAndTags)) // Combine API limits to 4 params
$locale.combineLatest($systemEmojiAnnotationsAndTags, $emojiUses.dropFirst()))
.map {
let (customEmoji, systemEmoji, query, (locale, systemEmojiAnnotationsAndTags)) = $0
var queriedCustomEmoji = customEmoji
var queriedSystemEmoji = systemEmoji
let (customEmoji, systemEmoji, query, (locale, systemEmojiAnnotationsAndTags, emojiUses)) = $0
var emojis = customEmoji.merging(systemEmoji) { $1 }
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 {
$0.key.matches(query: query, locale: locale)
}.values)
queriedSystemEmoji = queriedSystemEmoji.mapValues {
emojis = emojis.mapValues {
$0.filter {
guard case let .system(emoji) = $0 else { return false }
return matchingSystemEmojis.contains(emoji.emoji)
if $0.system {
return matchingSystemEmojis.contains($0.name)
} 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)
@ -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 {
func matches(query: String, locale: Locale) -> Bool {
lowercased(with: locale)

View File

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