mirror of
https://github.com/metabolist/metatext.git
synced 2024-11-25 17:50:59 +00:00
Frequently used emoji
This commit is contained in:
parent
65ba491a4b
commit
b2ff1d0a0b
10 changed files with 151 additions and 44 deletions
|
@ -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()
|
||||||
|
|
|
@ -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(
|
||||||
|
|
20
DB/Sources/DB/Entities/EmojiUse.swift
Normal file
20
DB/Sources/DB/Entities/EmojiUse.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
// Copyright © 2021 Metabolist. All rights reserved.
|
||||||
|
|
||||||
|
import DB
|
||||||
|
|
||||||
|
public typealias EmojiUse = DB.EmojiUse
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue