diff --git a/Extensions/UITextInput+Extensions.swift b/Extensions/UITextInput+Extensions.swift new file mode 100644 index 0000000..4869efa --- /dev/null +++ b/Extensions/UITextInput+Extensions.swift @@ -0,0 +1,13 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import UIKit + +extension UITextInput { + var textToSelectedRange: String? { + guard let selectedRange = selectedTextRange, + let range = textRange(from: beginningOfDocument, to: selectedRange.end) + else { return nil } + + return text(in: range) + } +} diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index c68648c..b68e940 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -171,6 +171,8 @@ D0DDA77F25C6058300FA0F91 /* ExploreSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DDA77E25C6058300FA0F91 /* ExploreSectionHeaderView.swift */; }; D0E1F583251F13EC00D45315 /* WebfingerIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E1F582251F13EC00D45315 /* WebfingerIndicatorView.swift */; }; 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 */; }; 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 */; }; @@ -378,6 +380,7 @@ D0E0F1E424FC49FC002C04BF /* Mastodon */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Mastodon; sourceTree = ""; }; D0E1F582251F13EC00D45315 /* WebfingerIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebfingerIndicatorView.swift; sourceTree = ""; }; D0E2C1CF24FD8BA400854680 /* ViewModels */ = {isa = PBXFileReference; lastKnownFileType = folder; path = ViewModels; sourceTree = ""; }; + D0E39AB325D8BF88009C10F8 /* UITextInput+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITextInput+Extensions.swift"; sourceTree = ""; }; 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 = ""; }; D0E5361D24E3EB4D00FB1CE1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -760,6 +763,7 @@ D0C7D46C24F76169001EBDBB /* UIColor+Extensions.swift */, D0BE980D25D241CE0057E161 /* UIImage+Extensions.swift */, D08DFB0025CE228E0005DA98 /* UIScrollView+Extensions.swift */, + D0E39AB325D8BF88009C10F8 /* UITextInput+Extensions.swift */, D05936F325AA66A600754FDF /* UIView+Extensions.swift */, D0E7AD3825870B13005F5E2D /* UIVIewController+Extensions.swift */, D0030981250C6C8500EACB32 /* URL+Extensions.swift */, @@ -1010,6 +1014,7 @@ D05936DE25A937EC00754FDF /* EditThumbnailView.swift in Sources */, D0F0B136251AA12700942152 /* CollectionItem+Extensions.swift in Sources */, D007023E25562A2800F38136 /* ConversationAvatarsView.swift in Sources */, + D0E39AB425D8BF88009C10F8 /* UITextInput+Extensions.swift in Sources */, D0E7AD3925870B13005F5E2D /* UIVIewController+Extensions.swift in Sources */, D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */, D0BEB1F324F8EE8C001B0F04 /* AttachmentView.swift in Sources */, @@ -1166,6 +1171,7 @@ D07EC7D025B13921006DF726 /* PickerEmoji+Extensions.swift in Sources */, D0FCC106259C4E62000B67DF /* NewStatusViewController.swift in Sources */, D036EBB3259FE28800EC1CFC /* UIColor+Extensions.swift in Sources */, + D0E39ABD25D8C046009C10F8 /* UITextInput+Extensions.swift in Sources */, D088406E25AFBBE200BB749B /* EmojiPickerViewController.swift in Sources */, D00CB23325C92F2D008EF267 /* Attachment+Extensions.swift in Sources */, D025B14725C4D26B001C69A8 /* ImageCacheSerializer.swift in Sources */, diff --git a/ViewModels/Sources/ViewModels/View Models/CompositionViewModel.swift b/ViewModels/Sources/ViewModels/View Models/CompositionViewModel.swift index b3b077d..fad1a12 100644 --- a/ViewModels/Sources/ViewModels/View Models/CompositionViewModel.swift +++ b/ViewModels/Sources/ViewModels/View Models/CompositionViewModel.swift @@ -10,12 +10,16 @@ public final class CompositionViewModel: AttachmentsRenderingViewModel, Observab public let id = Id() public var isPosted = false @Published public var text = "" + @Published public var textToSelectedRange = "" @Published public var contentWarning = "" + @Published public var contentWarningTextToSelectedRange = "" @Published public var displayContentWarning = false @Published public var sensitive = false @Published public var displayPoll = false @Published public var pollMultipleChoice = false @Published public var pollExpiresIn = PollExpiry.oneDay + @Published public private(set) var autocompleteQuery: String? + @Published public private(set) var contentWarningAutocompleteQuery: String? @Published public private(set) var pollOptions = [PollOption(text: ""), PollOption(text: "")] @Published public private(set) var attachmentViewModels = [AttachmentViewModel]() @Published public private(set) var attachmentUpload: AttachmentUpload? @@ -38,11 +42,14 @@ public final class CompositionViewModel: AttachmentsRenderingViewModel, Observab textPresent || attachmentPresent } .assign(to: &$isPostable) + $attachmentViewModels .combineLatest($attachmentUpload, $displayPoll) .map { $0.count < Self.maxAttachmentCount && $1 == nil && !$2 } .assign(to: &$canAddAttachment) + $attachmentViewModels.map(\.isEmpty).assign(to: &$canAddNonImageAttachment) + $text.map { let tokens = $0.components(separatedBy: " ") @@ -51,7 +58,18 @@ public final class CompositionViewModel: AttachmentsRenderingViewModel, Observab .combineLatest($displayContentWarning, $contentWarning) .map { Self.maxCharacters - ($0 + ($1 ? $2.count : 0)) } .assign(to: &$remainingCharacters) + $displayContentWarning.filter { $0 }.assign(to: &$sensitive) + + $textToSelectedRange + .map { Self.extractAutocompleteQuery(textToSelectedRange: $0, emojiOnly: false) } + .removeDuplicates() + .assign(to: &$autocompleteQuery) + + $contentWarningTextToSelectedRange + .map { Self.extractAutocompleteQuery(textToSelectedRange: $0, emojiOnly: true) } + .removeDuplicates() + .assign(to: &$contentWarningAutocompleteQuery) } public func attachmentSelected(viewModel: AttachmentViewModel) { @@ -86,11 +104,17 @@ public extension CompositionViewModel { class PollOption: ObservableObject { public let id = Id() @Published public var text: String + @Published public var textToSelectedRange = "" @Published public private(set) var remainingCharacters = CompositionViewModel.maxCharacters + @Published public private(set) var autocompleteQuery: String? public init(text: String) { self.text = text $text.map { Self.maxCharacters - $0.count }.assign(to: &$remainingCharacters) + $textToSelectedRange + .map { CompositionViewModel.extractAutocompleteQuery(textToSelectedRange: $0, emojiOnly: true) } + .removeDuplicates() + .assign(to: &$autocompleteQuery) } } @@ -234,6 +258,18 @@ public extension CompositionViewModel.PollOption { private extension CompositionViewModel { static let maxAttachmentCount = 4 + static let autocompleteQueryRegularExpression = #"([@#:]\S+)\z"# + static let emojiOnlyAutocompleteQueryRegularExpression = #"(:\S+)\z"# + + static func extractAutocompleteQuery(textToSelectedRange: String, emojiOnly: Bool) -> String? { + guard let range = textToSelectedRange.range( + of: emojiOnly ? emojiOnlyAutocompleteQueryRegularExpression: autocompleteQueryRegularExpression, + options: .regularExpression, + locale: .current) + else { return nil } + + return String(textToSelectedRange[range]) + } } private extension String { diff --git a/Views/UIKit/CompositionInputAccessoryView.swift b/Views/UIKit/CompositionInputAccessoryView.swift index 69195e0..f9043be 100644 --- a/Views/UIKit/CompositionInputAccessoryView.swift +++ b/Views/UIKit/CompositionInputAccessoryView.swift @@ -11,11 +11,15 @@ final class CompositionInputAccessoryView: UIToolbar { private let viewModel: CompositionViewModel private let parentViewModel: NewStatusViewModel + private let autocompleteQueryPublisher: AnyPublisher private var cancellables = Set() - init(viewModel: CompositionViewModel, parentViewModel: NewStatusViewModel) { + init(viewModel: CompositionViewModel, + parentViewModel: NewStatusViewModel, + autocompleteQueryPublisher: AnyPublisher) { self.viewModel = viewModel self.parentViewModel = parentViewModel + self.autocompleteQueryPublisher = autocompleteQueryPublisher super.init( frame: .init( @@ -170,6 +174,11 @@ private extension CompositionInputAccessoryView { .sink { addButton.isEnabled = $0 } .store(in: &cancellables) + autocompleteQueryPublisher + .print() + .sink { _ in /* TODO */ } + .store(in: &cancellables) + parentViewModel.$visibility .sink { [weak self] in visibilityButton.image = UIImage(systemName: $0.systemImageName) diff --git a/Views/UIKit/CompositionPollOptionView.swift b/Views/UIKit/CompositionPollOptionView.swift index a8efdf6..2553a02 100644 --- a/Views/UIKit/CompositionPollOptionView.swift +++ b/Views/UIKit/CompositionPollOptionView.swift @@ -46,12 +46,20 @@ private extension CompositionPollOptionView { textField.font = .preferredFont(forTextStyle: .body) let textInputAccessoryView = CompositionInputAccessoryView( viewModel: viewModel, - parentViewModel: parentViewModel) + parentViewModel: parentViewModel, + autocompleteQueryPublisher: option.$autocompleteQuery.eraseToAnyPublisher()) textField.inputAccessoryView = textInputAccessoryView textField.tag = textInputAccessoryView.tagForInputView textField.addAction( UIAction { [weak self] _ in - self?.option.text = self?.textField.text ?? "" }, + guard let self = self, let text = self.textField.text else { return } + + self.option.text = text + + if let textToSelectedRange = self.textField.textToSelectedRange { + self.option.textToSelectedRange = textToSelectedRange + } + }, for: .editingChanged) textField.text = option.text diff --git a/Views/UIKit/CompositionView.swift b/Views/UIKit/CompositionView.swift index 50c0e18..1996943 100644 --- a/Views/UIKit/CompositionView.swift +++ b/Views/UIKit/CompositionView.swift @@ -49,6 +49,10 @@ extension CompositionView { extension CompositionView: UITextViewDelegate { func textViewDidChange(_ textView: UITextView) { viewModel.text = textView.text + + if let textToSelectedRange = textView.textToSelectedRange { + viewModel.textToSelectedRange = textToSelectedRange + } } } @@ -81,7 +85,8 @@ private extension CompositionView { let spoilerTextinputAccessoryView = CompositionInputAccessoryView( viewModel: viewModel, - parentViewModel: parentViewModel) + parentViewModel: parentViewModel, + autocompleteQueryPublisher: viewModel.$contentWarningAutocompleteQuery.eraseToAnyPublisher()) stackView.addArrangedSubview(spoilerTextField) spoilerTextField.borderStyle = .roundedRect @@ -95,13 +100,18 @@ private extension CompositionView { guard let self = self, let text = self.spoilerTextField.text else { return } self.viewModel.contentWarning = text + + if let textToSelectedRange = self.spoilerTextField.textToSelectedRange { + self.viewModel.contentWarningTextToSelectedRange = textToSelectedRange + } }, for: .editingChanged) let textViewFont = UIFont.preferredFont(forTextStyle: .body) let textInputAccessoryView = CompositionInputAccessoryView( viewModel: viewModel, - parentViewModel: parentViewModel) + parentViewModel: parentViewModel, + autocompleteQueryPublisher: viewModel.$autocompleteQuery.eraseToAnyPublisher()) stackView.addArrangedSubview(textView) textView.keyboardType = .twitter