From 92a26b2f7346e37a2646cd678cdb80d6a1f6a7fd Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 12 Mar 2021 19:25:28 +0800 Subject: [PATCH] feat: [WIP] add mention and hashtag input highlight. Add emoji token replacing logic --- Mastodon.xcodeproj/project.pbxproj | 4 + .../Section/ComposeStatusSection.swift | 7 +- .../Diffiable/Section/StatusSection.swift | 2 +- .../Scene/Compose/ComposeViewController.swift | 115 +++++++++++++++++- .../Compose/ComposeViewModel+Diffable.swift | 7 +- .../Vender/TwitterTextEditor+String.swift | 54 ++++++++ 6 files changed, 180 insertions(+), 9 deletions(-) create mode 100644 Mastodon/Vender/TwitterTextEditor+String.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 980271a2..0d7b441d 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -145,6 +145,7 @@ DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */; }; DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DB5086B725CC0D6400C2C187 /* Kingfisher */; }; DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */; }; + DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */; }; DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */; }; DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */; }; DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */; }; @@ -409,6 +410,7 @@ DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = ""; }; DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = ""; }; DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashPreference.swift; sourceTree = ""; }; + DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "TwitterTextEditor+String.swift"; sourceTree = ""; }; DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDelegate.swift"; sourceTree = ""; }; DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = ""; }; DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Poll.swift"; sourceTree = ""; }; @@ -672,6 +674,7 @@ children = ( 2D5A3D0225CF8742002347D6 /* ControlContainableScrollViews.swift */, DB2B3AE825E38850007045F9 /* UIViewPreview.swift */, + DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */, ); path = Vender; sourceTree = ""; @@ -1671,6 +1674,7 @@ 2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */, DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */, 2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */, + DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */, 2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */, DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index be3608ef..5c212454 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -9,6 +9,7 @@ import UIKit import Combine import CoreData import CoreDataStack +import TwitterTextEditor enum ComposeStatusSection: Equatable, Hashable { case repliedTo @@ -27,9 +28,10 @@ extension ComposeStatusSection { for tableView: UITableView, dependency: NeedsDependency, managedObjectContext: NSManagedObjectContext, - composeKind: ComposeKind + composeKind: ComposeKind, + textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate ) -> UITableViewDiffableDataSource { - UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in + UITableViewDiffableDataSource(tableView: tableView) { [weak textEditorViewTextAttributesDelegate] tableView, indexPath, item -> UITableViewCell? in switch item { case .replyTo(let tootObjectID): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToTootContentTableViewCell.self), for: indexPath) as! ComposeRepliedToTootContentTableViewCell @@ -47,6 +49,7 @@ extension ComposeStatusSection { cell.statusView.headerInfoLabel.text = "[TODO] \(replyTo.author.displayName)" } ComposeStatusSection.configure(cell: cell, attribute: attribute) + cell.textEditorView.textAttributesDelegate = textEditorViewTextAttributesDelegate // self size input cell cell.composeContent .receive(on: DispatchQueue.main) diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 1d0169ab..517fd5a2 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -322,7 +322,7 @@ extension StatusSection { cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.timeLeft(expiresAt.shortTimeAgoSinceNow) } } else { - assertionFailure() + // assertionFailure() cell.pollCountdownSubscription = nil cell.statusView.pollCountdownLabel.text = "-" } diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index b1a1cf5b..22a48fb0 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -9,7 +9,6 @@ import os.log import UIKit import Combine import TwitterTextEditor -import KeyboardGuide final class ComposeViewController: UIViewController, NeedsDependency { @@ -44,11 +43,13 @@ final class ComposeViewController: UIViewController, NeedsDependency { let composeToolbarView: ComposeToolbarView = { let composeToolbarView = ComposeToolbarView() + composeToolbarView.backgroundColor = .secondarySystemBackground return composeToolbarView }() var composeToolbarViewBottomLayoutConstraint: NSLayoutConstraint! let composeToolbarBackgroundView: UIView = { let backgroundView = UIView() + backgroundView.backgroundColor = .secondarySystemBackground return backgroundView }() @@ -91,8 +92,21 @@ extension ComposeViewController { composeToolbarView.preservesSuperviewLayoutMargins = true composeToolbarView.delegate = self + composeToolbarBackgroundView.translatesAutoresizingMaskIntoConstraints = false + view.insertSubview(composeToolbarBackgroundView, belowSubview: composeToolbarView) + NSLayoutConstraint.activate([ + composeToolbarBackgroundView.topAnchor.constraint(equalTo: composeToolbarView.topAnchor), + composeToolbarBackgroundView.leadingAnchor.constraint(equalTo: composeToolbarView.leadingAnchor), + composeToolbarBackgroundView.trailingAnchor.constraint(equalTo: composeToolbarView.trailingAnchor), + view.bottomAnchor.constraint(equalTo: composeToolbarBackgroundView.bottomAnchor), + ]) + tableView.delegate = self - viewModel.setupDiffableDataSource(for: tableView, dependency: self) + viewModel.setupDiffableDataSource( + for: tableView, + dependency: self, + textEditorViewTextAttributesDelegate: self + ) // respond scrollView overlap change view.layoutIfNeeded() @@ -208,12 +222,105 @@ extension ComposeViewController { // MARK: - TextEditorViewTextAttributesDelegate extension ComposeViewController: TextEditorViewTextAttributesDelegate { - func textEditorView(_ textEditorView: TextEditorView, updateAttributedString attributedString: NSAttributedString, completion: @escaping (NSAttributedString?) -> Void) { - // TODO: + func textEditorView( + _ textEditorView: TextEditorView, + updateAttributedString attributedString: NSAttributedString, + completion: @escaping (NSAttributedString?) -> Void + ) { + + DispatchQueue.global().async { + let string = attributedString.string + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update: %s", ((#file as NSString).lastPathComponent), #line, #function, string) + + let stringRange = NSRange(location: 0, length: string.length) + let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)|#([^\\s]+))") + // not accept :$ to force user input space to make emoji take effect + let emojiMatches = string.matches(pattern: "(?:(^:|\\s:)([a-zA-Z0-9_]+):\\s)") + + DispatchQueue.main.async { [weak self] in + guard let self = self else { + completion(nil) + return + } + + // set normal apperance + let attributedString = NSMutableAttributedString(attributedString: attributedString) + attributedString.removeAttribute(.suffixedAttachment, range: stringRange) + attributedString.removeAttribute(.underlineStyle, range: stringRange) + attributedString.addAttribute(.foregroundColor, value: Asset.Colors.Label.primary.color, range: stringRange) + attributedString.addAttribute(.font, value: UIFont.preferredFont(forTextStyle: .body), range: stringRange) + + for match in highlightMatches { + // hashtag + if let name = string.substring(with: match, at: 2) { + let attachment: TextAttributes.SuffixedAttachment? + switch name { + // FIXME: + case "person": + attachment = .init(size: CGSize(width: 20.0, height: 20.0), + attachment: .image(UIImage(systemName: "person")!)) + default: + attachment = nil + } + + if let attachment = attachment { + let index = match.range.upperBound - 1 + attributedString.addAttribute( + .suffixedAttachment, + value: attachment, + range: NSRange(location: index, length: 1) + ) + } + } + + // set highlight + var attributes = [NSAttributedString.Key: Any]() + attributes[.foregroundColor] = Asset.Colors.Label.highlight.color + // See `traitCollectionDidChange(_:)` + // set accessibility + if #available(iOS 13.0, *) { + switch self.traitCollection.accessibilityContrast { + case .high: + attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue + default: + break + } + } + attributedString.addAttributes(attributes, range: match.range) + } + for match in emojiMatches { + if let name = string.substring(with: match, at: 2) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: handle emoji: %s", ((#file as NSString).lastPathComponent), #line, #function, name) + + // set emoji token invisiable (without upper bounce space) + var attributes = [NSAttributedString.Key: Any]() + attributes[.font] = UIFont.systemFont(ofSize: 0.01) + let rangeWithoutUpperBounceSpace = NSRange(location: match.range.location, length: match.range.length - 1) + attributedString.addAttributes(attributes, range: rangeWithoutUpperBounceSpace) + + // append emoji attachment + let attachment = TextAttributes.SuffixedAttachment( + size: CGSize(width: 20, height: 20), + attachment: .image(UIImage(systemName: "circle")!) + ) + let index = match.range.upperBound - 1 + attributedString.addAttribute( + .suffixedAttachment, + value: attachment, + range: NSRange(location: index, length: 1) + ) + } + } + + completion(attributedString) + } + } } } + + // MARK: - ComposeToolbarViewDelegate extension ComposeViewController: ComposeToolbarViewDelegate { diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index 5c27bf51..0ee3e0b3 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -6,18 +6,21 @@ // import UIKit +import TwitterTextEditor extension ComposeViewModel { func setupDiffableDataSource( for tableView: UITableView, - dependency: NeedsDependency + dependency: NeedsDependency, + textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate ) { diffableDataSource = ComposeStatusSection.tableViewDiffableDataSource( for: tableView, dependency: dependency, managedObjectContext: context.managedObjectContext, - composeKind: composeKind + composeKind: composeKind, + textEditorViewTextAttributesDelegate: textEditorViewTextAttributesDelegate ) var snapshot = NSDiffableDataSourceSnapshot() diff --git a/Mastodon/Vender/TwitterTextEditor+String.swift b/Mastodon/Vender/TwitterTextEditor+String.swift new file mode 100644 index 00000000..7abdba3a --- /dev/null +++ b/Mastodon/Vender/TwitterTextEditor+String.swift @@ -0,0 +1,54 @@ +// +// String.swift +// Example +// +// Copyright 2021 Twitter, Inc. +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension String { + @inlinable + var length: Int { + (self as NSString).length + } + + @inlinable + func substring(with range: NSRange) -> String { + (self as NSString).substring(with: range) + } + + func substring(with result: NSTextCheckingResult, at index: Int) -> String? { + guard index < result.numberOfRanges else { + return nil + } + let range = result.range(at: index) + guard range.location != NSNotFound else { + return nil + } + return substring(with: result.range(at: index)) + } + + func firstMatch(pattern: String, + options: NSRegularExpression.Options = [], + range: NSRange? = nil) -> NSTextCheckingResult? + { + guard let regularExpression = try? NSRegularExpression(pattern: pattern, options: options) else { + return nil + } + let range = range ?? NSRange(location: 0, length: length) + return regularExpression.firstMatch(in: self, options: [], range: range) + } + + func matches(pattern: String, + options: NSRegularExpression.Options = [], + range: NSRange? = nil) -> [NSTextCheckingResult] + { + guard let regularExpression = try? NSRegularExpression(pattern: pattern, options: options) else { + return [] + } + let range = range ?? NSRange(location: 0, length: length) + return regularExpression.matches(in: self, options: [], range: range) + } +}