diff --git a/DB/Sources/DB/Content/ContentDatabase+Migration.swift b/DB/Sources/DB/Content/ContentDatabase+Migration.swift index 8f061ee..0ef1404 100644 --- a/DB/Sources/DB/Content/ContentDatabase+Migration.swift +++ b/DB/Sources/DB/Content/ContentDatabase+Migration.swift @@ -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() diff --git a/DB/Sources/DB/Content/ContentDatabase.swift b/DB/Sources/DB/Content/ContentDatabase.swift index 94c132b..0a1a60e 100644 --- a/DB/Sources/DB/Content/ContentDatabase.swift +++ b/DB/Sources/DB/Content/ContentDatabase.swift @@ -394,6 +394,19 @@ public extension ContentDatabase { .eraseToAnyPublisher() } + func updateUse(emoji: String, system: Bool) -> AnyPublisher { + 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( diff --git a/DB/Sources/DB/Entities/EmojiUse.swift b/DB/Sources/DB/Entities/EmojiUse.swift new file mode 100644 index 0000000..4080c96 --- /dev/null +++ b/DB/Sources/DB/Entities/EmojiUse.swift @@ -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) + } +} diff --git a/ServiceLayer/Sources/ServiceLayer/Entities/EmojiUse.swift b/ServiceLayer/Sources/ServiceLayer/Entities/EmojiUse.swift new file mode 100644 index 0000000..61c33a8 --- /dev/null +++ b/ServiceLayer/Sources/ServiceLayer/Entities/EmojiUse.swift @@ -0,0 +1,5 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import DB + +public typealias EmojiUse = DB.EmojiUse diff --git a/ServiceLayer/Sources/ServiceLayer/Entities/PickerEmoji.swift b/ServiceLayer/Sources/ServiceLayer/Entities/PickerEmoji.swift index dbfb543..5ccd53d 100644 --- a/ServiceLayer/Sources/ServiceLayer/Entities/PickerEmoji.swift +++ b/ServiceLayer/Sources/ServiceLayer/Entities/PickerEmoji.swift @@ -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 { diff --git a/ServiceLayer/Sources/ServiceLayer/Services/EmojiPickerService.swift b/ServiceLayer/Sources/ServiceLayer/Services/EmojiPickerService.swift index 493fe97..a1025db 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/EmojiPickerService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/EmojiPickerService.swift @@ -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 { + contentDatabase.updateUse(emoji: emoji.name, system: emoji.system) + } } private extension EmojiPickerService { diff --git a/View Controllers/EmojiPickerViewController.swift b/View Controllers/EmojiPickerViewController.swift index 318f5a3..9ed2752 100644 --- a/View Controllers/EmojiPickerViewController.swift +++ b/View Controllers/EmojiPickerViewController.swift @@ -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 } diff --git a/View Controllers/NewStatusViewController.swift b/View Controllers/NewStatusViewController.swift index 1316211..180b75d 100644 --- a/View Controllers/NewStatusViewController.swift +++ b/View Controllers/NewStatusViewController.swift @@ -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() diff --git a/ViewModels/Sources/ViewModels/EmojiPickerViewModel.swift b/ViewModels/Sources/ViewModels/EmojiPickerViewModel.swift index 9c762c6..4ef2537 100644 --- a/ViewModels/Sources/ViewModels/EmojiPickerViewModel.swift +++ b/ViewModels/Sources/ViewModels/EmojiPickerViewModel.swift @@ -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() @@ -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) diff --git a/Views/EmojiView.swift b/Views/EmojiView.swift index d92c427..747628d 100644 --- a/Views/EmojiView.swift +++ b/Views/EmojiView.swift @@ -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 } } }