Autocomplete wip

This commit is contained in:
Justin Mazzocchi 2021-02-15 00:47:30 -08:00
parent 2cb8370e68
commit 38ffad5f60
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
18 changed files with 479 additions and 48 deletions

View file

@ -0,0 +1,130 @@
// Copyright © 2021 Metabolist. All rights reserved.
import Combine
import Mastodon
import UIKit
import ViewModels
enum AutocompleteSection: Int, Hashable {
case search
case emoji
}
enum AutocompleteItem: Hashable {
case account(Account)
case tag(Tag)
case emoji(PickerEmoji)
}
final class AutocompleteDataSource: UICollectionViewDiffableDataSource<AutocompleteSection, AutocompleteItem> {
@Published private var searchViewModel: SearchViewModel
@Published private var emojiPickerViewModel: EmojiPickerViewModel
private let updateQueue =
DispatchQueue(label: "com.metabolist.metatext.autocomplete-data-source.update-queue")
private var cancellables = Set<AnyCancellable>()
init(collectionView: UICollectionView,
queryPublisher: AnyPublisher<String?, Never>,
parentViewModel: NewStatusViewModel) {
searchViewModel = SearchViewModel(identityContext: parentViewModel.identityContext)
emojiPickerViewModel = EmojiPickerViewModel(identityContext: parentViewModel.identityContext, queryOnly: true)
let registration = UICollectionView.CellRegistration<AutocompleteItemCollectionViewCell, AutocompleteItem> {
$0.item = $2
$0.identityContext = parentViewModel.identityContext
}
let emojiRegistration = UICollectionView.CellRegistration<EmojiCollectionViewCell, PickerEmoji> {
$0.emoji = $2.applyingDefaultSkinTone(identityContext: parentViewModel.identityContext)
}
super.init(collectionView: collectionView) {
if case let .emoji(emoji) = $2 {
return $0.dequeueConfiguredReusableCell(using: emojiRegistration, for: $1, item: emoji)
} else {
return $0.dequeueConfiguredReusableCell(using: registration, for: $1, item: $2)
}
}
queryPublisher
.replaceNil(with: "")
.removeDuplicates()
.combineLatest($searchViewModel, $emojiPickerViewModel)
.sink(receiveValue: Self.combine(query:searchViewModel:emojiPickerViewModel:))
.store(in: &cancellables)
$searchViewModel.map(\.updates)
.switchToLatest()
.combineLatest($emojiPickerViewModel.map(\.$emoji).switchToLatest())
.receive(on: DispatchQueue.main)
.sink { [weak self] in self?.apply(searchViewModelUpdate: $0, emojiSections: $1) }
.store(in: &cancellables)
parentViewModel.$identityContext
.dropFirst()
.sink { [weak self] in
guard let self = self else { return }
self.searchViewModel = SearchViewModel(identityContext: $0)
self.emojiPickerViewModel = EmojiPickerViewModel(identityContext: $0, queryOnly: true)
}
.store(in: &cancellables)
}
override func apply(_ snapshot: NSDiffableDataSourceSnapshot<AutocompleteSection, AutocompleteItem>,
animatingDifferences: Bool = true,
completion: (() -> Void)? = nil) {
updateQueue.async {
super.apply(snapshot, animatingDifferences: animatingDifferences, completion: completion)
}
}
}
private extension AutocompleteDataSource {
static func combine(query: String, searchViewModel: SearchViewModel, emojiPickerViewModel: EmojiPickerViewModel) {
if query.starts(with: ":") {
searchViewModel.query = ""
emojiPickerViewModel.query = String(query.dropFirst())
} else {
if query.starts(with: "@") {
searchViewModel.scope = .accounts
} else if query.starts(with: "#") {
searchViewModel.scope = .tags
}
searchViewModel.query = String(query.dropFirst())
emojiPickerViewModel.query = ""
}
}
func apply(searchViewModelUpdate: CollectionUpdate, emojiSections: [PickerEmoji.Category: [PickerEmoji]]) {
var newSnapshot = NSDiffableDataSourceSnapshot<AutocompleteSection, AutocompleteItem>()
let items: [AutocompleteItem] = searchViewModelUpdate.sections.map(\.items).reduce([], +).compactMap {
switch $0 {
case let .account(account, _, _):
return .account(account)
case let .tag(tag):
return .tag(tag)
default:
return nil
}
}
let emojis = emojiSections.sorted { $0.0 < $1.0 }.map(\.value).reduce([], +).map(AutocompleteItem.emoji)
newSnapshot.appendSections([.search])
if !items.isEmpty {
newSnapshot.appendItems(items, toSection: .search)
} else if !emojis.isEmpty {
newSnapshot.appendSections([.emoji])
newSnapshot.appendItems(emojis, toSection: .emoji)
}
apply(newSnapshot, animatingDifferences: !UIAccessibility.isReduceMotionEnabled) {
// animation causes issue with custom emoji images requiring reload
newSnapshot.reloadItems(newSnapshot.itemIdentifiers)
self.apply(newSnapshot, animatingDifferences: false)
}
}
}

View file

@ -3,6 +3,17 @@
import UIKit
import ViewModels
extension PickerEmoji {
func applyingDefaultSkinTone(identityContext: IdentityContext) -> PickerEmoji {
if case let .system(systemEmoji, inFrequentlyUsed) = self,
let defaultEmojiSkinTone = identityContext.appPreferences.defaultEmojiSkinTone {
return .system(systemEmoji.applying(skinTone: defaultEmojiSkinTone), inFrequentlyUsed: inFrequentlyUsed)
} else {
return self
}
}
}
extension Dictionary where Key == PickerEmoji.Category, Value == [PickerEmoji] {
func snapshot() -> NSDiffableDataSourceSnapshot<PickerEmoji.Category, PickerEmoji> {
var snapshot = NSDiffableDataSourceSnapshot<PickerEmoji.Category, PickerEmoji>()

View file

@ -306,6 +306,7 @@
"tag.accessibility-recent-uses-%ld" = "%ld recent uses";
"tag.accessibility-hint.post" = "View posts associated with trend";
"tag.accessibility-hint.toot" = "View toots associated with trend";
"tag.per-week-%ld" = "%ld per week";
"timelines.home" = "Home";
"timelines.local" = "Local";
"timelines.federated" = "Federated";

View file

@ -165,6 +165,13 @@
D0D2AC4D25BCD2A9003D5DF2 /* TagTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D2AC4C25BCD2A9003D5DF2 /* TagTableViewCell.swift */; };
D0D2AC5325BCD2BA003D5DF2 /* TagContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D2AC5225BCD2BA003D5DF2 /* TagContentConfiguration.swift */; };
D0D2AC6725BD0484003D5DF2 /* LineChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D2AC6625BD0484003D5DF2 /* LineChartView.swift */; };
D0D93EBA25D9C70400C622ED /* AutocompleteItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D93EB925D9C70400C622ED /* AutocompleteItemView.swift */; };
D0D93EC025D9C71D00C622ED /* AutocompleteItemContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D93EBF25D9C71D00C622ED /* AutocompleteItemContentConfiguration.swift */; };
D0D93EC525D9C75E00C622ED /* AutocompleteItemContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D93EBF25D9C71D00C622ED /* AutocompleteItemContentConfiguration.swift */; };
D0D93ECA25D9C76500C622ED /* AutocompleteItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D93EB925D9C70400C622ED /* AutocompleteItemView.swift */; };
D0D93ED025D9C9ED00C622ED /* AutocompleteItemCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D93ECF25D9C9ED00C622ED /* AutocompleteItemCollectionViewCell.swift */; };
D0D93ED925D9CBE200C622ED /* AutocompleteItemCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D93ECF25D9C9ED00C622ED /* AutocompleteItemCollectionViewCell.swift */; };
D0D93EDE25DA014700C622ED /* SeparatorConfiguredCollectionViewListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D09D972125C65682007E6394 /* SeparatorConfiguredCollectionViewListCell.swift */; };
D0DD50CB256B1F24004A04F7 /* ReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DD50CA256B1F24004A04F7 /* ReportView.swift */; };
D0DDA76B25C5F20800FA0F91 /* ExploreDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DDA76A25C5F20800FA0F91 /* ExploreDataSource.swift */; };
D0DDA77525C5F73F00FA0F91 /* TagCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DDA77425C5F73F00FA0F91 /* TagCollectionViewCell.swift */; };
@ -173,6 +180,8 @@
D0E2C1D124FD97F000854680 /* ViewModels in Frameworks */ = {isa = PBXBuildFile; productRef = D0E2C1D024FD97F000854680 /* ViewModels */; };
D0E39AB425D8BF88009C10F8 /* UITextInput+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E39AB325D8BF88009C10F8 /* UITextInput+Extensions.swift */; };
D0E39ABD25D8C046009C10F8 /* UITextInput+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E39AB325D8BF88009C10F8 /* UITextInput+Extensions.swift */; };
D0E39B7E25D9AF23009C10F8 /* AutocompleteDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E39B7D25D9AF23009C10F8 /* AutocompleteDataSource.swift */; };
D0E39B8725D9B7FD009C10F8 /* AutocompleteDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E39B7D25D9AF23009C10F8 /* AutocompleteDataSource.swift */; };
D0E5361C24E3EB4D00FB1CE1 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E5361B24E3EB4D00FB1CE1 /* NotificationService.swift */; };
D0E5362024E3EB4D00FB1CE1 /* Notification Service Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = D0E5361924E3EB4D00FB1CE1 /* Notification Service Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
D0E569DB2529319100FA1D72 /* LoadMoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E569DA2529319100FA1D72 /* LoadMoreView.swift */; };
@ -373,6 +382,9 @@
D0D2AC5225BCD2BA003D5DF2 /* TagContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagContentConfiguration.swift; sourceTree = "<group>"; };
D0D2AC6625BD0484003D5DF2 /* LineChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineChartView.swift; sourceTree = "<group>"; };
D0D7C013250440610039AD6F /* CodableBloomFilter */ = {isa = PBXFileReference; lastKnownFileType = folder; path = CodableBloomFilter; sourceTree = "<group>"; };
D0D93EB925D9C70400C622ED /* AutocompleteItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteItemView.swift; sourceTree = "<group>"; };
D0D93EBF25D9C71D00C622ED /* AutocompleteItemContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteItemContentConfiguration.swift; sourceTree = "<group>"; };
D0D93ECF25D9C9ED00C622ED /* AutocompleteItemCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteItemCollectionViewCell.swift; sourceTree = "<group>"; };
D0DD50CA256B1F24004A04F7 /* ReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportView.swift; sourceTree = "<group>"; };
D0DDA76A25C5F20800FA0F91 /* ExploreDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreDataSource.swift; sourceTree = "<group>"; };
D0DDA77425C5F73F00FA0F91 /* TagCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagCollectionViewCell.swift; sourceTree = "<group>"; };
@ -381,6 +393,7 @@
D0E1F582251F13EC00D45315 /* WebfingerIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebfingerIndicatorView.swift; sourceTree = "<group>"; };
D0E2C1CF24FD8BA400854680 /* ViewModels */ = {isa = PBXFileReference; lastKnownFileType = folder; path = ViewModels; sourceTree = "<group>"; };
D0E39AB325D8BF88009C10F8 /* UITextInput+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITextInput+Extensions.swift"; sourceTree = "<group>"; };
D0E39B7D25D9AF23009C10F8 /* AutocompleteDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteDataSource.swift; sourceTree = "<group>"; };
D0E5361924E3EB4D00FB1CE1 /* Notification Service Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Notification Service Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
D0E5361B24E3EB4D00FB1CE1 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
D0E5361D24E3EB4D00FB1CE1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
@ -537,6 +550,7 @@
D021A66F25C3E1F9008A0C0D /* Collection View Cells */ = {
isa = PBXGroup;
children = (
D0D93ECF25D9C9ED00C622ED /* AutocompleteItemCollectionViewCell.swift */,
D07EC7DB25B13DBB006DF726 /* EmojiCollectionViewCell.swift */,
D09D971725C64682007E6394 /* InstanceCollectionViewCell.swift */,
D09D972125C65682007E6394 /* SeparatorConfiguredCollectionViewListCell.swift */,
@ -549,6 +563,7 @@
isa = PBXGroup;
children = (
D0F0B112251A86A000942152 /* AccountContentConfiguration.swift */,
D0D93EBF25D9C71D00C622ED /* AutocompleteItemContentConfiguration.swift */,
D00702352555F4C500F38136 /* ConversationContentConfiguration.swift */,
D07EC7E225B13DD3006DF726 /* EmojiContentConfiguration.swift */,
D07EC7F125B13E57006DF726 /* EmojiView.swift */,
@ -566,6 +581,7 @@
isa = PBXGroup;
children = (
D0F0B10D251A868200942152 /* AccountView.swift */,
D0D93EB925D9C70400C622ED /* AutocompleteItemView.swift */,
D00702302555F4AE00F38136 /* ConversationView.swift */,
D021A61325C36BFB008A0C0D /* IdentityView.swift */,
D09D970725C64522007E6394 /* InstanceView.swift */,
@ -677,6 +693,7 @@
D0A1F4F5252E7D2A004435BF /* Data Sources */ = {
isa = PBXGroup;
children = (
D0E39B7D25D9AF23009C10F8 /* AutocompleteDataSource.swift */,
D0DDA76A25C5F20800FA0F91 /* ExploreDataSource.swift */,
D021A5FF25C3478F008A0C0D /* IdentitiesDataSource.swift */,
D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */,
@ -1031,6 +1048,7 @@
D0CE9F87258B076900E3A6B6 /* AttachmentUploadView.swift in Sources */,
D0F0B113251A86A000942152 /* AccountContentConfiguration.swift in Sources */,
D05E688525B55AE8001FB2C6 /* AVURLAsset+Extensions.swift in Sources */,
D0D93EC025D9C71D00C622ED /* AutocompleteItemContentConfiguration.swift in Sources */,
D09D970E25C64539007E6394 /* InstanceContentConfiguration.swift in Sources */,
D036AA02254B6101009094DF /* NotificationTableViewCell.swift in Sources */,
D08B8D42253F92B600B1EBEF /* ImagePageViewController.swift in Sources */,
@ -1069,6 +1087,7 @@
D021A61425C36BFB008A0C0D /* IdentityView.swift in Sources */,
D06BC5E625202AD90079541D /* ProfileViewController.swift in Sources */,
D0D2AC4D25BCD2A9003D5DF2 /* TagTableViewCell.swift in Sources */,
D0D93ED025D9C9ED00C622ED /* AutocompleteItemCollectionViewCell.swift in Sources */,
D00CB22A25C92C0F008EF267 /* Attachment+Extensions.swift in Sources */,
D0D2AC5325BCD2BA003D5DF2 /* TagContentConfiguration.swift in Sources */,
D08B8D72254246E200B1EBEF /* PollView.swift in Sources */,
@ -1077,6 +1096,7 @@
D0F0B10E251A868200942152 /* AccountView.swift in Sources */,
D0BEB1FF24F9E5BB001B0F04 /* ListsView.swift in Sources */,
D021A60025C3478F008A0C0D /* IdentitiesDataSource.swift in Sources */,
D0E39B7E25D9AF23009C10F8 /* AutocompleteDataSource.swift in Sources */,
D0C7D49724F7616A001EBDBB /* IdentitiesView.swift in Sources */,
D025B14D25C4E482001C69A8 /* ImageCacheConfiguration.swift in Sources */,
D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */,
@ -1107,6 +1127,7 @@
D0BE980425D229D50057E161 /* SeparatorConfiguredTableViewCell.swift in Sources */,
D0A1F4F7252E7D4B004435BF /* TableViewDataSource.swift in Sources */,
D025B17E25C500BC001C69A8 /* CapsuleButton.swift in Sources */,
D0D93EBA25D9C70400C622ED /* AutocompleteItemView.swift in Sources */,
D0C7D4C424F7616A001EBDBB /* AppDelegate.swift in Sources */,
D0C7D49924F7616A001EBDBB /* AddIdentityView.swift in Sources */,
D0DDA77F25C6058300FA0F91 /* ExploreSectionHeaderView.swift in Sources */,
@ -1143,8 +1164,10 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D0D93EDE25DA014700C622ED /* SeparatorConfiguredCollectionViewListCell.swift in Sources */,
D08E52A6257C61C000FA2C5F /* ShareExtensionNavigationViewController.swift in Sources */,
D00CB23825C93047008EF267 /* String+Extensions.swift in Sources */,
D0D93EC525D9C75E00C622ED /* AutocompleteItemContentConfiguration.swift in Sources */,
D059373425AAEA7000754FDF /* CompositionPollView.swift in Sources */,
D021A67B25C3E32A008A0C0D /* PlayerView.swift in Sources */,
D021A69025C3E4B8008A0C0D /* EmojiContentConfiguration.swift in Sources */,
@ -1173,10 +1196,13 @@
D036EBB3259FE28800EC1CFC /* UIColor+Extensions.swift in Sources */,
D0E39ABD25D8C046009C10F8 /* UITextInput+Extensions.swift in Sources */,
D088406E25AFBBE200BB749B /* EmojiPickerViewController.swift in Sources */,
D0E39B8725D9B7FD009C10F8 /* AutocompleteDataSource.swift in Sources */,
D00CB23325C92F2D008EF267 /* Attachment+Extensions.swift in Sources */,
D0D93ECA25D9C76500C622ED /* AutocompleteItemView.swift in Sources */,
D025B14725C4D26B001C69A8 /* ImageCacheSerializer.swift in Sources */,
D036EBB8259FE29800EC1CFC /* Status+Extensions.swift in Sources */,
D021A6A625C3E584008A0C0D /* EditAttachmentView.swift in Sources */,
D0D93ED925D9CBE200C622ED /* AutocompleteItemCollectionViewCell.swift in Sources */,
D0BE97E025D086F80057E161 /* ImagePastableTextView.swift in Sources */,
D05936DF25A937EC00754FDF /* EditThumbnailView.swift in Sources */,
D021A69525C3E4C1008A0C0D /* EmojiView.swift in Sources */,

View file

@ -20,10 +20,6 @@ public struct ExploreService {
}
public extension ExploreService {
func searchService() -> SearchService {
SearchService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
}
func instanceServicePublisher(uri: String) -> AnyPublisher<InstanceService, Error> {
contentDatabase.instancePublisher(uri: uri)
.map { InstanceService(instance: $0, mastodonAPIClient: mastodonAPIClient) }

View file

@ -270,6 +270,10 @@ public extension IdentityService {
ExploreService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
}
func searchService() -> SearchService {
SearchService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
}
func notificationsService(excludeTypes: Set<MastodonNotification.NotificationType>) -> NotificationsService {
NotificationsService(excludeTypes: excludeTypes,
mastodonAPIClient: mastodonAPIClient,

View file

@ -20,7 +20,9 @@ final class EmojiPickerViewController: UICollectionViewController {
private lazy var dataSource: UICollectionViewDiffableDataSource<PickerEmoji.Category, PickerEmoji> = {
let cellRegistration = UICollectionView.CellRegistration
<EmojiCollectionViewCell, PickerEmoji> { [weak self] in
$0.emoji = self?.applyingDefaultSkinTone(emoji: $2) ?? $2
guard let self = self else { return }
$0.emoji = $2.applyingDefaultSkinTone(identityContext: self.viewModel.identityContext)
}
let headerRegistration = UICollectionView.SupplementaryRegistration
@ -149,9 +151,11 @@ final class EmojiPickerViewController: UICollectionViewController {
}
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
collectionView.deselectItem(at: indexPath, animated: true)
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
select(emoji: applyingDefaultSkinTone(emoji: item))
select(item.applyingDefaultSkinTone(identityContext: viewModel.identityContext))
viewModel.updateUse(emoji: item)
}
@ -234,13 +238,4 @@ private extension EmojiPickerViewController {
snapshot.reloadItems(visibleItems)
dataSource.apply(snapshot)
}
func applyingDefaultSkinTone(emoji: PickerEmoji) -> PickerEmoji {
if case let .system(systemEmoji, inFrequentlyUsed) = emoji,
let defaultEmojiSkinTone = viewModel.identityContext.appPreferences.defaultEmojiSkinTone {
return .system(systemEmoji.applying(skinTone: defaultEmojiSkinTone), inFrequentlyUsed: inFrequentlyUsed)
} else {
return emoji
}
}
}

View file

@ -11,7 +11,7 @@ final class ProfileViewController: TableViewController {
required init(
viewModel: ProfileViewModel,
rootViewModel: RootViewModel,
rootViewModel: RootViewModel?,
identityContext: IdentityContext,
parentNavigationController: UINavigationController?) {
self.viewModel = viewModel

View file

@ -13,7 +13,7 @@ class TableViewController: UITableViewController {
var transitionViewTag = -1
private let viewModel: CollectionViewModel
private let rootViewModel: RootViewModel
private let rootViewModel: RootViewModel?
private let loadingTableFooterView = LoadingTableFooterView()
private let webfingerIndicatorView = WebfingerIndicatorView()
@Published private var loading = false
@ -29,7 +29,7 @@ class TableViewController: UITableViewController {
}()
init(viewModel: CollectionViewModel,
rootViewModel: RootViewModel,
rootViewModel: RootViewModel? = nil,
insetBottom: Bool = true,
parentNavigationController: UINavigationController? = nil) {
self.viewModel = viewModel
@ -540,7 +540,7 @@ private extension TableViewController {
}
func compose(inReplyToViewModel: StatusViewModel?, redraft: Status?) {
rootViewModel.navigationViewModel?.presentedNewStatusViewModel = rootViewModel.newStatusViewModel(
rootViewModel?.navigationViewModel?.presentedNewStatusViewModel = rootViewModel?.newStatusViewModel(
identityContext: viewModel.identityContext,
inReplyTo: inReplyToViewModel,
redraft: redraft)

View file

@ -20,7 +20,7 @@ final public class EmojiPickerViewModel: ObservableObject {
private var cancellables = Set<AnyCancellable>()
// swiftlint:disable:next function_body_length
public init(identityContext: IdentityContext) {
public init(identityContext: IdentityContext, queryOnly: Bool = false) {
self.identityContext = identityContext
emojiPickerService = identityContext.service.emojiPickerService()
@ -67,12 +67,16 @@ final public class EmojiPickerViewModel: ObservableObject {
}
}
}
} else if queryOnly {
return [:]
}
emojis[.frequentlyUsed] = emojiUses.compactMap { use in
emojis.values.reduce([], +)
.first { use.system == $0.system && use.emoji == $0.name }
.map(\.inFrequentlyUsed)
if !queryOnly {
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 }

View file

@ -21,9 +21,7 @@ public final class ExploreViewModel: ObservableObject {
init(service: ExploreService, identityContext: IdentityContext) {
exploreService = service
self.identityContext = identityContext
searchViewModel = SearchViewModel(
searchService: exploreService.searchService(),
identityContext: identityContext)
searchViewModel = SearchViewModel(identityContext: identityContext)
events = eventsSubject.eraseToAnyPublisher()
identityContext.$identity

View file

@ -11,8 +11,8 @@ public final class SearchViewModel: CollectionItemsViewModel {
private let searchService: SearchService
private var cancellables = Set<AnyCancellable>()
public init(searchService: SearchService, identityContext: IdentityContext) {
self.searchService = searchService
public init(identityContext: IdentityContext) {
self.searchService = identityContext.service.searchService()
super.init(collectionService: searchService, identityContext: identityContext)
@ -40,7 +40,7 @@ public final class SearchViewModel: CollectionItemsViewModel {
}
private extension SearchViewModel {
static let debounceInterval: TimeInterval = 0.5
static let debounceInterval: TimeInterval = 0.2
}
private extension SearchScope {

View file

@ -0,0 +1,23 @@
// Copyright © 2021 Metabolist. All rights reserved.
import UIKit
import ViewModels
final class AutocompleteItemCollectionViewCell: SeparatorConfiguredCollectionViewListCell {
var item: AutocompleteItem?
var identityContext: IdentityContext?
override func updateConfiguration(using state: UICellConfigurationState) {
guard let item = item, let identityContext = identityContext else { return }
contentConfiguration = AutocompleteItemContentConfiguration(item: item, identityContext: identityContext)
var backgroundConfiguration = UIBackgroundConfiguration.listPlainCell()
backgroundConfiguration.backgroundColor = state.isHighlighted || state.isSelected ? nil : .clear
self.backgroundConfiguration = backgroundConfiguration
accessibilityElements = [contentView]
}
}

View file

@ -10,5 +10,12 @@ final class EmojiCollectionViewCell: UICollectionViewCell {
guard let emoji = emoji else { return }
contentConfiguration = EmojiContentConfiguration(emoji: emoji)
var backgroundConfiguration = UIBackgroundConfiguration.listPlainCell()
backgroundConfiguration.backgroundColor = state.isHighlighted || state.isSelected ? nil : .clear
backgroundConfiguration.cornerRadius = .defaultCornerRadius
self.backgroundConfiguration = backgroundConfiguration
}
}

View file

@ -6,12 +6,17 @@ import Mastodon
import UIKit
import ViewModels
final class CompositionInputAccessoryView: UIToolbar {
final class CompositionInputAccessoryView: UIView {
let tagForInputView = UUID().hashValue
private let viewModel: CompositionViewModel
private let parentViewModel: NewStatusViewModel
private let autocompleteQueryPublisher: AnyPublisher<String?, Never>
private let toolbar = UIToolbar()
private let autocompleteCollectionView = UICollectionView(
frame: .zero,
collectionViewLayout: CompositionInputAccessoryView.autocompleteLayout())
private let autocompleteDataSource: AutocompleteDataSource
private let autocompleteCollectionViewHeightConstraint: NSLayoutConstraint
private var cancellables = Set<AnyCancellable>()
init(viewModel: CompositionViewModel,
@ -19,7 +24,12 @@ final class CompositionInputAccessoryView: UIToolbar {
autocompleteQueryPublisher: AnyPublisher<String?, Never>) {
self.viewModel = viewModel
self.parentViewModel = parentViewModel
self.autocompleteQueryPublisher = autocompleteQueryPublisher
autocompleteDataSource = AutocompleteDataSource(
collectionView: autocompleteCollectionView,
queryPublisher: autocompleteQueryPublisher,
parentViewModel: parentViewModel)
autocompleteCollectionViewHeightConstraint =
autocompleteCollectionView.heightAnchor.constraint(equalToConstant: .minimumButtonDimension)
super.init(
frame: .init(
@ -36,11 +46,42 @@ final class CompositionInputAccessoryView: UIToolbar {
}
private extension CompositionInputAccessoryView {
static let autocompleteCollectionViewMaxHeight: CGFloat = 150
var heightConstraint: NSLayoutConstraint? {
superview?.constraints.first(where: { $0.identifier == "accessoryHeight" })
}
// swiftlint:disable:next function_body_length
func initialSetup() {
autoresizingMask = .flexibleHeight
heightAnchor.constraint(equalToConstant: .minimumButtonDimension).isActive = true
addSubview(autocompleteCollectionView)
autocompleteCollectionView.translatesAutoresizingMaskIntoConstraints = false
autocompleteCollectionView.alwaysBounceVertical = false
autocompleteCollectionView.backgroundColor = .clear
autocompleteCollectionView.layer.cornerRadius = .defaultCornerRadius
autocompleteCollectionView.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMinXMinYCorner]
let autocompleteBackgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial))
autocompleteCollectionView.backgroundView = autocompleteBackgroundView
addSubview(toolbar)
toolbar.translatesAutoresizingMaskIntoConstraints = false
toolbar.setContentCompressionResistancePriority(.required, for: .vertical)
NSLayoutConstraint.activate([
autocompleteCollectionView.leadingAnchor.constraint(equalTo: leadingAnchor),
autocompleteCollectionView.topAnchor.constraint(equalTo: topAnchor),
autocompleteCollectionView.trailingAnchor.constraint(equalTo: trailingAnchor),
autocompleteCollectionView.bottomAnchor.constraint(equalTo: toolbar.topAnchor),
toolbar.leadingAnchor.constraint(equalTo: leadingAnchor),
toolbar.trailingAnchor.constraint(equalTo: trailingAnchor),
toolbar.bottomAnchor.constraint(equalTo: bottomAnchor),
toolbar.heightAnchor.constraint(equalToConstant: .minimumButtonDimension),
autocompleteCollectionViewHeightConstraint
])
var attachmentActions = [
UIAction(
@ -129,15 +170,11 @@ private extension CompositionInputAccessoryView {
NSLocalizedString("compose.add-button-accessibility-label.post", comment: "")
}
let charactersLabel = UILabel()
let charactersBarItem = UIBarButtonItem()
charactersLabel.font = .preferredFont(forTextStyle: .callout)
charactersLabel.adjustsFontForContentSizeCategory = true
charactersLabel.adjustsFontSizeToFitWidth = true
charactersBarItem.isEnabled = false
let charactersBarItem = UIBarButtonItem(customView: charactersLabel)
items = [
toolbar.items = [
attachmentButton,
UIBarButtonItem.fixedSpace(.defaultSpacing),
pollButton,
@ -162,9 +199,11 @@ private extension CompositionInputAccessoryView {
.store(in: &cancellables)
viewModel.$remainingCharacters.sink {
charactersLabel.text = String($0)
charactersLabel.textColor = $0 < 0 ? .systemRed : .label
charactersLabel.accessibilityLabel = String.localizedStringWithFormat(
charactersBarItem.title = String($0)
charactersBarItem.setTitleTextAttributes(
[.foregroundColor: $0 < 0 ? UIColor.systemRed : UIColor.label],
for: .disabled)
charactersBarItem.accessibilityHint = String.localizedStringWithFormat(
NSLocalizedString("compose.characters-remaining-accessibility-label-%ld", comment: ""),
$0)
}
@ -174,9 +213,15 @@ private extension CompositionInputAccessoryView {
.sink { addButton.isEnabled = $0 }
.store(in: &cancellables)
autocompleteQueryPublisher
.print()
.sink { _ in /* TODO */ }
self.autocompleteCollectionView.publisher(for: \.contentSize)
.map(\.height)
.removeDuplicates()
.throttle(for: .seconds(TimeInterval.shortAnimationDuration), scheduler: DispatchQueue.main, latest: true)
.sink { [weak self] height in
UIView.animate(withDuration: .zeroIfReduceMotion(.shortAnimationDuration)) {
self?.setAutocompleteCollectionViewHeight(height)
}
}
.store(in: &cancellables)
parentViewModel.$visibility
@ -192,6 +237,41 @@ private extension CompositionInputAccessoryView {
}
private extension CompositionInputAccessoryView {
static func autocompleteLayout() -> UICollectionViewLayout {
var listConfig = UICollectionLayoutListConfiguration(appearance: .plain)
listConfig.backgroundColor = .clear
return UICollectionViewCompositionalLayout { index, environment -> NSCollectionLayoutSection? in
guard let autocompleteSection = AutocompleteSection(rawValue: index) else { return nil }
switch autocompleteSection {
case .search:
return .list(using: listConfig, layoutEnvironment: environment)
case .emoji:
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .fractionalHeight(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(
widthDimension: .absolute(.minimumButtonDimension),
heightDimension: .absolute(.minimumButtonDimension))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = .defaultSpacing
section.orthogonalScrollingBehavior = .continuous
section.contentInsets = NSDirectionalEdgeInsets(
top: .compactSpacing,
leading: .compactSpacing,
bottom: .compactSpacing,
trailing: .compactSpacing)
return section
}
}
}
func visibilityMenu(selectedVisibility: Status.Visibility) -> UIMenu {
UIMenu(children: Status.Visibility.allCasesExceptUnknown.reversed().map { visibility in
UIAction(
@ -203,4 +283,15 @@ private extension CompositionInputAccessoryView {
}
})
}
func setAutocompleteCollectionViewHeight(_ height: CGFloat) {
let autocompleteCollectionViewHeight = min(max(height, .hairline), Self.autocompleteCollectionViewMaxHeight)
autocompleteCollectionViewHeightConstraint.constant = autocompleteCollectionViewHeight
autocompleteCollectionView.alpha = autocompleteCollectionViewHeightConstraint.constant == .hairline ? 0 : 1
heightConstraint?.constant = .minimumButtonDimension + autocompleteCollectionViewHeight
updateConstraints()
superview?.superview?.layoutIfNeeded()
}
}

View file

@ -0,0 +1,19 @@
// Copyright © 2020 Metabolist. All rights reserved.
import UIKit
import ViewModels
struct AutocompleteItemContentConfiguration {
let item: AutocompleteItem
let identityContext: IdentityContext
}
extension AutocompleteItemContentConfiguration: UIContentConfiguration {
func makeContentView() -> UIView & UIContentView {
AutocompleteItemView(configuration: self)
}
func updated(for state: UIConfigurationState) -> AutocompleteItemContentConfiguration {
self
}
}

View file

@ -4,7 +4,7 @@ import Kingfisher
import UIKit
final class EmojiView: UIView {
private let imageView = UIImageView()
private let imageView = AnimatedImageView()
private let emojiLabel = UILabel()
private var emojiConfiguration: EmojiContentConfiguration

View file

@ -0,0 +1,126 @@
// Copyright © 2021 Metabolist. All rights reserved.
import Kingfisher
import UIKit
final class AutocompleteItemView: UIView {
private let imageView = AnimatedImageView()
private let primaryLabel = UILabel()
private let secondaryLabel = UILabel()
private let stackView = UIStackView()
private var autocompleteItemConfiguration: AutocompleteItemContentConfiguration
init(configuration: AutocompleteItemContentConfiguration) {
self.autocompleteItemConfiguration = configuration
super.init(frame: .zero)
initialSetup()
applyAutocompleteItemConfiguration()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension AutocompleteItemView: UIContentView {
var configuration: UIContentConfiguration {
get { autocompleteItemConfiguration }
set {
guard let autocompleteItemConfiguration = newValue as? AutocompleteItemContentConfiguration else { return }
self.autocompleteItemConfiguration = autocompleteItemConfiguration
applyAutocompleteItemConfiguration()
}
}
}
private extension AutocompleteItemView {
func initialSetup() {
addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.spacing = .defaultSpacing
stackView.addArrangedSubview(imageView)
imageView.layer.cornerRadius = .barButtonItemDimension / 2
imageView.clipsToBounds = true
imageView.contentMode = .scaleAspectFill
stackView.addArrangedSubview(primaryLabel)
primaryLabel.adjustsFontForContentSizeCategory = true
primaryLabel.font = .preferredFont(forTextStyle: .headline)
primaryLabel.setContentHuggingPriority(.required, for: .horizontal)
stackView.addArrangedSubview(secondaryLabel)
secondaryLabel.adjustsFontForContentSizeCategory = true
secondaryLabel.font = .preferredFont(forTextStyle: .subheadline)
secondaryLabel.textColor = .secondaryLabel
NSLayoutConstraint.activate([
imageView.widthAnchor.constraint(equalToConstant: .barButtonItemDimension),
imageView.heightAnchor.constraint(equalToConstant: .barButtonItemDimension),
stackView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
stackView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
stackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor)
])
}
func applyAutocompleteItemConfiguration() {
switch autocompleteItemConfiguration.item {
case let .account(account):
let appPreferences = autocompleteItemConfiguration.identityContext.appPreferences
let avatarURL = appPreferences.animateAvatars == .everywhere
&& !appPreferences.shouldReduceMotion
? account.avatar
: account.avatarStatic
imageView.kf.setImage(with: avatarURL)
imageView.isHidden = false
let mutableDisplayName = NSMutableAttributedString(string: account.displayName)
mutableDisplayName.insert(emojis: account.emojis, view: primaryLabel)
mutableDisplayName.resizeAttachments(toLineHeight: primaryLabel.font.lineHeight)
primaryLabel.attributedText = mutableDisplayName
primaryLabel.isHidden = account.displayName.isEmpty
secondaryLabel.text = "@".appending(account.acct)
primaryLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
secondaryLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
case let .tag(tag):
imageView.isHidden = true
imageView.image = nil
primaryLabel.text = "#".appending(tag.name)
primaryLabel.isHidden = false
if let uses = tag.history?.compactMap({ Int($0.uses) }).reduce(0, +), uses > 0 {
secondaryLabel.text =
String.localizedStringWithFormat(NSLocalizedString("tag.per-week-%ld", comment: ""), uses)
} else {
secondaryLabel.text = nil
}
primaryLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
secondaryLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
default:
break
}
let accessibilityAttributedLabel = NSMutableAttributedString(string: "")
if !primaryLabel.isHidden, let primaryLabelAttributedText = primaryLabel.attributedText {
accessibilityAttributedLabel.append(primaryLabelAttributedText)
}
if let secondaryLabelText = secondaryLabel.text, !secondaryLabelText.isEmpty {
accessibilityAttributedLabel.appendWithSeparator(secondaryLabelText)
}
self.accessibilityAttributedLabel = accessibilityAttributedLabel
isAccessibilityElement = true
}
}