Frequently used emoji
This commit is contained in:
parent
65ba491a4b
commit
b2ff1d0a0b
|
@ -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()
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright © 2021 Metabolist. All rights reserved.
|
||||
|
||||
import DB
|
||||
|
||||
public typealias EmojiUse = DB.EmojiUse
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue