diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 70e01d1e..cb96c5bb 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -188,6 +188,11 @@ DB029E95266A20430062874E /* MastodonAuthenticationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB029E94266A20430062874E /* MastodonAuthenticationController.swift */; }; DB02CDAB26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */; }; DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */; }; + DB03F7EB268976B5007B274C /* MastodonMeta in Frameworks */ = {isa = PBXBuildFile; productRef = DB03F7EA268976B5007B274C /* MastodonMeta */; }; + DB03F7ED268976B5007B274C /* MetaTextView in Frameworks */ = {isa = PBXBuildFile; productRef = DB03F7EC268976B5007B274C /* MetaTextView */; }; + DB03F7F026899097007B274C /* ComposeStatusContentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB03F7EF26899097007B274C /* ComposeStatusContentTableViewCell.swift */; }; + DB03F7F32689AEA3007B274C /* ComposeRepliedToStatusContentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB03F7F22689AEA3007B274C /* ComposeRepliedToStatusContentTableViewCell.swift */; }; + DB03F7F52689B782007B274C /* ComposeTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB03F7F42689B782007B274C /* ComposeTableView.swift */; }; DB040ECD26526EA600BEE9D8 /* ComposeCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB040ECC26526EA600BEE9D8 /* ComposeCollectionView.swift */; }; DB040ED126538E3D00BEE9D8 /* Trie.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB040ED026538E3C00BEE9D8 /* Trie.swift */; }; DB084B5725CBC56C00F898ED /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB084B5625CBC56C00F898ED /* Status.swift */; }; @@ -799,6 +804,9 @@ DB029E94266A20430062874E /* MastodonAuthenticationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAuthenticationController.swift; sourceTree = ""; }; DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadReplyLoaderTableViewCell.swift; sourceTree = ""; }; DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveUserInterfaceStyleBarButtonItem.swift; sourceTree = ""; }; + DB03F7EF26899097007B274C /* ComposeStatusContentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentTableViewCell.swift; sourceTree = ""; }; + DB03F7F22689AEA3007B274C /* ComposeRepliedToStatusContentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToStatusContentTableViewCell.swift; sourceTree = ""; }; + DB03F7F42689B782007B274C /* ComposeTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTableView.swift; sourceTree = ""; }; DB040ECC26526EA600BEE9D8 /* ComposeCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCollectionView.swift; sourceTree = ""; }; DB040ED026538E3C00BEE9D8 /* Trie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Trie.swift; sourceTree = ""; }; DB084B5625CBC56C00F898ED /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = ""; }; @@ -1120,6 +1128,7 @@ DBAEDE5F267A0B1500D25FF5 /* Nuke in Frameworks */, DB6F5E32264E7410009108F4 /* TwitterTextEditor in Frameworks */, DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */, + DB03F7ED268976B5007B274C /* MetaTextView in Frameworks */, DB89BA0325C10FD0008580ED /* CoreDataStack.framework in Frameworks */, 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */, DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */, @@ -1138,6 +1147,7 @@ 2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */, 87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */, DB6804C82637CE2F00430867 /* AppShared.framework in Frameworks */, + DB03F7EB268976B5007B274C /* MastodonMeta in Frameworks */, DBF7A0FC26830C33004176A2 /* FPSIndicator in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1732,6 +1742,15 @@ path = Status; sourceTree = ""; }; + DB03F7F1268990A2007B274C /* TableViewCell */ = { + isa = PBXGroup; + children = ( + DB03F7F22689AEA3007B274C /* ComposeRepliedToStatusContentTableViewCell.swift */, + DB03F7EF26899097007B274C /* ComposeStatusContentTableViewCell.swift */, + ); + path = TableViewCell; + sourceTree = ""; + }; DB084B5125CBC56300F898ED /* CoreDataStack */ = { isa = PBXGroup; children = ( @@ -1940,6 +1959,7 @@ DB55D32225FB4D320002F825 /* View */ = { isa = PBXGroup; children = ( + DB03F7F42689B782007B274C /* ComposeTableView.swift */, DB040ECC26526EA600BEE9D8 /* ComposeCollectionView.swift */, DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */, DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */, @@ -2082,6 +2102,7 @@ DB6F5E36264E78EA009108F4 /* AutoComplete */, DB55D32225FB4D320002F825 /* View */, DB789A2125F9F76D0071ACA0 /* CollectionViewCell */, + DB03F7F1268990A2007B274C /* TableViewCell */, DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */, DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */, DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */, @@ -2653,6 +2674,8 @@ DBAC64A0267E6D02007FE9FD /* Fuzi */, DBF7A0FB26830C33004176A2 /* FPSIndicator */, DB0E2D2D26833FF700865C3C /* NukeFLAnimatedImagePlugin */, + DB03F7EA268976B5007B274C /* MastodonMeta */, + DB03F7EC268976B5007B274C /* MetaTextView */, ); productName = Mastodon; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; @@ -2846,6 +2869,7 @@ DBAC649F267E6D01007FE9FD /* XCRemoteSwiftPackageReference "Fuzi" */, DBF7A0FA26830C33004176A2 /* XCRemoteSwiftPackageReference "FPSIndicator" */, DB0E2D2C26833FF600865C3C /* XCRemoteSwiftPackageReference "Nuke-FLAnimatedImage-Plugin" */, + DB03F7E9268976B5007B274C /* XCRemoteSwiftPackageReference "MetaTextView" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -3218,6 +3242,7 @@ DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */, 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */, DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */, + DB03F7F32689AEA3007B274C /* ComposeRepliedToStatusContentTableViewCell.swift in Sources */, 2D4AD8A826316D3500613EFC /* SelectedAccountItem.swift in Sources */, DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */, DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */, @@ -3325,6 +3350,7 @@ DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */, 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */, + DB03F7F52689B782007B274C /* ComposeTableView.swift in Sources */, 2D24E11D2626D8B100A59D4F /* NotificationStatusTableViewCell.swift in Sources */, DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */, DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */, @@ -3471,6 +3497,7 @@ DBBF1DC226524D2900E5B703 /* AutoCompleteTableViewCell.swift in Sources */, 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, DB1EE7B2267F9525000CC337 /* StatusProvider+StatusNodeDelegate.swift in Sources */, + DB03F7F026899097007B274C /* ComposeStatusContentTableViewCell.swift in Sources */, 5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */, 5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */, DBA94436265CBB7400C537E1 /* ProfileFieldItem.swift in Sources */, @@ -4730,6 +4757,14 @@ minimumVersion = 0.1.1; }; }; + DB03F7E9268976B5007B274C /* XCRemoteSwiftPackageReference "MetaTextView" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/TwidereProject/MetaTextView.git"; + requirement = { + kind = exactVersion; + version = 1.2.0; + }; + }; DB0E2D2C26833FF600865C3C /* XCRemoteSwiftPackageReference "Nuke-FLAnimatedImage-Plugin" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/kean/Nuke-FLAnimatedImage-Plugin.git"; @@ -4863,6 +4898,16 @@ package = DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */; productName = CommonOSLog; }; + DB03F7EA268976B5007B274C /* MastodonMeta */ = { + isa = XCSwiftPackageProductDependency; + package = DB03F7E9268976B5007B274C /* XCRemoteSwiftPackageReference "MetaTextView" */; + productName = MastodonMeta; + }; + DB03F7EC268976B5007B274C /* MetaTextView */ = { + isa = XCSwiftPackageProductDependency; + package = DB03F7E9268976B5007B274C /* XCRemoteSwiftPackageReference "MetaTextView" */; + productName = MetaTextView; + }; DB0E2D2D26833FF700865C3C /* NukeFLAnimatedImagePlugin */ = { isa = XCSwiftPackageProductDependency; package = DB0E2D2C26833FF600865C3C /* XCRemoteSwiftPackageReference "Nuke-FLAnimatedImage-Plugin" */; diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index f1135b12..ebbde832 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -12,7 +12,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 20 + 21 Mastodon - ASDK.xcscheme_^#shared#^_ @@ -37,7 +37,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 21 + 22 SuppressBuildableAutocreation diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index bf58fb3c..f6219c40 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -109,6 +109,15 @@ "version": "6.2.1" } }, + { + "package": "MetaTextView", + "repositoryURL": "https://github.com/TwidereProject/MetaTextView.git", + "state": { + "branch": null, + "revision": "637b73044e665e8b9678ed64dd2a83314c286aef", + "version": "1.2.0" + } + }, { "package": "Nuke", "repositoryURL": "https://github.com/kean/Nuke.git", diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 836d91e7..93005cca 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -9,7 +9,8 @@ import UIKit import Combine import CoreData import CoreDataStack -import TwitterTextEditor +import MetaTextView +import MastodonMeta import AlamofireImage enum ComposeStatusSection: Equatable, Hashable { @@ -36,8 +37,8 @@ extension ComposeStatusSection { composeKind: ComposeKind, repliedToCellFrameSubscriber: CurrentValueSubject, customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel, - textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate, - textEditorViewChangeObserver: TextEditorViewChangeObserver, + metaTextDelegate: MetaTextDelegate, + metaTextViewDelegate: UITextViewDelegate, composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate, composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate, composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate, @@ -45,8 +46,8 @@ extension ComposeStatusSection { ) -> UICollectionViewDiffableDataSource { UICollectionViewDiffableDataSource(collectionView: collectionView) { [ weak customEmojiPickerInputViewModel, - weak textEditorViewTextAttributesDelegate, - weak textEditorViewChangeObserver, + weak metaTextDelegate, + weak metaTextViewDelegate, weak composeStatusAttachmentTableViewCellDelegate, weak composeStatusPollOptionCollectionViewCellDelegate, weak composeStatusNewPollOptionCollectionViewCellDelegate, @@ -74,7 +75,7 @@ extension ComposeStatusSection { cell.statusView.usernameLabel.text = "@" + (status.reblog ?? status).author.acct // set text //status.emoji - cell.statusView.activeTextLabel.configure(content: status.content, emojiDict: [:]) +// cell.statusView.activeTextLabel.configure(content: status.content, emojiDict: [:]) // set date cell.statusView.dateLabel.text = status.createdAt.slowedTimeAgoSinceNow @@ -83,8 +84,17 @@ extension ComposeStatusSection { return cell case .input(let replyToStatusObjectID, let attribute): let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self), for: indexPath) as! ComposeStatusContentCollectionViewCell + do { + let metaContent = try MastodonMetaContent.convert( + document: MastodonContent(content: attribute.composeContent.value ?? "", emojis: [:]) + ) + cell.metaText.configure(content: metaContent) + } catch { + assertionFailure() + } + cell.metaText.delegate = metaTextDelegate + cell.metaText.textView.delegate = metaTextViewDelegate cell.statusContentWarningEditorView.textView.text = attribute.contentWarningContent.value - cell.textEditorView.text = attribute.composeContent.value ?? "" managedObjectContext.performAndWait { guard let replyToStatusObjectID = replyToStatusObjectID, let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else { @@ -96,24 +106,22 @@ extension ComposeStatusSection { cell.statusView.headerInfoLabel.text = L10n.Scene.Compose.replyingToUser(replyTo.author.displayNameWithFallback) } ComposeStatusSection.configureStatusContent(cell: cell, attribute: attribute) - cell.textEditorView.textAttributesDelegate = textEditorViewTextAttributesDelegate - cell.textEditorViewChangeObserver = textEditorViewChangeObserver // relay - cell.composeContent - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [weak collectionView] text in - guard let collectionView = collectionView else { return } - // self size input cell - // needs restore content offset to resolve issue #83 - let oldContentOffset = collectionView.contentOffset - collectionView.collectionViewLayout.invalidateLayout() - collectionView.layoutIfNeeded() - collectionView.contentOffset = oldContentOffset - - // bind input data - attribute.composeContent.value = text - } - .store(in: &cell.disposeBag) +// cell.composeContent +// .removeDuplicates() +// .receive(on: DispatchQueue.main) +// .sink { [weak collectionView] text in +// guard let collectionView = collectionView else { return } +// // self size input cell +// // needs restore content offset to resolve issue #83 +// let oldContentOffset = collectionView.contentOffset +// collectionView.collectionViewLayout.invalidateLayout() +// collectionView.layoutIfNeeded() +// collectionView.contentOffset = oldContentOffset +// +// // bind input data +// attribute.composeContent.value = text +// } +// .store(in: &cell.disposeBag) attribute.isContentWarningComposing .receive(on: DispatchQueue.main) .sink { [weak cell, weak collectionView] isContentWarningComposing in @@ -121,7 +129,7 @@ extension ComposeStatusSection { guard let collectionView = collectionView else { return } // self size input cell collectionView.collectionViewLayout.invalidateLayout() - cell.statusContentWarningEditorView.containerView.isHidden = !isContentWarningComposing + cell.statusContentWarningEditorView.isHidden = !isContentWarningComposing cell.statusContentWarningEditorView.alpha = 0 UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseOut]) { cell.statusContentWarningEditorView.alpha = 1 @@ -141,7 +149,7 @@ extension ComposeStatusSection { attribute.contentWarningContent.value = text } .store(in: &cell.disposeBag) - ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.textEditorView, disposeBag: &cell.disposeBag) + ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.metaText.textView, disposeBag: &cell.disposeBag) ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.statusContentWarningEditorView.textView, disposeBag: &cell.disposeBag) return cell @@ -282,6 +290,30 @@ extension ComposeStatusSection { .assign(to: \.value, on: attribute.composeContent) .store(in: &cell.disposeBag) } + + static func configureStatusContent( + cell: ComposeStatusContentTableViewCell, + attribute: ComposeStatusItem.ComposeStatusAttribute + ) { + // set avatar + attribute.avatarURL + .receive(on: DispatchQueue.main) + .sink { avatarURL in + cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarURL)) + } + .store(in: &cell.disposeBag) + // set display name and username + Publishers.CombineLatest( + attribute.displayName.eraseToAnyPublisher(), + attribute.username.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { displayName, username in + cell.statusView.nameLabel.text = displayName + cell.statusView.usernameLabel.text = username.flatMap { "@" + $0 } ?? " " + } + .store(in: &cell.disposeBag) + } } @@ -303,16 +335,16 @@ class CustomEmojiReplaceableTextInputReference { } } -extension TextEditorView: CustomEmojiReplaceableTextInput { - func insertText(_ text: String) { - try? updateByReplacing(range: selectedRange, with: text, selectedRange: nil) - } - - public override var isFirstResponder: Bool { - return isEditing - } - -} +//extension TextEditorView: CustomEmojiReplaceableTextInput { +// func insertText(_ text: String) { +// try? updateByReplacing(range: selectedRange, with: text, selectedRange: nil) +// } +// +// public override var isFirstResponder: Bool { +// return isEditing +// } +// +//} extension UITextField: CustomEmojiReplaceableTextInput { } extension UITextView: CustomEmojiReplaceableTextInput { } diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 43bc791c..b94ed332 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -12,7 +12,9 @@ import os.log import UIKit import AVKit import Nuke -import LinkPresentation +import MastodonMeta + +// import LinkPresentation #if ASDK import AsyncDisplayKit @@ -138,12 +140,15 @@ extension StatusSection { cell.delegate = statusTableViewCellDelegate switch item { case .root: - cell.statusView.activeTextLabel.isAccessibilityElement = false + // enable selection only for root + cell.statusView.contentMetaText.textView.isSelectable = true + cell.statusView.contentMetaText.textView.isAccessibilityElement = false var accessibilityElements: [Any] = [] accessibilityElements.append(cell.statusView.avatarView) accessibilityElements.append(cell.statusView.nameLabel) accessibilityElements.append(cell.statusView.dateLabel) - accessibilityElements.append(contentsOf: cell.statusView.activeTextLabel.createAccessibilityElements()) + // TODO: a11y + accessibilityElements.append(cell.statusView.contentMetaText.textView) accessibilityElements.append(contentsOf: cell.statusView.statusMosaicImageViewContainer.imageViews) accessibilityElements.append(cell.statusView.playerContainerView) accessibilityElements.append(cell.statusView.actionToolbarContainer) @@ -554,11 +559,19 @@ extension StatusSection { statusItemAttribute: Item.StatusAttribute ) { // set content - cell.statusView.activeTextLabel.configure( - content: (status.reblog ?? status).content, - emojiDict: (status.reblog ?? status).emojiDict - ) - cell.statusView.activeTextLabel.accessibilityLanguage = (status.reblog ?? status).language + do { + let content = MastodonContent( + content: (status.reblog ?? status).content, + emojis: (status.reblog ?? status).emojiMeta + ) + let metaContent = try MastodonMetaContent.convert(document: content) + cell.statusView.contentMetaText.configure(content: metaContent) + } catch { + cell.statusView.contentMetaText.textView.text = " " + assertionFailure() + } + + cell.statusView.contentMetaText.textView.accessibilityLanguage = (status.reblog ?? status).language // set visibility if let visibility = (status.reblog ?? status).visibility { diff --git a/Mastodon/Extension/CoreDataStack/Emojis.swift b/Mastodon/Extension/CoreDataStack/Emojis.swift index a35e2630..8d7c2975 100644 --- a/Mastodon/Extension/CoreDataStack/Emojis.swift +++ b/Mastodon/Extension/CoreDataStack/Emojis.swift @@ -7,6 +7,7 @@ import Foundation import MastodonSDK +import MastodonMeta protocol EmojiContainer { var emojisData: Data? { get } @@ -31,6 +32,14 @@ extension EmojiContainer { } return dict } + + var emojiMeta: MastodonContent.Emojis { + var dict = MastodonContent.Emojis() + for emoji in emojis ?? [] { + dict[emoji.shortcode] = emoji.url + } + return dict + } } diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index 60d61ecd..2803113d 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -12,6 +12,8 @@ import CoreData import CoreDataStack import MastodonSDK import ActiveLabel +import Meta +import MetaTextView // MARK: - StatusViewDelegate extension StatusTableViewCellDelegate where Self: StatusProvider { @@ -27,6 +29,10 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { StatusProviderFacade.responseToStatusActiveLabelAction(provider: self, cell: cell, activeLabel: activeLabel, didTapEntity: entity) } + + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) { + StatusProviderFacade.responseToStatusMetaTextAction(provider: self, cell: cell, metaText: metaText, didSelectMeta: meta) + } func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) { StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell) diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index 3122de95..e6711bb1 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -12,6 +12,8 @@ import CoreData import CoreDataStack import MastodonSDK import ActiveLabel +import Meta +import MetaTextView #if ASDK import AsyncDisplayKit @@ -149,6 +151,31 @@ extension StatusProviderFacade { } } + static func responseToStatusMetaTextAction(provider: StatusProvider, cell: UITableViewCell, metaText: MetaText, didSelectMeta meta: Meta) { + switch meta { + case .url(_, _, let url, _): + guard let url = URL(string: url) else { return } + if let domain = provider.context.authenticationService.activeMastodonAuthenticationBox.value?.domain, url.host == domain, + url.pathComponents.count >= 4, + url.pathComponents[0] == "/", + url.pathComponents[1] == "web", + url.pathComponents[2] == "statuses" { + let statusID = url.pathComponents[3] + let threadViewModel = RemoteThreadViewModel(context: provider.context, statusID: statusID) + provider.coordinator.present(scene: .thread(viewModel: threadViewModel), from: nil, transition: .show) + } else { + provider.coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil)) + } + case .hashtag(_, let hashtag, _): + let hashtagTimelineViewModel = HashtagTimelineViewModel(context: provider.context, hashtag: hashtag) + provider.coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), from: provider, transition: .show) + case .mention(_, let mention, _): + coordinateToStatusMentionProfileScene(for: .primary, provider: provider, cell: cell, mention: mention) + default: + break + } + } + #if ASDK static func responseToStatusActiveLabelAction(provider: StatusProvider, node: ASCellNode, didSelectActiveEntityType type: ActiveEntityType) { switch type { diff --git a/Mastodon/Scene/Compose/AutoComplete/AutoCompleteViewController.swift b/Mastodon/Scene/Compose/AutoComplete/AutoCompleteViewController.swift index c98f8407..56832b9b 100644 --- a/Mastodon/Scene/Compose/AutoComplete/AutoCompleteViewController.swift +++ b/Mastodon/Scene/Compose/AutoComplete/AutoCompleteViewController.swift @@ -38,7 +38,9 @@ final class AutoCompleteViewController: UIViewController { tableView.backgroundColor = .clear tableView.contentInset.top = AutoCompleteViewController.chevronViewHeight tableView.verticalScrollIndicatorInsets.top = AutoCompleteViewController.chevronViewHeight - tableView.showsVerticalScrollIndicator = false // avoid duplicate to the compose collection view indicator + tableView.showsVerticalScrollIndicator = false // avoid duplicate to the compose collection view indicator + tableView.preservesSuperviewLayoutMargins = false + tableView.cellLayoutMarginsFollowReadableWidth = false return tableView }() @@ -50,6 +52,9 @@ extension AutoCompleteViewController { super.viewDidLoad() view.backgroundColor = .clear + + // we hack the view hierarchy. Do not preserve from superview + view.preservesSuperviewLayoutMargins = false chevronView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(chevronView) diff --git a/Mastodon/Scene/Compose/AutoComplete/Cell/AutoCompleteTableViewCell.swift b/Mastodon/Scene/Compose/AutoComplete/Cell/AutoCompleteTableViewCell.swift index a13e82f3..f629177d 100644 --- a/Mastodon/Scene/Compose/AutoComplete/Cell/AutoCompleteTableViewCell.swift +++ b/Mastodon/Scene/Compose/AutoComplete/Cell/AutoCompleteTableViewCell.swift @@ -97,8 +97,8 @@ extension AutoCompleteTableViewCell { contentView.addSubview(containerStackView) NSLayoutConstraint.activate([ containerStackView.topAnchor.constraint(equalTo: topPaddingView.bottomAnchor), - containerStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), - containerStackView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + containerStackView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), ]) avatarImageView.translatesAutoresizingMaskIntoConstraints = false diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift index bb725e9b..fbe7a202 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift @@ -8,7 +8,7 @@ import os.log import UIKit import Combine -import TwitterTextEditor +import MetaTextView final class ComposeStatusContentCollectionViewCell: UICollectionViewCell { @@ -19,22 +19,35 @@ final class ComposeStatusContentCollectionViewCell: UICollectionViewCell { let statusContentWarningEditorView = StatusContentWarningEditorView() let textEditorViewContainerView = UIView() - let textEditorView: TextEditorView = { - let textEditorView = TextEditorView() - textEditorView.font = .preferredFont(forTextStyle: .body) - textEditorView.scrollView.isScrollEnabled = false - textEditorView.isScrollEnabled = false - textEditorView.placeholderText = L10n.Scene.Compose.contentInputPlaceholder - textEditorView.keyboardType = .twitter - return textEditorView + + static let metaTextViewTag: Int = 333 + let metaText: MetaText = { + let metaText = MetaText() + metaText.textView.tag = ComposeStatusContentCollectionViewCell.metaTextViewTag + metaText.textView.isScrollEnabled = false + metaText.textView.keyboardType = .twitter + metaText.textView.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)) + metaText.textView.attributedPlaceholder = { + var attributes = metaText.textAttributes + attributes[.foregroundColor] = Asset.Colors.Label.secondary.color + return NSAttributedString( + string: L10n.Scene.Compose.contentInputPlaceholder, + attributes: attributes + ) + }() + return metaText }() - // input - weak var textEditorViewChangeObserver: TextEditorViewChangeObserver? - // output let composeContent = PassthroughSubject() let contentWarningContent = PassthroughSubject() + + override func prepareForReuse() { + super.prepareForReuse() + + metaText.delegate = nil + metaText.textView.delegate = nil + } override init(frame: CGRect) { super.init(frame: frame) @@ -90,45 +103,58 @@ extension ComposeStatusContentCollectionViewCell { ]) textEditorViewContainerView.preservesSuperviewLayoutMargins = true - textEditorView.translatesAutoresizingMaskIntoConstraints = false - textEditorViewContainerView.addSubview(textEditorView) +// textEditorView.translatesAutoresizingMaskIntoConstraints = false +// textEditorViewContainerView.addSubview(textEditorView) +// NSLayoutConstraint.activate([ +// textEditorView.topAnchor.constraint(equalTo: textEditorViewContainerView.topAnchor), +// textEditorView.leadingAnchor.constraint(equalTo: textEditorViewContainerView.readableContentGuide.leadingAnchor), +// textEditorView.trailingAnchor.constraint(equalTo: textEditorViewContainerView.readableContentGuide.trailingAnchor), +// textEditorView.bottomAnchor.constraint(equalTo: textEditorViewContainerView.bottomAnchor), +// textEditorView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh), +// ]) +// textEditorView.setContentCompressionResistancePriority(.required - 2, for: .vertical) + + metaText.textView.translatesAutoresizingMaskIntoConstraints = false + textEditorViewContainerView.addSubview(metaText.textView) NSLayoutConstraint.activate([ - textEditorView.topAnchor.constraint(equalTo: textEditorViewContainerView.topAnchor), - textEditorView.leadingAnchor.constraint(equalTo: textEditorViewContainerView.readableContentGuide.leadingAnchor), - textEditorView.trailingAnchor.constraint(equalTo: textEditorViewContainerView.readableContentGuide.trailingAnchor), - textEditorView.bottomAnchor.constraint(equalTo: textEditorViewContainerView.bottomAnchor), - textEditorView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh), + metaText.textView.topAnchor.constraint(equalTo: textEditorViewContainerView.topAnchor), + metaText.textView.leadingAnchor.constraint(equalTo: textEditorViewContainerView.readableContentGuide.leadingAnchor), + metaText.textView.trailingAnchor.constraint(equalTo: textEditorViewContainerView.readableContentGuide.trailingAnchor), + metaText.textView.bottomAnchor.constraint(equalTo: textEditorViewContainerView.bottomAnchor), + metaText.textView.heightAnchor.constraint(greaterThanOrEqualToConstant: 88).priority(.defaultHigh), ]) - textEditorView.setContentCompressionResistancePriority(.required - 2, for: .vertical) + metaText.textView.setContentCompressionResistancePriority(.required - 2, for: .vertical) statusContentWarningEditorView.textView.delegate = self - textEditorView.changeObserver = self + //textEditorView.changeObserver = self - statusContentWarningEditorView.containerView.isHidden = true + statusContentWarningEditorView.isHidden = true statusView.revealContentWarningButton.isHidden = true } } // MARK: - TextEditorViewChangeObserver -extension ComposeStatusContentCollectionViewCell: TextEditorViewChangeObserver { - func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) { - defer { - textEditorViewChangeObserver?.textEditorView(textEditorView, didChangeWithChangeResult: changeResult) - } - - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: text: %s", ((#file as NSString).lastPathComponent), #line, #function, textEditorView.text) - guard changeResult.isTextChanged else { return } - composeContent.send(textEditorView.text) - } -} +//extension ComposeStatusContentCollectionViewCell: TextEditorViewChangeObserver { +// func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) { +// defer { +// textEditorViewChangeObserver?.textEditorView(textEditorView, didChangeWithChangeResult: changeResult) +// } +// +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: text: %s", ((#file as NSString).lastPathComponent), #line, #function, textEditorView.text) +// guard changeResult.isTextChanged else { return } +// composeContent.send(textEditorView.text) +// } +//} // MARK: - UITextViewDelegate extension ComposeStatusContentCollectionViewCell: UITextViewDelegate { func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { - // disable input line break - guard text != "\n" else { return false } + if textView === statusContentWarningEditorView.textView { + // disable input line break + guard text != "\n" else { return false } + } return true } diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index a9fe951f..2a8844cf 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -12,6 +12,9 @@ import PhotosUI import Kingfisher import MastodonSDK import TwitterTextEditor +import MetaTextView +import MastodonMeta +import Meta final class ComposeViewController: UIViewController, NeedsDependency { @@ -22,7 +25,9 @@ final class ComposeViewController: UIViewController, NeedsDependency { var disposeBag = Set() var viewModel: ComposeViewModel! - + + let logger = Logger(subsystem: "ComposeViewController", category: "logic") + private var suffixedAttachmentViews: [UIView] = [] let publishButton: UIButton = { @@ -44,19 +49,30 @@ final class ComposeViewController: UIViewController, NeedsDependency { return barButtonItem }() - let collectionView: ComposeCollectionView = { - let collectionViewLayout = ComposeViewController.createLayout() - let collectionView = ComposeCollectionView(frame: .zero, collectionViewLayout: collectionViewLayout) - collectionView.register(ComposeRepliedToStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeRepliedToStatusContentCollectionViewCell.self)) - collectionView.register(ComposeStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self)) - collectionView.register(ComposeStatusAttachmentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self)) - collectionView.register(ComposeStatusPollOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self)) - collectionView.register(ComposeStatusPollOptionAppendEntryCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self)) - collectionView.register(ComposeStatusPollExpiresOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollExpiresOptionCollectionViewCell.self)) - collectionView.backgroundColor = Asset.Scene.Compose.background.color - collectionView.alwaysBounceVertical = true - collectionView.keyboardDismissMode = .onDrag - return collectionView +// let collectionView: ComposeCollectionView = { +// let collectionViewLayout = ComposeViewController.createLayout() +// let collectionView = ComposeCollectionView(frame: .zero, collectionViewLayout: collectionViewLayout) +// collectionView.register(ComposeRepliedToStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeRepliedToStatusContentCollectionViewCell.self)) +// collectionView.register(ComposeStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self)) +// collectionView.register(ComposeStatusAttachmentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self)) +// collectionView.register(ComposeStatusPollOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self)) +// collectionView.register(ComposeStatusPollOptionAppendEntryCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self)) +// collectionView.register(ComposeStatusPollExpiresOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollExpiresOptionCollectionViewCell.self)) +// collectionView.backgroundColor = Asset.Scene.Compose.background.color +// collectionView.alwaysBounceVertical = true +// collectionView.keyboardDismissMode = .onDrag +// return collectionView +// }() + + let tableView: ComposeTableView = { + let tableView = ComposeTableView() + tableView.register(ComposeRepliedToStatusContentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeRepliedToStatusContentTableViewCell.self)) + tableView.register(ComposeStatusContentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeStatusContentTableViewCell.self)) + tableView.backgroundColor = Asset.Scene.Compose.background.color + tableView.alwaysBounceVertical = true + tableView.separatorStyle = .none + tableView.tableFooterView = UIView() + return tableView }() var systemKeyboardHeight: CGFloat = .zero { @@ -148,15 +164,25 @@ extension ComposeViewController { navigationItem.leftBarButtonItem = cancelBarButtonItem navigationItem.rightBarButtonItem = publishBarButtonItem publishButton.addTarget(self, action: #selector(ComposeViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside) - - collectionView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(collectionView) + + + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) NSLayoutConstraint.activate([ - collectionView.topAnchor.constraint(equalTo: view.topAnchor), - collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) + +// collectionView.translatesAutoresizingMaskIntoConstraints = false +// view.addSubview(collectionView) +// NSLayoutConstraint.activate([ +// collectionView.topAnchor.constraint(equalTo: view.topAnchor), +// collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), +// collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), +// collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), +// ]) composeToolbarView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(composeToolbarView) @@ -178,21 +204,41 @@ extension ComposeViewController { composeToolbarBackgroundView.trailingAnchor.constraint(equalTo: composeToolbarView.trailingAnchor), view.bottomAnchor.constraint(equalTo: composeToolbarBackgroundView.bottomAnchor), ]) - - collectionView.delegate = self + + tableView.delegate = self viewModel.setupDiffableDataSource( - for: collectionView, - dependency: self, - customEmojiPickerInputViewModel: viewModel.customEmojiPickerInputViewModel, - textEditorViewTextAttributesDelegate: self, - textEditorViewChangeObserver: self, - composeStatusAttachmentTableViewCellDelegate: self, - composeStatusPollOptionCollectionViewCellDelegate: self, - composeStatusNewPollOptionCollectionViewCellDelegate: self, - composeStatusPollExpiresOptionCollectionViewCellDelegate: self + tableView: tableView, + metaTextDelegate: self, + metaTextViewDelegate: self, + customEmojiPickerInputViewModel: viewModel.customEmojiPickerInputViewModel ) - let longPressReorderGesture = UILongPressGestureRecognizer(target: self, action: #selector(ComposeViewController.longPressReorderGestureHandler(_:))) - collectionView.addGestureRecognizer(longPressReorderGesture) + + viewModel.composeStatusAttribute.composeContent + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak self] _ in + guard let self = self else { return } + UIView.performWithoutAnimation { + self.tableView.beginUpdates() + self.tableView.endUpdates() + } + } + .store(in: &disposeBag) + +// collectionView.delegate = self +// viewModel.setupDiffableDataSource( +// for: collectionView, +// dependency: self, +// customEmojiPickerInputViewModel: viewModel.customEmojiPickerInputViewModel, +// metaTextDelegate: self, +// metaTextViewDelegate: self, +// composeStatusAttachmentTableViewCellDelegate: self, +// composeStatusPollOptionCollectionViewCellDelegate: self, +// composeStatusNewPollOptionCollectionViewCellDelegate: self, +// composeStatusPollExpiresOptionCollectionViewCellDelegate: self +// ) +// let longPressReorderGesture = UILongPressGestureRecognizer(target: self, action: #selector(ComposeViewController.longPressReorderGestureHandler(_:))) +// collectionView.addGestureRecognizer(longPressReorderGesture) customEmojiPickerInputView.collectionView.delegate = self viewModel.customEmojiPickerInputViewModel.customEmojiPickerInputView = customEmojiPickerInputView @@ -227,8 +273,8 @@ extension ComposeViewController { // update keyboard background color guard isShow, state == .dock else { - self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin - self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin + self.tableView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin + self.tableView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin if let superView = self.autoCompleteViewController.tableView.superview { let autoCompleteTableViewBottomInset: CGFloat = { @@ -263,18 +309,18 @@ extension ComposeViewController { self.autoCompleteViewController.tableView.verticalScrollIndicatorInsets.bottom = autoCompleteTableViewBottomInset // adjust inset for collectionView - let contentFrame = self.view.convert(self.collectionView.frame, to: nil) + let contentFrame = self.view.convert(self.tableView.frame, to: nil) let padding = contentFrame.maxY + extraMargin - endFrame.minY guard padding > 0 else { - self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin - self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin + self.tableView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin + self.tableView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin self.updateKeyboardBackground(isKeyboardDisplay: false) return } - self.collectionView.contentInset.bottom = padding - self.collectionView.verticalScrollIndicatorInsets.bottom = padding + self.tableView.contentInset.bottom = padding + self.tableView.verticalScrollIndicatorInsets.bottom = padding UIView.animate(withDuration: 0.3) { self.composeToolbarViewBottomLayoutConstraint.constant = endFrame.height self.view.layoutIfNeeded() @@ -292,15 +338,16 @@ extension ComposeViewController { if self.autoCompleteViewController.view.superview == nil { self.autoCompleteViewController.view.frame = self.view.bounds // add to container view. seealso: `viewDidLayoutSubviews()` - textEditorView.superview!.addSubview(self.autoCompleteViewController.view) + self.viewModel.composeStatusContentTableViewCell.textEditorViewContainerView.addSubview(self.autoCompleteViewController.view) self.addChild(self.autoCompleteViewController) self.autoCompleteViewController.didMove(toParent: self) self.autoCompleteViewController.view.isHidden = true - self.collectionView.autoCompleteViewController = self.autoCompleteViewController + self.tableView.autoCompleteViewController = self.autoCompleteViewController } + self.updateAutoCompleteViewControllerLayout() self.autoCompleteViewController.view.isHidden = info == nil guard let info = info else { return } - let symbolBoundingRectInContainer = textEditorView.convert(info.symbolBoundingRect, to: self.autoCompleteViewController.chevronView) + let symbolBoundingRectInContainer = textEditorView.textView.convert(info.symbolBoundingRect, to: self.autoCompleteViewController.chevronView) self.autoCompleteViewController.view.frame.origin.y = info.textBoundingRect.maxY self.autoCompleteViewController.viewModel.symbolBoundingRect.value = symbolBoundingRectInContainer self.autoCompleteViewController.viewModel.inputText.value = String(info.inputText) @@ -423,9 +470,9 @@ extension ComposeViewController { guard repliedToCellFrame != .zero else { return } switch collectionViewState { case .fold: - self.collectionView.contentInset.top = -repliedToCellFrame.height + self.tableView.contentInset.top = -repliedToCellFrame.height case .expand: - self.collectionView.contentInset.top = 0 + self.tableView.contentInset.top = 0 } } .store(in: &disposeBag) @@ -449,13 +496,17 @@ extension ComposeViewController { override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - - // pin autoCompleteViewController frame to window + updateAutoCompleteViewControllerLayout() + } + + func updateAutoCompleteViewControllerLayout() { + // pin autoCompleteViewController frame to current view if let containerView = autoCompleteViewController.view.superview { - let viewFrameInWindow = containerView.convert(autoCompleteViewController.view.frame, to: nil) + let viewFrameInWindow = containerView.convert(autoCompleteViewController.view.frame, to: view) if viewFrameInWindow.origin.x != 0 { autoCompleteViewController.view.frame.origin.x = -viewFrameInWindow.origin.x } + autoCompleteViewController.view.frame.size.width = view.frame.width } } @@ -463,98 +514,68 @@ extension ComposeViewController { extension ComposeViewController { - private func textEditorView() -> TextEditorView? { - guard let diffableDataSource = viewModel.diffableDataSource else { return nil } - let items = diffableDataSource.snapshot().itemIdentifiers - for item in items { - switch item { - case .input: - guard let indexPath = diffableDataSource.indexPath(for: item), - let cell = collectionView.cellForItem(at: indexPath) as? ComposeStatusContentCollectionViewCell else { - continue - } - return cell.textEditorView - default: - continue - } - } - - return nil + private func textEditorView() -> MetaText? { + return viewModel.composeStatusContentTableViewCell.metaText } private func markTextEditorViewBecomeFirstResponser() { - textEditorView()?.isEditing = true + textEditorView()?.textView.becomeFirstResponder() } private func contentWarningEditorTextView() -> UITextView? { - guard let diffableDataSource = viewModel.diffableDataSource else { return nil } - let items = diffableDataSource.snapshot().itemIdentifiers - for item in items { - switch item { - case .input: - guard let indexPath = diffableDataSource.indexPath(for: item), - let cell = collectionView.cellForItem(at: indexPath) as? ComposeStatusContentCollectionViewCell else { - continue - } - return cell.statusContentWarningEditorView.textView - default: - continue - } - } - - return nil + viewModel.composeStatusContentTableViewCell.statusContentWarningEditorView.textView } - private func pollOptionCollectionViewCell(of item: ComposeStatusItem) -> ComposeStatusPollOptionCollectionViewCell? { - guard case .pollOption = item else { return nil } - guard let diffableDataSource = viewModel.diffableDataSource else { return nil } - guard let indexPath = diffableDataSource.indexPath(for: item), - let cell = collectionView.cellForItem(at: indexPath) as? ComposeStatusPollOptionCollectionViewCell else { - return nil - } - - return cell - } +// private func pollOptionCollectionViewCell(of item: ComposeStatusItem) -> ComposeStatusPollOptionCollectionViewCell? { +// guard case .pollOption = item else { return nil } +// guard let diffableDataSource = viewModel.diffableDataSource else { return nil } +// guard let indexPath = diffableDataSource.indexPath(for: item), +// let cell = collectionView.cellForItem(at: indexPath) as? ComposeStatusPollOptionCollectionViewCell else { +// return nil +// } +// +// return cell +// } - private func firstPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? { - guard let diffableDataSource = viewModel.diffableDataSource else { return nil } - let items = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll) - let firstPollItem = items.first { item -> Bool in - guard case .pollOption = item else { return false } - return true - } - - guard let item = firstPollItem else { - return nil - } - - return pollOptionCollectionViewCell(of: item) - } +// private func firstPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? { +// guard let diffableDataSource = viewModel.diffableDataSource else { return nil } +// let items = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll) +// let firstPollItem = items.first { item -> Bool in +// guard case .pollOption = item else { return false } +// return true +// } +// +// guard let item = firstPollItem else { +// return nil +// } +// +// return pollOptionCollectionViewCell(of: item) +// } - private func lastPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? { - guard let diffableDataSource = viewModel.diffableDataSource else { return nil } - let items = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll) - let lastPollItem = items.last { item -> Bool in - guard case .pollOption = item else { return false } - return true - } - - guard let item = lastPollItem else { - return nil - } - - return pollOptionCollectionViewCell(of: item) - } +// private func lastPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? { +// guard let diffableDataSource = viewModel.diffableDataSource else { return nil } +// let items = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll) +// let lastPollItem = items.last { item -> Bool in +// guard case .pollOption = item else { return false } +// return true +// } +// +// guard let item = lastPollItem else { +// return nil +// } +// +// return pollOptionCollectionViewCell(of: item) +// } - private func markFirstPollOptionCollectionViewCellBecomeFirstResponser() { - guard let cell = firstPollOptionCollectionViewCell() else { return } - cell.pollOptionView.optionTextField.becomeFirstResponder() - } +// private func markFirstPollOptionCollectionViewCellBecomeFirstResponser() { +// guard let cell = firstPollOptionCollectionViewCell() else { return } +// cell.pollOptionView.optionTextField.becomeFirstResponder() +// } - private func markLastPollOptionCollectionViewCellBecomeFirstResponser() { - guard let cell = lastPollOptionCollectionViewCell() else { return } - cell.pollOptionView.optionTextField.becomeFirstResponder() - } +// private func markLastPollOptionCollectionViewCellBecomeFirstResponser() { +// guard let cell = lastPollOptionCollectionViewCell() else { return } +// cell.pollOptionView.optionTextField.becomeFirstResponder() +// } private func showDismissConfirmAlertController() { let alertController = UIAlertController( @@ -632,42 +653,178 @@ extension ComposeViewController { } // seealso: ComposeViewModel.setupDiffableDataSource(…) - @objc private func longPressReorderGestureHandler(_ sender: UILongPressGestureRecognizer) { - switch(sender.state) { - case .began: - guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)), - let cell = collectionView.cellForItem(at: selectedIndexPath) as? ComposeStatusPollOptionCollectionViewCell else { - break - } - // check if pressing reorder bar no not - let locationInCell = sender.location(in: cell) - guard cell.reorderBarImageView.frame.contains(locationInCell) else { - return - } - - collectionView.beginInteractiveMovementForItem(at: selectedIndexPath) - case .changed: - guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)), - let diffableDataSource = viewModel.diffableDataSource else { - break - } - guard let item = diffableDataSource.itemIdentifier(for: selectedIndexPath), - case .pollOption = item else { - collectionView.cancelInteractiveMovement() - return - } +// @objc private func longPressReorderGestureHandler(_ sender: UILongPressGestureRecognizer) { +// switch(sender.state) { +// case .began: +// guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)), +// let cell = collectionView.cellForItem(at: selectedIndexPath) as? ComposeStatusPollOptionCollectionViewCell else { +// break +// } +// // check if pressing reorder bar no not +// let locationInCell = sender.location(in: cell) +// guard cell.reorderBarImageView.frame.contains(locationInCell) else { +// return +// } +// +// collectionView.beginInteractiveMovementForItem(at: selectedIndexPath) +// case .changed: +// guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)), +// let diffableDataSource = viewModel.diffableDataSource else { +// break +// } +// guard let item = diffableDataSource.itemIdentifier(for: selectedIndexPath), +// case .pollOption = item else { +// collectionView.cancelInteractiveMovement() +// return +// } +// +// var position = sender.location(in: collectionView) +// position.x = collectionView.frame.width * 0.5 +// collectionView.updateInteractiveMovementTargetPosition(position) +// case .ended: +// collectionView.endInteractiveMovement() +// collectionView.reloadData() +// default: +// collectionView.cancelInteractiveMovement() +// } +// } + +} - var position = sender.location(in: collectionView) - position.x = collectionView.frame.width * 0.5 - collectionView.updateInteractiveMovementTargetPosition(position) - case .ended: - collectionView.endInteractiveMovement() - collectionView.reloadData() - default: - collectionView.cancelInteractiveMovement() +// MARK: - MetaTextDelegate +extension ComposeViewController: MetaTextDelegate { + func metaText(_ metaText: MetaText, processEditing textStorage: MetaTextStorage) -> MetaContent? { + let string = metaText.textStorage.string + let content = MastodonContent( + content: string, + emojis: viewModel.customEmojiViewModel.value?.emojiMapping.value ?? [:] + ) + let metaContent = MastodonMetaContent.convert(text: content) + return metaContent + } +} + +// MARK: - UITextViewDelegate +extension ComposeViewController: UITextViewDelegate { + + func textViewDidChange(_ textView: UITextView) { + if textView.tag == ComposeStatusContentCollectionViewCell.metaTextViewTag { + // update model + guard let metaText = textEditorView() else { return } + let backedString = metaText.backedString + viewModel.composeStatusAttribute.composeContent.value = backedString + logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(backedString)") + + // configure auto completion + setupAutoComplete(for: textView) } } - + + struct AutoCompleteInfo { + // model + let inputText: Substring + // range + let symbolRange: Range + let symbolString: Substring + let toCursorRange: Range + let toCursorString: Substring + let toHighlightEndRange: Range + let toHighlightEndString: Substring + // geometry + var textBoundingRect: CGRect = .zero + var symbolBoundingRect: CGRect = .zero + } + + private func setupAutoComplete(for textView: UITextView) { + guard var autoCompletion = ComposeViewController.scanAutoCompleteInfo(textView: textView) else { + viewModel.autoCompleteInfo.value = nil + return + } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: auto complete %s (%s)", ((#file as NSString).lastPathComponent), #line, #function, String(autoCompletion.toHighlightEndString), String(autoCompletion.toCursorString)) + + // get layout text bounding rect + var glyphRange = NSRange() + textView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompletion.toCursorRange, in: textView.text), actualGlyphRange: &glyphRange) + let textContainer = textView.layoutManager.textContainers[0] + let textBoundingRect = textView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + + let retryLayoutTimes = viewModel.autoCompleteRetryLayoutTimes.value + guard textBoundingRect.size != .zero else { + viewModel.autoCompleteRetryLayoutTimes.value += 1 + // avoid infinite loop + guard retryLayoutTimes < 3 else { return } + // needs retry calculate layout when the rect position changing + DispatchQueue.main.async { + self.setupAutoComplete(for: textView) + } + return + } + viewModel.autoCompleteRetryLayoutTimes.value = 0 + + // get symbol bounding rect + textView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompletion.symbolRange, in: textView.text), actualGlyphRange: &glyphRange) + let symbolBoundingRect = textView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + + // set bounding rect and trigger layout + autoCompletion.textBoundingRect = textBoundingRect + autoCompletion.symbolBoundingRect = symbolBoundingRect + viewModel.autoCompleteInfo.value = autoCompletion + } + + private static func scanAutoCompleteInfo(textView: UITextView) -> AutoCompleteInfo? { + guard let text = textView.text, + textView.selectedRange.location > 0, !text.isEmpty, + let selectedRange = Range(textView.selectedRange, in: text) else { + return nil + } + let cursorIndex = selectedRange.upperBound + let _highlightStartIndex: String.Index? = { + var index = text.index(before: cursorIndex) + while index > text.startIndex { + let char = text[index] + if char == "@" || char == "#" || char == ":" { + return index + } + index = text.index(before: index) + } + assert(index == text.startIndex) + let char = text[index] + if char == "@" || char == "#" || char == ":" { + return index + } else { + return nil + } + }() + + guard let highlightStartIndex = _highlightStartIndex else { return nil } + let scanRange = NSRange(highlightStartIndex..= cursorIndex else { return nil } + let symbolRange = highlightStartIndex.. - let symbolString: Substring - let toCursorRange: Range - let toCursorString: Substring - let toHighlightEndRange: Range - let toHighlightEndString: Substring - // geometry - var textBoundingRect: CGRect = .zero - var symbolBoundingRect: CGRect = .zero - } - - private static func scanAutoCompleteInfo(textEditorView: TextEditorView) -> AutoCompleteInfo? { - let text = textEditorView.text - - guard textEditorView.selectedRange.location > 0, !text.isEmpty, - let selectedRange = Range(textEditorView.selectedRange, in: text) else { - return nil - } - let cursorIndex = selectedRange.upperBound - let _highlightStartIndex: String.Index? = { - var index = text.index(before: cursorIndex) - while index > text.startIndex { - let char = text[index] - if char == "@" || char == "#" || char == ":" { - return index - } - index = text.index(before: index) - } - assert(index == text.startIndex) - let char = text[index] - if char == "@" || char == "#" || char == ":" { - return index - } else { - return nil - } - }() - - guard let highlightStartIndex = _highlightStartIndex else { return nil } - let scanRange = NSRange(highlightStartIndex..= cursorIndex else { return nil } - let symbolRange = highlightStartIndex..) { - guard scrollView === collectionView else { return } + guard scrollView === tableView else { return } let repliedToCellFrame = viewModel.repliedToCellFrame.value guard repliedToCellFrame != .zero else { return } @@ -1007,6 +1053,9 @@ extension ComposeViewController { } } +// MARK: - UITableViewDelegate +extension ComposeViewController: UITableViewDelegate { } + // MARK: - UICollectionViewDelegate extension ComposeViewController: UICollectionViewDelegate { @@ -1018,26 +1067,13 @@ extension ComposeViewController: UICollectionViewDelegate { let item = diffableDataSource.itemIdentifier(for: indexPath) guard case let .emoji(attribute) = item else { return } let emoji = attribute.emoji - let textEditorView = self.textEditorView() - + + // make click sound + UIDevice.current.playInputClick() + // retrieve active text input and insert emoji - // the leading and trailing space is REQUIRED to fix `UITextStorage` layout issue - let reference = viewModel.customEmojiPickerInputViewModel.insertText(" :\(emoji.shortcode): ") - - // workaround: non-user interactive change do not trigger value update event - if reference?.value === textEditorView { - viewModel.composeStatusAttribute.composeContent.value = textEditorView?.text - // update text storage - textEditorView?.setNeedsUpdateTextAttributes() - // collection self-size - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - self.collectionView.collectionViewLayout.invalidateLayout() - - // make click sound - UIDevice.current.playInputClick() - } - } + // the trailing space is REQUIRED to make regex happy + _ = viewModel.customEmojiPickerInputViewModel.insertText(":\(emoji.shortcode): ") } else { // do nothing } @@ -1124,19 +1160,19 @@ extension ComposeViewController: UIDocumentPickerDelegate { extension ComposeViewController: ComposeStatusAttachmentCollectionViewCellDelegate { func composeStatusAttachmentCollectionViewCell(_ cell: ComposeStatusAttachmentCollectionViewCell, removeButtonDidPressed button: UIButton) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - guard let indexPath = collectionView.indexPath(for: cell) else { return } - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } - guard case let .attachment(attachmentService) = item else { return } - - var attachmentServices = viewModel.attachmentServices.value - guard let index = attachmentServices.firstIndex(of: attachmentService) else { return } - let removedItem = attachmentServices[index] - attachmentServices.remove(at: index) - viewModel.attachmentServices.value = attachmentServices - - // cancel task - removedItem.disposeBag.removeAll() +// guard let diffableDataSource = viewModel.diffableDataSource else { return } +// guard let indexPath = collectionView.indexPath(for: cell) else { return } +// guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } +// guard case let .attachment(attachmentService) = item else { return } +// +// var attachmentServices = viewModel.attachmentServices.value +// guard let index = attachmentServices.firstIndex(of: attachmentService) else { return } +// let removedItem = attachmentServices[index] +// attachmentServices.remove(at: index) +// viewModel.attachmentServices.value = attachmentServices +// +// // cancel task +// removedItem.disposeBag.removeAll() } } @@ -1154,72 +1190,72 @@ extension ComposeViewController: ComposeStatusPollOptionCollectionViewCellDelega // handle delete backward event for poll option input func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textBeforeDeleteBackward text: String?) { - guard (text ?? "").isEmpty else { return } - guard let diffableDataSource = viewModel.diffableDataSource else { return } - guard let indexPath = collectionView.indexPath(for: cell) else { return } - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } - guard case let .pollOption(attribute) = item else { return } - - var pollAttributes = viewModel.pollOptionAttributes.value - guard let index = pollAttributes.firstIndex(of: attribute) else { return } - - // mark previous (fallback to next) item of removed middle poll option become first responder - let pollItems = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll) - if let indexOfItem = pollItems.firstIndex(of: item), index > 0 { - func cellBeforeRemoved() -> ComposeStatusPollOptionCollectionViewCell? { - guard index > 0 else { return nil } - let indexBeforeRemoved = pollItems.index(before: indexOfItem) - let itemBeforeRemoved = pollItems[indexBeforeRemoved] - return pollOptionCollectionViewCell(of: itemBeforeRemoved) - } - - func cellAfterRemoved() -> ComposeStatusPollOptionCollectionViewCell? { - guard index < pollItems.count - 1 else { return nil } - let indexAfterRemoved = pollItems.index(after: index) - let itemAfterRemoved = pollItems[indexAfterRemoved] - return pollOptionCollectionViewCell(of: itemAfterRemoved) - } - - var cell: ComposeStatusPollOptionCollectionViewCell? = cellBeforeRemoved() - if cell == nil { - cell = cellAfterRemoved() - } - cell?.pollOptionView.optionTextField.becomeFirstResponder() - } - - guard pollAttributes.count > 2 else { - return - } - pollAttributes.remove(at: index) - - // update data source - viewModel.pollOptionAttributes.value = pollAttributes +// guard (text ?? "").isEmpty else { return } +// guard let diffableDataSource = viewModel.diffableDataSource else { return } +// guard let indexPath = collectionView.indexPath(for: cell) else { return } +// guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } +// guard case let .pollOption(attribute) = item else { return } +// +// var pollAttributes = viewModel.pollOptionAttributes.value +// guard let index = pollAttributes.firstIndex(of: attribute) else { return } +// +// // mark previous (fallback to next) item of removed middle poll option become first responder +// let pollItems = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll) +// if let indexOfItem = pollItems.firstIndex(of: item), index > 0 { +// func cellBeforeRemoved() -> ComposeStatusPollOptionCollectionViewCell? { +// guard index > 0 else { return nil } +// let indexBeforeRemoved = pollItems.index(before: indexOfItem) +// let itemBeforeRemoved = pollItems[indexBeforeRemoved] +// return pollOptionCollectionViewCell(of: itemBeforeRemoved) +// } +// +// func cellAfterRemoved() -> ComposeStatusPollOptionCollectionViewCell? { +// guard index < pollItems.count - 1 else { return nil } +// let indexAfterRemoved = pollItems.index(after: index) +// let itemAfterRemoved = pollItems[indexAfterRemoved] +// return pollOptionCollectionViewCell(of: itemAfterRemoved) +// } +// +// var cell: ComposeStatusPollOptionCollectionViewCell? = cellBeforeRemoved() +// if cell == nil { +// cell = cellAfterRemoved() +// } +// cell?.pollOptionView.optionTextField.becomeFirstResponder() +// } +// +// guard pollAttributes.count > 2 else { +// return +// } +// pollAttributes.remove(at: index) +// +// // update data source +// viewModel.pollOptionAttributes.value = pollAttributes } // handle keyboard return event for poll option input func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, pollOptionTextFieldDidReturn: UITextField) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - guard let indexPath = collectionView.indexPath(for: cell) else { return } - let pollItems = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll).filter { item in - guard case .pollOption = item else { return false } - return true - } - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } - guard let index = pollItems.firstIndex(of: item) else { return } - - if index == pollItems.count - 1 { - // is the last - viewModel.createNewPollOptionIfPossible() - DispatchQueue.main.async { - self.markLastPollOptionCollectionViewCellBecomeFirstResponser() - } - } else { - // not the last - let indexAfter = pollItems.index(after: index) - let itemAfter = pollItems[indexAfter] - let cell = pollOptionCollectionViewCell(of: itemAfter) - cell?.pollOptionView.optionTextField.becomeFirstResponder() - } +// guard let diffableDataSource = viewModel.diffableDataSource else { return } +// guard let indexPath = collectionView.indexPath(for: cell) else { return } +// let pollItems = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll).filter { item in +// guard case .pollOption = item else { return false } +// return true +// } +// guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } +// guard let index = pollItems.firstIndex(of: item) else { return } +// +// if index == pollItems.count - 1 { +// // is the last +// viewModel.createNewPollOptionIfPossible() +// DispatchQueue.main.async { +// self.markLastPollOptionCollectionViewCellBecomeFirstResponser() +// } +// } else { +// // not the last +// let indexAfter = pollItems.index(after: index) +// let itemAfter = pollItems[indexAfter] +// let cell = pollOptionCollectionViewCell(of: itemAfter) +// cell?.pollOptionView.optionTextField.becomeFirstResponder() +// } } } @@ -1228,9 +1264,9 @@ extension ComposeViewController: ComposeStatusPollOptionCollectionViewCellDelega extension ComposeViewController: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate { func composeStatusPollOptionAppendEntryCollectionViewCellDidPressed(_ cell: ComposeStatusPollOptionAppendEntryCollectionViewCell) { viewModel.createNewPollOptionIfPossible() - DispatchQueue.main.async { - self.markLastPollOptionCollectionViewCellBecomeFirstResponser() - } +// DispatchQueue.main.async { +// self.markLastPollOptionCollectionViewCellBecomeFirstResponser() +// } } } @@ -1264,14 +1300,22 @@ extension ComposeViewController: AutoCompleteViewControllerDelegate { }() guard let replacedText = _replacedText else { return } - guard let textEditorView = textEditorView() else { return } - let text = textEditorView.text - - do { - try textEditorView.updateByReplacing(range: NSRange(info.toHighlightEndRange, in: text), with: replacedText) - viewModel.autoCompleteInfo.value = nil - } catch { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: auto complete fail %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + guard let textEditorView = textEditorView(), + let text = textEditorView.textView.text else { return } + + + let range = NSRange(info.toHighlightEndRange, in: text) + textEditorView.textStorage.replaceCharacters(in: range, with: replacedText) + viewModel.autoCompleteInfo.value = nil + + switch item { + case .emoji, .bottomLoader: + break + default: + // set selected range except emoji + let newRange = NSRange(location: range.location + (replacedText as NSString).length, length: 0) + guard textEditorView.textStorage.length <= newRange.location else { return } + textEditorView.textView.selectedRange = newRange } } } diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index 0b8d3e8f..936d70f0 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -7,17 +7,143 @@ import UIKit import Combine +import CoreDataStack import TwitterTextEditor import MastodonSDK +import MastodonMeta +import MetaTextView extension ComposeViewModel { + + func setupDiffableDataSource( + tableView: UITableView, + metaTextDelegate: MetaTextDelegate, + metaTextViewDelegate: UITextViewDelegate, + customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel + ) { + let dataSource = UITableViewDiffableDataSource(tableView: tableView) { [ + weak self, + weak metaTextDelegate, + weak metaTextViewDelegate, + weak customEmojiPickerInputViewModel + ] tableView, indexPath, item in + guard let self = self else { return UITableViewCell() } + let managedObjectContext = self.context.managedObjectContext + + switch item { + case .replyTo(let statusObjectID): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToStatusContentTableViewCell.self), for: indexPath) as! ComposeRepliedToStatusContentTableViewCell + managedObjectContext.performAndWait { + guard let replyTo = managedObjectContext.object(with: statusObjectID) as? Status else { + return + } + let status = replyTo.reblog ?? replyTo + + // set avatar + cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL())) + // set name username + cell.statusView.nameLabel.text = { + let author = status.author + return author.displayName.isEmpty ? author.username : author.displayName + }() + cell.statusView.usernameLabel.text = "@" + (status.reblog ?? status).author.acct + // set text + let content = MastodonContent(content: status.content, emojis: status.emojiMeta) + do { + let metaContent = try MastodonMetaContent.convert(document: content) + cell.statusView.contentMetaText.configure(content: metaContent) + } catch { + cell.statusView.contentMetaText.textView.text = " " + assertionFailure() + } + // set date + cell.statusView.dateLabel.text = status.createdAt.slowedTimeAgoSinceNow + + cell.framePublisher + .assign(to: \.value, on: self.repliedToCellFrame) + .store(in: &cell.disposeBag) + } + return cell + case .input(let replyToStatusObjectID, let attribute): + let cell = self.composeStatusContentTableViewCell + // configure header + managedObjectContext.performAndWait { + guard let replyToStatusObjectID = replyToStatusObjectID, + let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else { + cell.statusView.headerContainerView.isHidden = true + return + } + cell.statusView.headerContainerView.isHidden = false + cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage) + cell.statusView.headerInfoLabel.text = L10n.Scene.Compose.replyingToUser(replyTo.author.displayNameWithFallback) + } + // configure author + ComposeStatusSection.configureStatusContent(cell: cell, attribute: attribute) + // bind content warning + attribute.isContentWarningComposing + .receive(on: DispatchQueue.main) + .sink { [weak cell, weak tableView] isContentWarningComposing in + guard let cell = cell else { return } + guard let tableView = tableView else { return } + // self size input cell + //tableView. + cell.statusContentWarningEditorView.isHidden = !isContentWarningComposing + cell.statusContentWarningEditorView.alpha = 0 + UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseOut]) { + cell.statusContentWarningEditorView.alpha = 1 + } completion: { _ in + // do nothing + } + } + .store(in: &cell.disposeBag) + cell.contentWarningContent + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak tableView] text in + guard let tableView = tableView else { return } + // self size input cell + UIView.performWithoutAnimation { + tableView.beginUpdates() + tableView.endUpdates() + } + // bind input data + attribute.contentWarningContent.value = text + } + .store(in: &cell.disposeBag) + // configure custom emoji picker + ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.metaText.textView, disposeBag: &cell.disposeBag) + ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.statusContentWarningEditorView.textView, disposeBag: &cell.disposeBag) + // setup delegate + cell.metaText.delegate = metaTextDelegate + cell.metaText.textView.delegate = metaTextViewDelegate + + return cell + case .attachment(let attachmentService): + return UITableViewCell() + case .pollOption, .pollOptionAppendEntry, .pollExpiresOption: + return UITableViewCell() + } + } + self.dataSource = dataSource + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.repliedTo, .status, .attachment, .poll]) + switch composeKind { + case .reply(let statusObjectID): + snapshot.appendItems([.replyTo(statusObjectID: statusObjectID)], toSection: .repliedTo) + snapshot.appendItems([.input(replyToStatusObjectID: statusObjectID, attribute: composeStatusAttribute)], toSection: .repliedTo) + case .hashtag, .mention, .post: + snapshot.appendItems([.input(replyToStatusObjectID: nil, attribute: composeStatusAttribute)], toSection: .status) + } + dataSource.apply(snapshot, animatingDifferences: false) + } func setupDiffableDataSource( for collectionView: UICollectionView, dependency: NeedsDependency, customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel, - textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate, - textEditorViewChangeObserver: TextEditorViewChangeObserver, + metaTextDelegate: MetaTextDelegate, + metaTextViewDelegate: UITextViewDelegate, composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate, composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate, composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate, @@ -30,8 +156,8 @@ extension ComposeViewModel { composeKind: composeKind, repliedToCellFrameSubscriber: repliedToCellFrame, customEmojiPickerInputViewModel: customEmojiPickerInputViewModel, - textEditorViewTextAttributesDelegate: textEditorViewTextAttributesDelegate, - textEditorViewChangeObserver: textEditorViewChangeObserver, + metaTextDelegate: metaTextDelegate, + metaTextViewDelegate: metaTextViewDelegate, composeStatusAttachmentTableViewCellDelegate: composeStatusAttachmentTableViewCellDelegate, composeStatusPollOptionCollectionViewCellDelegate: composeStatusPollOptionCollectionViewCellDelegate, composeStatusNewPollOptionCollectionViewCellDelegate: composeStatusNewPollOptionCollectionViewCellDelegate, diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index a368bfbb..1deb698f 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -35,6 +35,8 @@ final class ComposeViewModel { let autoCompleteInfo = CurrentValueSubject(nil) // output + let composeStatusContentTableViewCell = ComposeStatusContentTableViewCell() + var dataSource: UITableViewDiffableDataSource! var diffableDataSource: UICollectionViewDiffableDataSource! var customEmojiPickerDiffableDataSource: UICollectionViewDiffableDataSource! private(set) lazy var publishStateMachine: GKStateMachine = { @@ -61,7 +63,7 @@ final class ComposeViewModel { let characterCount = CurrentValueSubject(0) let collectionViewState = CurrentValueSubject(.fold) - // for hashtag: "# " + // for hashtag: "# " // for mention: "@ " private(set) var preInsertedContent: String? diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToStatusContentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToStatusContentTableViewCell.swift new file mode 100644 index 00000000..586895dd --- /dev/null +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToStatusContentTableViewCell.swift @@ -0,0 +1,62 @@ +// +// ComposeRepliedToStatusContentTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-6-28. +// + +import UIKit +import Combine + +final class ComposeRepliedToStatusContentTableViewCell: UITableViewCell { + + var disposeBag = Set() + + let statusView = StatusView() + + let framePublisher = PassthroughSubject() + + override func prepareForReuse() { + super.prepareForReuse() + + statusView.updateContentWarningDisplay(isHidden: true, animated: false) + disposeBag.removeAll() + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + override func layoutSubviews() { + super.layoutSubviews() + framePublisher.send(bounds) + } + +} + +extension ComposeRepliedToStatusContentTableViewCell { + + private func _init() { + backgroundColor = .clear + + statusView.actionToolbarContainer.isHidden = true + statusView.revealContentWarningButton.isHidden = true + + statusView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(statusView) + NSLayoutConstraint.activate([ + statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20).identifier("statusView.top to ComposeRepliedToStatusContentCollectionViewCell.contentView.top"), + statusView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + contentView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: statusView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10).identifier("ComposeRepliedToStatusContentCollectionViewCell.contentView.bottom to statusView.bottom"), + ]) + } + +} + diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift new file mode 100644 index 00000000..cd3ede00 --- /dev/null +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift @@ -0,0 +1,147 @@ +// +// ComposeStatusContentTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-6-28. +// + + +import os.log +import UIKit +import Combine +import MetaTextView + +final class ComposeStatusContentTableViewCell: UITableViewCell { + + var disposeBag = Set() + + let statusView = StatusView() + + let statusContentWarningEditorView = StatusContentWarningEditorView() + + let textEditorViewContainerView = UIView() + + static let metaTextViewTag: Int = 333 + let metaText: MetaText = { + let metaText = MetaText() + metaText.textView.tag = ComposeStatusContentCollectionViewCell.metaTextViewTag + metaText.textView.backgroundColor = .clear + metaText.textView.isScrollEnabled = false + metaText.textView.keyboardType = .twitter + metaText.textView.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)) + metaText.textView.attributedPlaceholder = { + var attributes = metaText.textAttributes + attributes[.foregroundColor] = Asset.Colors.Label.secondary.color + return NSAttributedString( + string: L10n.Scene.Compose.contentInputPlaceholder, + attributes: attributes + ) + }() + return metaText + }() + + // output + let contentWarningContent = PassthroughSubject() + + override func prepareForReuse() { + super.prepareForReuse() + + metaText.delegate = nil + metaText.textView.delegate = nil + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ComposeStatusContentTableViewCell { + + private func _init() { + // selectionStyle = .none + layer.zPosition = 999 + preservesSuperviewLayoutMargins = true + + let containerStackView = UIStackView() + containerStackView.axis = .vertical + containerStackView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor), + containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + containerStackView.preservesSuperviewLayoutMargins = true + + containerStackView.addArrangedSubview(statusContentWarningEditorView) + statusContentWarningEditorView.setContentHuggingPriority(.required - 1, for: .vertical) + + let statusContainerView = UIView() + statusContainerView.preservesSuperviewLayoutMargins = true + containerStackView.addArrangedSubview(statusContainerView) + statusView.translatesAutoresizingMaskIntoConstraints = false + statusContainerView.addSubview(statusView) + NSLayoutConstraint.activate([ + statusView.topAnchor.constraint(equalTo: statusContainerView.topAnchor, constant: 20), + statusView.leadingAnchor.constraint(equalTo: statusContainerView.layoutMarginsGuide.leadingAnchor), + statusView.trailingAnchor.constraint(equalTo: statusContainerView.layoutMarginsGuide.trailingAnchor), + statusView.bottomAnchor.constraint(equalTo: statusContainerView.bottomAnchor), + ]) + + containerStackView.addArrangedSubview(textEditorViewContainerView) + metaText.textView.translatesAutoresizingMaskIntoConstraints = false + textEditorViewContainerView.addSubview(metaText.textView) + NSLayoutConstraint.activate([ + metaText.textView.topAnchor.constraint(equalTo: textEditorViewContainerView.topAnchor), + metaText.textView.leadingAnchor.constraint(equalTo: textEditorViewContainerView.layoutMarginsGuide.leadingAnchor), + metaText.textView.trailingAnchor.constraint(equalTo: textEditorViewContainerView.layoutMarginsGuide.trailingAnchor), + metaText.textView.bottomAnchor.constraint(equalTo: textEditorViewContainerView.bottomAnchor), + metaText.textView.heightAnchor.constraint(greaterThanOrEqualToConstant: 88).priority(.defaultHigh), + ]) + statusContentWarningEditorView.textView.delegate = self + + statusContentWarningEditorView.isHidden = true + statusView.statusContainerStackView.isHidden = true + statusView.actionToolbarContainer.isHidden = true + statusView.revealContentWarningButton.isHidden = true + + statusView.contentMetaText.textView.delegate = self + } + +} + +// MARK: - UITextViewDelegate +extension ComposeStatusContentTableViewCell: UITextViewDelegate { + + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + switch textView { + case statusView.contentMetaText.textView: + return false + case statusContentWarningEditorView.textView: + // disable input line break + guard text != "\n" else { return false } + return true + default: + assertionFailure() + return true + } + } + + func textViewDidChange(_ textView: UITextView) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: text: %s", ((#file as NSString).lastPathComponent), #line, #function, textView.text) + guard textView === statusContentWarningEditorView.textView else { return } + // replace line break with space + textView.text = textView.text.replacingOccurrences(of: "\n", with: " ") + contentWarningContent.send(textView.text) + } + +} + diff --git a/Mastodon/Scene/Compose/View/ComposeTableView.swift b/Mastodon/Scene/Compose/View/ComposeTableView.swift new file mode 100644 index 00000000..9d95df03 --- /dev/null +++ b/Mastodon/Scene/Compose/View/ComposeTableView.swift @@ -0,0 +1,28 @@ +// +// ComposeTableView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-6-28. +// + +import UIKit + +final class ComposeTableView: UITableView { + + weak var autoCompleteViewController: AutoCompleteViewController? + + // adjust hitTest for auto-complete + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard let autoCompleteViewController = autoCompleteViewController else { + return super.hitTest(point, with: event) + } + + let thePoint = convert(point, to: autoCompleteViewController.view) + if let hitView = autoCompleteViewController.view.hitTest(thePoint, with: event) { + return hitView + } else { + return super.hitTest(point, with: event) + } + } + +} diff --git a/Mastodon/Scene/Compose/View/StatusContentWarningEditorView.swift b/Mastodon/Scene/Compose/View/StatusContentWarningEditorView.swift index 510edd46..d782f702 100644 --- a/Mastodon/Scene/Compose/View/StatusContentWarningEditorView.swift +++ b/Mastodon/Scene/Compose/View/StatusContentWarningEditorView.swift @@ -8,19 +8,12 @@ import UIKit final class StatusContentWarningEditorView: UIView { - - let containerView: UIView = { - let view = UIView() - view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color - return view - }() - - // due to section following readable inset. We overlap the bleeding to make backgorund fill + + // due to section following readable inset. We overlap the bleeding to make background fill // default hidden let containerBackgroundView: UIView = { let view = UIView() view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color - view.isHidden = true return view }() @@ -55,44 +48,38 @@ final class StatusContentWarningEditorView: UIView { extension StatusContentWarningEditorView { private func _init() { - let contentWarningStackView = UIStackView() - contentWarningStackView.axis = .horizontal - contentWarningStackView.translatesAutoresizingMaskIntoConstraints = false - addSubview(contentWarningStackView) - NSLayoutConstraint.activate([ - contentWarningStackView.topAnchor.constraint(equalTo: topAnchor), - contentWarningStackView.leadingAnchor.constraint(equalTo: leadingAnchor), - contentWarningStackView.trailingAnchor.constraint(equalTo: trailingAnchor), - contentWarningStackView.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - contentWarningStackView.addArrangedSubview(containerView) - + containerBackgroundView.translatesAutoresizingMaskIntoConstraints = false - containerView.addSubview(containerBackgroundView) + addSubview(containerBackgroundView) NSLayoutConstraint.activate([ - containerBackgroundView.topAnchor.constraint(equalTo: containerView.topAnchor), - containerBackgroundView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: -1024), - containerBackgroundView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: 1024), - containerBackgroundView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + containerBackgroundView.topAnchor.constraint(equalTo: topAnchor), + containerBackgroundView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: -1024), + containerBackgroundView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 1024), + containerBackgroundView.bottomAnchor.constraint(equalTo: bottomAnchor), ]) iconImageView.translatesAutoresizingMaskIntoConstraints = false - containerView.addSubview(iconImageView) + addSubview(iconImageView) NSLayoutConstraint.activate([ - iconImageView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), - iconImageView.leadingAnchor.constraint(equalTo: containerView.readableContentGuide.leadingAnchor), + iconImageView.centerYAnchor.constraint(equalTo: centerYAnchor), + iconImageView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), iconImageView.widthAnchor.constraint(equalToConstant: StatusView.avatarImageSize.width).priority(.defaultHigh), // center alignment to avatar ]) - iconImageView.setContentHuggingPriority(.defaultHigh, for: .horizontal) + iconImageView.setContentHuggingPriority(.required - 2, for: .horizontal) textView.translatesAutoresizingMaskIntoConstraints = false - containerView.addSubview(textView) + addSubview(textView) NSLayoutConstraint.activate([ - textView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 6), - textView.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, constant: StatusView.avatarToLabelSpacing - 4), // align to name label. minus magic 4pt to remove addtion inset - textView.trailingAnchor.constraint(equalTo: containerView.readableContentGuide.trailingAnchor), - containerView.bottomAnchor.constraint(equalTo: textView.bottomAnchor, constant: 6), + textView.centerYAnchor.constraint(equalTo: centerYAnchor), + textView.topAnchor.constraint(greaterThanOrEqualTo: topAnchor, constant: 6).priority(.required - 1), + textView.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, constant: StatusView.avatarToLabelSpacing - 4), // align to name label. minus magic 4pt to remove addition inset + textView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + bottomAnchor.constraint(greaterThanOrEqualTo: textView.bottomAnchor, constant: 6).priority(.required - 1), + //textView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh), ]) + + textView.setContentHuggingPriority(.required - 1, for: .vertical) + textView.setContentCompressionResistancePriority(.required - 1, for: .vertical) } } diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift index f6ca84c9..3fb93d89 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift @@ -9,6 +9,8 @@ import Combine import Foundation import UIKit import ActiveLabel +import MetaTextView +import Meta final class NotificationStatusTableViewCell: UITableViewCell, StatusCell { static let actionImageBorderWidth: CGFloat = 2 @@ -255,6 +257,10 @@ extension NotificationStatusTableViewCell: StatusViewDelegate { func statusView(_ statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { // do nothing } + + func statusView(_ statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) { + // do nothing + } } diff --git a/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift index 3a71a64b..f5fa003a 100644 --- a/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift +++ b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift @@ -12,6 +12,8 @@ import Combine import CoreData import CoreDataStack import ActiveLabel +import Meta +import MetaTextView final class ReportedStatusTableViewCell: UITableViewCell, StatusCell { @@ -203,4 +205,8 @@ extension ReportedStatusTableViewCell: StatusViewDelegate { func statusView(_ statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { } + + func statusView(_ statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) { + } + } diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index d6d3dfe2..cdabdc1c 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -12,6 +12,8 @@ import AVKit import ActiveLabel import AlamofireImage import FLAnimatedImage +import MetaTextView +import Meta // TODO: // import LinkPresentation @@ -24,9 +26,12 @@ protocol StatusViewDelegate: AnyObject { func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) func statusView(_ statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) + func statusView(_ statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) } final class StatusView: UIView { + + let logger = Logger(subsystem: "StatusView", category: "logic") var statusPollTableViewHeightObservation: NSKeyValueObservation? var pollCountdownSubscription: AnyCancellable? @@ -78,6 +83,7 @@ final class StatusView: UIView { let headerInfoLabel: ActiveLabel = { let label = ActiveLabel(style: .statusHeader) label.text = "Bob reblogged" + label.layer.masksToBounds = false return label }() @@ -201,7 +207,18 @@ final class StatusView: UIView { return actionToolbarContainer }() - let activeTextLabel = ActiveLabel(style: .default) + //let activeTextLabel = ActiveLabel(style: .default) + let contentMetaText: MetaText = { + let metaText = MetaText() + metaText.textView.backgroundColor = .clear + metaText.textView.isEditable = false + metaText.textView.isSelectable = false + metaText.textView.isScrollEnabled = false + metaText.textView.textContainer.lineFragmentPadding = 0 + metaText.textView.textContainerInset = .zero + metaText.textView.layer.masksToBounds = false + return metaText + }() private let headerInfoLabelTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer @@ -261,6 +278,9 @@ extension StatusView { headerContainerView.bottomAnchor.constraint(equalTo: headerContainerStackView.bottomAnchor, constant: StatusView.containerStackViewSpacing).priority(.defaultHigh), ]) containerStackView.addArrangedSubview(headerContainerView) + defer { + containerStackView.bringSubviewToFront(headerContainerView) + } // author container: [avatar | author meta container | reveal button] let authorContainerStackView = UIStackView() @@ -360,8 +380,8 @@ extension StatusView { } // status - statusContainerStackView.addArrangedSubview(activeTextLabel) - activeTextLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical) + statusContainerStackView.addArrangedSubview(contentMetaText.textView) + contentMetaText.textView.setContentCompressionResistancePriority(.required - 1, for: .vertical) // TODO: // link preview @@ -423,8 +443,9 @@ extension StatusView { avatarStackedContainerButton.isHidden = true contentWarningOverlayView.isHidden = true - - activeTextLabel.delegate = self + + contentMetaText.textView.delegate = self + contentMetaText.textView.linkDelegate = self playerContainerView.delegate = self contentWarningOverlayView.delegate = self @@ -515,6 +536,34 @@ extension StatusView { } +// MARK: - MetaTextViewDelegate +extension StatusView: MetaTextViewDelegate { + func metaTextView(_ metaTextView: MetaTextView, didSelectLink link: URL) { + logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + switch metaTextView { + case contentMetaText.textView: + guard let meta = Meta(url: link) else { return } + delegate?.statusView(self, metaText: contentMetaText, didSelectMeta: meta) + default: + assertionFailure() + break + } + } +} + +// MARK: - UITextViewDelegate +extension StatusView: UITextViewDelegate { + func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { + switch textView { + case contentMetaText.textView: + return false + default: + assertionFailure() + return true + } + } +} + // MARK: - ActiveLabelDelegate extension StatusView: ActiveLabelDelegate { func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 784a4bfa..ceb211a7 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -12,6 +12,8 @@ import Combine import CoreData import CoreDataStack import ActiveLabel +import Meta +import MetaTextView protocol StatusTableViewCellDelegate: AnyObject { var context: AppContext! { get } @@ -26,6 +28,7 @@ protocol StatusTableViewCellDelegate: AnyObject { func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) @@ -71,6 +74,7 @@ final class StatusTableViewCell: UITableViewCell, StatusCell { override func prepareForReuse() { super.prepareForReuse() selectionStyle = .default + statusView.contentMetaText.textView.isSelectable = false statusView.updateContentWarningDisplay(isHidden: true, animated: false) statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isUserInteractionEnabled = true statusView.pollTableView.dataSource = nil @@ -301,6 +305,10 @@ extension StatusTableViewCell: StatusViewDelegate { delegate?.statusTableViewCell(self, statusView: statusView, activeLabel: activeLabel, didSelectActiveEntity: entity) } + func statusView(_ statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) { + delegate?.statusTableViewCell(self, statusView: statusView, metaText: metaText, didSelectMeta: meta) + } + } // MARK: - MosaicImageViewDelegate diff --git a/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel.swift b/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel.swift index fb6e5ec0..b0ee6cb8 100644 --- a/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel.swift +++ b/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel.swift @@ -33,6 +33,7 @@ extension EmojiService { }() let emojis = CurrentValueSubject<[Mastodon.Entity.Emoji], Never>([]) let emojiDict = CurrentValueSubject<[String: [Mastodon.Entity.Emoji]], Never>([:]) + let emojiMapping = CurrentValueSubject<[String: String], Never>([:]) let emojiTrie = CurrentValueSubject?, Never>(nil) private var learnedEmoji: Set = Set() @@ -45,6 +46,18 @@ extension EmojiService { .map { Dictionary(grouping: $0, by: { $0.shortcode }) } .assign(to: \.value, on: emojiDict) .store(in: &disposeBag) + + emojiDict + .map { dict in + var mapping: [String: String] = [:] + for (key, values) in dict { + guard let emoji = values.first else { continue } + mapping[key] = emoji.url + } + return mapping + } + .assign(to: \.value, on: emojiMapping) + .store(in: &disposeBag) emojis .map { emojis -> Trie? in diff --git a/Mastodon/Supporting Files/AppDelegate.swift b/Mastodon/Supporting Files/AppDelegate.swift index 4b219380..83dffc34 100644 --- a/Mastodon/Supporting Files/AppDelegate.swift +++ b/Mastodon/Supporting Files/AppDelegate.swift @@ -59,8 +59,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } extension AppDelegate { - func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { + func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { + #if DEBUG + return .all + #else return UIDevice.current.userInterfaceIdiom == .phone ? .portrait : .all + #endif } }