From c8e0cd05cf12aea709e3061c342540913df8d9a4 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 24 Jun 2021 12:08:57 +0800 Subject: [PATCH 01/31] fix: authentication session force to ephemeral cause login session in Safari not using issue. resolve #178 --- .../PickServer/MastodonPickServerViewController.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index 46c5e234d..012f173d9 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -44,7 +44,6 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency tableView.backgroundColor = .clear tableView.keyboardDismissMode = .onDrag tableView.translatesAutoresizingMaskIntoConstraints = false - return tableView }() @@ -319,7 +318,6 @@ extension MastodonPickServerViewController { ) self.mastodonAuthenticationController = authenticationController - authenticationController.authenticationSession?.prefersEphemeralWebBrowserSession = true authenticationController.authenticationSession?.presentationContextProvider = self authenticationController.authenticationSession?.start() From 2d374f59082619acee09af844169316de7458371 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 28 Jun 2021 19:41:41 +0800 Subject: [PATCH 02/31] chore: [WIP] migrate compose scene from collection view to table view. Add MetaTextView --- Mastodon.xcodeproj/project.pbxproj | 45 + .../xcschemes/xcschememanagement.plist | 4 +- .../xcshareddata/swiftpm/Package.resolved | 9 + .../Section/ComposeStatusSection.swift | 106 ++- .../Diffiable/Section/StatusSection.swift | 29 +- Mastodon/Extension/CoreDataStack/Emojis.swift | 9 + ...Provider+StatusTableViewCellDelegate.swift | 6 + .../StatusProvider/StatusProviderFacade.swift | 27 + .../AutoCompleteViewController.swift | 7 +- .../Cell/AutoCompleteTableViewCell.swift | 4 +- ...mposeStatusContentCollectionViewCell.swift | 96 +- .../Scene/Compose/ComposeViewController.swift | 818 +++++++++--------- .../Compose/ComposeViewModel+Diffable.swift | 134 ++- Mastodon/Scene/Compose/ComposeViewModel.swift | 4 +- ...eRepliedToStatusContentTableViewCell.swift | 62 ++ .../ComposeStatusContentTableViewCell.swift | 147 ++++ .../Scene/Compose/View/ComposeTableView.swift | 28 + .../View/StatusContentWarningEditorView.swift | 57 +- .../NotificationStatusTableViewCell.swift | 6 + .../Report/ReportedStatusTableviewCell.swift | 6 + .../Scene/Share/View/Content/StatusView.swift | 59 +- .../TableviewCell/StatusTableViewCell.swift | 8 + .../EmojiService+CustomEmojiViewModel.swift | 13 + Mastodon/Supporting Files/AppDelegate.swift | 6 +- 24 files changed, 1172 insertions(+), 518 deletions(-) create mode 100644 Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToStatusContentTableViewCell.swift create mode 100644 Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift create mode 100644 Mastodon/Scene/Compose/View/ComposeTableView.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 70e01d1e7..cb96c5bb0 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 f1135b12e..ebbde8329 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 bf58fb3ce..f6219c400 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 836d91e73..93005cca8 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 43bc791c0..b94ed3328 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 a35e2630e..8d7c29753 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 60d61ecdb..2803113dd 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 3122de952..e6711bb1a 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 c98f84071..56832b9bf 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 a13e82f31..f629177dd 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 bb725e9b8..fbe7a2023 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 a9fe951f7..2a8844cfa 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 0b8d3e8f1..936d70f0f 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 a368bfbb9..1deb698f4 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 000000000..586895dd8 --- /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 000000000..cd3ede003 --- /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 000000000..9d95df03a --- /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 510edd464..d782f7024 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 f6ca84c95..3fb93d89c 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 3a71a64b6..f5fa003a3 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 d6d3dfe25..cdabdc1c1 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 784a4bfa2..ceb211a7b 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 fb6e5ec01..b0ee6cb80 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 4b219380a..83dffc342 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 } } From b4358572149dedd9469bdcb9088cf5cba6bdc2a0 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 29 Jun 2021 16:41:58 +0800 Subject: [PATCH 03/31] feat: complete compose scene refactor --- Mastodon.xcodeproj/project.pbxproj | 34 +- .../xcschemes/xcschememanagement.plist | 4 +- .../Item/ComposeStatusAttachmentItem.swift | 14 + .../Diffiable/Item/ComposeStatusItem.swift | 92 +---- .../Item/ComposeStatusPollItem.swift | 105 +++++ .../ComposeStatusAttachmentSection.swift | 13 + .../Section/ComposeStatusPollSection.swift | 12 + .../Section/ComposeStatusSection.swift | 270 ------------- .../Section/CustomEmojiPickerSection.swift | 18 +- ...iedToStatusContentCollectionViewCell.swift | 62 --- ...mposeStatusContentCollectionViewCell.swift | 169 -------- ...sPollExpiresOptionCollectionViewCell.swift | 4 +- ...jiPickerHeaderCollectionReusableView.swift | 14 +- ...tomEmojiPickerItemCollectionViewCell.swift | 9 + .../Scene/Compose/ComposeViewController.swift | 376 ++++++++---------- .../Compose/ComposeViewModel+Diffable.swift | 376 ++++++++++-------- Mastodon/Scene/Compose/ComposeViewModel.swift | 63 +-- ...eRepliedToStatusContentTableViewCell.swift | 1 + ...ComposeStatusAttachmentTableViewCell.swift | 155 ++++++++ .../ComposeStatusContentTableViewCell.swift | 4 +- .../ComposeStatusPollTableViewCell.swift | 184 +++++++++ .../View/Container/AudioContainerView.swift | 6 +- 22 files changed, 935 insertions(+), 1050 deletions(-) create mode 100644 Mastodon/Diffiable/Item/ComposeStatusAttachmentItem.swift create mode 100644 Mastodon/Diffiable/Item/ComposeStatusPollItem.swift create mode 100644 Mastodon/Diffiable/Section/ComposeStatusAttachmentSection.swift create mode 100644 Mastodon/Diffiable/Section/ComposeStatusPollSection.swift delete mode 100644 Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift delete mode 100644 Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift create mode 100644 Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift create mode 100644 Mastodon/Scene/Compose/TableViewCell/ComposeStatusPollTableViewCell.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index cb96c5bb0..6f195f704 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -226,6 +226,12 @@ DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */; }; DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */; }; DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC2E26130172006193C9 /* MastodonField.swift */; }; + DB36679D268AB91B0027D07F /* ComposeStatusAttachmentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB36679C268AB91B0027D07F /* ComposeStatusAttachmentTableViewCell.swift */; }; + DB36679F268ABAF20027D07F /* ComposeStatusAttachmentSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB36679E268ABAF20027D07F /* ComposeStatusAttachmentSection.swift */; }; + DB3667A1268ABB2E0027D07F /* ComposeStatusAttachmentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3667A0268ABB2E0027D07F /* ComposeStatusAttachmentItem.swift */; }; + DB3667A4268AE2370027D07F /* ComposeStatusPollTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3667A3268AE2370027D07F /* ComposeStatusPollTableViewCell.swift */; }; + DB3667A6268AE2620027D07F /* ComposeStatusPollSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3667A5268AE2620027D07F /* ComposeStatusPollSection.swift */; }; + DB3667A8268AE2900027D07F /* ComposeStatusPollItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3667A7268AE2900027D07F /* ComposeStatusPollItem.swift */; }; DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB3D0FF225BAA61700EAA174 /* AlamofireImage */; }; DB3D100D25BAA75E00EAA174 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB3D100F25BAA75E00EAA174 /* Localizable.strings */; }; DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DD525BAA00100D1B89D /* AppDelegate.swift */; }; @@ -344,8 +350,6 @@ DB75BF1E263C1C1B00EDBF1F /* CustomScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */; }; DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */; }; DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */; }; - DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */; }; - DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */; }; DB7F48452620241000796008 /* ProfileHeaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */; }; DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */; }; DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */; }; @@ -842,6 +846,13 @@ DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRelationshipActionButton.swift; sourceTree = ""; }; DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldView.swift; sourceTree = ""; }; DB35FC2E26130172006193C9 /* MastodonField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonField.swift; sourceTree = ""; }; + DB36679C268AB91B0027D07F /* ComposeStatusAttachmentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentTableViewCell.swift; sourceTree = ""; }; + DB36679E268ABAF20027D07F /* ComposeStatusAttachmentSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentSection.swift; sourceTree = ""; }; + DB3667A0268ABB2E0027D07F /* ComposeStatusAttachmentItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentItem.swift; sourceTree = ""; }; + DB3667A2268AC3BB0027D07F /* MetaTextView */ = {isa = PBXFileReference; lastKnownFileType = folder; name = MetaTextView; path = ../MetaTextView; sourceTree = ""; }; + DB3667A3268AE2370027D07F /* ComposeStatusPollTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollTableViewCell.swift; sourceTree = ""; }; + DB3667A5268AE2620027D07F /* ComposeStatusPollSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollSection.swift; sourceTree = ""; }; + DB3667A7268AE2900027D07F /* ComposeStatusPollItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollItem.swift; sourceTree = ""; }; DB3D0FED25BAA42200EAA174 /* MastodonSDK */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MastodonSDK; sourceTree = ""; }; DB3D100E25BAA75E00EAA174 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; DB427DD225BAA00100D1B89D /* Mastodon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mastodon.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -957,8 +968,6 @@ DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomScheduler.swift; sourceTree = ""; }; DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = ""; }; DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = ""; }; - DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentCollectionViewCell.swift; sourceTree = ""; }; - DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToStatusContentCollectionViewCell.swift; sourceTree = ""; }; DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderViewModel.swift; sourceTree = ""; }; DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentContainerView.swift; sourceTree = ""; }; DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollOptionCollectionViewCell.swift; sourceTree = ""; }; @@ -1529,6 +1538,8 @@ 2D35237926256D920031AF25 /* NotificationSection.swift */, 2D198648261C0B8500F0B013 /* SearchResultSection.swift */, DB66729525F9F91600D60309 /* ComposeStatusSection.swift */, + DB36679E268ABAF20027D07F /* ComposeStatusAttachmentSection.swift */, + DB3667A5268AE2620027D07F /* ComposeStatusPollSection.swift */, DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */, DB6D9F7C26358ED4008423CD /* SettingsSection.swift */, 5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */, @@ -1593,6 +1604,8 @@ DB1E347725F519300079D7DF /* PickServerItem.swift */, DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */, DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */, + DB3667A0268ABB2E0027D07F /* ComposeStatusAttachmentItem.swift */, + DB3667A7268AE2900027D07F /* ComposeStatusPollItem.swift */, DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */, DB6D9F8326358EEC008423CD /* SettingsItem.swift */, DBBF1DCA2652539E00E5B703 /* AutoCompleteItem.swift */, @@ -1747,6 +1760,8 @@ children = ( DB03F7F22689AEA3007B274C /* ComposeRepliedToStatusContentTableViewCell.swift */, DB03F7EF26899097007B274C /* ComposeStatusContentTableViewCell.swift */, + DB36679C268AB91B0027D07F /* ComposeStatusAttachmentTableViewCell.swift */, + DB3667A3268AE2370027D07F /* ComposeStatusPollTableViewCell.swift */, ); path = TableViewCell; sourceTree = ""; @@ -1812,6 +1827,7 @@ children = ( DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */, DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */, + DB3667A2268AC3BB0027D07F /* MetaTextView */, DB3D0FED25BAA42200EAA174 /* MastodonSDK */, DB427DD425BAA00100D1B89D /* Mastodon */, DB427DEB25BAA00100D1B89D /* MastodonTests */, @@ -2114,8 +2130,6 @@ DB789A2125F9F76D0071ACA0 /* CollectionViewCell */ = { isa = PBXGroup; children = ( - DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */, - DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */, DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */, DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */, DB87D4502609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift */, @@ -3217,6 +3231,7 @@ DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */, 2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */, 2DAC9E3E262FC2400062E1A6 /* SuggestionAccountViewModel.swift in Sources */, + DB3667A8268AE2900027D07F /* ComposeStatusPollItem.swift in Sources */, DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */, DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */, 2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */, @@ -3240,6 +3255,7 @@ DB98338825C945ED00AD9700 /* Assets.swift in Sources */, DB6180E926391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift in Sources */, DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */, + DB36679F268ABAF20027D07F /* ComposeStatusAttachmentSection.swift in Sources */, 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */, DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */, DB03F7F32689AEA3007B274C /* ComposeRepliedToStatusContentTableViewCell.swift in Sources */, @@ -3329,17 +3345,18 @@ 2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */, DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */, DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */, + DB3667A6268AE2620027D07F /* ComposeStatusPollSection.swift in Sources */, DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */, DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */, 2D34D9D126148D9E0081BFC0 /* APIService+Recommend.swift in Sources */, DBB525562611EDCA002F1F29 /* UserTimelineViewModel.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, DB221B16260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift in Sources */, - DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */, DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */, 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, 2D084B8D26258EA3003AA3AF /* NotificationViewModel+Diffable.swift in Sources */, DB6D1B24263684C600ACB481 /* UserDefaults.swift in Sources */, + DB3667A1268ABB2E0027D07F /* ComposeStatusAttachmentItem.swift in Sources */, DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, DBA94434265CBB5300C537E1 /* ProfileFieldSection.swift in Sources */, DB6F5E2F264E5518009108F4 /* MastodonRegex.swift in Sources */, @@ -3426,6 +3443,7 @@ DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */, DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */, 2D5A3D0325CF8742002347D6 /* ControlContainableScrollViews.swift in Sources */, + DB36679D268AB91B0027D07F /* ComposeStatusAttachmentTableViewCell.swift in Sources */, DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */, DBCBCC032680AF6E000F5B51 /* AsyncHomeTimelineViewController+DebugAction.swift in Sources */, DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */, @@ -3494,6 +3512,7 @@ DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */, DB6D1B3D2636857500ACB481 /* AppearancePreference.swift in Sources */, DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */, + DB3667A4268AE2370027D07F /* ComposeStatusPollTableViewCell.swift in Sources */, DBBF1DC226524D2900E5B703 /* AutoCompleteTableViewCell.swift in Sources */, 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, DB1EE7B2267F9525000CC337 /* StatusProvider+StatusNodeDelegate.swift in Sources */, @@ -3516,7 +3535,6 @@ DBAFB7352645463500371D5F /* Emojis.swift in Sources */, DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */, DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */, - DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */, DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */, 5BB04FD5262E7AFF0043BFF6 /* ReportViewController.swift in Sources */, DBAE3F942616E28B004B8251 /* APIService+Follow.swift in Sources */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index ebbde8329..b782cd198 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 - 21 + 23 Mastodon - ASDK.xcscheme_^#shared#^_ @@ -37,7 +37,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 22 + 21 SuppressBuildableAutocreation diff --git a/Mastodon/Diffiable/Item/ComposeStatusAttachmentItem.swift b/Mastodon/Diffiable/Item/ComposeStatusAttachmentItem.swift new file mode 100644 index 000000000..834e1da49 --- /dev/null +++ b/Mastodon/Diffiable/Item/ComposeStatusAttachmentItem.swift @@ -0,0 +1,14 @@ +// +// ComposeStatusAttachmentItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-6-29. +// + +import Foundation + +enum ComposeStatusAttachmentItem { + case attachment(attachmentService: MastodonAttachmentService) +} + +extension ComposeStatusAttachmentItem: Hashable { } diff --git a/Mastodon/Diffiable/Item/ComposeStatusItem.swift b/Mastodon/Diffiable/Item/ComposeStatusItem.swift index d60a76e82..96ea8b05f 100644 --- a/Mastodon/Diffiable/Item/ComposeStatusItem.swift +++ b/Mastodon/Diffiable/Item/ComposeStatusItem.swift @@ -13,14 +13,10 @@ import CoreData enum ComposeStatusItem { case replyTo(statusObjectID: NSManagedObjectID) case input(replyToStatusObjectID: NSManagedObjectID?, attribute: ComposeStatusAttribute) - case attachment(attachmentService: MastodonAttachmentService) - case pollOption(attribute: ComposePollOptionAttribute) - case pollOptionAppendEntry - case pollExpiresOption(attribute: ComposePollExpiresOptionAttribute) + case attachment(attachmentAttribute: ComposeStatusAttachmentAttribute) + case pollOption(pollOptionAttributes: [ComposeStatusPollItem.PollOptionAttribute], pollExpiresOptionAttribute: ComposeStatusPollItem.PollExpiresOptionAttribute) } -extension ComposeStatusItem: Equatable { } - extension ComposeStatusItem: Hashable { } extension ComposeStatusItem { @@ -50,88 +46,22 @@ extension ComposeStatusItem { } } -protocol ComposePollAttributeDelegate: AnyObject { - func composePollAttribute(_ attribute: ComposeStatusItem.ComposePollOptionAttribute, pollOptionDidChange: String?) -} - extension ComposeStatusItem { - final class ComposePollOptionAttribute: Equatable, Hashable { + final class ComposeStatusAttachmentAttribute: Hashable { private let id = UUID() - - var disposeBag = Set() - weak var delegate: ComposePollAttributeDelegate? - let option = CurrentValueSubject("") - - init() { - option - .sink { [weak self] option in - guard let self = self else { return } - self.delegate?.composePollAttribute(self, pollOptionDidChange: option) - } - .store(in: &disposeBag) + var attachmentServices: [MastodonAttachmentService] + + init(attachmentServices: [MastodonAttachmentService]) { + self.attachmentServices = attachmentServices } - - deinit { - disposeBag.removeAll() + + static func == (lhs: ComposeStatusAttachmentAttribute, rhs: ComposeStatusAttachmentAttribute) -> Bool { + return lhs.attachmentServices == rhs.attachmentServices } - - static func == (lhs: ComposePollOptionAttribute, rhs: ComposePollOptionAttribute) -> Bool { - return lhs.id == rhs.id && - lhs.option.value == rhs.option.value - } - + func hash(into hasher: inout Hasher) { hasher.combine(id) } } } - -extension ComposeStatusItem { - final class ComposePollExpiresOptionAttribute: Equatable, Hashable { - private let id = UUID() - - let expiresOption = CurrentValueSubject(.thirtyMinutes) - - - static func == (lhs: ComposePollExpiresOptionAttribute, rhs: ComposePollExpiresOptionAttribute) -> Bool { - return lhs.id == rhs.id && - lhs.expiresOption.value == rhs.expiresOption.value - } - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - } - - enum ExpiresOption: Equatable, Hashable, CaseIterable { - case thirtyMinutes - case oneHour - case sixHours - case oneDay - case threeDays - case sevenDays - - var title: String { - switch self { - case .thirtyMinutes: return L10n.Scene.Compose.Poll.thirtyMinutes - case .oneHour: return L10n.Scene.Compose.Poll.oneHour - case .sixHours: return L10n.Scene.Compose.Poll.sixHours - case .oneDay: return L10n.Scene.Compose.Poll.oneDay - case .threeDays: return L10n.Scene.Compose.Poll.threeDays - case .sevenDays: return L10n.Scene.Compose.Poll.sevenDays - } - } - - var seconds: Int { - switch self { - case .thirtyMinutes: return 60 * 30 - case .oneHour: return 60 * 60 * 1 - case .sixHours: return 60 * 60 * 6 - case .oneDay: return 60 * 60 * 24 - case .threeDays: return 60 * 60 * 24 * 3 - case .sevenDays: return 60 * 60 * 24 * 7 - } - } - } - } -} diff --git a/Mastodon/Diffiable/Item/ComposeStatusPollItem.swift b/Mastodon/Diffiable/Item/ComposeStatusPollItem.swift new file mode 100644 index 000000000..a6d9a36e8 --- /dev/null +++ b/Mastodon/Diffiable/Item/ComposeStatusPollItem.swift @@ -0,0 +1,105 @@ +// +// ComposeStatusPollItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-6-29. +// + +import Foundation +import Combine + +enum ComposeStatusPollItem { + case pollOption(attribute: PollOptionAttribute) + case pollOptionAppendEntry + case pollExpiresOption(attribute: PollExpiresOptionAttribute) +} + +extension ComposeStatusPollItem: Hashable { } + +extension ComposeStatusPollItem { + + final class PollOptionAttribute: Equatable, Hashable { + private let id = UUID() + + var disposeBag = Set() + weak var delegate: ComposePollAttributeDelegate? + + let option = CurrentValueSubject("") + + init() { + option + .sink { [weak self] option in + guard let self = self else { return } + self.delegate?.composePollAttribute(self, pollOptionDidChange: option) + } + .store(in: &disposeBag) + } + + deinit { + disposeBag.removeAll() + } + + static func == (lhs: PollOptionAttribute, rhs: PollOptionAttribute) -> Bool { + return lhs.id == rhs.id && + lhs.option.value == rhs.option.value + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + } + +} + +protocol ComposePollAttributeDelegate: AnyObject { + func composePollAttribute(_ attribute: ComposeStatusPollItem.PollOptionAttribute, pollOptionDidChange: String?) +} + +extension ComposeStatusPollItem { + final class PollExpiresOptionAttribute: Equatable, Hashable { + private let id = UUID() + + let expiresOption = CurrentValueSubject(.thirtyMinutes) + + + static func == (lhs: PollExpiresOptionAttribute, rhs: PollExpiresOptionAttribute) -> Bool { + return lhs.id == rhs.id && + lhs.expiresOption.value == rhs.expiresOption.value + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + enum ExpiresOption: Equatable, Hashable, CaseIterable { + case thirtyMinutes + case oneHour + case sixHours + case oneDay + case threeDays + case sevenDays + + var title: String { + switch self { + case .thirtyMinutes: return L10n.Scene.Compose.Poll.thirtyMinutes + case .oneHour: return L10n.Scene.Compose.Poll.oneHour + case .sixHours: return L10n.Scene.Compose.Poll.sixHours + case .oneDay: return L10n.Scene.Compose.Poll.oneDay + case .threeDays: return L10n.Scene.Compose.Poll.threeDays + case .sevenDays: return L10n.Scene.Compose.Poll.sevenDays + } + } + + var seconds: Int { + switch self { + case .thirtyMinutes: return 60 * 30 + case .oneHour: return 60 * 60 * 1 + case .sixHours: return 60 * 60 * 6 + case .oneDay: return 60 * 60 * 24 + case .threeDays: return 60 * 60 * 24 * 3 + case .sevenDays: return 60 * 60 * 24 * 7 + } + } + } + } +} diff --git a/Mastodon/Diffiable/Section/ComposeStatusAttachmentSection.swift b/Mastodon/Diffiable/Section/ComposeStatusAttachmentSection.swift new file mode 100644 index 000000000..4de7653a5 --- /dev/null +++ b/Mastodon/Diffiable/Section/ComposeStatusAttachmentSection.swift @@ -0,0 +1,13 @@ +// +// ComposeStatusAttachmentSection.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-6-29. +// + +import Foundation + +enum ComposeStatusAttachmentSection: Hashable { + case main +} + diff --git a/Mastodon/Diffiable/Section/ComposeStatusPollSection.swift b/Mastodon/Diffiable/Section/ComposeStatusPollSection.swift new file mode 100644 index 000000000..cd06572dc --- /dev/null +++ b/Mastodon/Diffiable/Section/ComposeStatusPollSection.swift @@ -0,0 +1,12 @@ +// +// ComposeStatusPollSection.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-6-29. +// + +import Foundation + +enum ComposeStatusPollSection: Hashable { + case main +} diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 93005cca8..b82116ad4 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -30,266 +30,6 @@ extension ComposeStatusSection { } extension ComposeStatusSection { - static func collectionViewDiffableDataSource( - for collectionView: UICollectionView, - dependency: NeedsDependency, - managedObjectContext: NSManagedObjectContext, - composeKind: ComposeKind, - repliedToCellFrameSubscriber: CurrentValueSubject, - customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel, - metaTextDelegate: MetaTextDelegate, - metaTextViewDelegate: UITextViewDelegate, - composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate, - composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate, - composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate, - composeStatusPollExpiresOptionCollectionViewCellDelegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate - ) -> UICollectionViewDiffableDataSource { - UICollectionViewDiffableDataSource(collectionView: collectionView) { [ - weak customEmojiPickerInputViewModel, - weak metaTextDelegate, - weak metaTextViewDelegate, - weak composeStatusAttachmentTableViewCellDelegate, - weak composeStatusPollOptionCollectionViewCellDelegate, - weak composeStatusNewPollOptionCollectionViewCellDelegate, - weak composeStatusPollExpiresOptionCollectionViewCellDelegate - ] collectionView, indexPath, item -> UICollectionViewCell? in - switch item { - case .replyTo(let replyToStatusObjectID): - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeRepliedToStatusContentCollectionViewCell.self), for: indexPath) as! ComposeRepliedToStatusContentCollectionViewCell - // set empty text before retrieve real data to fix pseudo-text display issue - cell.statusView.nameLabel.text = " " - cell.statusView.usernameLabel.text = " " - managedObjectContext.performAndWait { - guard let replyTo = managedObjectContext.object(with: replyToStatusObjectID) 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 - //status.emoji -// cell.statusView.activeTextLabel.configure(content: status.content, emojiDict: [:]) - // set date - cell.statusView.dateLabel.text = status.createdAt.slowedTimeAgoSinceNow - - cell.framePublisher.assign(to: \.value, on: repliedToCellFrameSubscriber).store(in: &cell.disposeBag) - } - 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 - 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) - } - ComposeStatusSection.configureStatusContent(cell: cell, attribute: attribute) -// 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 - guard let cell = cell else { return } - guard let collectionView = collectionView else { return } - // self size input cell - collectionView.collectionViewLayout.invalidateLayout() - 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 collectionView] text in - guard let collectionView = collectionView else { return } - // self size input cell - collectionView.collectionViewLayout.invalidateLayout() - // bind input data - attribute.contentWarningContent.value = text - } - .store(in: &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 - case .attachment(let attachmentService): - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self), for: indexPath) as! ComposeStatusAttachmentCollectionViewCell - cell.attachmentContainerView.descriptionTextView.text = attachmentService.description.value - cell.delegate = composeStatusAttachmentTableViewCellDelegate - attachmentService.thumbnailImage - .receive(on: DispatchQueue.main) - .sink { [weak cell] thumbnailImage in - guard let cell = cell else { return } - let size = cell.attachmentContainerView.previewImageView.frame.size != .zero ? cell.attachmentContainerView.previewImageView.frame.size : CGSize(width: 1, height: 1) - guard let image = thumbnailImage else { - let placeholder = UIImage.placeholder( - size: size, - color: Asset.Colors.Background.systemGroupedBackground.color - ) - .af.imageRounded( - withCornerRadius: AttachmentContainerView.containerViewCornerRadius - ) - cell.attachmentContainerView.previewImageView.image = placeholder - return - } - cell.attachmentContainerView.previewImageView.image = image - .af.imageAspectScaled(toFill: size) - .af.imageRounded(withCornerRadius: AttachmentContainerView.containerViewCornerRadius) - } - .store(in: &cell.disposeBag) - Publishers.CombineLatest( - attachmentService.uploadStateMachineSubject.eraseToAnyPublisher(), - attachmentService.error.eraseToAnyPublisher() - ) - .receive(on: DispatchQueue.main) - .sink { [weak cell, weak attachmentService] uploadState, error in - guard let cell = cell else { return } - guard let attachmentService = attachmentService else { return } - cell.attachmentContainerView.emptyStateView.isHidden = error == nil - cell.attachmentContainerView.descriptionBackgroundView.isHidden = error != nil - if let error = error { - cell.attachmentContainerView.activityIndicatorView.stopAnimating() - cell.attachmentContainerView.emptyStateView.label.text = error.localizedDescription - } else { - guard let uploadState = uploadState else { return } - switch uploadState { - case is MastodonAttachmentService.UploadState.Finish, - is MastodonAttachmentService.UploadState.Fail: - cell.attachmentContainerView.activityIndicatorView.stopAnimating() - cell.attachmentContainerView.emptyStateView.label.text = { - if let file = attachmentService.file.value { - switch file { - case .jpeg, .png, .gif: - return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo) - case .other: - return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.video) - } - } else { - return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo) - } - }() - default: - break - } - } - } - .store(in: &cell.disposeBag) - NotificationCenter.default.publisher( - for: UITextView.textDidChangeNotification, - object: cell.attachmentContainerView.descriptionTextView - ) - .receive(on: DispatchQueue.main) - .sink { notification in - guard let textField = notification.object as? UITextView else { return } - let text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) - attachmentService.description.value = text - } - .store(in: &cell.disposeBag) - return cell - case .pollOption(let attribute): - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self), for: indexPath) as! ComposeStatusPollOptionCollectionViewCell - cell.pollOptionView.optionTextField.text = attribute.option.value - cell.pollOptionView.optionTextField.placeholder = L10n.Scene.Compose.Poll.optionNumber(indexPath.item + 1) - cell.pollOption - .receive(on: DispatchQueue.main) - .assign(to: \.value, on: attribute.option) - .store(in: &cell.disposeBag) - cell.delegate = composeStatusPollOptionCollectionViewCellDelegate - ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.pollOptionView.optionTextField, disposeBag: &cell.disposeBag) - return cell - case .pollOptionAppendEntry: - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self), for: indexPath) as! ComposeStatusPollOptionAppendEntryCollectionViewCell - cell.delegate = composeStatusNewPollOptionCollectionViewCellDelegate - return cell - case .pollExpiresOption(let attribute): - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollExpiresOptionCollectionViewCell.self), for: indexPath) as! ComposeStatusPollExpiresOptionCollectionViewCell - cell.durationButton.setTitle(L10n.Scene.Compose.Poll.durationTime(attribute.expiresOption.value.title), for: .normal) - attribute.expiresOption - .receive(on: DispatchQueue.main) - .sink { [weak cell] expiresOption in - guard let cell = cell else { return } - cell.durationButton.setTitle(L10n.Scene.Compose.Poll.durationTime(expiresOption.title), for: .normal) - } - .store(in: &cell.disposeBag) - cell.delegate = composeStatusPollExpiresOptionCollectionViewCellDelegate - return cell - } - } - } -} - -extension ComposeStatusSection { - - static func configureStatusContent( - cell: ComposeStatusContentCollectionViewCell, - 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) - - // bind compose content - cell.composeContent - .map { $0 as String? } - .assign(to: \.value, on: attribute.composeContent) - .store(in: &cell.disposeBag) - } static func configureStatusContent( cell: ComposeStatusContentTableViewCell, @@ -335,16 +75,6 @@ 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 UITextField: CustomEmojiReplaceableTextInput { } extension UITextView: CustomEmojiReplaceableTextInput { } diff --git a/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift b/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift index 20dc5b809..57d7b6019 100644 --- a/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift +++ b/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift @@ -6,7 +6,7 @@ // import UIKit -import Kingfisher +import Nuke enum CustomEmojiPickerSection: Equatable, Hashable { case emoji(name: String) @@ -24,13 +24,13 @@ extension CustomEmojiPickerSection { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: CustomEmojiPickerItemCollectionViewCell.self), for: indexPath) as! CustomEmojiPickerItemCollectionViewCell let placeholder = UIImage.placeholder(size: CustomEmojiPickerItemCollectionViewCell.itemSize, color: .systemFill) .af.imageRounded(withCornerRadius: 4) - cell.emojiImageView.kf.setImage( - with: URL(string: attribute.emoji.url), - placeholder: placeholder, - options: [ - .transition(.fade(0.2)) - ], - completionHandler: nil + cell.imageTask = Nuke.loadImage( + with: attribute.emoji.url, + options: .init( + placeholder: placeholder, + transition: .fadeIn(duration: 0.2) + ), + into: cell.emojiImageView ) cell.accessibilityLabel = attribute.emoji.shortcode return cell @@ -48,7 +48,7 @@ extension CustomEmojiPickerSection { let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: String(describing: CustomEmojiPickerHeaderCollectionReusableView.self), for: indexPath) as! CustomEmojiPickerHeaderCollectionReusableView switch section { case .emoji(let name): - header.titlelabel.text = name + header.titleLabel.text = name } return header default: diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift deleted file mode 100644 index 8da4c0729..000000000 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// ComposeRepliedToStatusContentCollectionViewCell.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-11. -// - -import UIKit -import Combine - -final class ComposeRepliedToStatusContentCollectionViewCell: UICollectionViewCell { - - var disposeBag = Set() - - let statusView = StatusView() - - let framePublisher = PassthroughSubject() - - override func prepareForReuse() { - super.prepareForReuse() - - statusView.updateContentWarningDisplay(isHidden: true, animated: false) - disposeBag.removeAll() - } - - override init(frame: CGRect) { - super.init(frame: frame) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - - override func layoutSubviews() { - super.layoutSubviews() - framePublisher.send(bounds) - } - -} - -extension ComposeRepliedToStatusContentCollectionViewCell { - - 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.readableContentGuide.leadingAnchor), - contentView.readableContentGuide.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/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift deleted file mode 100644 index fbe7a2023..000000000 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift +++ /dev/null @@ -1,169 +0,0 @@ -// -// ComposeStatusContentCollectionViewCell.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-11. -// - -import os.log -import UIKit -import Combine -import MetaTextView - -final class ComposeStatusContentCollectionViewCell: UICollectionViewCell { - - 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.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 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) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - -} - -extension ComposeStatusContentCollectionViewCell { - - private func _init() { - // selectionStyle = .none - layer.zPosition = 999 - preservesSuperviewLayoutMargins = true - - statusContentWarningEditorView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(statusContentWarningEditorView) - NSLayoutConstraint.activate([ - statusContentWarningEditorView.topAnchor.constraint(equalTo: contentView.topAnchor), - statusContentWarningEditorView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - statusContentWarningEditorView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - ]) - statusContentWarningEditorView.preservesSuperviewLayoutMargins = true - statusContentWarningEditorView.containerBackgroundView.isHidden = false - - statusView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(statusView) - NSLayoutConstraint.activate([ - statusView.topAnchor.constraint(equalTo: statusContentWarningEditorView.bottomAnchor, constant: 20), - statusView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), - statusView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), - ]) - statusView.statusContainerStackView.isHidden = true - statusView.actionToolbarContainer.isHidden = true - statusView.nameTrialingDotLabel.isHidden = true - statusView.dateLabel.isHidden = true - - statusView.setContentHuggingPriority(.defaultHigh, for: .vertical) - statusView.setContentCompressionResistancePriority(.required - 1, for: .vertical) - - textEditorViewContainerView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(textEditorViewContainerView) - NSLayoutConstraint.activate([ - textEditorViewContainerView.topAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10), - textEditorViewContainerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - textEditorViewContainerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - contentView.bottomAnchor.constraint(equalTo: textEditorViewContainerView.bottomAnchor, constant: 10), - ]) - textEditorViewContainerView.preservesSuperviewLayoutMargins = true - -// 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([ - 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), - ]) - metaText.textView.setContentCompressionResistancePriority(.required - 2, for: .vertical) - - statusContentWarningEditorView.textView.delegate = self - //textEditorView.changeObserver = self - - 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) -// } -//} - -// MARK: - UITextViewDelegate -extension ComposeStatusContentCollectionViewCell: UITextViewDelegate { - - func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { - if textView === statusContentWarningEditorView.textView { - // disable input line break - guard text != "\n" else { return false } - } - 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/CollectionViewCell/ComposeStatusPollExpiresOptionCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollExpiresOptionCollectionViewCell.swift index 4ef0dbe5a..e4569356f 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollExpiresOptionCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollExpiresOptionCollectionViewCell.swift @@ -10,7 +10,7 @@ import UIKit import Combine protocol ComposeStatusPollExpiresOptionCollectionViewCellDelegate: AnyObject { - func composeStatusPollExpiresOptionCollectionViewCell(_ cell: ComposeStatusPollExpiresOptionCollectionViewCell, didSelectExpiresOption expiresOption: ComposeStatusItem.ComposePollExpiresOptionAttribute.ExpiresOption) + func composeStatusPollExpiresOptionCollectionViewCell(_ cell: ComposeStatusPollExpiresOptionCollectionViewCell, didSelectExpiresOption expiresOption: ComposeStatusPollItem.PollExpiresOptionAttribute.ExpiresOption) } final class ComposeStatusPollExpiresOptionCollectionViewCell: UICollectionViewCell { @@ -41,7 +41,7 @@ final class ComposeStatusPollExpiresOptionCollectionViewCell: UICollectionViewCe extension ComposeStatusPollExpiresOptionCollectionViewCell { - private typealias ExpiresOption = ComposeStatusItem.ComposePollExpiresOptionAttribute.ExpiresOption + private typealias ExpiresOption = ComposeStatusPollItem.PollExpiresOptionAttribute.ExpiresOption private func _init() { durationButton.translatesAutoresizingMaskIntoConstraints = false diff --git a/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerHeaderCollectionReusableView.swift b/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerHeaderCollectionReusableView.swift index 61753a4c2..30d5986ab 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerHeaderCollectionReusableView.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerHeaderCollectionReusableView.swift @@ -9,7 +9,7 @@ import UIKit final class CustomEmojiPickerHeaderCollectionReusableView: UICollectionReusableView { - let titlelabel: UILabel = { + let titleLabel: UILabel = { let label = UILabel() label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12, weight: .bold)) label.textColor = Asset.Colors.Label.secondary.color @@ -30,13 +30,13 @@ final class CustomEmojiPickerHeaderCollectionReusableView: UICollectionReusableV extension CustomEmojiPickerHeaderCollectionReusableView { private func _init() { - titlelabel.translatesAutoresizingMaskIntoConstraints = false - addSubview(titlelabel) + titleLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(titleLabel) NSLayoutConstraint.activate([ - titlelabel.topAnchor.constraint(equalTo: topAnchor, constant: 20), - titlelabel.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), - titlelabel.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), - titlelabel.bottomAnchor.constraint(equalTo: bottomAnchor), + titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 20), + titleLabel.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), + titleLabel.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), + titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor), ]) } } diff --git a/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerItemCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerItemCollectionViewCell.swift index 49e6c1fe2..7e305dbb0 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerItemCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerItemCollectionViewCell.swift @@ -6,10 +6,13 @@ // import UIKit +import Nuke final class CustomEmojiPickerItemCollectionViewCell: UICollectionViewCell { static let itemSize = CGSize(width: 44, height: 44) + + var imageTask: ImageTask? let emojiImageView: UIImageView = { let imageView = UIImageView() @@ -23,6 +26,12 @@ final class CustomEmojiPickerItemCollectionViewCell: UICollectionViewCell { emojiImageView.alpha = isHighlighted ? 0.5 : 1.0 } } + + override func prepareForReuse() { + super.prepareForReuse() + imageTask?.cancel() + imageTask = nil + } override init(frame: CGRect) { super.init(frame: frame) diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 2a8844cfa..0e3efede2 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -48,26 +48,12 @@ final class ComposeViewController: UIViewController, NeedsDependency { let barButtonItem = UIBarButtonItem(customView: publishButton) 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 tableView: ComposeTableView = { let tableView = ComposeTableView() tableView.register(ComposeRepliedToStatusContentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeRepliedToStatusContentTableViewCell.self)) tableView.register(ComposeStatusContentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeStatusContentTableViewCell.self)) + tableView.register(ComposeStatusAttachmentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeStatusAttachmentTableViewCell.self)) tableView.backgroundColor = Asset.Scene.Compose.background.color tableView.alwaysBounceVertical = true tableView.separatorStyle = .none @@ -174,15 +160,6 @@ extension ComposeViewController { 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) @@ -210,7 +187,11 @@ extension ComposeViewController { tableView: tableView, metaTextDelegate: self, metaTextViewDelegate: self, - customEmojiPickerInputViewModel: viewModel.customEmojiPickerInputViewModel + customEmojiPickerInputViewModel: viewModel.customEmojiPickerInputViewModel, + composeStatusAttachmentCollectionViewCellDelegate: self, + composeStatusPollOptionCollectionViewCellDelegate: self, + composeStatusPollOptionAppendEntryCollectionViewCellDelegate: self, + composeStatusPollExpiresOptionCollectionViewCellDelegate: self ) viewModel.composeStatusAttribute.composeContent @@ -218,6 +199,7 @@ extension ComposeViewController { .receive(on: RunLoop.main) .sink { [weak self] _ in guard let self = self else { return } + guard self.view.window != nil else { return } UIView.performWithoutAnimation { self.tableView.beginUpdates() self.tableView.endUpdates() @@ -225,21 +207,6 @@ extension ComposeViewController { } .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 viewModel.setupCustomEmojiPickerDiffableDataSource( @@ -273,8 +240,8 @@ extension ComposeViewController { // update keyboard background color guard isShow, state == .dock else { - self.tableView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin - self.tableView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin + self.tableView.contentInset.bottom = extraMargin + self.tableView.verticalScrollIndicatorInsets.bottom = extraMargin if let superView = self.autoCompleteViewController.tableView.superview { let autoCompleteTableViewBottomInset: CGFloat = { @@ -319,8 +286,8 @@ extension ComposeViewController { return } - self.tableView.contentInset.bottom = padding - self.tableView.verticalScrollIndicatorInsets.bottom = padding + self.tableView.contentInset.bottom = padding - self.view.safeAreaInsets.bottom + self.tableView.verticalScrollIndicatorInsets.bottom = padding - self.view.safeAreaInsets.bottom UIView.animate(withDuration: 0.3) { self.composeToolbarViewBottomLayoutConstraint.constant = endFrame.height self.view.layoutIfNeeded() @@ -487,6 +454,12 @@ extension ComposeViewController { self.markTextEditorViewBecomeFirstResponser() } } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + viewModel.isViewAppeared = true + } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) @@ -526,56 +499,56 @@ extension ComposeViewController { 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 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 markFirstPollOptionCollectionViewCellBecomeFirstResponser() { -// guard let cell = firstPollOptionCollectionViewCell() else { return } -// cell.pollOptionView.optionTextField.becomeFirstResponder() -// } + private func pollOptionCollectionViewCell(of item: ComposeStatusPollItem) -> ComposeStatusPollOptionCollectionViewCell? { + guard case .pollOption = item else { return nil } + guard let dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return nil } + guard let indexPath = dataSource.indexPath(for: item), + let cell = viewModel.composeStatusPollTableViewCell.collectionView.cellForItem(at: indexPath) as? ComposeStatusPollOptionCollectionViewCell else { + return nil + } -// private func markLastPollOptionCollectionViewCellBecomeFirstResponser() { -// guard let cell = lastPollOptionCollectionViewCell() else { return } -// cell.pollOptionView.optionTextField.becomeFirstResponder() -// } + return cell + } + + private func firstPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? { + guard let dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return nil } + let items = dataSource.snapshot().itemIdentifiers(inSection: .main) + 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 dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return nil } + let items = dataSource.snapshot().itemIdentifiers(inSection: .main) + 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 markLastPollOptionCollectionViewCellBecomeFirstResponser() { + guard let cell = lastPollOptionCollectionViewCell() else { return } + cell.pollOptionView.optionTextField.becomeFirstResponder() + } private func showDismissConfirmAlertController() { let alertController = UIAlertController( @@ -652,43 +625,6 @@ extension ComposeViewController { dismiss(animated: true, completion: nil) } - // 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 -// } -// -// 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 @@ -708,7 +644,7 @@ extension ComposeViewController: MetaTextDelegate { extension ComposeViewController: UITextViewDelegate { func textViewDidChange(_ textView: UITextView) { - if textView.tag == ComposeStatusContentCollectionViewCell.metaTextViewTag { + if textEditorView()?.textView === textView { // update model guard let metaText = textEditorView() else { return } let backedString = metaText.backedString @@ -857,7 +793,7 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { } self.suffixedAttachmentViews.removeAll() - // set normal apperance + // set normal appearance let attributedString = NSMutableAttributedString(attributedString: attributedString) attributedString.removeAttribute(.suffixedAttachment, range: stringRange) attributedString.removeAttribute(.underlineStyle, range: stringRange) @@ -987,17 +923,17 @@ extension ComposeViewController: ComposeToolbarViewDelegate { // setup initial poll option if needs if viewModel.isPollComposing.value, viewModel.pollOptionAttributes.value.isEmpty { - viewModel.pollOptionAttributes.value = [ComposeStatusItem.ComposePollOptionAttribute(), ComposeStatusItem.ComposePollOptionAttribute()] + viewModel.pollOptionAttributes.value = [ComposeStatusPollItem.PollOptionAttribute(), ComposeStatusPollItem.PollOptionAttribute()] } -// if viewModel.isPollComposing.value { -// // Magic RunLoop -// DispatchQueue.main.async { -// self.markFirstPollOptionCollectionViewCellBecomeFirstResponser() -// } -// } else { -// markTextEditorViewBecomeFirstResponser() -// } + if viewModel.isPollComposing.value { + // Magic RunLoop + DispatchQueue.main.async { + self.markFirstPollOptionCollectionViewCellBecomeFirstResponser() + } + } else { + markTextEditorViewBecomeFirstResponser() + } } func composeToolbarView(_ composeToolbarView: ComposeToolbarView, emojiButtonDidPressed sender: UIButton) { @@ -1160,19 +1096,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.composeStatusAttachmentTableViewCell.dataSource else { return } + guard let indexPath = viewModel.composeStatusAttachmentTableViewCell.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() } } @@ -1190,72 +1126,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 dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return } + guard let indexPath = viewModel.composeStatusPollTableViewCell.collectionView.indexPath(for: cell) else { return } + guard let item = dataSource.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 = dataSource.snapshot().itemIdentifiers(inSection: .main) + 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 dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return } + guard let indexPath = viewModel.composeStatusPollTableViewCell.collectionView.indexPath(for: cell) else { return } + let pollItems = dataSource.snapshot().itemIdentifiers(inSection: .main).filter { item in + guard case .pollOption = item else { return false } + return true + } + guard let item = dataSource.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() + } } } @@ -1264,15 +1200,15 @@ extension ComposeViewController: ComposeStatusPollOptionCollectionViewCellDelega extension ComposeViewController: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate { func composeStatusPollOptionAppendEntryCollectionViewCellDidPressed(_ cell: ComposeStatusPollOptionAppendEntryCollectionViewCell) { viewModel.createNewPollOptionIfPossible() -// DispatchQueue.main.async { -// self.markLastPollOptionCollectionViewCellBecomeFirstResponser() -// } + DispatchQueue.main.async { + self.markLastPollOptionCollectionViewCellBecomeFirstResponser() + } } } // MARK: - ComposeStatusPollExpiresOptionCollectionViewCellDelegate extension ComposeViewController: ComposeStatusPollExpiresOptionCollectionViewCellDelegate { - func composeStatusPollExpiresOptionCollectionViewCell(_ cell: ComposeStatusPollExpiresOptionCollectionViewCell, didSelectExpiresOption expiresOption: ComposeStatusItem.ComposePollExpiresOptionAttribute.ExpiresOption) { + func composeStatusPollExpiresOptionCollectionViewCell(_ cell: ComposeStatusPollExpiresOptionCollectionViewCell, didSelectExpiresOption expiresOption: ComposeStatusPollItem.PollExpiresOptionAttribute.ExpiresOption) { viewModel.pollExpiresOptionAttribute.expiresOption.value = expiresOption } } diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index 936d70f0f..1999152d3 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -5,6 +5,7 @@ // Created by MainasuK Cirno on 2021-3-11. // +import os.log import UIKit import Combine import CoreDataStack @@ -19,185 +20,83 @@ extension ComposeViewModel { 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, - metaTextDelegate: MetaTextDelegate, - metaTextViewDelegate: UITextViewDelegate, - composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate, + composeStatusAttachmentCollectionViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate, composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate, - composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate, + composeStatusPollOptionAppendEntryCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate, composeStatusPollExpiresOptionCollectionViewCellDelegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate ) { - let diffableDataSource = ComposeStatusSection.collectionViewDiffableDataSource( - for: collectionView, - dependency: dependency, - managedObjectContext: context.managedObjectContext, - composeKind: composeKind, - repliedToCellFrameSubscriber: repliedToCellFrame, - customEmojiPickerInputViewModel: customEmojiPickerInputViewModel, - metaTextDelegate: metaTextDelegate, - metaTextViewDelegate: metaTextViewDelegate, - composeStatusAttachmentTableViewCellDelegate: composeStatusAttachmentTableViewCellDelegate, - composeStatusPollOptionCollectionViewCellDelegate: composeStatusPollOptionCollectionViewCellDelegate, - composeStatusNewPollOptionCollectionViewCellDelegate: composeStatusNewPollOptionCollectionViewCellDelegate, - composeStatusPollExpiresOptionCollectionViewCellDelegate: composeStatusPollExpiresOptionCollectionViewCellDelegate - ) + // content + composeStatusContentTableViewCell.metaText.delegate = metaTextDelegate + composeStatusContentTableViewCell.metaText.textView.delegate = metaTextViewDelegate + // attachment + composeStatusAttachmentTableViewCell.composeStatusAttachmentCollectionViewCellDelegate = composeStatusAttachmentCollectionViewCellDelegate + // poll + composeStatusPollTableViewCell.delegate = self + composeStatusPollTableViewCell.customEmojiPickerInputViewModel = customEmojiPickerInputViewModel + composeStatusPollTableViewCell.composeStatusPollOptionCollectionViewCellDelegate = composeStatusPollOptionCollectionViewCellDelegate + composeStatusPollTableViewCell.composeStatusPollOptionAppendEntryCollectionViewCellDelegate = composeStatusPollOptionAppendEntryCollectionViewCellDelegate + composeStatusPollTableViewCell.composeStatusPollExpiresOptionCollectionViewCellDelegate = composeStatusPollExpiresOptionCollectionViewCellDelegate - diffableDataSource.reorderingHandlers.canReorderItem = { item in - switch item { - case .pollOption: return true - default: return false + // setup data source + tableView.dataSource = self + + attachmentServices + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] attachmentServices in + guard let self = self else { return } + guard self.isViewAppeared else { return } + + let cell = self.composeStatusAttachmentTableViewCell + guard let dataSource = cell.dataSource else { return } + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + let items = attachmentServices.map { ComposeStatusAttachmentItem.attachment(attachmentService: $0) } + snapshot.appendItems(items, toSection: .main) + + tableView.performBatchUpdates { + dataSource.apply(snapshot, animatingDifferences: true) + } completion: { _ in + // do nothing + } } - } - - // update reordered data source - diffableDataSource.reorderingHandlers.didReorder = { [weak self] transaction in + .store(in: &disposeBag) + + Publishers.CombineLatest( + isPollComposing, + pollOptionAttributes + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] isPollComposing, pollOptionAttributes in guard let self = self else { return } - - let items = transaction.finalSnapshot.itemIdentifiers - var pollOptionAttributes: [ComposeStatusItem.ComposePollOptionAttribute] = [] - for item in items { - guard case let .pollOption(attribute) = item else { continue } - pollOptionAttributes.append(attribute) + guard self.isViewAppeared else { return } + + let cell = self.composeStatusPollTableViewCell + guard let dataSource = cell.dataSource else { return } + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + var items: [ComposeStatusPollItem] = [] + if isPollComposing { + for attribute in pollOptionAttributes { + items.append(.pollOption(attribute: attribute)) + } + if pollOptionAttributes.count < 4 { + items.append(.pollOptionAppendEntry) + } + items.append(.pollExpiresOption(attribute: self.pollExpiresOptionAttribute)) + } + snapshot.appendItems(items, toSection: .main) + + tableView.performBatchUpdates { + dataSource.apply(snapshot, animatingDifferences: true) + } completion: { _ in + // do nothing } - self.pollOptionAttributes.value = pollOptionAttributes } - - self.diffableDataSource = diffableDataSource - 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) - } - diffableDataSource.apply(snapshot, animatingDifferences: false) - - // some magic fix modal presentation animation issue - collectionView.dataSource = diffableDataSource + .store(in: &disposeBag) } func setupCustomEmojiPickerDiffableDataSource( @@ -246,3 +145,140 @@ extension ComposeViewModel { } } + +// MARK: - UITableViewDataSource +extension ComposeViewModel: UITableViewDataSource { + + enum Section: CaseIterable { + case repliedTo + case status + case attachment + case poll + } + + func numberOfSections(in tableView: UITableView) -> Int { + return Section.allCases.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch Section.allCases[section] { + case .repliedTo: + switch composeKind { + case .reply: return 1 + default: return 0 + } + case .status: return 1 + case .attachment: + return 1 + case .poll: + return 1 + } + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + switch Section.allCases[indexPath.section] { + case .repliedTo: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToStatusContentTableViewCell.self), for: indexPath) as! ComposeRepliedToStatusContentTableViewCell + guard case let .reply(statusObjectID) = composeKind else { return cell } + let managedObjectContext = context.managedObjectContext + 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 .status: + let cell = self.composeStatusContentTableViewCell + // configure header + let managedObjectContext = context.managedObjectContext + managedObjectContext.performAndWait { + guard case let .reply(replyToStatusObjectID) = self.composeKind, + 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: composeStatusAttribute) + // bind content warning + composeStatusAttribute.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, weak self] text in + guard let tableView = tableView else { return } + guard let self = self else { return } + // self size input cell + UIView.performWithoutAnimation { + tableView.beginUpdates() + tableView.endUpdates() + } + // bind input data + self.composeStatusAttribute.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) + return cell + case .attachment: + let cell = self.composeStatusAttachmentTableViewCell + return cell + case .poll: + let cell = self.composeStatusPollTableViewCell + return cell + } + } +} + +// MARK: - ComposeStatusPollTableViewCellDelegate +extension ComposeViewModel: ComposeStatusPollTableViewCellDelegate { + func composeStatusPollTableViewCell(_ cell: ComposeStatusPollTableViewCell, pollOptionAttributesDidReorder options: [ComposeStatusPollItem.PollOptionAttribute]) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + self.pollOptionAttributes.value = options + } +} diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 1deb698f4..af8c8e2ac 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -13,7 +13,7 @@ import CoreDataStack import GameplayKit import MastodonSDK -final class ComposeViewModel { +final class ComposeViewModel: NSObject { static let composeContentLimit: Int = 500 @@ -33,11 +33,14 @@ final class ComposeViewModel { let repliedToCellFrame = CurrentValueSubject(.zero) let autoCompleteRetryLayoutTimes = CurrentValueSubject(0) let autoCompleteInfo = CurrentValueSubject(nil) + var isViewAppeared = false // output let composeStatusContentTableViewCell = ComposeStatusContentTableViewCell() + let composeStatusAttachmentTableViewCell = ComposeStatusAttachmentTableViewCell() + let composeStatusPollTableViewCell = ComposeStatusPollTableViewCell() + var dataSource: UITableViewDiffableDataSource! - var diffableDataSource: UICollectionViewDiffableDataSource! var customEmojiPickerDiffableDataSource: UICollectionViewDiffableDataSource! private(set) lazy var publishStateMachine: GKStateMachine = { // exclude timeline middle fetcher state @@ -77,8 +80,8 @@ final class ComposeViewModel { let attachmentServices = CurrentValueSubject<[MastodonAttachmentService], Never>([]) // polls - let pollOptionAttributes = CurrentValueSubject<[ComposeStatusItem.ComposePollOptionAttribute], Never>([]) - let pollExpiresOptionAttribute = ComposeStatusItem.ComposePollExpiresOptionAttribute() + let pollOptionAttributes = CurrentValueSubject<[ComposeStatusPollItem.PollOptionAttribute], Never>([]) + let pollExpiresOptionAttribute = ComposeStatusPollItem.PollExpiresOptionAttribute() init( context: AppContext, @@ -93,7 +96,9 @@ final class ComposeViewModel { self.selectedStatusVisibility = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value?.user.locked == true ? .private : .public) self.activeAuthentication = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value) self.activeAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value) + super.init() // end init + switch composeKind { case .reply(let repliedToStatusObjectID): context.managedObjectContext.performAndWait { @@ -145,7 +150,7 @@ final class ComposeViewModel { case .post: self.preInsertedContent = nil } - + isCustomEmojiComposing .assign(to: \.value, on: customEmojiPickerInputViewModel.isCustomEmojiComposing) .store(in: &disposeBag) @@ -284,45 +289,13 @@ final class ComposeViewModel { self.customEmojiViewModel.value = self.context.emojiService.dequeueCustomEmojiViewModel(for: domain) } .store(in: &disposeBag) - - // bind snapshot - Publishers.CombineLatest3( - attachmentServices.eraseToAnyPublisher(), - isPollComposing.eraseToAnyPublisher(), - pollOptionAttributes.eraseToAnyPublisher() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] attachmentServices, isPollComposing, pollAttributes in - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: trigger attachments upload…", ((#file as NSString).lastPathComponent), #line, #function) - guard let self = self else { return } - guard let diffableDataSource = self.diffableDataSource else { return } - var snapshot = diffableDataSource.snapshot() - - snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .attachment)) - var attachmentItems: [ComposeStatusItem] = [] - for attachmentService in attachmentServices { - let item = ComposeStatusItem.attachment(attachmentService: attachmentService) - attachmentItems.append(item) - } - snapshot.appendItems(attachmentItems, toSection: .attachment) - - snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .poll)) - if isPollComposing { - var pollItems: [ComposeStatusItem] = [] - for pollAttribute in pollAttributes { - let item = ComposeStatusItem.pollOption(attribute: pollAttribute) - pollItems.append(item) - } - snapshot.appendItems(pollItems, toSection: .poll) - if pollAttributes.count < 4 { - snapshot.appendItems([ComposeStatusItem.pollOptionAppendEntry], toSection: .poll) - } - snapshot.appendItems([ComposeStatusItem.pollExpiresOption(attribute: self.pollExpiresOptionAttribute)], toSection: .poll) - } - - diffableDataSource.apply(snapshot) - + // setup attribute updater + Publishers.CombineLatest( + attachmentServices, + context.timestampUpdatePublisher + ) + .sink { attachmentServices, _ in // drive service upload state // make image upload in the queue for attachmentService in attachmentServices { @@ -395,7 +368,7 @@ extension ComposeViewModel { func createNewPollOptionIfPossible() { guard pollOptionAttributes.value.count < 4 else { return } - let attribute = ComposeStatusItem.ComposePollOptionAttribute() + let attribute = ComposeStatusPollItem.PollOptionAttribute() pollOptionAttributes.value = pollOptionAttributes.value + [attribute] } @@ -467,7 +440,7 @@ extension ComposeViewModel: MastodonAttachmentServiceDelegate { // MARK: - ComposePollAttributeDelegate extension ComposeViewModel: ComposePollAttributeDelegate { - func composePollAttribute(_ attribute: ComposeStatusItem.ComposePollOptionAttribute, pollOptionDidChange: String?) { + func composePollAttribute(_ attribute: ComposeStatusPollItem.PollOptionAttribute, pollOptionDidChange: String?) { // trigger update pollOptionAttributes.value = pollOptionAttributes.value } diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToStatusContentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToStatusContentTableViewCell.swift index 586895dd8..3e8d732f6 100644 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToStatusContentTableViewCell.swift +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToStatusContentTableViewCell.swift @@ -43,6 +43,7 @@ final class ComposeRepliedToStatusContentTableViewCell: UITableViewCell { extension ComposeRepliedToStatusContentTableViewCell { private func _init() { + selectionStyle = .none backgroundColor = .clear statusView.actionToolbarContainer.isHidden = true diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift new file mode 100644 index 000000000..713fcc356 --- /dev/null +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift @@ -0,0 +1,155 @@ +// +// ComposeStatusAttachmentTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-6-29. +// + +import UIKit +import Combine +import AlamofireImage + +final class ComposeStatusAttachmentTableViewCell: UITableViewCell { + + private(set) var dataSource: UICollectionViewDiffableDataSource! + weak var composeStatusAttachmentCollectionViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate? + var observations = Set() + + private static func createLayout() -> UICollectionViewLayout { + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)) + let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item]) + let section = NSCollectionLayoutSection(group: group) + section.contentInsetsReference = .readableContent + return UICollectionViewCompositionalLayout(section: section) + } + + private(set) var collectionViewHeightLayoutConstraint: NSLayoutConstraint! + let collectionView: ComposeCollectionView = { + let collectionViewLayout = ComposeStatusAttachmentTableViewCell.createLayout() + let collectionView = ComposeCollectionView(frame: .zero, collectionViewLayout: collectionViewLayout) + collectionView.register(ComposeStatusAttachmentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self)) + collectionView.backgroundColor = Asset.Scene.Compose.background.color + collectionView.alwaysBounceVertical = true + collectionView.isScrollEnabled = false + return collectionView + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ComposeStatusAttachmentTableViewCell { + + private func _init() { + collectionView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(collectionView) + collectionViewHeightLayoutConstraint = collectionView.heightAnchor.constraint(equalToConstant: 200).priority(.defaultHigh) + NSLayoutConstraint.activate([ + collectionView.topAnchor.constraint(equalTo: contentView.topAnchor), + collectionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + collectionViewHeightLayoutConstraint, + ]) + + collectionView.observe(\.contentSize, options: [.initial, .new]) { [weak self] collectionView, _ in + guard let self = self else { return } + self.collectionViewHeightLayoutConstraint.constant = collectionView.contentSize.height + } + .store(in: &observations) + + self.dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { [ + weak self + ] collectionView, indexPath, item -> UICollectionViewCell? in + guard let self = self else { return UICollectionViewCell() } + switch item { + case .attachment(let attachmentService): + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self), for: indexPath) as! ComposeStatusAttachmentCollectionViewCell + cell.attachmentContainerView.descriptionTextView.text = attachmentService.description.value + cell.delegate = self.composeStatusAttachmentCollectionViewCellDelegate + attachmentService.thumbnailImage + .receive(on: DispatchQueue.main) + .sink { [weak cell] thumbnailImage in + guard let cell = cell else { return } + let size = cell.attachmentContainerView.previewImageView.frame.size != .zero ? cell.attachmentContainerView.previewImageView.frame.size : CGSize(width: 1, height: 1) + guard let image = thumbnailImage else { + let placeholder = UIImage.placeholder( + size: size, + color: Asset.Colors.Background.systemGroupedBackground.color + ) + .af.imageRounded( + withCornerRadius: AttachmentContainerView.containerViewCornerRadius + ) + cell.attachmentContainerView.previewImageView.image = placeholder + return + } + cell.attachmentContainerView.previewImageView.image = image + .af.imageAspectScaled(toFill: size) + .af.imageRounded(withCornerRadius: AttachmentContainerView.containerViewCornerRadius) + } + .store(in: &cell.disposeBag) + Publishers.CombineLatest( + attachmentService.uploadStateMachineSubject.eraseToAnyPublisher(), + attachmentService.error.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak cell, weak attachmentService] uploadState, error in + guard let cell = cell else { return } + guard let attachmentService = attachmentService else { return } + cell.attachmentContainerView.emptyStateView.isHidden = error == nil + cell.attachmentContainerView.descriptionBackgroundView.isHidden = error != nil + if let error = error { + cell.attachmentContainerView.activityIndicatorView.stopAnimating() + cell.attachmentContainerView.emptyStateView.label.text = error.localizedDescription + } else { + guard let uploadState = uploadState else { return } + switch uploadState { + case is MastodonAttachmentService.UploadState.Finish, + is MastodonAttachmentService.UploadState.Fail: + cell.attachmentContainerView.activityIndicatorView.stopAnimating() + cell.attachmentContainerView.emptyStateView.label.text = { + if let file = attachmentService.file.value { + switch file { + case .jpeg, .png, .gif: + return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo) + case .other: + return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.video) + } + } else { + return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo) + } + }() + default: + break + } + } + } + .store(in: &cell.disposeBag) + NotificationCenter.default.publisher( + for: UITextView.textDidChangeNotification, + object: cell.attachmentContainerView.descriptionTextView + ) + .receive(on: DispatchQueue.main) + .sink { notification in + guard let textField = notification.object as? UITextView else { return } + let text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) + attachmentService.description.value = text + } + .store(in: &cell.disposeBag) + return cell + } + } + } + +} + diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift index cd3ede003..c9f6b758b 100644 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift @@ -24,10 +24,10 @@ final class ComposeStatusContentTableViewCell: UITableViewCell { 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.textContainer.lineFragmentPadding = 10 // leading inset metaText.textView.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)) metaText.textView.attributedPlaceholder = { var attributes = metaText.textAttributes @@ -65,7 +65,7 @@ final class ComposeStatusContentTableViewCell: UITableViewCell { extension ComposeStatusContentTableViewCell { private func _init() { - // selectionStyle = .none + selectionStyle = .none layer.zPosition = 999 preservesSuperviewLayoutMargins = true diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusPollTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusPollTableViewCell.swift new file mode 100644 index 000000000..b39a86daf --- /dev/null +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusPollTableViewCell.swift @@ -0,0 +1,184 @@ +// +// ComposeStatusPollTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-6-29. +// + +import UIKit + +protocol ComposeStatusPollTableViewCellDelegate: AnyObject { + func composeStatusPollTableViewCell(_ cell: ComposeStatusPollTableViewCell, pollOptionAttributesDidReorder options: [ComposeStatusPollItem.PollOptionAttribute]) +} + +final class ComposeStatusPollTableViewCell: UITableViewCell { + + private(set) var dataSource: UICollectionViewDiffableDataSource! + var observations = Set() + + weak var customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel? + weak var delegate: ComposeStatusPollTableViewCellDelegate? + weak var composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate? + weak var composeStatusPollOptionAppendEntryCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate? + weak var composeStatusPollExpiresOptionCollectionViewCellDelegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate? + + + private static func createLayout() -> UICollectionViewLayout { + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)) + let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item]) + let section = NSCollectionLayoutSection(group: group) + section.contentInsetsReference = .readableContent + return UICollectionViewCompositionalLayout(section: section) + } + + private(set) var collectionViewHeightLayoutConstraint: NSLayoutConstraint! + let collectionView: ComposeCollectionView = { + let collectionViewLayout = ComposeStatusPollTableViewCell.createLayout() + let collectionView = ComposeCollectionView(frame: .zero, collectionViewLayout: collectionViewLayout) + 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.isScrollEnabled = false + return collectionView + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ComposeStatusPollTableViewCell { + + private func _init() { + collectionView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(collectionView) + collectionViewHeightLayoutConstraint = collectionView.heightAnchor.constraint(equalToConstant: 300).priority(.defaultHigh) + NSLayoutConstraint.activate([ + collectionView.topAnchor.constraint(equalTo: contentView.topAnchor), + collectionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + collectionViewHeightLayoutConstraint, + ]) + + let longPressReorderGesture = UILongPressGestureRecognizer(target: self, action: #selector(ComposeStatusPollTableViewCell.longPressReorderGestureHandler(_:))) + collectionView.addGestureRecognizer(longPressReorderGesture) + + collectionView.observe(\.contentSize, options: [.initial, .new]) { [weak self] collectionView, _ in + guard let self = self else { return } + print(collectionView.contentSize) + self.collectionViewHeightLayoutConstraint.constant = collectionView.contentSize.height + } + .store(in: &observations) + + self.dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { [ + weak self + ] collectionView, indexPath, item -> UICollectionViewCell? in + guard let self = self else { return UICollectionViewCell() } + + switch item { + case .pollOption(let attribute): + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self), for: indexPath) as! ComposeStatusPollOptionCollectionViewCell + cell.pollOptionView.optionTextField.text = attribute.option.value + cell.pollOptionView.optionTextField.placeholder = L10n.Scene.Compose.Poll.optionNumber(indexPath.item + 1) + cell.pollOption + .receive(on: DispatchQueue.main) + .assign(to: \.value, on: attribute.option) + .store(in: &cell.disposeBag) + cell.delegate = self.composeStatusPollOptionCollectionViewCellDelegate + if let customEmojiPickerInputViewModel = self.customEmojiPickerInputViewModel { + ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.pollOptionView.optionTextField, disposeBag: &cell.disposeBag) + } + return cell + case .pollOptionAppendEntry: + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self), for: indexPath) as! ComposeStatusPollOptionAppendEntryCollectionViewCell + cell.delegate = self.composeStatusPollOptionAppendEntryCollectionViewCellDelegate + return cell + case .pollExpiresOption(let attribute): + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollExpiresOptionCollectionViewCell.self), for: indexPath) as! ComposeStatusPollExpiresOptionCollectionViewCell + cell.durationButton.setTitle(L10n.Scene.Compose.Poll.durationTime(attribute.expiresOption.value.title), for: .normal) + attribute.expiresOption + .receive(on: DispatchQueue.main) + .sink { [weak cell] expiresOption in + guard let cell = cell else { return } + cell.durationButton.setTitle(L10n.Scene.Compose.Poll.durationTime(expiresOption.title), for: .normal) + } + .store(in: &cell.disposeBag) + cell.delegate = self.composeStatusPollExpiresOptionCollectionViewCellDelegate + return cell + } + } + + dataSource.reorderingHandlers.canReorderItem = { item in + switch item { + case .pollOption: return true + default: return false + } + } + + // update reordered data source + dataSource.reorderingHandlers.didReorder = { [weak self] transaction in + guard let self = self else { return } + + let items = transaction.finalSnapshot.itemIdentifiers + var pollOptionAttributes: [ComposeStatusPollItem.PollOptionAttribute] = [] + for item in items { + guard case let .pollOption(attribute) = item else { continue } + pollOptionAttributes.append(attribute) + } + self.delegate?.composeStatusPollTableViewCell(self, pollOptionAttributesDidReorder: pollOptionAttributes) + } + } + +} + +extension ComposeStatusPollTableViewCell { + + @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 dataSource = self.dataSource else { + break + } + guard let item = dataSource.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() + } + } + +} diff --git a/Mastodon/Scene/Share/View/Container/AudioContainerView.swift b/Mastodon/Scene/Share/View/Container/AudioContainerView.swift index c4cf4f490..8516db569 100644 --- a/Mastodon/Scene/Share/View/Container/AudioContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/AudioContainerView.swift @@ -98,15 +98,15 @@ extension AudioContainerView { NSLayoutConstraint.activate([ playButton.centerXAnchor.constraint(equalTo: playButtonBackgroundView.centerXAnchor), playButton.centerYAnchor.constraint(equalTo: playButtonBackgroundView.centerYAnchor), - playButtonBackgroundView.heightAnchor.constraint(equalToConstant: 32), - playButtonBackgroundView.widthAnchor.constraint(equalToConstant: 32), + playButtonBackgroundView.heightAnchor.constraint(equalToConstant: 32).priority(.required - 1), + playButtonBackgroundView.widthAnchor.constraint(equalToConstant: 32).priority(.required - 1), ]) container.addArrangedSubview(slider) container.addArrangedSubview(timeLabel) NSLayoutConstraint.activate([ - timeLabel.widthAnchor.constraint(equalToConstant: 40), + timeLabel.widthAnchor.constraint(equalToConstant: 40).priority(.required - 1), ]) } } From 65887f963a74e17a2856af1abf3fd8ca225af40a Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 29 Jun 2021 17:08:41 +0800 Subject: [PATCH 04/31] feat: using replica status view in compose scene --- Mastodon.xcodeproj/project.pbxproj | 8 +- .../Compose/ComposeViewModel+Diffable.swift | 18 +- ...eRepliedToStatusContentTableViewCell.swift | 8 +- ...ComposeStatusAttachmentTableViewCell.swift | 4 +- .../ComposeStatusContentTableViewCell.swift | 10 +- .../ComposeStatusPollTableViewCell.swift | 4 +- .../Compose/View/ComposeCollectionView.swift | 28 -- .../Compose/View/ReplicaStatusView.swift | 263 ++++++++++++++++++ .../Scene/Share/View/Content/StatusView.swift | 1 - 9 files changed, 286 insertions(+), 58 deletions(-) delete mode 100644 Mastodon/Scene/Compose/View/ComposeCollectionView.swift create mode 100644 Mastodon/Scene/Compose/View/ReplicaStatusView.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 6f195f704..9911352ce 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -193,7 +193,6 @@ 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 */; }; DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */; }; @@ -232,6 +231,7 @@ DB3667A4268AE2370027D07F /* ComposeStatusPollTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3667A3268AE2370027D07F /* ComposeStatusPollTableViewCell.swift */; }; DB3667A6268AE2620027D07F /* ComposeStatusPollSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3667A5268AE2620027D07F /* ComposeStatusPollSection.swift */; }; DB3667A8268AE2900027D07F /* ComposeStatusPollItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3667A7268AE2900027D07F /* ComposeStatusPollItem.swift */; }; + DB3667CA268B14A80027D07F /* ReplicaStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3667C9268B14A80027D07F /* ReplicaStatusView.swift */; }; DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB3D0FF225BAA61700EAA174 /* AlamofireImage */; }; DB3D100D25BAA75E00EAA174 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB3D100F25BAA75E00EAA174 /* Localizable.strings */; }; DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DD525BAA00100D1B89D /* AppDelegate.swift */; }; @@ -811,7 +811,6 @@ 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 = ""; }; DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Instance.swift"; sourceTree = ""; }; @@ -853,6 +852,7 @@ DB3667A3268AE2370027D07F /* ComposeStatusPollTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollTableViewCell.swift; sourceTree = ""; }; DB3667A5268AE2620027D07F /* ComposeStatusPollSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollSection.swift; sourceTree = ""; }; DB3667A7268AE2900027D07F /* ComposeStatusPollItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollItem.swift; sourceTree = ""; }; + DB3667C9268B14A80027D07F /* ReplicaStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplicaStatusView.swift; sourceTree = ""; }; DB3D0FED25BAA42200EAA174 /* MastodonSDK */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MastodonSDK; sourceTree = ""; }; DB3D100E25BAA75E00EAA174 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; DB427DD225BAA00100D1B89D /* Mastodon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mastodon.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1975,8 +1975,8 @@ DB55D32225FB4D320002F825 /* View */ = { isa = PBXGroup; children = ( + DB3667C9268B14A80027D07F /* ReplicaStatusView.swift */, DB03F7F42689B782007B274C /* ComposeTableView.swift */, - DB040ECC26526EA600BEE9D8 /* ComposeCollectionView.swift */, DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */, DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */, DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */, @@ -3484,8 +3484,8 @@ DB1D842E26552C4D000346B3 /* StatusTableViewControllerNavigateable.swift in Sources */, DB938F1F2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift in Sources */, 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */, - DB040ECD26526EA600BEE9D8 /* ComposeCollectionView.swift in Sources */, DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */, + DB3667CA268B14A80027D07F /* ReplicaStatusView.swift in Sources */, DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */, 0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */, DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */, diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index 1999152d3..7f60a5b3f 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -190,11 +190,8 @@ extension ComposeViewModel: UITableViewDataSource { // 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 + cell.statusView.nameLabel.configure(content: status.author.displayNameWithFallback, emojiDict: status.author.emojiDict) + cell.statusView.usernameLabel.text = "@" + status.author.acct // set text let content = MastodonContent(content: status.content, emojis: status.emojiMeta) do { @@ -228,6 +225,8 @@ extension ComposeViewModel: UITableViewDataSource { } // configure author ComposeStatusSection.configureStatusContent(cell: cell, attribute: composeStatusAttribute) + // configure content warning + cell.statusContentWarningEditorView.textView.text = composeStatusAttribute.contentWarningContent.value // bind content warning composeStatusAttribute.isContentWarningComposing .receive(on: DispatchQueue.main) @@ -235,7 +234,6 @@ extension ComposeViewModel: UITableViewDataSource { 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]) { @@ -245,19 +243,21 @@ extension ComposeViewModel: UITableViewDataSource { } } .store(in: &cell.disposeBag) + cell.contentWarningContent .removeDuplicates() .receive(on: DispatchQueue.main) .sink { [weak tableView, weak self] text in - guard let tableView = tableView else { return } guard let self = self else { return } + // bind input data + self.composeStatusAttribute.contentWarningContent.value = text + // self size input cell + guard let tableView = tableView else { return } UIView.performWithoutAnimation { tableView.beginUpdates() tableView.endUpdates() } - // bind input data - self.composeStatusAttribute.contentWarningContent.value = text } .store(in: &cell.disposeBag) // configure custom emoji picker diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToStatusContentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToStatusContentTableViewCell.swift index 3e8d732f6..4ba68cedd 100644 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToStatusContentTableViewCell.swift +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToStatusContentTableViewCell.swift @@ -12,14 +12,13 @@ final class ComposeRepliedToStatusContentTableViewCell: UITableViewCell { var disposeBag = Set() - let statusView = StatusView() + let statusView = ReplicaStatusView() let framePublisher = PassthroughSubject() override func prepareForReuse() { super.prepareForReuse() - statusView.updateContentWarningDisplay(isHidden: true, animated: false) disposeBag.removeAll() } @@ -46,9 +45,6 @@ extension ComposeRepliedToStatusContentTableViewCell { selectionStyle = .none backgroundColor = .clear - statusView.actionToolbarContainer.isHidden = true - statusView.revealContentWarningButton.isHidden = true - statusView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(statusView) NSLayoutConstraint.activate([ @@ -57,6 +53,8 @@ extension ComposeRepliedToStatusContentTableViewCell { contentView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: statusView.trailingAnchor), contentView.bottomAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10).identifier("ComposeRepliedToStatusContentCollectionViewCell.contentView.bottom to statusView.bottom"), ]) + + statusView.headerContainerView.isHidden = true } } diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift index 713fcc356..8b2ce455d 100644 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift @@ -26,9 +26,9 @@ final class ComposeStatusAttachmentTableViewCell: UITableViewCell { } private(set) var collectionViewHeightLayoutConstraint: NSLayoutConstraint! - let collectionView: ComposeCollectionView = { + let collectionView: UICollectionView = { let collectionViewLayout = ComposeStatusAttachmentTableViewCell.createLayout() - let collectionView = ComposeCollectionView(frame: .zero, collectionViewLayout: collectionViewLayout) + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout) collectionView.register(ComposeStatusAttachmentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self)) collectionView.backgroundColor = Asset.Scene.Compose.background.color collectionView.alwaysBounceVertical = true diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift index c9f6b758b..9946b1c9c 100644 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift @@ -15,7 +15,7 @@ final class ComposeStatusContentTableViewCell: UITableViewCell { var disposeBag = Set() - let statusView = StatusView() + let statusView = ReplicaStatusView() let statusContentWarningEditorView = StatusContentWarningEditorView() @@ -108,12 +108,10 @@ extension ComposeStatusContentTableViewCell { ]) statusContentWarningEditorView.textView.delegate = self + statusView.nameTrialingDotLabel.isHidden = true + statusView.dateLabel.isHidden = true statusContentWarningEditorView.isHidden = true statusView.statusContainerStackView.isHidden = true - statusView.actionToolbarContainer.isHidden = true - statusView.revealContentWarningButton.isHidden = true - - statusView.contentMetaText.textView.delegate = self } } @@ -123,8 +121,6 @@ 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 } diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusPollTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusPollTableViewCell.swift index b39a86daf..c12c346f3 100644 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusPollTableViewCell.swift +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusPollTableViewCell.swift @@ -34,9 +34,9 @@ final class ComposeStatusPollTableViewCell: UITableViewCell { } private(set) var collectionViewHeightLayoutConstraint: NSLayoutConstraint! - let collectionView: ComposeCollectionView = { + let collectionView: UICollectionView = { let collectionViewLayout = ComposeStatusPollTableViewCell.createLayout() - let collectionView = ComposeCollectionView(frame: .zero, collectionViewLayout: collectionViewLayout) + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout) 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)) diff --git a/Mastodon/Scene/Compose/View/ComposeCollectionView.swift b/Mastodon/Scene/Compose/View/ComposeCollectionView.swift deleted file mode 100644 index 2dc03bb84..000000000 --- a/Mastodon/Scene/Compose/View/ComposeCollectionView.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// ComposeCollectionView.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-5-17. -// - -import UIKit - -final class ComposeCollectionView: UICollectionView { - - 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/ReplicaStatusView.swift b/Mastodon/Scene/Compose/View/ReplicaStatusView.swift new file mode 100644 index 000000000..7b1378625 --- /dev/null +++ b/Mastodon/Scene/Compose/View/ReplicaStatusView.swift @@ -0,0 +1,263 @@ +// +// ReplicaStatusView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-6-29. +// + +import os.log +import UIKit +import ActiveLabel +import FLAnimatedImage +import MetaTextView + +final class ReplicaStatusView: UIView { + + static let avatarImageSize = CGSize(width: 42, height: 42) + static let avatarImageCornerRadius: CGFloat = 4 + static let avatarToLabelSpacing: CGFloat = 5 + static let contentWarningBlurRadius: CGFloat = 12 + static let containerStackViewSpacing: CGFloat = 10 + + let containerStackView = UIStackView() + let headerContainerView = UIView() + let authorContainerView = UIView() + + static let reblogIconImage: UIImage = { + let font = UIFont.systemFont(ofSize: 13, weight: .medium) + let configuration = UIImage.SymbolConfiguration(font: font) + let image = UIImage(systemName: "arrow.2.squarepath", withConfiguration: configuration)!.withTintColor(Asset.Colors.Label.secondary.color) + return image + }() + + static let replyIconImage: UIImage = { + let font = UIFont.systemFont(ofSize: 13, weight: .medium) + let configuration = UIImage.SymbolConfiguration(font: font) + let image = UIImage(systemName: "arrowshape.turn.up.left.fill", withConfiguration: configuration)!.withTintColor(Asset.Colors.Label.secondary.color) + return image + }() + + static func iconAttributedString(image: UIImage) -> NSAttributedString { + let attributedString = NSMutableAttributedString() + let imageTextAttachment = NSTextAttachment() + let imageAttribute = NSAttributedString(attachment: imageTextAttachment) + imageTextAttachment.image = image + attributedString.append(imageAttribute) + return attributedString + } + + let headerIconLabel: UILabel = { + let label = UILabel() + label.attributedText = StatusView.iconAttributedString(image: StatusView.reblogIconImage) + return label + }() + + let headerInfoLabel: ActiveLabel = { + let label = ActiveLabel(style: .statusHeader) + label.text = "Bob reblogged" + label.layer.masksToBounds = false + return label + }() + + let avatarView: UIView = { + let view = UIView() + view.isAccessibilityElement = true + view.accessibilityTraits = .button + view.accessibilityLabel = L10n.Common.Controls.Status.showUserProfile + return view + }() + let avatarImageView: UIImageView = FLAnimatedImageView() + let avatarStackedContainerButton: AvatarStackContainerButton = AvatarStackContainerButton() + + let nameLabel: ActiveLabel = { + let label = ActiveLabel(style: .statusName) + return label + }() + + let nameTrialingDotLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.secondary.color + label.font = .systemFont(ofSize: 17) + label.text = "·" + label.isAccessibilityElement = false + return label + }() + + let usernameLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 15, weight: .regular) + label.textColor = Asset.Colors.Label.secondary.color + label.text = "@alice" + label.isAccessibilityElement = false + return label + }() + + let dateLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 13, weight: .regular) + label.textColor = Asset.Colors.Label.secondary.color + label.text = "1d" + return label + }() + + 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 + }() + + let statusContainerStackView = UIStackView() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ReplicaStatusView { + private func _init() { + // container: [reblog | author | status | action toolbar] + // note: do not set spacing for nested stackView to avoid SDK layout conflict issue + containerStackView.axis = .vertical + // containerStackView.spacing = 10 + containerStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: topAnchor), + containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + trailingAnchor.constraint(equalTo: containerStackView.trailingAnchor), + bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor), + ]) + containerStackView.setContentHuggingPriority(.required - 1, for: .vertical) + containerStackView.setContentCompressionResistancePriority(.required - 1, for: .vertical) + + // header container: [icon | info] + let headerContainerStackView = UIStackView() + headerContainerStackView.axis = .horizontal + headerContainerStackView.spacing = 4 + headerContainerStackView.addArrangedSubview(headerIconLabel) + headerContainerStackView.addArrangedSubview(headerInfoLabel) + headerIconLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + + headerContainerStackView.translatesAutoresizingMaskIntoConstraints = false + headerContainerView.addSubview(headerContainerStackView) + NSLayoutConstraint.activate([ + headerContainerStackView.topAnchor.constraint(equalTo: headerContainerView.topAnchor), + headerContainerStackView.leadingAnchor.constraint(equalTo: headerContainerView.leadingAnchor), + headerContainerStackView.trailingAnchor.constraint(equalTo: headerContainerView.trailingAnchor), + 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() + authorContainerStackView.axis = .horizontal + authorContainerStackView.spacing = StatusView.avatarToLabelSpacing + authorContainerStackView.distribution = .fill + + // avatar + avatarView.translatesAutoresizingMaskIntoConstraints = false + authorContainerStackView.addArrangedSubview(avatarView) + NSLayoutConstraint.activate([ + avatarView.widthAnchor.constraint(equalToConstant: StatusView.avatarImageSize.width).priority(.required - 1), + avatarView.heightAnchor.constraint(equalToConstant: StatusView.avatarImageSize.height).priority(.required - 1), + ]) + avatarImageView.translatesAutoresizingMaskIntoConstraints = false + avatarView.addSubview(avatarImageView) + NSLayoutConstraint.activate([ + avatarImageView.topAnchor.constraint(equalTo: avatarView.topAnchor), + avatarImageView.leadingAnchor.constraint(equalTo: avatarView.leadingAnchor), + avatarImageView.trailingAnchor.constraint(equalTo: avatarView.trailingAnchor), + avatarImageView.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor), + ]) + avatarStackedContainerButton.translatesAutoresizingMaskIntoConstraints = false + avatarView.addSubview(avatarStackedContainerButton) + NSLayoutConstraint.activate([ + avatarStackedContainerButton.topAnchor.constraint(equalTo: avatarView.topAnchor), + avatarStackedContainerButton.leadingAnchor.constraint(equalTo: avatarView.leadingAnchor), + avatarStackedContainerButton.trailingAnchor.constraint(equalTo: avatarView.trailingAnchor), + avatarStackedContainerButton.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor), + ]) + + // author meta container: [title container | subtitle container] + let authorMetaContainerStackView = UIStackView() + authorContainerStackView.addArrangedSubview(authorMetaContainerStackView) + authorMetaContainerStackView.axis = .vertical + authorMetaContainerStackView.spacing = 4 + + // title container: [display name | "·" | date | padding] + let titleContainerStackView = UIStackView() + authorMetaContainerStackView.addArrangedSubview(titleContainerStackView) + titleContainerStackView.axis = .horizontal + titleContainerStackView.spacing = 4 + nameLabel.translatesAutoresizingMaskIntoConstraints = false + titleContainerStackView.addArrangedSubview(nameLabel) + NSLayoutConstraint.activate([ + nameLabel.heightAnchor.constraint(equalToConstant: 22).priority(.defaultHigh), + ]) + titleContainerStackView.alignment = .firstBaseline + titleContainerStackView.addArrangedSubview(nameTrialingDotLabel) + titleContainerStackView.addArrangedSubview(dateLabel) + titleContainerStackView.addArrangedSubview(UIView()) // padding + nameLabel.setContentHuggingPriority(.defaultHigh + 1, for: .horizontal) + nameTrialingDotLabel.setContentHuggingPriority(.defaultHigh + 2, for: .horizontal) + nameTrialingDotLabel.setContentCompressionResistancePriority(.required - 2, for: .horizontal) + dateLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + dateLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) + + // subtitle container: [username] + let subtitleContainerStackView = UIStackView() + authorMetaContainerStackView.addArrangedSubview(subtitleContainerStackView) + subtitleContainerStackView.axis = .horizontal + subtitleContainerStackView.addArrangedSubview(usernameLabel) + + authorContainerStackView.translatesAutoresizingMaskIntoConstraints = false + authorContainerView.addSubview(authorContainerStackView) + NSLayoutConstraint.activate([ + authorContainerStackView.topAnchor.constraint(equalTo: authorContainerView.topAnchor), + authorContainerStackView.leadingAnchor.constraint(equalTo: authorContainerView.leadingAnchor), + authorContainerStackView.trailingAnchor.constraint(equalTo: authorContainerView.trailingAnchor), + authorContainerView.bottomAnchor.constraint(equalTo: authorContainerStackView.bottomAnchor, constant: StatusView.containerStackViewSpacing).priority(.defaultHigh), + ]) + containerStackView.addArrangedSubview(authorContainerView) + + // status container: [status] + containerStackView.addArrangedSubview(statusContainerStackView) + statusContainerStackView.axis = .vertical + statusContainerStackView.spacing = 10 + + // avoid overlay behind other views + defer { + containerStackView.bringSubviewToFront(authorContainerView) + } + + // status + statusContainerStackView.addArrangedSubview(contentMetaText.textView) + contentMetaText.textView.setContentCompressionResistancePriority(.required - 1, for: .vertical) + + avatarStackedContainerButton.isHidden = true + } +} + +// MARK: - AvatarConfigurableView +extension ReplicaStatusView: AvatarConfigurableView { + static var configurableAvatarImageSize: CGSize { return Self.avatarImageSize } + static var configurableAvatarImageCornerRadius: CGFloat { return 4 } + var configurableAvatarImageView: UIImageView? { avatarImageView } + var configurableAvatarButton: UIButton? { nil } +} diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index cdabdc1c1..84166c627 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -207,7 +207,6 @@ final class StatusView: UIView { return actionToolbarContainer }() - //let activeTextLabel = ActiveLabel(style: .default) let contentMetaText: MetaText = { let metaText = MetaText() metaText.textView.backgroundColor = .clear From ace2b1cbdfd51bfa573cb81060c5b9deecfe853c Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 29 Jun 2021 17:46:43 +0800 Subject: [PATCH 05/31] fix: reply to label missing emoji issue in compose scene --- .../Scene/Compose/ComposeViewController.swift | 20 ++++++++++++------- .../Compose/ComposeViewModel+Diffable.swift | 13 +++++++++++- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 0e3efede2..c13cedb8c 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -215,6 +215,7 @@ extension ComposeViewController { ) // update layout when keyboard show/dismiss + view.layoutIfNeeded() let keyboardEventPublishers = Publishers.CombineLatest3( KeyboardResponderService.shared.isShow, KeyboardResponderService.shared.state, @@ -428,8 +429,8 @@ extension ComposeViewController { // setup snap behavior Publishers.CombineLatest( - viewModel.repliedToCellFrame.removeDuplicates().eraseToAnyPublisher(), - viewModel.collectionViewState.eraseToAnyPublisher() + viewModel.repliedToCellFrame, + viewModel.collectionViewState ) .receive(on: DispatchQueue.main) .sink { [weak self] repliedToCellFrame, collectionViewState in @@ -438,6 +439,8 @@ extension ComposeViewController { switch collectionViewState { case .fold: self.tableView.contentInset.top = -repliedToCellFrame.height + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: set contentInset.top: -%s", ((#file as NSString).lastPathComponent), #line, #function, repliedToCellFrame.height.description) + case .expand: self.tableView.contentInset.top = 0 } @@ -447,12 +450,15 @@ extension ComposeViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - - // Fix AutoLayout conflict issue - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - self.markTextEditorViewBecomeFirstResponser() + + // using index to make table view layout + // otherwise, the content offset will be wrong + guard let indexPath = tableView.indexPath(for: viewModel.composeStatusContentTableViewCell), + let cell = tableView.cellForRow(at: indexPath) as? ComposeStatusContentTableViewCell else { + assertionFailure() + return } + cell.metaText.textView.becomeFirstResponder() } override func viewDidAppear(_ animated: Bool) { diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index 7f60a5b3f..a7b3ee028 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -221,7 +221,18 @@ extension ComposeViewModel: UITableViewDataSource { } 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) + let headerText: String = { + let author = replyTo.author + let name = author.displayName.isEmpty ? author.username : author.displayName + return L10n.Scene.Compose.replyingToUser(name) + }() + MastodonStatusContent.parseResult(content: headerText, emojiDict: replyTo.author.emojiDict) + .receive(on: DispatchQueue.main) + .sink { [weak cell] parseResult in + guard let cell = cell else { return } + cell.statusView.headerInfoLabel.configure(contentParseResult: parseResult) + } + .store(in: &cell.disposeBag) } // configure author ComposeStatusSection.configureStatusContent(cell: cell, attribute: composeStatusAttribute) From fbb46b55a69762797c40c407022d1456ac861343 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 29 Jun 2021 17:54:38 +0800 Subject: [PATCH 06/31] fix: content inset may wrong during compose scene modal transition issue --- Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift | 8 ++++---- Mastodon/Scene/Compose/View/ReplicaStatusView.swift | 5 ++++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index a7b3ee028..0d00fe239 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -180,6 +180,10 @@ extension ComposeViewModel: UITableViewDataSource { case .repliedTo: let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToStatusContentTableViewCell.self), for: indexPath) as! ComposeRepliedToStatusContentTableViewCell guard case let .reply(statusObjectID) = composeKind else { return cell } + cell.framePublisher + .receive(on: DispatchQueue.main) + .assign(to: \.value, on: self.repliedToCellFrame) + .store(in: &cell.disposeBag) let managedObjectContext = context.managedObjectContext managedObjectContext.performAndWait { guard let replyTo = managedObjectContext.object(with: statusObjectID) as? Status else { @@ -203,10 +207,6 @@ extension ComposeViewModel: UITableViewDataSource { } // set date cell.statusView.dateLabel.text = status.createdAt.slowedTimeAgoSinceNow - - cell.framePublisher - .assign(to: \.value, on: self.repliedToCellFrame) - .store(in: &cell.disposeBag) } return cell case .status: diff --git a/Mastodon/Scene/Compose/View/ReplicaStatusView.swift b/Mastodon/Scene/Compose/View/ReplicaStatusView.swift index 7b1378625..0f53b113a 100644 --- a/Mastodon/Scene/Compose/View/ReplicaStatusView.swift +++ b/Mastodon/Scene/Compose/View/ReplicaStatusView.swift @@ -213,12 +213,15 @@ extension ReplicaStatusView { titleContainerStackView.alignment = .firstBaseline titleContainerStackView.addArrangedSubview(nameTrialingDotLabel) titleContainerStackView.addArrangedSubview(dateLabel) - titleContainerStackView.addArrangedSubview(UIView()) // padding + let padding = UIView() + titleContainerStackView.addArrangedSubview(padding) // padding nameLabel.setContentHuggingPriority(.defaultHigh + 1, for: .horizontal) nameTrialingDotLabel.setContentHuggingPriority(.defaultHigh + 2, for: .horizontal) nameTrialingDotLabel.setContentCompressionResistancePriority(.required - 2, for: .horizontal) dateLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) dateLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) + padding.setContentHuggingPriority(.defaultLow - 1, for: .horizontal) + padding.setContentCompressionResistancePriority(.defaultLow - 1, for: .horizontal) // subtitle container: [username] let subtitleContainerStackView = UIStackView() From b7d5b711cbf0f40770fed3c3ed9e1df6b9319ef8 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 29 Jun 2021 17:58:22 +0800 Subject: [PATCH 07/31] chore: update package version --- Mastodon.xcodeproj/project.pbxproj | 4 +--- Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 9911352ce..674f1d72c 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -848,7 +848,6 @@ DB36679C268AB91B0027D07F /* ComposeStatusAttachmentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentTableViewCell.swift; sourceTree = ""; }; DB36679E268ABAF20027D07F /* ComposeStatusAttachmentSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentSection.swift; sourceTree = ""; }; DB3667A0268ABB2E0027D07F /* ComposeStatusAttachmentItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentItem.swift; sourceTree = ""; }; - DB3667A2268AC3BB0027D07F /* MetaTextView */ = {isa = PBXFileReference; lastKnownFileType = folder; name = MetaTextView; path = ../MetaTextView; sourceTree = ""; }; DB3667A3268AE2370027D07F /* ComposeStatusPollTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollTableViewCell.swift; sourceTree = ""; }; DB3667A5268AE2620027D07F /* ComposeStatusPollSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollSection.swift; sourceTree = ""; }; DB3667A7268AE2900027D07F /* ComposeStatusPollItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollItem.swift; sourceTree = ""; }; @@ -1827,7 +1826,6 @@ children = ( DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */, DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */, - DB3667A2268AC3BB0027D07F /* MetaTextView */, DB3D0FED25BAA42200EAA174 /* MastodonSDK */, DB427DD425BAA00100D1B89D /* Mastodon */, DB427DEB25BAA00100D1B89D /* MastodonTests */, @@ -4780,7 +4778,7 @@ repositoryURL = "https://github.com/TwidereProject/MetaTextView.git"; requirement = { kind = exactVersion; - version = 1.2.0; + version = 1.2.1; }; }; DB0E2D2C26833FF600865C3C /* XCRemoteSwiftPackageReference "Nuke-FLAnimatedImage-Plugin" */ = { diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index f6219c400..d3b943377 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -114,8 +114,8 @@ "repositoryURL": "https://github.com/TwidereProject/MetaTextView.git", "state": { "branch": null, - "revision": "637b73044e665e8b9678ed64dd2a83314c286aef", - "version": "1.2.0" + "revision": "2660fc30ef6ed8de347ddca499341a965d1fda56", + "version": "1.2.1" } }, { From bac8721aec92b68e41124540660c44102b4c470c Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 29 Jun 2021 19:19:14 +0800 Subject: [PATCH 08/31] fix: make recommend trigger refresh when view appear. resolve #164 --- Mastodon/Scene/Search/SearchViewController.swift | 6 ++++++ Mastodon/Scene/Search/SearchViewModel.swift | 14 +++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index 3731f118b..10dd46119 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -151,6 +151,12 @@ extension SearchViewController { view.bringSubviewToFront(statusBar) } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + viewModel.viewDidAppeared.send() + } + func setupSearchBar() { searchBar.delegate = self view.addSubview(searchBar) diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index e10b04c9e..48bd87b88 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -23,6 +23,7 @@ final class SearchViewModel: NSObject { let mastodonUser = CurrentValueSubject(nil) let currentMastodonUser = CurrentValueSubject(nil) + let viewDidAppeared = PassthroughSubject() // output let searchText = CurrentValueSubject("") @@ -145,9 +146,10 @@ final class SearchViewModel: NSObject { dataSource.apply(snapshot, animatingDifferences: false, completion: nil) } .store(in: &disposeBag) - - requestRecommendHashTags() - .receive(on: DispatchQueue.main) + + viewDidAppeared + .compactMap { _ in self.requestRecommendHashTags() } + .receive(on: RunLoop.main) .sink { [weak self] _ in guard let self = self else { return } if !self.recommendHashTags.isEmpty { @@ -160,8 +162,9 @@ final class SearchViewModel: NSObject { } receiveValue: { _ in } .store(in: &disposeBag) - - requestRecommendAccountsV2() + viewDidAppeared + .compactMap { _ in self.requestRecommendAccountsV2() } + .receive(on: RunLoop.main) .sink { [weak self] _ in guard let self = self else { return } if !self.recommendAccounts.isEmpty { @@ -172,6 +175,7 @@ final class SearchViewModel: NSObject { .store(in: &disposeBag) recommendAccountsFallback + .receive(on: RunLoop.main) .sink { [weak self] _ in guard let self = self else { return } self.requestRecommendAccounts() From 71cb19ccaeb1c755d20e93b5bab1bca2af9e5995 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 29 Jun 2021 19:19:55 +0800 Subject: [PATCH 09/31] fix: auto-complete emoji may crash issue --- Mastodon.xcodeproj/project.pbxproj | 2 +- .../mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist | 4 ++-- Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 674f1d72c..da036e65d 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -4778,7 +4778,7 @@ repositoryURL = "https://github.com/TwidereProject/MetaTextView.git"; requirement = { kind = exactVersion; - version = 1.2.1; + version = 1.2.2; }; }; 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 b782cd198..ebbde8329 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 - 23 + 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 d3b943377..fcd3317da 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -114,8 +114,8 @@ "repositoryURL": "https://github.com/TwidereProject/MetaTextView.git", "state": { "branch": null, - "revision": "2660fc30ef6ed8de347ddca499341a965d1fda56", - "version": "1.2.1" + "revision": "d48cf6a2479ce6fc4f836b6c4d7ba855cdbc71cc", + "version": "1.2.2" } }, { From 66643e10582cec18780bc04a00e6e8ae6eebc666 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 29 Jun 2021 19:27:08 +0800 Subject: [PATCH 10/31] fix: disable drag and link preview for text editor in compose scene --- .../Scene/Compose/ComposeViewController.swift | 16 ++++++++++++++++ .../ComposeStatusContentTableViewCell.swift | 1 + 2 files changed, 17 insertions(+) diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index c13cedb8c..361aff090 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -767,6 +767,22 @@ extension ComposeViewController: UITextViewDelegate { return autoCompleteInfo } + func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { + if textView === textEditorView()?.textView { + return false + } + + return true + } + + func textView(_ textView: UITextView, shouldInteractWith textAttachment: NSTextAttachment, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { + if textView === textEditorView()?.textView { + return false + } + + return true + } + } // MARK: - TextEditorViewTextAttributesDelegate diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift index 9946b1c9c..6ada8d7c5 100644 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift @@ -27,6 +27,7 @@ final class ComposeStatusContentTableViewCell: UITableViewCell { metaText.textView.backgroundColor = .clear metaText.textView.isScrollEnabled = false metaText.textView.keyboardType = .twitter + metaText.textView.textDragInteraction?.isEnabled = false // disable drag for link and attachment metaText.textView.textContainer.lineFragmentPadding = 10 // leading inset metaText.textView.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)) metaText.textView.attributedPlaceholder = { From ce3ac2bfd2efe0d32032e3551e08b28964c9d952 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 29 Jun 2021 19:27:40 +0800 Subject: [PATCH 11/31] fix: title view label missing custom emoji issue --- .../HashtagTimelineViewController.swift | 4 +-- .../Favorite/FavoriteViewController.swift | 2 +- .../Header/ProfileHeaderViewController.swift | 26 ++++++++++---- .../Header/View/ProfileHeaderView.swift | 35 +++++++++++++++++-- .../Scene/Profile/ProfileViewController.swift | 13 +++---- Mastodon/Scene/Profile/ProfileViewModel.swift | 6 ++-- ...hRecommendAccountsCollectionViewCell.swift | 7 ++-- ...ubleTitleLabelNavigationBarTitleView.swift | 9 ++--- .../SuggestionAccountTableViewCell.swift | 7 ++-- .../Scene/Thread/ThreadViewController.swift | 8 +++-- Mastodon/Scene/Thread/ThreadViewModel.swift | 6 ++-- 11 files changed, 88 insertions(+), 35 deletions(-) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index eb7753337..ee5c44971 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -56,7 +56,7 @@ extension HashtagTimelineViewController { super.viewDidLoad() title = "#\(viewModel.hashtag)" - titleView.update(title: viewModel.hashtag, subtitle: nil) + titleView.update(title: viewModel.hashtag, subtitle: nil, emojiDict: [:]) navigationItem.titleView = titleView view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color @@ -143,7 +143,7 @@ extension HashtagTimelineViewController { private func updatePromptTitle() { var subtitle: String? defer { - titleView.update(title: "#" + viewModel.hashtag, subtitle: subtitle) + titleView.update(title: "#" + viewModel.hashtag, subtitle: subtitle, emojiDict: [:]) } guard let histories = viewModel.hashtagEntity.value?.history else { return diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift index 46b88796e..d34240a85 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift @@ -49,7 +49,7 @@ extension FavoriteViewController { view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color navigationItem.titleView = titleView - titleView.update(title: L10n.Scene.Favorite.title, subtitle: nil) + titleView.update(title: L10n.Scene.Favorite.title, subtitle: nil, emojiDict: [:]) tableView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tableView) diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index 48470a241..94be3e6f5 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -13,6 +13,7 @@ import ActiveLabel import AlamofireImage import CropViewController import TwitterTextEditor +import MastodonMeta protocol ProfileHeaderViewControllerDelegate: AnyObject { func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView) @@ -166,14 +167,27 @@ extension ProfileHeaderViewController { ) } .store(in: &disposeBag) - Publishers.CombineLatest3( - viewModel.isEditing.eraseToAnyPublisher(), - viewModel.displayProfileInfo.name.removeDuplicates().eraseToAnyPublisher(), - viewModel.editProfileInfo.name.removeDuplicates().eraseToAnyPublisher() + Publishers.CombineLatest4( + viewModel.isEditing, + viewModel.displayProfileInfo.name.removeDuplicates(), + viewModel.editProfileInfo.name.removeDuplicates(), + viewModel.emojiDict ) .receive(on: DispatchQueue.main) - .sink { [weak self] isEditing, name, editingName in + .sink { [weak self] isEditing, name, editingName, emojiDict in guard let self = self else { return } + do { + var emojis = MastodonContent.Emojis() + for (key, value) in emojiDict { + emojis[key] = value.absoluteString + } + let metaContent = try MastodonMetaContent.convert( + document: MastodonContent(content: name ?? " ", emojis: emojis) + ) + self.profileHeaderView.nameMetaText.configure(content: metaContent) + } catch { + assertionFailure() + } self.profileHeaderView.nameTextField.text = isEditing ? editingName : name } .store(in: &disposeBag) @@ -412,7 +426,7 @@ extension ProfileHeaderViewController { profileHeaderView.avatarImageView.alpha = alpha profileHeaderView.editAvatarBackgroundView.alpha = alpha profileHeaderView.nameTextFieldBackgroundView.alpha = alpha - profileHeaderView.nameTextField.alpha = alpha + profileHeaderView.displayNameStackView.alpha = alpha profileHeaderView.usernameLabel.alpha = alpha } diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index f9f2e98d4..abeac9855 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -10,6 +10,7 @@ import UIKit import ActiveLabel import TwitterTextEditor import FLAnimatedImage +import MetaTextView protocol ProfileHeaderViewDelegate: AnyObject { func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarImageViewDidPressed imageView: UIImageView) @@ -111,7 +112,24 @@ final class ProfileHeaderView: UIView { view.layer.cornerRadius = 10 return view }() - + + let displayNameStackView = UIStackView() + let nameMetaText: MetaText = { + let metaText = MetaText() + metaText.textView.backgroundColor = .clear + metaText.textView.isEditable = false + metaText.textView.isSelectable = false + metaText.textView.isScrollEnabled = false + metaText.textView.layer.masksToBounds = false + metaText.textView.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold), maximumPointSize: 28) + metaText.textView.textColor = .white + metaText.textView.textContainer.lineFragmentPadding = 0 + metaText.textAttributes = [ + .font: UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold), maximumPointSize: 28), + .foregroundColor: UIColor.white + ] + return metaText + }() let nameTextField: UITextField = { let textField = UITextField() textField.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold), maximumPointSize: 28) @@ -303,7 +321,6 @@ extension ProfileHeaderView { nameContainerStackView.centerYAnchor.constraint(equalTo: avatarImageView.centerYAnchor), ]) - let displayNameStackView = UIStackView() displayNameStackView.axis = .horizontal nameTextField.translatesAutoresizingMaskIntoConstraints = false displayNameStackView.addArrangedSubview(nameTextField) @@ -321,6 +338,16 @@ extension ProfileHeaderView { ]) displayNameStackView.bringSubviewToFront(nameTextField) displayNameStackView.addArrangedSubview(UIView()) + + // overlay meta text for display name + nameMetaText.textView.translatesAutoresizingMaskIntoConstraints = false + displayNameStackView.addSubview(nameMetaText.textView) + NSLayoutConstraint.activate([ + nameMetaText.textView.topAnchor.constraint(equalTo: nameTextField.topAnchor), + nameMetaText.textView.leadingAnchor.constraint(equalTo: nameTextField.leadingAnchor), + nameMetaText.textView.trailingAnchor.constraint(equalTo: nameTextField.trailingAnchor), + nameMetaText.textView.bottomAnchor.constraint(equalTo: nameTextField.bottomAnchor), + ]) nameContainerStackView.addArrangedSubview(displayNameStackView) nameContainerStackView.addArrangedSubview(usernameLabel) @@ -436,6 +463,8 @@ extension ProfileHeaderView { switch state { case .normal: + nameMetaText.textView.alpha = 1 + nameTextField.alpha = 0 nameTextField.isEnabled = false bioActiveLabelContainer.isHidden = false bioTextEditorView.isHidden = true @@ -449,7 +478,9 @@ extension ProfileHeaderView { self.editAvatarBackgroundView.isHidden = true } case .editing: + nameMetaText.textView.alpha = 0 nameTextField.isEnabled = true + nameTextField.alpha = 1 bioActiveLabelContainer.isHidden = true bioTextEditorView.isHidden = false diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 476d03a9d..7b6a1db86 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -303,12 +303,13 @@ extension ProfileViewController { profileSegmentedViewController.pagingViewController.pagingDelegate = self // bind view model - Publishers.CombineLatest( - viewModel.name.eraseToAnyPublisher(), - viewModel.statusesCount.eraseToAnyPublisher() + Publishers.CombineLatest3( + viewModel.name, + viewModel.emojiDict, + viewModel.statusesCount ) .receive(on: DispatchQueue.main) - .sink { [weak self] name, statusesCount in + .sink { [weak self] name, emojiDict, statusesCount in guard let self = self else { return } guard let title = name, let statusesCount = statusesCount, let formattedStatusCount = MastodonMetricFormatter().string(from: statusesCount) else { @@ -316,7 +317,7 @@ extension ProfileViewController { return } let subtitle = L10n.Scene.Profile.subtitle(formattedStatusCount) - self.titleView.update(title: title, subtitle: subtitle) + self.titleView.update(title: title, subtitle: subtitle, emojiDict: emojiDict) self.titleView.isHidden = false } .store(in: &disposeBag) @@ -368,7 +369,7 @@ extension ProfileViewController { .receive(on: DispatchQueue.main) .assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.name) .store(in: &disposeBag) - viewModel.fileds + viewModel.fields .removeDuplicates() .map { fields -> [ProfileFieldItem.FieldValue] in fields.map { ProfileFieldItem.FieldValue(name: $0.name, value: $0.value) } diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift index ddd1ee291..45a2386be 100644 --- a/Mastodon/Scene/Profile/ProfileViewModel.swift +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -39,7 +39,7 @@ class ProfileViewModel: NSObject { let statusesCount: CurrentValueSubject let followingCount: CurrentValueSubject let followersCount: CurrentValueSubject - let fileds: CurrentValueSubject<[Mastodon.Entity.Field], Never> + let fields: CurrentValueSubject<[Mastodon.Entity.Field], Never> let emojiDict: CurrentValueSubject // fulfill this before editing @@ -82,7 +82,7 @@ class ProfileViewModel: NSObject { self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followersCount) }) self.protected = CurrentValueSubject(mastodonUser?.locked) self.suspended = CurrentValueSubject(mastodonUser?.suspended ?? false) - self.fileds = CurrentValueSubject(mastodonUser?.fields ?? []) + self.fields = CurrentValueSubject(mastodonUser?.fields ?? []) self.emojiDict = CurrentValueSubject(mastodonUser?.emojiDict ?? [:]) super.init() @@ -257,7 +257,7 @@ extension ProfileViewModel { self.followersCount.value = mastodonUser.flatMap { Int(truncating: $0.followersCount) } self.protected.value = mastodonUser?.locked self.suspended.value = mastodonUser?.suspended ?? false - self.fileds.value = mastodonUser?.fields ?? [] + self.fields.value = mastodonUser?.fields ?? [] self.emojiDict.value = mastodonUser?.emojiDict ?? [:] } diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift index d76cb24bd..b69914dbe 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift @@ -10,6 +10,7 @@ import CoreDataStack import Foundation import MastodonSDK import UIKit +import ActiveLabel protocol SearchRecommendAccountsCollectionViewCellDelegate: NSObject { func followButtonDidPressed(clickedUser: MastodonUser) @@ -42,8 +43,8 @@ class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell { let visualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) - let displayNameLabel: UILabel = { - let label = UILabel() + let displayNameLabel: ActiveLabel = { + let label = ActiveLabel(style: .statusName) label.textColor = .white label.textAlignment = .center label.font = .systemFont(ofSize: 18, weight: .semibold) @@ -164,7 +165,7 @@ extension SearchRecommendAccountsCollectionViewCell { } func config(with mastodonUser: MastodonUser) { - displayNameLabel.text = mastodonUser.displayName.isEmpty ? mastodonUser.username : mastodonUser.displayName + displayNameLabel.configure(content: mastodonUser.displayNameWithFallback, emojiDict: mastodonUser.emojiDict) acctLabel.text = "@" + mastodonUser.acct avatarImageView.af.setImage( withURL: URL(string: mastodonUser.avatar)!, diff --git a/Mastodon/Scene/Share/View/Content/DoubleTitleLabelNavigationBarTitleView.swift b/Mastodon/Scene/Share/View/Content/DoubleTitleLabelNavigationBarTitleView.swift index b136859a8..33ef86dd0 100644 --- a/Mastodon/Scene/Share/View/Content/DoubleTitleLabelNavigationBarTitleView.swift +++ b/Mastodon/Scene/Share/View/Content/DoubleTitleLabelNavigationBarTitleView.swift @@ -6,13 +6,14 @@ // import UIKit +import ActiveLabel final class DoubleTitleLabelNavigationBarTitleView: UIView { let containerView = UIStackView() - let titleLabel: UILabel = { - let label = UILabel() + let titleLabel: ActiveLabel = { + let label = ActiveLabel(style: .default) label.font = .systemFont(ofSize: 17, weight: .semibold) label.textColor = Asset.Colors.Label.primary.color label.textAlignment = .center @@ -58,8 +59,8 @@ extension DoubleTitleLabelNavigationBarTitleView { containerView.addArrangedSubview(subtitleLabel) } - func update(title: String, subtitle: String?) { - titleLabel.text = title + func update(title: String, subtitle: String?, emojiDict: MastodonStatusContent.EmojiDict) { + titleLabel.configure(content: title, emojiDict: emojiDict) if let subtitle = subtitle { subtitleLabel.text = subtitle subtitleLabel.isHidden = false diff --git a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift index db56d63ca..221f9a208 100644 --- a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift +++ b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift @@ -11,6 +11,7 @@ import CoreDataStack import Foundation import MastodonSDK import UIKit +import ActiveLabel protocol SuggestionAccountTableViewCellDelegate: AnyObject { func accountButtonPressed(objectID: NSManagedObjectID, cell: SuggestionAccountTableViewCell) @@ -28,8 +29,8 @@ final class SuggestionAccountTableViewCell: UITableViewCell { return imageView }() - let titleLabel: UILabel = { - let label = UILabel() + let titleLabel: ActiveLabel = { + let label = ActiveLabel(style: .statusName) label.textColor = Asset.Colors.brandBlue.color label.font = .systemFont(ofSize: 17, weight: .semibold) label.lineBreakMode = .byTruncatingTail @@ -153,7 +154,7 @@ extension SuggestionAccountTableViewCell { imageTransition: .crossDissolve(0.2) ) } - titleLabel.text = account.displayName.isEmpty ? account.username : account.displayName + titleLabel.configure(content: account.displayNameWithFallback, emojiDict: account.emojiDict) subTitleLabel.text = account.acct button.isSelected = isSelected button.publisher(for: .touchUpInside) diff --git a/Mastodon/Scene/Thread/ThreadViewController.swift b/Mastodon/Scene/Thread/ThreadViewController.swift index 2023d1b1d..02cf3c38d 100644 --- a/Mastodon/Scene/Thread/ThreadViewController.swift +++ b/Mastodon/Scene/Thread/ThreadViewController.swift @@ -80,9 +80,13 @@ extension ThreadViewController { viewModel.navigationBarTitle .receive(on: DispatchQueue.main) - .sink { [weak self] title in + .sink { [weak self] tuple in guard let self = self else { return } - self.titleView.update(title: title ?? L10n.Scene.Thread.backTitle, subtitle: nil) + guard let (title, emojiDict) = tuple else { + self.titleView.update(title: L10n.Scene.Thread.backTitle, subtitle: nil, emojiDict: [:]) + return + } + self.titleView.update(title: title, subtitle: nil, emojiDict: emojiDict) } .store(in: &disposeBag) } diff --git a/Mastodon/Scene/Thread/ThreadViewModel.swift b/Mastodon/Scene/Thread/ThreadViewModel.swift index febc34d17..0599a0654 100644 --- a/Mastodon/Scene/Thread/ThreadViewModel.swift +++ b/Mastodon/Scene/Thread/ThreadViewModel.swift @@ -45,7 +45,7 @@ class ThreadViewModel { let ancestorItems = CurrentValueSubject<[Item], Never>([]) let descendantNodes = CurrentValueSubject<[LeafNode], Never>([]) let descendantItems = CurrentValueSubject<[Item], Never>([]) - let navigationBarTitle: CurrentValueSubject + let navigationBarTitle: CurrentValueSubject<(String, MastodonStatusContent.EmojiDict)?, Never> init(context: AppContext, optionalStatus: Status?) { self.context = context @@ -53,7 +53,7 @@ class ThreadViewModel { self.rootItem = CurrentValueSubject(optionalStatus.flatMap { Item.root(statusObjectID: $0.objectID, attribute: Item.StatusAttribute()) }) self.existStatusFetchedResultsController = StatusFetchedResultsController(managedObjectContext: context.managedObjectContext, domain: nil, additionalTweetPredicate: nil) self.navigationBarTitle = CurrentValueSubject( - optionalStatus.flatMap { L10n.Scene.Thread.title($0.author.displayNameWithFallback) } + optionalStatus.flatMap { (L10n.Scene.Thread.title($0.author.displayNameWithFallback), $0.emojiDict) } ) // bind fetcher domain @@ -85,7 +85,7 @@ class ThreadViewModel { return } self.rootNode.value = RootNode(domain: status.domain, statusID: status.id, replyToID: status.inReplyToID) - self.navigationBarTitle.value = L10n.Scene.Thread.title(status.author.displayNameWithFallback) + self.navigationBarTitle.value = (L10n.Scene.Thread.title(status.author.displayNameWithFallback), status.author.emojiDict) } } .store(in: &disposeBag) From 9858a39f3d6fb21babd8ce2a9deb2e4cf6aab520 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 29 Jun 2021 19:55:42 +0800 Subject: [PATCH 12/31] fix: reblog not disable for non-public post issue. resolve #173 --- .../Diffiable/Section/StatusSection.swift | 10 ++++++++-- Mastodon/Extension/CoreDataStack/Status.swift | 7 +++++++ .../Compose/View/ComposeToolbarView.swift | 9 --------- .../Scene/Share/View/Content/StatusView.swift | 19 +++++++++++++++---- 4 files changed, 30 insertions(+), 15 deletions(-) diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index b94ed3328..c7408339c 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -574,9 +574,8 @@ extension StatusSection { cell.statusView.contentMetaText.textView.accessibilityLanguage = (status.reblog ?? status).language // set visibility - if let visibility = (status.reblog ?? status).visibility { + if let visibility = (status.reblog ?? status).visibilityEnum { cell.statusView.updateVisibility(visibility: visibility) - cell.statusView.revealContentWarningButton.publisher(for: \.isHidden) .receive(on: DispatchQueue.main) .sink { [weak cell] isHidden in @@ -953,6 +952,13 @@ extension StatusSection { guard status.reblogsCount.intValue > 0 else { return nil } return L10n.Common.Controls.Timeline.Accessibility.countReblogs(status.reblogsCount.intValue) }() + + // disable reblog when non-public (except self) + cell.statusView.actionToolbarContainer.reblogButton.isEnabled = true + if let visibility = status.visibilityEnum, visibility != .public, status.author.id != requestUserID { + cell.statusView.actionToolbarContainer.reblogButton.isEnabled = false + } + // set like let isLike = status.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false let favoriteCountTitle: String = { diff --git a/Mastodon/Extension/CoreDataStack/Status.swift b/Mastodon/Extension/CoreDataStack/Status.swift index d8e1b9307..7b64e6a9d 100644 --- a/Mastodon/Extension/CoreDataStack/Status.swift +++ b/Mastodon/Extension/CoreDataStack/Status.swift @@ -89,3 +89,10 @@ extension Status { } extension Status: EmojiContainer { } + + +extension Status { + var visibilityEnum: Mastodon.Entity.Status.Visibility? { + return visibility.flatMap { Mastodon.Entity.Status.Visibility(rawValue: $0) } + } +} diff --git a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift index 1dd19c552..3edb17e41 100644 --- a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift +++ b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift @@ -202,15 +202,6 @@ extension ComposeToolbarView { } } - func imageNameForTimeline() -> String { - switch self { - case .public: return "globe" - // case .unlisted: return "eye.slash" - case .private: return "person.3" - case .direct: return "at" - } - } - var visibility: Mastodon.Entity.Status.Visibility { switch self { case .public: return .public diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 84166c627..e4c3b6f0f 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -14,6 +14,7 @@ import AlamofireImage import FLAnimatedImage import MetaTextView import Meta +import MastodonSDK // TODO: // import LinkPresentation @@ -498,10 +499,20 @@ extension StatusView { } // TODO: a11y } - - func updateVisibility(visibility: String) { - guard let visibility = ComposeToolbarView.VisibilitySelectionType(rawValue: visibility) else { return } - visibilityImageView.image = UIImage(systemName: visibility.imageNameForTimeline(), withConfiguration: UIImage.SymbolConfiguration(pointSize: 13, weight: .regular)) + + func updateVisibility(visibility: Mastodon.Entity.Status.Visibility) { + switch visibility { + case .public: + visibilityImageView.image = UIImage(systemName: "globe", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13, weight: .regular)) + case .private: + visibilityImageView.image = UIImage(systemName: "person.3", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13, weight: .regular)) + case .unlisted: + visibilityImageView.image = UIImage(systemName: "eye.slash", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13, weight: .regular)) + case .direct: + visibilityImageView.image = UIImage(systemName: "at", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13, weight: .regular)) + case ._other: + visibilityImageView.image = nil + } } } From 6aa91dbcfa9372e69b349f0c0aeea3167cad74d3 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 29 Jun 2021 20:00:58 +0800 Subject: [PATCH 13/31] fix: author name missing emoji issue in compose scene --- Mastodon/Diffiable/Item/ComposeStatusItem.swift | 2 ++ Mastodon/Diffiable/Section/ComposeStatusSection.swift | 9 +++++---- Mastodon/Scene/Compose/ComposeViewModel.swift | 1 + 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Mastodon/Diffiable/Item/ComposeStatusItem.swift b/Mastodon/Diffiable/Item/ComposeStatusItem.swift index 96ea8b05f..7c916b166 100644 --- a/Mastodon/Diffiable/Item/ComposeStatusItem.swift +++ b/Mastodon/Diffiable/Item/ComposeStatusItem.swift @@ -25,6 +25,7 @@ extension ComposeStatusItem { let avatarURL = CurrentValueSubject(nil) let displayName = CurrentValueSubject(nil) + let emojiDict = CurrentValueSubject([:]) let username = CurrentValueSubject(nil) let composeContent = CurrentValueSubject(nil) @@ -34,6 +35,7 @@ extension ComposeStatusItem { static func == (lhs: ComposeStatusAttribute, rhs: ComposeStatusAttribute) -> Bool { return lhs.avatarURL.value == rhs.avatarURL.value && lhs.displayName.value == rhs.displayName.value && + lhs.emojiDict.value == rhs.emojiDict.value && lhs.username.value == rhs.username.value && lhs.composeContent.value == rhs.composeContent.value && lhs.isContentWarningComposing.value == rhs.isContentWarningComposing.value && diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index b82116ad4..3419cc113 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -43,13 +43,14 @@ extension ComposeStatusSection { } .store(in: &cell.disposeBag) // set display name and username - Publishers.CombineLatest( - attribute.displayName.eraseToAnyPublisher(), + Publishers.CombineLatest3( + attribute.displayName, + attribute.emojiDict, attribute.username.eraseToAnyPublisher() ) .receive(on: DispatchQueue.main) - .sink { displayName, username in - cell.statusView.nameLabel.text = displayName + .sink { displayName, emojiDict, username in + cell.statusView.nameLabel.configure(content: displayName ?? " ", emojiDict: emojiDict) cell.statusView.usernameLabel.text = username.flatMap { "@" + $0 } ?? " " } .store(in: &cell.disposeBag) diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index af8c8e2ac..68880e819 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -181,6 +181,7 @@ final class ComposeViewModel: NSObject { } return displayName }() + self.composeStatusAttribute.emojiDict.value = mastodonUser?.emojiDict ?? [:] self.composeStatusAttribute.username.value = username } .store(in: &disposeBag) From 17bdce13211a451cbb8048d875939938542f54c6 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 30 Jun 2021 00:56:37 +0800 Subject: [PATCH 14/31] fix: recommend request publisher logic issue --- .../xcschemes/xcschememanagement.plist | 8 +- .../UserProvider/UserProviderFacade.swift | 50 ++++ .../Search/SearchViewController+Follow.swift | 18 +- Mastodon/Scene/Search/SearchViewModel.swift | 218 +++++++++--------- 4 files changed, 177 insertions(+), 117 deletions(-) diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index ebbde8329..6a088c82b 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,17 +7,17 @@ AppShared.xcscheme_^#shared#^_ orderHint - 26 + 19 CoreDataStack.xcscheme_^#shared#^_ orderHint - 21 + 20 Mastodon - ASDK.xcscheme_^#shared#^_ orderHint - 2 + 1 Mastodon - RTL.xcscheme_^#shared#^_ @@ -37,7 +37,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 22 + 18 SuppressBuildableAutocreation diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift index b5e381f1b..79185338a 100644 --- a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift @@ -29,6 +29,23 @@ extension UserProviderFacade { mastodonUser: provider.mastodonUser().eraseToAnyPublisher() ) } + + static func toggleUserFollowRelationship( + provider: UserProvider, + mastodonUser: MastodonUser + ) -> AnyPublisher, Error> { + // prepare authentication + guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { + assertionFailure() + return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() + } + + return _toggleUserFollowRelationship( + context: provider.context, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, + mastodonUser: Just(mastodonUser).eraseToAnyPublisher() + ) + } private static func _toggleUserFollowRelationship( context: AppContext, @@ -52,6 +69,22 @@ extension UserProviderFacade { } extension UserProviderFacade { + static func toggleUserBlockRelationship( + provider: UserProvider, + mastodonUser: MastodonUser + ) -> AnyPublisher, Error> { + // prepare authentication + guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { + assertionFailure() + return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() + } + return _toggleUserBlockRelationship( + context: provider.context, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, + mastodonUser: Just(mastodonUser).eraseToAnyPublisher() + ) + } + static func toggleUserBlockRelationship( provider: UserProvider, cell: UITableViewCell? @@ -98,6 +131,23 @@ extension UserProviderFacade { } extension UserProviderFacade { + + static func toggleUserMuteRelationship( + provider: UserProvider, + mastodonUser: MastodonUser + ) -> AnyPublisher, Error> { + // prepare authentication + guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { + assertionFailure() + return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() + } + return _toggleUserMuteRelationship( + context: provider.context, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, + mastodonUser: Just(mastodonUser).eraseToAnyPublisher() + ) + } + static func toggleUserMuteRelationship( provider: UserProvider, cell: UITableViewCell? diff --git a/Mastodon/Scene/Search/SearchViewController+Follow.swift b/Mastodon/Scene/Search/SearchViewController+Follow.swift index c31f6d82a..c345336df 100644 --- a/Mastodon/Scene/Search/SearchViewController+Follow.swift +++ b/Mastodon/Scene/Search/SearchViewController+Follow.swift @@ -20,14 +20,13 @@ extension SearchViewController: UserProvider { func mastodonUser() -> Future { Future { promise in - promise(.success(self.viewModel.mastodonUser.value)) + promise(.success(nil)) } } } extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegate { func followButtonDidPressed(clickedUser: MastodonUser) { - viewModel.mastodonUser.value = clickedUser guard let currentMastodonUser = viewModel.currentMastodonUser.value else { return } @@ -36,17 +35,17 @@ extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegat case .none: break case .follow, .following: - UserProviderFacade.toggleUserFollowRelationship(provider: self) + UserProviderFacade.toggleUserFollowRelationship(provider: self, mastodonUser: clickedUser) .sink { _ in - + // error handling } receiveValue: { _ in + // success } .store(in: &disposeBag) case .pending: break case .muting: - guard let mastodonUser = viewModel.mastodonUser.value else { return } - let name = mastodonUser.displayNameWithFallback + let name = clickedUser.displayNameWithFallback let alertController = UIAlertController( title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.title, message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.message(name), @@ -54,7 +53,7 @@ extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegat ) let unmuteAction = UIAlertAction(title: L10n.Common.Controls.Friendship.unmute, style: .default) { [weak self] _ in guard let self = self else { return } - UserProviderFacade.toggleUserMuteRelationship(provider: self, cell: nil) + UserProviderFacade.toggleUserMuteRelationship(provider: self, mastodonUser: clickedUser) .sink { _ in // do nothing } receiveValue: { _ in @@ -67,8 +66,7 @@ extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegat alertController.addAction(cancelAction) present(alertController, animated: true, completion: nil) case .blocking: - guard let mastodonUser = viewModel.mastodonUser.value else { return } - let name = mastodonUser.displayNameWithFallback + let name = clickedUser.displayNameWithFallback let alertController = UIAlertController( title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.title, message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.message(name), @@ -76,7 +74,7 @@ extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegat ) let unblockAction = UIAlertAction(title: L10n.Common.Controls.Friendship.unblock, style: .default) { [weak self] _ in guard let self = self else { return } - UserProviderFacade.toggleUserBlockRelationship(provider: self, cell: nil) + UserProviderFacade.toggleUserBlockRelationship(provider: self, mastodonUser: clickedUser) .sink { _ in // do nothing } receiveValue: { _ in diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 48bd87b88..35075dfe1 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -21,7 +21,6 @@ final class SearchViewModel: NSObject { let context: AppContext weak var coordinator: SceneCoordinator! - let mastodonUser = CurrentValueSubject(nil) let currentMastodonUser = CurrentValueSubject(nil) let viewDidAppeared = PassthroughSubject() @@ -33,7 +32,7 @@ final class SearchViewModel: NSObject { let searchResult = CurrentValueSubject(nil) - var recommendHashTags = [Mastodon.Entity.Tag]() + // var recommendHashTags = [Mastodon.Entity.Tag]() var recommendAccounts = [NSManagedObjectID]() var recommendAccountsFallback = PassthroughSubject() @@ -61,11 +60,7 @@ final class SearchViewModel: NSObject { self.coordinator = coordinator self.context = context super.init() - - guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { - return - } - + // bind active authentication context.authenticationService.activeMastodonAuthentication .sink { [weak self] activeMastodonAuthentication in @@ -86,26 +81,43 @@ final class SearchViewModel: NSObject { .filter { text, _ in !text.isEmpty } - .flatMap { (text, scope) -> AnyPublisher, Error> in - - let query = Mastodon.API.V2.Search.Query(q: text, - type: scope, - accountID: nil, - maxID: nil, - minID: nil, - excludeUnreviewed: nil, - resolve: nil, - limit: nil, - offset: nil, - following: nil) - return context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + .compactMap { (text, scope) -> AnyPublisher, Error>, Never>? in + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return nil } + let query = Mastodon.API.V2.Search.Query( + q: text, + type: scope, + accountID: nil, + maxID: nil, + minID: nil, + excludeUnreviewed: nil, + resolve: nil, + limit: nil, + offset: nil, + following: nil + ) + return context.apiService.search( + domain: activeMastodonAuthenticationBox.domain, + query: query, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + // .retry(3) // iOS 14.0 SDK may not works here. needs testing before add this + .map { response in Result, Error> { response } } + .catch { error in Just(Result, Error> { throw error }) } + .eraseToAnyPublisher() } - .sink { _ in - } receiveValue: { [weak self] result in - self?.searchResult.value = result.value + .switchToLatest() + .sink { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let response): + guard self.isSearching.value else { return } + self.searchResult.value = response.value + case .failure(let error): + break + } } .store(in: &disposeBag) - + isSearching .sink { [weak self] isSearching in if !isSearching { @@ -147,48 +159,71 @@ final class SearchViewModel: NSObject { } .store(in: &disposeBag) - viewDidAppeared - .compactMap { _ in self.requestRecommendHashTags() } - .receive(on: RunLoop.main) - .sink { [weak self] _ in - guard let self = self else { return } - if !self.recommendHashTags.isEmpty { - guard let dataSource = self.hashtagDiffableDataSource else { return } - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - snapshot.appendItems(self.recommendHashTags, toSection: .main) - dataSource.apply(snapshot, animatingDifferences: false, completion: nil) - } - } receiveValue: { _ in + Publishers.CombineLatest( + context.authenticationService.activeMastodonAuthenticationBox, + viewDidAppeared + ) + .compactMap { activeMastodonAuthenticationBox, _ -> AuthenticationService.MastodonAuthenticationBox? in + return activeMastodonAuthenticationBox + } + .throttle(for: 1, scheduler: DispatchQueue.main, latest: false) + .flatMap { box in + context.apiService.recommendTrends(domain: box.domain, query: nil) + .map { response in Result, Error> { response } } + .catch { error in Just(Result, Error> { throw error }) } + .eraseToAnyPublisher() + } + .receive(on: RunLoop.main) + .sink { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let response): + guard let dataSource = self.hashtagDiffableDataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(response.value, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + case .failure(let error): + break } - .store(in: &disposeBag) - viewDidAppeared - .compactMap { _ in self.requestRecommendAccountsV2() } - .receive(on: RunLoop.main) - .sink { [weak self] _ in - guard let self = self else { return } - if !self.recommendAccounts.isEmpty { - self.applyDataSource() - } - } receiveValue: { _ in - } - .store(in: &disposeBag) - - recommendAccountsFallback - .receive(on: RunLoop.main) - .sink { [weak self] _ in - guard let self = self else { return } - self.requestRecommendAccounts() - .sink { [weak self] _ in - guard let self = self else { return } - if !self.recommendAccounts.isEmpty { - self.applyDataSource() - } - } receiveValue: { _ in + } + .store(in: &disposeBag) + + Publishers.CombineLatest( + context.authenticationService.activeMastodonAuthenticationBox, + viewDidAppeared + ) + .compactMap { activeMastodonAuthenticationBox, _ -> AuthenticationService.MastodonAuthenticationBox? in + return activeMastodonAuthenticationBox + } + .throttle(for: 1, scheduler: DispatchQueue.main, latest: false) + .flatMap { box -> AnyPublisher, Never> in + context.apiService.suggestionAccountV2(domain: box.domain, query: nil, mastodonAuthenticationBox: box) + .map { response in Result<[Mastodon.Entity.Account.ID], Error> { response.value.map { $0.account.id } } } + .catch { error -> AnyPublisher, Never> in + if let apiError = error as? Mastodon.API.Error, apiError.httpResponseStatus == .notFound { + return context.apiService.suggestionAccount(domain: box.domain, query: nil, mastodonAuthenticationBox: box) + .map { response in Result<[Mastodon.Entity.Account.ID], Error> { response.value.map { $0.id } } } + .catch { error in Just(Result<[Mastodon.Entity.Account.ID], Error> { throw error }) } + .eraseToAnyPublisher() + } else { + return Just(Result<[Mastodon.Entity.Account.ID], Error> { throw error }) + .eraseToAnyPublisher() } - .store(in: &self.disposeBag) + } + .eraseToAnyPublisher() + } + .receive(on: RunLoop.main) + .sink { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let userIDs): + self.receiveAccounts(ids: userIDs) + case .failure(let error): + break } - .store(in: &disposeBag) + } + .store(in: &disposeBag) searchResult .receive(on: DispatchQueue.main) @@ -216,30 +251,6 @@ final class SearchViewModel: NSObject { } .store(in: &disposeBag) } - - func requestRecommendHashTags() -> Future { - Future { promise in - guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { - promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing))) - return - } - self.context.apiService.recommendTrends(domain: activeMastodonAuthenticationBox.domain, query: nil) - .sink { completion in - switch completion { - case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendHashTags request fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) - promise(.failure(error)) - case .finished: - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendHashTags request success", (#file as NSString).lastPathComponent, #line, #function) - promise(.success(())) - } - } receiveValue: { [weak self] tags in - guard let self = self else { return } - self.recommendHashTags = tags.value - } - .store(in: &self.disposeBag) - } - } func requestRecommendAccountsV2() -> Future { Future { promise in @@ -296,17 +307,7 @@ final class SearchViewModel: NSObject { } } - func applyDataSource() { - DispatchQueue.main.async { - guard let dataSource = self.accountDiffableDataSource else { return } - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - snapshot.appendItems(self.recommendAccounts, toSection: .main) - dataSource.apply(snapshot, animatingDifferences: false, completion: nil) - } - } - - func receiveAccounts(ids: [String]) { + func receiveAccounts(ids: [Mastodon.Entity.Account.ID]) { guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } @@ -323,12 +324,23 @@ final class SearchViewModel: NSObject { return nil } }() - if let users = mastodonUsers { - let sortedUsers = users.sorted { (user1, user2) -> Bool in - (ids.firstIndex(of: user1.id) ?? 0) < (ids.firstIndex(of: user2.id) ?? 0) + guard let mastodonUsers = mastodonUsers else { return } + let objectIDs = mastodonUsers + .compactMap { object in + ids.firstIndex(of: object.id).map { index in (index, object) } } - recommendAccounts = sortedUsers.map(\.objectID) - } + .sorted { $0.0 < $1.0 } + .map { $0.1.objectID } + + // append at front + let newObjectIDs = objectIDs.filter { !self.recommendAccounts.contains($0) } + self.recommendAccounts = newObjectIDs + self.recommendAccounts + + guard let dataSource = self.accountDiffableDataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(self.recommendAccounts, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) } func accountCollectionViewItemDidSelected(mastodonUser: MastodonUser, from: UIViewController) { From 93a72cbadaafdbd81e914c93e01a782a4d26da78 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 30 Jun 2021 13:06:12 +0800 Subject: [PATCH 15/31] chore: update version to 0.7.7 (23) --- Mastodon.xcodeproj/project.pbxproj | 32 +++++++++---------- .../xcschemes/xcschememanagement.plist | 4 +-- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index da036e65d..d5585c329 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -3843,7 +3843,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 23; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = Mastodon/Info.plist; @@ -3851,7 +3851,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.7.6; + MARKETING_VERSION = 0.7.7; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3870,7 +3870,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 23; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = Mastodon/Info.plist; @@ -3878,7 +3878,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.7.6; + MARKETING_VERSION = 0.7.7; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -4198,7 +4198,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 23; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = Mastodon/Info.plist; @@ -4206,7 +4206,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.7.6; + MARKETING_VERSION = 0.7.7; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -4312,7 +4312,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 23; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = NotificationService/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -4320,7 +4320,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 0.7.6; + MARKETING_VERSION = 0.7.7; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -4431,7 +4431,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 23; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = Mastodon/Info.plist; @@ -4439,7 +4439,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.7.6; + MARKETING_VERSION = 0.7.7; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -4545,7 +4545,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 23; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = NotificationService/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -4553,7 +4553,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 0.7.6; + MARKETING_VERSION = 0.7.7; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -4599,7 +4599,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 23; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = NotificationService/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -4607,7 +4607,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 0.7.6; + MARKETING_VERSION = 0.7.7; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -4622,7 +4622,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 23; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = NotificationService/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -4630,7 +4630,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 0.7.6; + MARKETING_VERSION = 0.7.7; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index ebbde8329..f1135b12e 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 - 21 + 20 Mastodon - ASDK.xcscheme_^#shared#^_ @@ -37,7 +37,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 22 + 21 SuppressBuildableAutocreation From 9190b2716393de8c94f9ac23f5e17e55fecf170f Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 30 Jun 2021 14:23:26 +0800 Subject: [PATCH 16/31] fix: compiler failure --- Mastodon/Scene/Search/SearchViewModel.swift | 59 +-------------------- 1 file changed, 2 insertions(+), 57 deletions(-) diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 35075dfe1..5c443e09a 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -251,61 +251,6 @@ final class SearchViewModel: NSObject { } .store(in: &disposeBag) } - - func requestRecommendAccountsV2() -> Future { - Future { promise in - guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { - promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing))) - return - } - self.context.apiService.suggestionAccountV2(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) - .sink { [weak self] completion in - switch completion { - case .failure(let error): - if let apiError = error as? Mastodon.API.Error { - if apiError.httpResponseStatus == .notFound { - self?.recommendAccountsFallback.send() - } - } - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) - promise(.failure(error)) - case .finished: - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request success", (#file as NSString).lastPathComponent, #line, #function) - promise(.success(())) - } - } receiveValue: { [weak self] accounts in - guard let self = self else { return } - let ids = accounts.value.compactMap({$0.account.id}) - self.receiveAccounts(ids: ids) - } - .store(in: &self.disposeBag) - } - } - - func requestRecommendAccounts() -> Future { - Future { promise in - guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { - promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing))) - return - } - self.context.apiService.suggestionAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) - .sink { completion in - switch completion { - case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) - promise(.failure(error)) - case .finished: - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request success", (#file as NSString).lastPathComponent, #line, #function) - promise(.success(())) - } - } receiveValue: { [weak self] accounts in - guard let self = self else { return } - let ids = accounts.value.compactMap({$0.id}) - self.receiveAccounts(ids: ids) - } - .store(in: &self.disposeBag) - } - } func receiveAccounts(ids: [Mastodon.Entity.Account.ID]) { guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { @@ -324,8 +269,8 @@ final class SearchViewModel: NSObject { return nil } }() - guard let mastodonUsers = mastodonUsers else { return } - let objectIDs = mastodonUsers + guard let users = mastodonUsers else { return } + let objectIDs: [NSManagedObjectID] = users .compactMap { object in ids.firstIndex(of: object.id).map { index in (index, object) } } From 6126c15c6676dc42d64dcfe9c4535dd8131d2c84 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 30 Jun 2021 15:30:25 +0800 Subject: [PATCH 17/31] fix: emoji picker set wrong selected range after insert text issue --- Mastodon.xcodeproj/project.pbxproj | 2 +- .../xcschemes/xcschememanagement.plist | 4 ++-- .../xcshareddata/swiftpm/Package.resolved | 4 ++-- .../Section/ComposeStatusSection.swift | 8 +------- .../CustomEmojiPickerInputViewModel.swift | 19 +++++++++++++++++-- 5 files changed, 23 insertions(+), 14 deletions(-) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index d5585c329..9b79bf298 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -4778,7 +4778,7 @@ repositoryURL = "https://github.com/TwidereProject/MetaTextView.git"; requirement = { kind = exactVersion; - version = 1.2.2; + version = 1.2.3; }; }; 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 f1135b12e..ebbde8329 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 fcd3317da..8414b940a 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -114,8 +114,8 @@ "repositoryURL": "https://github.com/TwidereProject/MetaTextView.git", "state": { "branch": null, - "revision": "d48cf6a2479ce6fc4f836b6c4d7ba855cdbc71cc", - "version": "1.2.2" + "revision": "5b86b386464be8a6da5383aa714c458c07da6c01", + "version": "1.2.3" } }, { diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 3419cc113..6a9dd2b3b 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -58,14 +58,8 @@ extension ComposeStatusSection { } -protocol CustomEmojiReplaceableTextInput: AnyObject { +protocol CustomEmojiReplaceableTextInput: UITextInput & UIResponder { var inputView: UIView? { get set } - func reloadInputViews() - - // UIKeyInput - func insertText(_ text: String) - // UIResponder - var isFirstResponder: Bool { get } } class CustomEmojiReplaceableTextInputReference { diff --git a/Mastodon/Scene/Compose/View/CustomEmojiPickerInputViewModel.swift b/Mastodon/Scene/Compose/View/CustomEmojiPickerInputViewModel.swift index 8314dfc3f..760203a39 100644 --- a/Mastodon/Scene/Compose/View/CustomEmojiPickerInputViewModel.swift +++ b/Mastodon/Scene/Compose/View/CustomEmojiPickerInputViewModel.swift @@ -46,8 +46,23 @@ extension CustomEmojiPickerInputViewModel { removeEmptyReferences() for reference in customEmojiReplaceableTextInputReferences { - guard reference.value?.isFirstResponder == true else { continue } - reference.value?.insertText(text) + guard let textInput = reference.value else { continue } + guard textInput.isFirstResponder == true else { continue } + + let selectedTextRange = textInput.selectedTextRange + textInput.insertText(text) + + // due to insert text render as attachment + // the cursor reset logic not works + // hack with hard code +2 offset + assert(text.hasSuffix(": ")) + if text.hasPrefix(":") && text.hasSuffix(": "), + let selectedTextRange = selectedTextRange, + let newPosition = textInput.position(from: selectedTextRange.start, offset: 2) { + let newSelectedTextRange = textInput.textRange(from: newPosition, to: newPosition) + textInput.selectedTextRange = newSelectedTextRange + } + return reference } From 78436dbb56445001e9bca721764352e43fb1aefa Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 30 Jun 2021 15:43:19 +0800 Subject: [PATCH 18/31] fix: add missing compose predefined text for reply and hashtag --- Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index 0d00fe239..fa38afcfe 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -236,6 +236,10 @@ extension ComposeViewModel: UITableViewDataSource { } // configure author ComposeStatusSection.configureStatusContent(cell: cell, attribute: composeStatusAttribute) + // configure content. bind text in UITextViewDelegate + if let composeContent = composeStatusAttribute.composeContent.value { + cell.metaText.textView.text = composeContent + } // configure content warning cell.statusContentWarningEditorView.textView.text = composeStatusAttribute.contentWarningContent.value // bind content warning @@ -254,7 +258,6 @@ extension ComposeViewModel: UITableViewDataSource { } } .store(in: &cell.disposeBag) - cell.contentWarningContent .removeDuplicates() .receive(on: DispatchQueue.main) From f5addf2430523a7b44239c5a926eecb2d89c8388 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 30 Jun 2021 15:43:33 +0800 Subject: [PATCH 19/31] fix: meta text label color issue --- .../ComposeStatusContentTableViewCell.swift | 15 +++++++++++++++ .../Scene/Share/View/Content/StatusView.swift | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift index 6ada8d7c5..8f9acd50a 100644 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift @@ -38,6 +38,21 @@ final class ComposeStatusContentTableViewCell: UITableViewCell { attributes: attributes ) }() + let paragraphStyle: NSMutableParagraphStyle = { + let style = NSMutableParagraphStyle() + style.lineSpacing = 5 + return style + }() + metaText.textAttributes = [ + .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)), + .foregroundColor: Asset.Colors.Label.primary.color, + .paragraphStyle: paragraphStyle, + ] + metaText.linkAttributes = [ + .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)), + .foregroundColor: Asset.Colors.brandBlue.color, + .paragraphStyle: paragraphStyle, + ] return metaText }() diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index e4c3b6f0f..c647a6463 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -217,6 +217,21 @@ final class StatusView: UIView { metaText.textView.textContainer.lineFragmentPadding = 0 metaText.textView.textContainerInset = .zero metaText.textView.layer.masksToBounds = false + let paragraphStyle: NSMutableParagraphStyle = { + let style = NSMutableParagraphStyle() + style.lineSpacing = 5 + return style + }() + metaText.textAttributes = [ + .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)), + .foregroundColor: Asset.Colors.Label.primary.color, + .paragraphStyle: paragraphStyle, + ] + metaText.linkAttributes = [ + .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)), + .foregroundColor: Asset.Colors.brandBlue.color, + .paragraphStyle: paragraphStyle, + ] return metaText }() From 62ec9a8e8df09dd3a28cc195edd3cc141c3e6092 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 30 Jun 2021 15:47:04 +0800 Subject: [PATCH 20/31] fix: reblog action can not trigger for unlisted post issue --- Mastodon/Diffiable/Section/StatusSection.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index c7408339c..ce9f07ae0 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -953,10 +953,15 @@ extension StatusSection { return L10n.Common.Controls.Timeline.Accessibility.countReblogs(status.reblogsCount.intValue) }() - // disable reblog when non-public (except self) + // disable reblog if needs (except self) cell.statusView.actionToolbarContainer.reblogButton.isEnabled = true - if let visibility = status.visibilityEnum, visibility != .public, status.author.id != requestUserID { - cell.statusView.actionToolbarContainer.reblogButton.isEnabled = false + if let visibility = status.visibilityEnum, status.author.id != requestUserID { + switch visibility { + case .public, .unlisted: + break + default: + cell.statusView.actionToolbarContainer.reblogButton.isEnabled = false + } } // set like From 2aede8d461e77c1ecfcb92804b47f7bdad32a7a8 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 30 Jun 2021 15:51:05 +0800 Subject: [PATCH 21/31] fix: post visibility icon layout issue --- Mastodon/Scene/Share/View/Content/StatusView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index c647a6463..2b51f028d 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -353,7 +353,9 @@ extension StatusView { nameTrialingDotLabel.setContentCompressionResistancePriority(.required - 2, for: .horizontal) dateLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) dateLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) - visibilityImageView.setContentHuggingPriority(.defaultHigh + 3, for: .horizontal) + visibilityImageView.setContentHuggingPriority(.required - 1, for: .horizontal) + visibilityImageView.setContentHuggingPriority(.required - 1, for: .vertical) + visibilityImageView.setContentCompressionResistancePriority(.required - 1, for: .horizontal) // subtitle container: [username] let subtitleContainerStackView = UIStackView() From 9460ab2c587458bd857f545ad22319e71622f59c Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 30 Jun 2021 15:54:36 +0800 Subject: [PATCH 22/31] fix: thread title view using wrong emoji source issue --- Mastodon/Scene/Thread/ThreadViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mastodon/Scene/Thread/ThreadViewModel.swift b/Mastodon/Scene/Thread/ThreadViewModel.swift index 0599a0654..b3c89012f 100644 --- a/Mastodon/Scene/Thread/ThreadViewModel.swift +++ b/Mastodon/Scene/Thread/ThreadViewModel.swift @@ -53,7 +53,7 @@ class ThreadViewModel { self.rootItem = CurrentValueSubject(optionalStatus.flatMap { Item.root(statusObjectID: $0.objectID, attribute: Item.StatusAttribute()) }) self.existStatusFetchedResultsController = StatusFetchedResultsController(managedObjectContext: context.managedObjectContext, domain: nil, additionalTweetPredicate: nil) self.navigationBarTitle = CurrentValueSubject( - optionalStatus.flatMap { (L10n.Scene.Thread.title($0.author.displayNameWithFallback), $0.emojiDict) } + optionalStatus.flatMap { (L10n.Scene.Thread.title($0.author.displayNameWithFallback), $0.author.emojiDict) } ) // bind fetcher domain From 5dc0cb0cc583d989806c68694410d76fd05d07c7 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 30 Jun 2021 16:14:01 +0800 Subject: [PATCH 23/31] fix: post attachment task not trigger on queue issue --- Mastodon/Scene/Compose/ComposeViewModel.swift | 49 +++++++++---------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 68880e819..15dd0ef3b 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -292,33 +292,32 @@ final class ComposeViewModel: NSObject { .store(in: &disposeBag) // setup attribute updater - Publishers.CombineLatest( - attachmentServices, - context.timestampUpdatePublisher - ) - .sink { attachmentServices, _ in - // drive service upload state - // make image upload in the queue - for attachmentService in attachmentServices { - // skip when prefix N task when task finish OR fail OR uploading - guard let currentState = attachmentService.uploadStateMachine.currentState else { break } - if currentState is MastodonAttachmentService.UploadState.Fail { - continue - } - if currentState is MastodonAttachmentService.UploadState.Finish { - continue - } - if currentState is MastodonAttachmentService.UploadState.Uploading { - break - } - // trigger uploading one by one - if currentState is MastodonAttachmentService.UploadState.Initial { - attachmentService.uploadStateMachine.enter(MastodonAttachmentService.UploadState.Uploading.self) - break + attachmentServices + .receive(on: DispatchQueue.main) + .debounce(for: 0.3, scheduler: DispatchQueue.main) + .sink { attachmentServices in + // drive service upload state + // make image upload in the queue + for attachmentService in attachmentServices { + // skip when prefix N task when task finish OR fail OR uploading + guard let currentState = attachmentService.uploadStateMachine.currentState else { break } + if currentState is MastodonAttachmentService.UploadState.Fail { + continue + } + if currentState is MastodonAttachmentService.UploadState.Finish { + continue + } + if currentState is MastodonAttachmentService.UploadState.Uploading { + break + } + // trigger uploading one by one + if currentState is MastodonAttachmentService.UploadState.Initial { + attachmentService.uploadStateMachine.enter(MastodonAttachmentService.UploadState.Uploading.self) + break + } } } - } - .store(in: &disposeBag) + .store(in: &disposeBag) // bind delegate attachmentServices From d0dd92789f4f4086c81ceb10aa3c874779db17b8 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 30 Jun 2021 16:14:33 +0800 Subject: [PATCH 24/31] fix: attachment preview corner radius may missing issue --- .../TableViewCell/ComposeStatusAttachmentTableViewCell.swift | 3 +-- Mastodon/Scene/Compose/View/AttachmentContainerView.swift | 2 ++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift index 8b2ce455d..cb4da5765 100644 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift @@ -93,9 +93,8 @@ extension ComposeStatusAttachmentTableViewCell { cell.attachmentContainerView.previewImageView.image = placeholder return } + // cannot get correct size. set corner radius on layer cell.attachmentContainerView.previewImageView.image = image - .af.imageAspectScaled(toFill: size) - .af.imageRounded(withCornerRadius: AttachmentContainerView.containerViewCornerRadius) } .store(in: &cell.disposeBag) Publishers.CombineLatest( diff --git a/Mastodon/Scene/Compose/View/AttachmentContainerView.swift b/Mastodon/Scene/Compose/View/AttachmentContainerView.swift index eb5f01f41..71893c474 100644 --- a/Mastodon/Scene/Compose/View/AttachmentContainerView.swift +++ b/Mastodon/Scene/Compose/View/AttachmentContainerView.swift @@ -19,6 +19,8 @@ final class AttachmentContainerView: UIView { let previewImageView: UIImageView = { let imageView = UIImageView() imageView.contentMode = .scaleAspectFill + imageView.layer.cornerRadius = AttachmentContainerView.containerViewCornerRadius + imageView.layer.cornerCurve = .continuous imageView.layer.masksToBounds = true return imageView }() From 9e5d7836eee00111172a09f04bdc4fbf9840c397 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 30 Jun 2021 16:15:15 +0800 Subject: [PATCH 25/31] fix: text editor background using wrong color in compose scene issue --- .../TableViewCell/ComposeStatusContentTableViewCell.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift index 8f9acd50a..aa641fe4a 100644 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift @@ -83,6 +83,7 @@ extension ComposeStatusContentTableViewCell { private func _init() { selectionStyle = .none layer.zPosition = 999 + backgroundColor = .clear preservesSuperviewLayoutMargins = true let containerStackView = UIStackView() From 37df2fbd456cb559e15311aba42d56a2cfbe768e Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 30 Jun 2021 16:20:46 +0800 Subject: [PATCH 26/31] fix: notification always trigger scroll-to-top issue --- Mastodon/Scene/Notification/NotificationViewController.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index 322f804af..6004c48df 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -93,6 +93,7 @@ extension NotificationViewController { .receive(on: RunLoop.main) .sink { [weak self] in guard let self = self else { return } + guard self.viewModel.needsScrollToTopAfterDataSourceUpdate else { return } self.viewModel.needsScrollToTopAfterDataSourceUpdate = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.33) { self.scrollToTop(animated: true) @@ -106,6 +107,9 @@ extension NotificationViewController { .sink { [weak self] segment in guard let self = self else { return } self.segmentControl.selectedSegmentIndex = segment.rawValue + + // trigger scroll-to-top after data reload + self.viewModel.needsScrollToTopAfterDataSourceUpdate = true guard let domain = self.viewModel.activeMastodonAuthenticationBox.value?.domain, let userID = self.viewModel.activeMastodonAuthenticationBox.value?.userID else { return From 001d216d122465d9f5612927c80a6b5cfa4995ca Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 30 Jun 2021 16:52:02 +0800 Subject: [PATCH 27/31] fix: reversed ancestor issue --- Mastodon/Scene/Thread/ThreadViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mastodon/Scene/Thread/ThreadViewModel.swift b/Mastodon/Scene/Thread/ThreadViewModel.swift index b3c89012f..26be56430 100644 --- a/Mastodon/Scene/Thread/ThreadViewModel.swift +++ b/Mastodon/Scene/Thread/ThreadViewModel.swift @@ -239,7 +239,7 @@ extension ThreadViewModel { nextID = object.inReplyToID } } - return nodes.reversed() + return nodes } } From cdbb9cf2d1670249a56ce326c12af7e26cac29f8 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 30 Jun 2021 17:56:31 +0800 Subject: [PATCH 28/31] fix: make audio player safe with other app audio/music session --- .../ViewModel/VideoPlayerViewModel.swift | 6 ++- Mastodon/Service/AudioPlaybackService.swift | 47 +++++++------------ Mastodon/Service/PlaybackState.swift | 18 +++++++ Mastodon/Service/VideoPlaybackService.swift | 4 +- 4 files changed, 42 insertions(+), 33 deletions(-) diff --git a/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift b/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift index 6da59aef0..83e342dcd 100644 --- a/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift @@ -69,7 +69,8 @@ final class VideoPlayerViewModel { case .gif: break case .video: - try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) + break +// try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) } } .store(in: &disposeBag) @@ -107,7 +108,8 @@ extension VideoPlayerViewModel { case .gif: break case .video: - try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) + break +// try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) } player.play() diff --git a/Mastodon/Service/AudioPlaybackService.swift b/Mastodon/Service/AudioPlaybackService.swift index 274cd5598..6fdac4bf3 100644 --- a/Mastodon/Service/AudioPlaybackService.swift +++ b/Mastodon/Service/AudioPlaybackService.swift @@ -23,7 +23,6 @@ final class AudioPlaybackService: NSObject { var statusObserver: Any? var attachment: Attachment? - let session = AVAudioSession.sharedInstance() let playbackState = CurrentValueSubject(PlaybackState.unknown) let currentTimeSubject = CurrentValueSubject(0) @@ -31,6 +30,23 @@ final class AudioPlaybackService: NSObject { override init() { super.init() addObserver() + + playbackState + .receive(on: RunLoop.main) + .sink { status in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: audio status: %s", ((#file as NSString).lastPathComponent), #line, #function, status.description) + switch status { + case .unknown, .buffering, .readyToPlay: + break + case .playing: + try? AVAudioSession.sharedInstance().setCategory(.soloAmbient) + try? AVAudioSession.sharedInstance().setActive(true) + case .paused, .stopped, .failed: + try? AVAudioSession.sharedInstance().setCategory(.ambient) + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + } + .store(in: &disposeBag) } } @@ -39,12 +55,6 @@ extension AudioPlaybackService { guard let url = URL(string: audioAttachment.url) else { return } - do { - try session.setCategory(.playback) - } catch { - print(error) - return - } notifyWillPlayAudioNotification() if audioAttachment == attachment { @@ -64,27 +74,6 @@ extension AudioPlaybackService { } func addObserver() { - UIDevice.current.isProximityMonitoringEnabled = true - NotificationCenter.default.publisher(for: UIDevice.proximityStateDidChangeNotification, object: nil) - .sink { [weak self] _ in - guard let self = self else { return } - if UIDevice.current.proximityState == true { - do { - try self.session.setCategory(.playAndRecord) - } catch { - print(error) - return - } - } else { - do { - try self.session.setCategory(.playback) - } catch { - print(error) - return - } - } - } - .store(in: &disposeBag) NotificationCenter.default.publisher(for: VideoPlayerViewModel.appWillPlayVideoNotification) .sink { [weak self] _ in guard let self = self else { return } @@ -96,7 +85,7 @@ extension AudioPlaybackService { guard let self = self else { return } self.currentTimeSubject.value = time.seconds }) - player.publisher(for: \.status, options: .new) + player.publisher(for: \.status, options: [.initial, .new]) .sink(receiveValue: { [weak self] status in guard let self = self else { return } switch status { diff --git a/Mastodon/Service/PlaybackState.swift b/Mastodon/Service/PlaybackState.swift index 75fced7bb..8e62fa145 100644 --- a/Mastodon/Service/PlaybackState.swift +++ b/Mastodon/Service/PlaybackState.swift @@ -23,3 +23,21 @@ public enum PlaybackState : Int { case failed = 6 } + +// MARK: - CustomStringConvertible +extension PlaybackState: CustomStringConvertible { + public var description: String { + switch self { + case .unknown: return "unknown" + case .buffering: return "buffering" + case .readyToPlay: return "readyToPlay" + case .playing: return "playing" + case .paused: return "paused" + case .stopped: return "stopped" + case .failed: return "failed" + default: + assertionFailure() + return "" + } + } +} diff --git a/Mastodon/Service/VideoPlaybackService.swift b/Mastodon/Service/VideoPlaybackService.swift index a15431f01..e0ac5e6ff 100644 --- a/Mastodon/Service/VideoPlaybackService.swift +++ b/Mastodon/Service/VideoPlaybackService.swift @@ -40,7 +40,7 @@ extension VideoPlaybackService { } else { if latestPlayingVideoPlayerViewModel === playerViewModel { latestPlayingVideoPlayerViewModel = nil - try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) +// try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) } } } @@ -111,7 +111,7 @@ extension VideoPlaybackService { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) // note: do not retain view controller - // pause all player when view disppear exclude full screen player and other transitioning scene + // pause all player when view disappear exclude full screen player and other transitioning scene for viewModel in viewPlayerViewModelDict.values { guard !viewModel.isTransitioning else { viewModel.isTransitioning = false From 4318e3c9d1b7e5415e281a1be66f339158a7f67f Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 30 Jun 2021 18:02:01 +0800 Subject: [PATCH 29/31] fix: audio session not deactivate after enter background issue --- Mastodon/Supporting Files/AppDelegate.swift | 2 +- Mastodon/Supporting Files/SceneDelegate.swift | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Mastodon/Supporting Files/AppDelegate.swift b/Mastodon/Supporting Files/AppDelegate.swift index 83dffc342..70d259ca7 100644 --- a/Mastodon/Supporting Files/AppDelegate.swift +++ b/Mastodon/Supporting Files/AppDelegate.swift @@ -9,6 +9,7 @@ import os.log import UIKit import UserNotifications import AppShared +import AVFoundation #if ASDK import AsyncDisplayKit @@ -55,7 +56,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Use this method to release any resources that were specific to the discarded scenes, as they will not return. } - } extension AppDelegate { diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift index 7c961dbd2..697e90653 100644 --- a/Mastodon/Supporting Files/SceneDelegate.swift +++ b/Mastodon/Supporting Files/SceneDelegate.swift @@ -83,6 +83,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // Called as the scene transitions from the foreground to the background. // Use this method to save data, release shared resources, and store enough scene-specific state information // to restore the scene back to its current state. + AppContext.shared.audioPlaybackService.pauseIfNeed() } From 777534c82ddf52fd994f01229568e79e4ae9ef6c Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 30 Jun 2021 18:19:12 +0800 Subject: [PATCH 30/31] fix: GIF may cause background audio session pause issue --- .../ViewModel/VideoPlayerViewModel.swift | 69 +++++++++++++++---- 1 file changed, 57 insertions(+), 12 deletions(-) diff --git a/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift b/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift index 83e342dcd..64bde2e66 100644 --- a/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift @@ -37,7 +37,8 @@ final class VideoPlayerViewModel { private var timeControlStatusObservation: NSKeyValueObservation? let timeControlStatus = CurrentValueSubject(.paused) - + let playbackState = CurrentValueSubject(PlaybackState.unknown) + init(previewImageURL: URL?, videoURL: URL, videoSize: CGSize, videoKind: VideoPlayerViewModel.Kind) { self.previewImageURL = previewImageURL self.videoURL = videoURL @@ -58,19 +59,42 @@ final class VideoPlayerViewModel { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: player state: %s", (#file as NSString).lastPathComponent, #line, #function, player.timeControlStatus.debugDescription) self.timeControlStatus.value = player.timeControlStatus } - - // update audio session category for user interactive event stream + + player.publisher(for: \.status, options: [.initial, .new]) + .sink(receiveValue: { [weak self] status in + guard let self = self else { return } + switch status { + case .failed: + self.playbackState.value = .failed + case .readyToPlay: + self.playbackState.value = .readyToPlay + case .unknown: + self.playbackState.value = .unknown + @unknown default: + assertionFailure() + } + }) + .store(in: &disposeBag) + timeControlStatus .sink { [weak self] timeControlStatus in - guard let _ = self else { return } - guard timeControlStatus == .playing else { return } - NotificationCenter.default.post(name: VideoPlayerViewModel.appWillPlayVideoNotification, object: nil) - switch videoKind { - case .gif: - break - case .video: - break -// try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) + guard let self = self else { return } + + // emit playing event + if timeControlStatus == .playing { + NotificationCenter.default.post(name: VideoPlayerViewModel.appWillPlayVideoNotification, object: nil) + } + + switch timeControlStatus { + case .paused: + self.playbackState.value = .paused + case .waitingToPlayAtSpecifiedRate: + self.playbackState.value = .buffering + case .playing: + self.playbackState.value = .playing + @unknown default: + assertionFailure() + self.playbackState.value = .unknown } } .store(in: &disposeBag) @@ -82,6 +106,27 @@ final class VideoPlayerViewModel { isPlay ? self.play() : self.pause() } .store(in: &disposeBag) + + let sessionName = videoKind == .gif ? "GIF" : "Video" + playbackState + .receive(on: RunLoop.main) + .sink { [weak self] status in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: %s status: %s", ((#file as NSString).lastPathComponent), #line, #function, sessionName, status.description) + guard let self = self else { return } + // only update audio session for video + guard self.videoKind == .video else { return } + switch status { + case .unknown, .buffering, .readyToPlay: + break + case .playing: + try? AVAudioSession.sharedInstance().setCategory(.soloAmbient) + try? AVAudioSession.sharedInstance().setActive(true) + case .paused, .stopped, .failed: + try? AVAudioSession.sharedInstance().setCategory(.ambient) + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + } + .store(in: &disposeBag) } deinit { From 72b2176fdd35d3d446598456b518a5242ed8d14a Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 30 Jun 2021 18:23:54 +0800 Subject: [PATCH 31/31] chore: update version to 0.7.7 (24) --- Mastodon.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 9b79bf298..104ac08d9 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -3843,7 +3843,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 23; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = Mastodon/Info.plist; @@ -3870,7 +3870,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 23; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = Mastodon/Info.plist; @@ -4198,7 +4198,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 23; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = Mastodon/Info.plist; @@ -4312,7 +4312,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 23; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = NotificationService/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -4431,7 +4431,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 23; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = Mastodon/Info.plist; @@ -4545,7 +4545,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 23; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = NotificationService/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -4599,7 +4599,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 23; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = NotificationService/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -4622,7 +4622,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 23; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = NotificationService/Info.plist; LD_RUNPATH_SEARCH_PATHS = (