This commit is contained in:
Justin Mazzocchi 2021-01-15 02:13:10 -08:00
parent def0e3fff0
commit dfcc949864
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
42 changed files with 733 additions and 24 deletions

View file

@ -132,6 +132,17 @@ extension ContentDatabase {
t.column("wholeWord", .boolean).notNull()
}
try db.create(table: "emoji") { t in
t.column("shortcode", .text)
.primaryKey(onConflict: .replace)
.collate(.localizedCaseInsensitiveCompare)
.notNull()
t.column("staticUrl", .text).notNull()
t.column("url", .text).notNull()
t.column("visibleInPicker", .boolean).notNull()
t.column("category", .text)
}
try db.create(table: "conversationRecord") { t in
t.column("id", .text).primaryKey(onConflict: .replace)
t.column("unread", .boolean).notNull()

View file

@ -505,7 +505,10 @@ public extension ContentDatabase {
}
func pickerEmojisPublisher() -> AnyPublisher<[Emoji], Error> {
ValueObservation.tracking(Emoji.filter(Emoji.Columns.visibleInPicker == true).fetchAll)
ValueObservation.tracking(
Emoji.filter(Emoji.Columns.visibleInPicker == true)
.order(Emoji.Columns.shortcode.asc)
.fetchAll)
.removeDuplicates()
.publisher(in: databaseWriter)
.eraseToAnyPublisher()

View file

@ -0,0 +1,54 @@
// Copyright © 2021 Metabolist. All rights reserved.
import UIKit
import ViewModels
extension Dictionary where Key == PickerEmoji.Category, Value == [PickerEmoji] {
func snapshot() -> NSDiffableDataSourceSnapshot<PickerEmoji.Category, PickerEmoji> {
var snapshot = NSDiffableDataSourceSnapshot<PickerEmoji.Category, PickerEmoji>()
snapshot.appendSections(keys.sorted())
for (key, value) in self {
snapshot.appendItems(value, toSection: key)
}
return snapshot
}
}
extension PickerEmoji.Category {
var displayName: String {
switch self {
case .frequentlyUsed:
return NSLocalizedString("emoji.frequently-used", comment: "")
case .custom:
return NSLocalizedString("emoji.custom", comment: "")
case let .customNamed(name):
return name
case let .systemGroup(group):
switch group {
case .smileysAndEmotion:
return NSLocalizedString("emoji.system-group.smileys-and-emotion", comment: "")
case .peopleAndBody:
return NSLocalizedString("emoji.system-group.people-and-body", comment: "")
case .components:
return NSLocalizedString("emoji.system-group.components", comment: "")
case .animalsAndNature:
return NSLocalizedString("Animals & Nature", comment: "")
case .foodAndDrink:
return NSLocalizedString("emoji.system-group.food-and-drink", comment: "")
case .travelAndPlaces:
return NSLocalizedString("emoji.system-group.travel-and-places", comment: "")
case .activites:
return NSLocalizedString("emoji.system-group.activites", comment: "")
case .objects:
return NSLocalizedString("emoji.system-group.objects", comment: "")
case .symbols:
return NSLocalizedString("emoji.system-group.symbols", comment: "")
case .flags:
return NSLocalizedString("emoji.system-group.flags", comment: "")
}
}
}
}

View file

@ -51,6 +51,19 @@
"compose.poll.allow-multiple-choices" = "Allow multiple choices";
"compose.prompt" = "What's on your mind?";
"compose.take-photo-or-video" = "Take Photo or Video";
"emoji.custom" = "Custom";
"emoji.frequently-used" = "Frequently used";
"emoji.search" = "Search Emoji";
"emoji.system-group.smileys-and-emotion" = "Smileys & Emotion";
"emoji.system-group.people-and-body" = "People & Body";
"emoji.system-group.components" = "Components";
"emoji.system-group.animals-and-nature" = "Animals & Nature";
"emoji.system-group.food-and-drink" = "Food & Drink";
"emoji.system-group.travel-and-places" = "Travel & Places";
"emoji.system-group.activites" = "Activities";
"emoji.system-group.objects" = "Objects";
"emoji.system-group.symbols" = "Symbols";
"emoji.system-group.flags" = "Flags";
"error" = "Error";
"favorites" = "Favorites";
"registration.review-terms-of-use-and-privacy-policy-%@" = "Please review %@'s Terms of Use and Privacy Policy to continue";

View file

@ -54,6 +54,16 @@
D0625E59250F092900502611 /* StatusListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0625E58250F092900502611 /* StatusListCell.swift */; };
D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */; };
D06BC5E625202AD90079541D /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D06BC5E525202AD90079541D /* ProfileViewController.swift */; };
D07EC7CF25B13921006DF726 /* PickerEmoji+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EC7CE25B13921006DF726 /* PickerEmoji+Extensions.swift */; };
D07EC7D025B13921006DF726 /* PickerEmoji+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EC7CE25B13921006DF726 /* PickerEmoji+Extensions.swift */; };
D07EC7DC25B13DBB006DF726 /* EmojiCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EC7DB25B13DBB006DF726 /* EmojiCollectionViewCell.swift */; };
D07EC7DD25B13DBB006DF726 /* EmojiCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EC7DB25B13DBB006DF726 /* EmojiCollectionViewCell.swift */; };
D07EC7E325B13DD3006DF726 /* EmojiContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EC7E225B13DD3006DF726 /* EmojiContentConfiguration.swift */; };
D07EC7E425B13DD3006DF726 /* EmojiContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EC7E225B13DD3006DF726 /* EmojiContentConfiguration.swift */; };
D07EC7F225B13E57006DF726 /* EmojiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EC7F125B13E57006DF726 /* EmojiView.swift */; };
D07EC7F325B13E57006DF726 /* EmojiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EC7F125B13E57006DF726 /* EmojiView.swift */; };
D07EC7FD25B16994006DF726 /* EmojiCategoryHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EC7FC25B16994006DF726 /* EmojiCategoryHeaderView.swift */; };
D07EC7FE25B16994006DF726 /* EmojiCategoryHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EC7FC25B16994006DF726 /* EmojiCategoryHeaderView.swift */; };
D0849C7F25903C4900A5EBCC /* Status+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0849C7E25903C4900A5EBCC /* Status+Extensions.swift */; };
D088406D25AFBBE200BB749B /* EmojiPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D088406C25AFBBE200BB749B /* EmojiPickerViewController.swift */; };
D088406E25AFBBE200BB749B /* EmojiPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D088406C25AFBBE200BB749B /* EmojiPickerViewController.swift */; };
@ -211,6 +221,11 @@
D0666A2124C677B400F3F04B /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
D0666A2524C677B400F3F04B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D06BC5E525202AD90079541D /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = "<group>"; };
D07EC7CE25B13921006DF726 /* PickerEmoji+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PickerEmoji+Extensions.swift"; sourceTree = "<group>"; };
D07EC7DB25B13DBB006DF726 /* EmojiCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCollectionViewCell.swift; sourceTree = "<group>"; };
D07EC7E225B13DD3006DF726 /* EmojiContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiContentConfiguration.swift; sourceTree = "<group>"; };
D07EC7F125B13E57006DF726 /* EmojiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiView.swift; sourceTree = "<group>"; };
D07EC7FC25B16994006DF726 /* EmojiCategoryHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCategoryHeaderView.swift; sourceTree = "<group>"; };
D0849C7E25903C4900A5EBCC /* Status+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Status+Extensions.swift"; sourceTree = "<group>"; };
D085C3BB25008DEC008A6C5E /* DB */ = {isa = PBXFileReference; lastKnownFileType = folder; path = DB; sourceTree = "<group>"; };
D088406C25AFBBE200BB749B /* EmojiPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerViewController.swift; sourceTree = "<group>"; };
@ -473,6 +488,10 @@
D05936E825AA3F3D00754FDF /* EditAttachmentView.swift */,
D0BEB21024FA2A90001B0F04 /* EditFilterView.swift */,
D05936DD25A937EC00754FDF /* EditThumbnailView.swift */,
D07EC7FC25B16994006DF726 /* EmojiCategoryHeaderView.swift */,
D07EC7DB25B13DBB006DF726 /* EmojiCollectionViewCell.swift */,
D07EC7E225B13DD3006DF726 /* EmojiContentConfiguration.swift */,
D07EC7F125B13E57006DF726 /* EmojiView.swift */,
D0BEB20424FA1107001B0F04 /* FiltersView.swift */,
D0C7D42224F76169001EBDBB /* IdentitiesView.swift */,
D0BEB1FE24F9E5BB001B0F04 /* ListsView.swift */,
@ -550,6 +569,7 @@
D0F0B135251AA12700942152 /* CollectionItem+Extensions.swift */,
D0C7D46E24F76169001EBDBB /* KingfisherOptionsInfo+Extensions.swift */,
D0C7D46B24F76169001EBDBB /* NSMutableAttributedString+Extensions.swift */,
D07EC7CE25B13921006DF726 /* PickerEmoji+Extensions.swift */,
D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */,
D0849C7E25903C4900A5EBCC /* Status+Extensions.swift */,
D0C7D46A24F76169001EBDBB /* String+Extensions.swift */,
@ -785,8 +805,10 @@
files = (
D0C7D4A324F7616A001EBDBB /* TabNavigationView.swift in Sources */,
D02E1F95250B13210071AD56 /* SafariView.swift in Sources */,
D07EC7CF25B13921006DF726 /* PickerEmoji+Extensions.swift in Sources */,
D00702292555E51200F38136 /* ConversationListCell.swift in Sources */,
D03B1B2A253818F3008F964B /* MediaPreferencesView.swift in Sources */,
D07EC7DC25B13DBB006DF726 /* EmojiCollectionViewCell.swift in Sources */,
D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */,
D0F0B126251A90F400942152 /* AccountListCell.swift in Sources */,
D0B32F50250B373600311912 /* RegistrationView.swift in Sources */,
@ -818,6 +840,7 @@
D0E569DB2529319100FA1D72 /* LoadMoreView.swift in Sources */,
D05936FF25AA94EA00754FDF /* MarkAttachmentsSensitiveView.swift in Sources */,
D0C7D49D24F7616A001EBDBB /* PostingReadingPreferencesView.swift in Sources */,
D07EC7E325B13DD3006DF726 /* EmojiContentConfiguration.swift in Sources */,
D0B5FE9B251583DB00478838 /* ProfileCollection+Extensions.swift in Sources */,
D0C7D49E24F7616A001EBDBB /* SecondaryNavigationView.swift in Sources */,
D08B8D602540DE3B00B1EBEF /* ZoomAnimator.swift in Sources */,
@ -827,6 +850,7 @@
D059373325AAEA7000754FDF /* CompositionPollView.swift in Sources */,
D08B8D8D2544E6EC00B1EBEF /* PollResultView.swift in Sources */,
D0C7D4D524F7616A001EBDBB /* String+Extensions.swift in Sources */,
D07EC7F225B13E57006DF726 /* EmojiView.swift in Sources */,
D05936CF25A8D79800754FDF /* EditAttachmentViewController.swift in Sources */,
D0C7D4A224F7616A001EBDBB /* NotificationTypesPreferencesView.swift in Sources */,
D00702362555F4C500F38136 /* ConversationContentConfiguration.swift in Sources */,
@ -871,6 +895,7 @@
D0FCC110259C4F20000B67DF /* NewStatusView.swift in Sources */,
D0C7D49B24F7616A001EBDBB /* PreferencesView.swift in Sources */,
D088406D25AFBBE200BB749B /* EmojiPickerViewController.swift in Sources */,
D07EC7FD25B16994006DF726 /* EmojiCategoryHeaderView.swift in Sources */,
D0C7D4D724F7616A001EBDBB /* UIColor+Extensions.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -894,6 +919,8 @@
D05936D025A8D79800754FDF /* EditAttachmentViewController.swift in Sources */,
D038273C259EA38F00056E0F /* NewStatusView.swift in Sources */,
D08E52EF257D757100FA2C5F /* CompositionView.swift in Sources */,
D07EC7FE25B16994006DF726 /* EmojiCategoryHeaderView.swift in Sources */,
D07EC7E425B13DD3006DF726 /* EmojiContentConfiguration.swift in Sources */,
D0CE9F88258B076900E3A6B6 /* AttachmentUploadView.swift in Sources */,
D08E52C7257C7AEE00FA2C5F /* ShareErrorViewController.swift in Sources */,
D059370025AA94EA00754FDF /* MarkAttachmentsSensitiveView.swift in Sources */,
@ -905,13 +932,16 @@
D08E52F8257D78BE00FA2C5F /* ViewConstants.swift in Sources */,
D036EBC2259FE2AD00EC1CFC /* UIVIewController+Extensions.swift in Sources */,
D015B13525A812DD006D88A8 /* AttachmentsView.swift in Sources */,
D07EC7DD25B13DBB006DF726 /* EmojiCollectionViewCell.swift in Sources */,
D05936EA25AA3F3D00754FDF /* EditAttachmentView.swift in Sources */,
D07EC7D025B13921006DF726 /* PickerEmoji+Extensions.swift in Sources */,
D0FCC106259C4E62000B67DF /* NewStatusViewController.swift in Sources */,
D036EBBD259FE2A100EC1CFC /* Array+Extensions.swift in Sources */,
D036EBB3259FE28800EC1CFC /* UIColor+Extensions.swift in Sources */,
D088406E25AFBBE200BB749B /* EmojiPickerViewController.swift in Sources */,
D036EBB8259FE29800EC1CFC /* Status+Extensions.swift in Sources */,
D05936DF25A937EC00754FDF /* EditThumbnailView.swift in Sources */,
D07EC7F325B13E57006DF726 /* EmojiView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View file

@ -27,7 +27,8 @@ let package = Package(
targets: [
.target(
name: "ServiceLayer",
dependencies: ["CodableBloomFilter", "DB", "MastodonAPI", "Secrets"]),
dependencies: ["CodableBloomFilter", "DB", "MastodonAPI", "Secrets"],
resources: [.process("Resources")]),
.target(
name: "ServiceLayerMocks",
dependencies: [

View file

@ -0,0 +1,39 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import Mastodon
public enum PickerEmoji: Hashable {
case custom(Emoji)
case system(SystemEmoji)
}
public extension PickerEmoji {
enum Category: Hashable {
case frequentlyUsed
case custom
case customNamed(String)
case systemGroup(SystemEmoji.Group)
}
}
extension PickerEmoji.Category: Comparable {
public static func < (lhs: PickerEmoji.Category, rhs: PickerEmoji.Category) -> Bool {
lhs.order < rhs.order
}
}
private extension PickerEmoji.Category {
var order: String {
switch self {
case .frequentlyUsed:
return "0"
case .custom:
return "1"
case let .customNamed(name):
return "2.\(name)"
case let .systemGroup(group):
return "3.\(group.rawValue)"
}
}
}

View file

@ -0,0 +1,30 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
public struct SystemEmoji: Codable, Hashable {
public let emoji: String
public let version: Float
public let skins: Bool
}
public extension SystemEmoji {
enum Group: Int, Codable, Hashable, CaseIterable {
case smileysAndEmotion
case peopleAndBody
case components
case animalsAndNature
case foodAndDrink
case travelAndPlaces
case activites
case objects
case symbols
case flags
}
}
extension SystemEmoji: Comparable {
public static func < (lhs: SystemEmoji, rhs: SystemEmoji) -> Bool {
lhs.emoji < rhs.emoji
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,129 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import DB
import Foundation
import Mastodon
public enum EmojiPickerError: Error {
case invalidLocaleLanguageCode
case emojisFileMissing
case invalidSystemEmojiGroup
case annotationsAndTagsFileMissing
}
public struct EmojiPickerService {
private let contentDatabase: ContentDatabase
init(contentDatabase: ContentDatabase) {
self.contentDatabase = contentDatabase
}
}
public extension EmojiPickerService {
func customEmojiPublisher() -> AnyPublisher<[PickerEmoji.Category: [PickerEmoji]], Error> {
contentDatabase.pickerEmojisPublisher().map {
var typed = [PickerEmoji.Category: [PickerEmoji]]()
for emoji in $0 {
let category: PickerEmoji.Category
if let categoryName = emoji.category {
category = .customNamed(categoryName)
} else {
category = .custom
}
if typed[category] == nil {
typed[category] = [.custom(emoji)]
} else {
typed[category]?.append(.custom(emoji))
}
}
return typed
}
.eraseToAnyPublisher()
}
func systemEmojiPublisher() -> AnyPublisher<[PickerEmoji.Category: [PickerEmoji]], Error> {
Future { promise in
DispatchQueue.global(qos: .userInteractive).async {
guard let url = Bundle.module.url(forResource: "emojis", withExtension: "json") else {
promise(.failure(EmojiPickerError.emojisFileMissing))
return
}
do {
let data = try Data(contentsOf: url)
let decoded = try JSONDecoder().decode([String: [SystemEmoji]].self, from: data)
var typed = [PickerEmoji.Category: [PickerEmoji]]()
for (groupString, emoji) in decoded {
guard let rawValue = Int(groupString),
let group = SystemEmoji.Group(rawValue: rawValue)
else {
promise(.failure(EmojiPickerError.invalidSystemEmojiGroup))
return
}
typed[.systemGroup(group)] = emoji
.filter { !($0.version > Self.maximumEmojiVersion) }
.map(PickerEmoji.system)
}
return promise(.success(typed))
} catch {
promise(.failure(error))
}
}
}
.eraseToAnyPublisher()
}
func systemEmojiAnnotationsAndTagsPublisher(locale: Locale) -> AnyPublisher<[String: String], Error> {
Future { promise in
guard let languageCode = locale.languageCode else {
promise(.failure(EmojiPickerError.invalidLocaleLanguageCode))
return
}
let language: String
if languageCode == "zh" && locale.scriptCode == "Hant" {
language = "zh_Hant"
} else {
language = languageCode
}
guard let url = Bundle.module.url(forResource: language, withExtension: "json") else {
promise(.failure(EmojiPickerError.annotationsAndTagsFileMissing))
return
}
do {
let data = try Data(contentsOf: url)
let decoded = try JSONDecoder().decode([String: String].self, from: data)
promise(.success(decoded))
} catch {
promise(.failure(error))
}
}
.eraseToAnyPublisher()
}
}
private extension EmojiPickerService {
static var maximumEmojiVersion: Float = {
if #available(iOS 14.2, *) {
return 13.0
}
return 12.1
}()
}

View file

@ -254,6 +254,10 @@ public extension IdentityService {
func domainBlocksService() -> DomainBlocksService {
DomainBlocksService(mastodonAPIClient: mastodonAPIClient)
}
func emojiPickerService() -> EmojiPickerService {
EmojiPickerService(contentDatabase: contentDatabase)
}
}
private extension IdentityService {

View file

@ -8,10 +8,75 @@ final class EmojiPickerViewController: UIViewController {
let searchBar = UISearchBar()
private let viewModel: EmojiPickerViewModel
private let selectionAction: (PickerEmoji) -> Void
private let dismissAction: () -> Void
private var cancellables = Set<AnyCancellable>()
init(viewModel: EmojiPickerViewModel) {
private lazy var collectionView: UICollectionView = {
let itemSize = NSCollectionLayoutSize(
widthDimension: .absolute(.minimumButtonDimension),
heightDimension: .absolute(.minimumButtonDimension))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(.minimumButtonDimension))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
group.interItemSpacing = .flexible(.defaultSpacing)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = .defaultSpacing
section.contentInsets = NSDirectionalEdgeInsets(
top: .defaultSpacing,
leading: .defaultSpacing,
bottom: .defaultSpacing,
trailing: .defaultSpacing)
let headerSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .estimated(.defaultSpacing))
let header = NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: headerSize,
elementKind: Self.headerElementKind,
alignment: .top)
section.boundarySupplementaryItems = [header]
let layout = UICollectionViewCompositionalLayout(section: section)
return UICollectionView(frame: .zero, collectionViewLayout: layout)
}()
private lazy var dataSource: UICollectionViewDiffableDataSource<PickerEmoji.Category, PickerEmoji> = {
let cellRegistration = UICollectionView.CellRegistration
<EmojiCollectionViewCell, PickerEmoji> {
$0.emoji = $2
}
let headerRegistration = UICollectionView.SupplementaryRegistration
<EmojiCategoryHeaderView>(elementKind: "Header") { [weak self] in
$0.label.text = self?.dataSource.snapshot().sectionIdentifiers[$2.section].displayName
}
let dataSource = UICollectionViewDiffableDataSource
<PickerEmoji.Category, PickerEmoji>(collectionView: collectionView) {
$0.dequeueConfiguredReusableCell(using: cellRegistration, for: $1, item: $2)
}
dataSource.supplementaryViewProvider = {
$0.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: $2)
}
return dataSource
}()
init(viewModel: EmojiPickerViewModel,
selectionAction: @escaping (PickerEmoji) -> Void,
dismissAction: @escaping () -> Void) {
self.viewModel = viewModel
self.selectionAction = selectionAction
self.dismissAction = dismissAction
super.init(nibName: nil, bundle: nil)
}
@ -24,20 +89,48 @@ final class EmojiPickerViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let searchBar = UISearchBar()
view.addSubview(searchBar)
searchBar.translatesAutoresizingMaskIntoConstraints = false
searchBar.searchBarStyle = .minimal
searchBar.placeholder = NSLocalizedString("emoji.search", comment: "")
searchBar.searchTextField.addAction(
UIAction { [weak self] _ in self?.viewModel.query = self?.searchBar.text ?? "" },
for: .editingChanged)
view.addSubview(collectionView)
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.backgroundColor = .clear
collectionView.dataSource = dataSource
collectionView.delegate = self
NSLayoutConstraint.activate([
searchBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
searchBar.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
searchBar.trailingAnchor.constraint(equalTo: view.trailingAnchor)
searchBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
collectionView.topAnchor.constraint(equalTo: searchBar.bottomAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
// print(UITextInputMode.activeInputModes.map(\.primaryLanguage))
print(Locale.availableIdentifiers)
viewModel.$emoji
.sink { [weak self] in self?.dataSource.apply($0.snapshot()) }
.store(in: &cancellables)
if let currentKeyboardLanguageIdentifier = searchBar.textInputMode?.primaryLanguage {
viewModel.locale = Locale(identifier: currentKeyboardLanguageIdentifier)
}
NotificationCenter.default.publisher(for: UITextInputMode.currentInputModeDidChangeNotification)
.compactMap { [weak self] _ in self?.searchBar.textInputMode?.primaryLanguage }
.compactMap(Locale.init(identifier:))
.assign(to: \.locale, on: viewModel)
.store(in: &cancellables)
publisher(for: \.isBeingDismissed).print().sink { (_) in
}
.store(in: &cancellables)
}
override func viewDidAppear(_ animated: Bool) {
@ -60,4 +153,24 @@ final class EmojiPickerViewController: UIViewController {
setClear(view: containerView)
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
dismissAction()
}
}
extension EmojiPickerViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let emoji = dataSource.itemIdentifier(for: indexPath) else { return }
selectionAction(emoji)
UISelectionFeedbackGenerator().selectionChanged()
}
}
private extension EmojiPickerViewController {
static let headerElementKind = "com.metabolist.metatext.emoji-picker.header"
}

View file

@ -232,9 +232,10 @@ private extension NewStatusViewController {
.store(in: &cancellables)
viewModel.$alertItem
.compactMap { $0 }
.sink { [weak self] in
self?.dismissEmojiPickerIfPresented()
self?.present(alertItem: $0)
.sink { [weak self] alertItem in
self?.dismissEmojiPickerIfPresented {
self?.present(alertItem: alertItem)
}
}
.store(in: &cancellables)
}
@ -271,8 +272,9 @@ private extension NewStatusViewController {
picker.modalPresentationStyle = .overFullScreen
picker.delegate = self
dismissEmojiPickerIfPresented()
present(picker, animated: true)
dismissEmojiPickerIfPresented {
self.present(picker, animated: true)
}
}
#if !IS_SHARE_EXTENSION
@ -323,8 +325,9 @@ private extension NewStatusViewController {
picker.mediaTypes = [UTType.image.description]
}
dismissEmojiPickerIfPresented()
present(picker, animated: true)
dismissEmojiPickerIfPresented {
self.present(picker, animated: true)
}
}
#endif
@ -346,8 +349,9 @@ private extension NewStatusViewController {
documentPickerController.delegate = self
documentPickerController.allowsMultipleSelection = false
documentPickerController.modalPresentationStyle = .overFullScreen
dismissEmojiPickerIfPresented()
present(documentPickerController, animated: true)
dismissEmojiPickerIfPresented {
self.present(documentPickerController, animated: true)
}
}
func presentEmojiPicker(tag: Int) {
@ -357,11 +361,33 @@ private extension NewStatusViewController {
guard let fromView = view.viewWithTag(tag) else { return }
let emojiPickerController = EmojiPickerViewController(
viewModel: .init(identification: viewModel.identification))
let emojiPickerViewModel = EmojiPickerViewModel(identification: viewModel.identification)
emojiPickerViewModel.$alertItem.assign(to: \.alertItem, on: viewModel).store(in: &cancellables)
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(" "))
}
} dismissAction: {
fromView.becomeFirstResponder()
}
emojiPickerController.searchBar.inputAccessoryView = fromView.inputAccessoryView
emojiPickerController.preferredContentSize = view.frame.size
emojiPickerController.preferredContentSize = .init(
width: view.readableContentGuide.layoutFrame.width,
height: view.frame.height)
emojiPickerController.modalPresentationStyle = .popover
emojiPickerController.popoverPresentationController?.delegate = self
emojiPickerController.popoverPresentationController?.sourceView = fromView
@ -372,11 +398,11 @@ private extension NewStatusViewController {
}
@discardableResult
func dismissEmojiPickerIfPresented() -> Bool {
func dismissEmojiPickerIfPresented(completion: (() -> Void)? = nil) -> Bool {
let emojiPickerPresented = presentedViewController is EmojiPickerViewController
if emojiPickerPresented {
dismiss(animated: true)
dismiss(animated: true, completion: completion)
}
return emojiPickerPresented
@ -388,8 +414,9 @@ private extension NewStatusViewController {
let navigationController = UINavigationController(rootViewController: editAttachmentViewController)
navigationController.modalPresentationStyle = .overFullScreen
dismissEmojiPickerIfPresented()
present(navigationController, animated: true)
dismissEmojiPickerIfPresented {
self.present(navigationController, animated: true)
}
}
func changeIdentityButton(identification: Identification) -> UIButton {

View file

@ -2,11 +2,85 @@
import Combine
import Foundation
import Mastodon
import ServiceLayer
final public class EmojiPickerViewModel: ObservableObject {
@Published public var alertItem: AlertItem?
@Published public var query = ""
@Published public var locale = Locale.current
@Published public private(set) var emoji = [PickerEmoji.Category: [PickerEmoji]]()
private let identification: Identification
private let emojiPickerService: EmojiPickerService
@Published private var customEmoji = [PickerEmoji.Category: [PickerEmoji]]()
@Published private var systemEmoji = [PickerEmoji.Category: [PickerEmoji]]()
@Published private var systemEmojiAnnotationsAndTags = [String: String]()
private var cancellables = Set<AnyCancellable>()
public init(identification: Identification) {
self.identification = identification
emojiPickerService = identification.service.emojiPickerService()
emojiPickerService.customEmojiPublisher()
.receive(on: DispatchQueue.main)
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.assign(to: &$customEmoji)
emojiPickerService.systemEmojiPublisher()
.receive(on: DispatchQueue.main)
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.assign(to: &$systemEmoji)
$customEmoji.dropFirst().combineLatest(
$systemEmoji.dropFirst(),
$query,
$locale.combineLatest($systemEmojiAnnotationsAndTags)) // Combine API limits to 4 params
.map {
let (customEmoji, systemEmoji, query, (locale, systemEmojiAnnotationsAndTags)) = $0
var queriedCustomEmoji = customEmoji
var queriedSystemEmoji = systemEmoji
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 {
$0.filter {
guard case let .system(emoji) = $0 else { return false }
return matchingSystemEmojis.contains(emoji.emoji)
}
}
queriedSystemEmoji = queriedSystemEmoji.filter { !$0.value.isEmpty }
}
return queriedSystemEmoji.merging(queriedCustomEmoji) { $1 }
}
.assign(to: &$emoji)
$locale.removeDuplicates().flatMap(emojiPickerService.systemEmojiAnnotationsAndTagsPublisher(locale:))
.replaceError(with: [:])
.assign(to: &$systemEmojiAnnotationsAndTags)
}
}
private extension String {
func matches(query: String, locale: Locale) -> Bool {
lowercased(with: locale)
.folding(options: .diacriticInsensitive, locale: locale)
.contains(query.lowercased(with: locale)
.folding(options: .diacriticInsensitive, locale: locale))
}
}

View file

@ -0,0 +1,5 @@
// Copyright © 2020 Metabolist. All rights reserved.
import ServiceLayer
public typealias PickerEmoji = ServiceLayer.PickerEmoji

View file

@ -0,0 +1,37 @@
// Copyright © 2021 Metabolist. All rights reserved.
import UIKit
final class EmojiCategoryHeaderView: UICollectionReusableView {
let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
initialSetup()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
private extension EmojiCategoryHeaderView {
func initialSetup() {
backgroundColor = .clear
addSubview(label)
label.translatesAutoresizingMaskIntoConstraints = false
label.adjustsFontForContentSizeCategory = true
label.font = .preferredFont(forTextStyle: .headline)
label.textColor = .secondaryLabel
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
label.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
label.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
label.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor)
])
}
}

View file

@ -0,0 +1,14 @@
// Copyright © 2021 Metabolist. All rights reserved.
import UIKit
import ViewModels
final class EmojiCollectionViewCell: UICollectionViewCell {
var emoji: PickerEmoji?
override func updateConfiguration(using state: UICellConfigurationState) {
guard let emoji = emoji else { return }
contentConfiguration = EmojiContentConfiguration(emoji: emoji)
}
}

View file

@ -0,0 +1,18 @@
// Copyright © 2021 Metabolist. All rights reserved.
import UIKit
import ViewModels
struct EmojiContentConfiguration {
let emoji: PickerEmoji
}
extension EmojiContentConfiguration: UIContentConfiguration {
func makeContentView() -> UIView & UIContentView {
EmojiView(configuration: self)
}
func updated(for state: UIConfigurationState) -> EmojiContentConfiguration {
self
}
}

83
Views/EmojiView.swift Normal file
View file

@ -0,0 +1,83 @@
// Copyright © 2021 Metabolist. All rights reserved.
import Kingfisher
import UIKit
final class EmojiView: UIView {
private let imageView = UIImageView()
private let emojiLabel = UILabel()
private var emojiConfiguration: EmojiContentConfiguration
init(configuration: EmojiContentConfiguration) {
emojiConfiguration = configuration
super.init(frame: .zero)
initialSetup()
applyEmojiConfiguration()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension EmojiView: UIContentView {
var configuration: UIContentConfiguration {
get { emojiConfiguration }
set {
guard let emojiConfiguration = newValue as? EmojiContentConfiguration else { return }
self.emojiConfiguration = emojiConfiguration
applyEmojiConfiguration()
}
}
}
private extension EmojiView {
func initialSetup() {
layoutMargins = .init(
top: .compactSpacing,
left: .compactSpacing,
bottom: .compactSpacing,
right: .compactSpacing)
addSubview(imageView)
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFit
addSubview(emojiLabel)
emojiLabel.translatesAutoresizingMaskIntoConstraints = false
emojiLabel.textAlignment = .center
emojiLabel.adjustsFontSizeToFitWidth = true
emojiLabel.font = .preferredFont(forTextStyle: .largeTitle)
NSLayoutConstraint.activate([
imageView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
imageView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
imageView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
imageView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor),
emojiLabel.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
emojiLabel.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
emojiLabel.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
emojiLabel.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor)
])
}
func applyEmojiConfiguration() {
switch emojiConfiguration.emoji {
case let .custom(emoji):
imageView.isHidden = false
emojiLabel.isHidden = true
imageView.kf.setImage(with: emoji.url)
case let .system(emoji):
imageView.isHidden = true
emojiLabel.isHidden = false
emojiLabel.text = emoji.emoji
}
}
}