From 97ecbb1bfb3160392f199da158a7b4b30e3ecc45 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 11 Mar 2021 15:41:27 +0800 Subject: [PATCH 01/17] feat: add compose scene --- Localization/app.json | 6 ++ Mastodon.xcodeproj/project.pbxproj | 63 +++++++++++ .../xcshareddata/swiftpm/Package.resolved | 9 ++ Mastodon/Coordinator/SceneCoordinator.swift | 7 ++ .../Diffiable/Item/ComposeStatusItem.swift | 34 ++++++ .../Section/ComposeStatusSection.swift | 13 +++ Mastodon/Generated/Strings.swift | 8 ++ .../Resources/en.lproj/Localizable.strings | 2 + .../Scene/Compose/ComposeViewController.swift | 102 ++++++++++++++++++ .../Compose/ComposeViewModel+Diffable.swift | 40 +++++++ Mastodon/Scene/Compose/ComposeViewModel.swift | 44 ++++++++ ...oseRepliedToTootContentTableViewCell.swift | 31 ++++++ .../ComposeTootContentTableViewCell.swift | 40 +++++++ .../HomeTimelineViewController.swift | 3 +- README.md | 1 + 15 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 Mastodon/Diffiable/Item/ComposeStatusItem.swift create mode 100644 Mastodon/Diffiable/Section/ComposeStatusSection.swift create mode 100644 Mastodon/Scene/Compose/ComposeViewController.swift create mode 100644 Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift create mode 100644 Mastodon/Scene/Compose/ComposeViewModel.swift create mode 100644 Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToTootContentTableViewCell.swift create mode 100644 Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift diff --git a/Localization/app.json b/Localization/app.json index 123655955..ab5b3f659 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -173,6 +173,12 @@ }, "public_timeline": { "title": "Public" + }, + "compose": { + "title": { + "new_toot": "New Toot", + "new_reply": "New Reply" + } } } } \ No newline at end of file diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 69f431909..d999c0e81 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -149,6 +149,11 @@ DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */; }; DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */; }; DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F11725EFA35B001F1DAB /* StripProgressView.swift */; }; + DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */; }; + DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729525F9F91600D60309 /* ComposeStatusSection.swift */; }; + DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */; }; + DB6672A325F9FDE500D60309 /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DB6672A225F9FDE500D60309 /* TwitterTextEditor */; }; + DB6672A425F9FDE500D60309 /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DB6672A225F9FDE500D60309 /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */; }; DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */; }; DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; }; @@ -156,6 +161,10 @@ DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; }; DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; }; DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */; }; + DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */; }; + DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */; }; + DB789A1C25F9F76A0071ACA0 /* ComposeTootContentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1B25F9F76A0071ACA0 /* ComposeTootContentTableViewCell.swift */; }; + DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToTootContentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentTableViewCell.swift */; }; DB89B9F725C10FD0008580ED /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; }; DB89B9FE25C10FD0008580ED /* CoreDataStackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89B9FD25C10FD0008580ED /* CoreDataStackTests.swift */; }; DB89BA0025C10FD0008580ED /* CoreDataStack.h in Headers */ = {isa = PBXBuildFile; fileRef = DB89B9F025C10FD0008580ED /* CoreDataStack.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -244,6 +253,7 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( + DB6672A425F9FDE500D60309 /* TwitterTextEditor in Embed Frameworks */, DB89BA0425C10FD0008580ED /* CoreDataStack.framework in Embed Frameworks */, ); name = "Embed Frameworks"; @@ -401,6 +411,9 @@ DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = ""; }; DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Poll.swift"; sourceTree = ""; }; DB59F11725EFA35B001F1DAB /* StripProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripProgressView.swift; sourceTree = ""; }; + DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+Diffable.swift"; sourceTree = ""; }; + DB66729525F9F91600D60309 /* ComposeStatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusSection.swift; sourceTree = ""; }; + DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusItem.swift; sourceTree = ""; }; DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSKeyValueObservation.swift; sourceTree = ""; }; DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DarkContentStatusBarStyleNavigationController.swift; sourceTree = ""; }; DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; @@ -408,6 +421,10 @@ DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = ""; }; DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = ""; }; DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.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 /* ComposeTootContentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTootContentTableViewCell.swift; sourceTree = ""; }; + DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToTootContentTableViewCell.swift; sourceTree = ""; }; DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CoreDataStack.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DB89B9F025C10FD0008580ED /* CoreDataStack.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CoreDataStack.h; sourceTree = ""; }; DB89B9F125C10FD0008580ED /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -461,6 +478,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DB6672A325F9FDE500D60309 /* TwitterTextEditor in Frameworks */, DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */, DB89BA0325C10FD0008580ED /* CoreDataStack.framework in Frameworks */, 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */, @@ -718,6 +736,7 @@ DB4481C525EE2ADA00BEFB67 /* PollSection.swift */, DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */, DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */, + DB66729525F9F91600D60309 /* ComposeStatusSection.swift */, ); path = Section; sourceTree = ""; @@ -765,6 +784,7 @@ DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */, DB1E347725F519300079D7DF /* PickServerItem.swift */, DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */, + DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */, ); path = Item; sourceTree = ""; @@ -996,6 +1016,26 @@ path = ServerRules; sourceTree = ""; }; + DB789A1025F9F29B0071ACA0 /* Compose */ = { + isa = PBXGroup; + children = ( + DB789A2125F9F76D0071ACA0 /* TableViewCell */, + DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */, + DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */, + DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */, + ); + path = Compose; + sourceTree = ""; + }; + DB789A2125F9F76D0071ACA0 /* TableViewCell */ = { + isa = PBXGroup; + children = ( + DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentTableViewCell.swift */, + DB789A1B25F9F76A0071ACA0 /* ComposeTootContentTableViewCell.swift */, + ); + path = TableViewCell; + sourceTree = ""; + }; DB89B9EF25C10FD0008580ED /* CoreDataStack */ = { isa = PBXGroup; children = ( @@ -1097,6 +1137,7 @@ DB9D6BEE25E4F5370051B173 /* Search */, DB9D6BFD25E4F57B0051B173 /* Notification */, DB9D6C0825E4F5A60051B173 /* Profile */, + DB789A1025F9F29B0071ACA0 /* Compose */, ); path = Scene; sourceTree = ""; @@ -1253,6 +1294,7 @@ DB5086B725CC0D6400C2C187 /* Kingfisher */, 2D5981B925E4D7F8000FB903 /* ThirdPartyMailer */, 2D939AC725EE14620076FA61 /* CropViewController */, + DB6672A225F9FDE500D60309 /* TwitterTextEditor */, ); productName = Mastodon; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; @@ -1382,6 +1424,7 @@ DB5086B625CC0D6400C2C187 /* XCRemoteSwiftPackageReference "Kingfisher" */, 2D5981B825E4D7F8000FB903 /* XCRemoteSwiftPackageReference "ThirdPartyMailer" */, 2D939AC625EE14620076FA61 /* XCRemoteSwiftPackageReference "TOCropViewController" */, + DB6672A125F9FDE500D60309 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -1576,6 +1619,7 @@ 5DF1054125F886D400D6C0D4 /* ViedeoPlaybackService.swift in Sources */, 0FB3D2F725E4C24D00AAD544 /* MastodonPickServerViewModel.swift in Sources */, 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */, + DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */, 2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */, 0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */, 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */, @@ -1593,6 +1637,7 @@ 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */, DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */, 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */, + DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */, 0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */, DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */, DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */, @@ -1617,6 +1662,7 @@ 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */, DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */, + DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */, DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */, DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */, @@ -1641,6 +1687,7 @@ DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, + DB789A1C25F9F76A0071ACA0 /* ComposeTootContentTableViewCell.swift in Sources */, DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */, 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, @@ -1705,10 +1752,13 @@ 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, + DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */, 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, + DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */, 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */, 5DF1057925F88A1D00D6C0D4 /* MosaicPlayerView.swift in Sources */, DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, + DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToTootContentTableViewCell.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2295,6 +2345,14 @@ minimumVersion = 6.1.0; }; }; + DB6672A125F9FDE500D60309 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/twitter/TwitterTextEditor.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -2337,6 +2395,11 @@ package = DB5086B625CC0D6400C2C187 /* XCRemoteSwiftPackageReference "Kingfisher" */; productName = Kingfisher; }; + DB6672A225F9FDE500D60309 /* TwitterTextEditor */ = { + isa = XCSwiftPackageProductDependency; + package = DB6672A125F9FDE500D60309 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */; + productName = TwitterTextEditor; + }; /* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3183f10d5..21afdd4cd 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -99,6 +99,15 @@ "revision": "dad97167bf1be16aeecd109130900995dd01c515", "version": "2.6.0" } + }, + { + "package": "TwitterTextEditor", + "repositoryURL": "https://github.com/twitter/TwitterTextEditor.git", + "state": { + "branch": null, + "revision": "8aa914134c5b6aa46e862de63f239ec0e3b52a91", + "version": "1.0.0" + } } ] }, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index fa2963386..24d33c83f 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -47,6 +47,9 @@ extension SceneCoordinator { case mastodonConfirmEmail(viewModel: MastodonConfirmEmailViewModel) case mastodonResendEmail(viewModel: MastodonResendEmailViewModel) + // compose + case compose(viewModel: ComposeViewModel) + // misc case alertController(alertController: UIAlertController) @@ -190,6 +193,10 @@ private extension SceneCoordinator { let _viewController = MastodonResendEmailViewController() _viewController.viewModel = viewModel viewController = _viewController + case .compose(let viewModel): + let _viewController = ComposeViewController() + _viewController.viewModel = viewModel + viewController = _viewController case .alertController(let alertController): if let popoverPresentationController = alertController.popoverPresentationController { assert( diff --git a/Mastodon/Diffiable/Item/ComposeStatusItem.swift b/Mastodon/Diffiable/Item/ComposeStatusItem.swift new file mode 100644 index 000000000..35977f3d4 --- /dev/null +++ b/Mastodon/Diffiable/Item/ComposeStatusItem.swift @@ -0,0 +1,34 @@ +// +// ComposeStatusItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import Foundation +import CoreData + +enum ComposeStatusItem { + case replyTo(tootObjectID: NSManagedObjectID) + case toot(attribute: InputAttribute) +} + +extension ComposeStatusItem: Hashable { } + +extension ComposeStatusItem { + class InputAttribute: Hashable { + let hasReplyTo: Bool + + init(hasReplyTo: Bool) { + self.hasReplyTo = hasReplyTo + } + + func hash(into hasher: inout Hasher) { + hasher.combine(hasReplyTo) + } + + static func == (lhs: ComposeStatusItem.InputAttribute, rhs: ComposeStatusItem.InputAttribute) -> Bool { + return lhs.hasReplyTo == rhs.hasReplyTo + } + } +} diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift new file mode 100644 index 000000000..56b006892 --- /dev/null +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -0,0 +1,13 @@ +// +// ComposeStatusSection.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import Foundation + +enum ComposeStatusSection: Equatable, Hashable { + case repliedTo + case status +} diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 7c595918e..6df84fb7d 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -127,6 +127,14 @@ internal enum L10n { } internal enum Scene { + internal enum Compose { + internal enum Title { + /// New Reply + internal static let newReply = L10n.tr("Localizable", "Scene.Compose.Title.NewReply") + /// New Toot + internal static let newToot = L10n.tr("Localizable", "Scene.Compose.Title.NewToot") + } + } internal enum ConfirmEmail { /// We just sent an email to %@,\ntap the link to confirm your account. internal static func subtitle(_ p1: Any) -> String { diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 54b69e274..a83819ddc 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -34,6 +34,8 @@ "Common.Controls.Timeline.LoadMore" = "Load More"; "Common.Countable.Photo.Multiple" = "photos"; "Common.Countable.Photo.Single" = "photo"; +"Scene.Compose.Title.NewReply" = "New Reply"; +"Scene.Compose.Title.NewToot" = "New Toot"; "Scene.ConfirmEmail.Button.DontReceiveEmail" = "I never got an email"; "Scene.ConfirmEmail.Button.OpenEmailApp" = "Open Email App"; "Scene.ConfirmEmail.DontReceiveEmail.Description" = "Check if your email address is correct as well as your junk folder if you haven’t."; diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift new file mode 100644 index 000000000..f5f2746d8 --- /dev/null +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -0,0 +1,102 @@ +// +// ComposeViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import os.log +import UIKit +import Combine +import TwitterTextEditor + +final class ComposeViewController: UIViewController, NeedsDependency { + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + var viewModel: ComposeViewModel! + + let tableView: UITableView = { + let tableView = ControlContainableTableView() + tableView.register(ComposeRepliedToTootContentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeRepliedToTootContentTableViewCell.self)) + tableView.register(ComposeTootContentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeTootContentTableViewCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + return tableView + }() + +} + +extension ComposeViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = Asset.Colors.Background.systemBackground.color + viewModel.title + .receive(on: DispatchQueue.main) + .sink { [weak self] title in + guard let self = self else { return } + self.title = title + } + .store(in: &disposeBag) + navigationItem.leftBarButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ComposeViewController.cancelBarButtonItemPressed(_:))) + + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + tableView.delegate = self + viewModel.setupDiffableDataSource(for: tableView) + + + } + +} + +extension ComposeViewController { + + @objc private func cancelBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + dismiss(animated: true, completion: nil) + } + +} + +// MARK: - TextEditorViewTextAttributesDelegate +extension ComposeViewController: TextEditorViewTextAttributesDelegate { + + func textEditorView(_ textEditorView: TextEditorView, updateAttributedString attributedString: NSAttributedString, completion: @escaping (NSAttributedString?) -> Void) { + // TODO: + } + +} + +// MARK: - UITableViewDelegate +extension ComposeViewController: UITableViewDelegate { + +} + +// MARK: - ComposeViewController +extension ComposeViewController: UIAdaptivePresentationControllerDelegate { + + func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { + return viewModel.shouldDismiss.value + } + + func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift new file mode 100644 index 000000000..65a608653 --- /dev/null +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -0,0 +1,40 @@ +// +// ComposeViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import UIKit + +extension ComposeViewModel { + + func setupDiffableDataSource(for tableView: UITableView) { + diffableDataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item -> UITableViewCell? in + guard let self = self else { return nil } + + switch item { + case .replyTo(let tootObjectID): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToTootContentTableViewCell.self), for: indexPath) as! ComposeRepliedToTootContentTableViewCell + // TODO: + return cell + case .toot(let attribute): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeTootContentTableViewCell.self), for: indexPath) as! ComposeTootContentTableViewCell + // TODO: + return cell + } + } + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.repliedTo, .status]) + switch composeKind { + case .replyToot(let tootObjectID): + snapshot.appendItems([.replyTo(tootObjectID: tootObjectID)], toSection: .repliedTo) + snapshot.appendItems([.toot(attribute: ComposeStatusItem.InputAttribute(hasReplyTo: true))], toSection: .status) + case .toot: + snapshot.appendItems([.toot(attribute: ComposeStatusItem.InputAttribute(hasReplyTo: false))], toSection: .status) + } + diffableDataSource.apply(snapshot, animatingDifferences: false) + } + +} diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift new file mode 100644 index 000000000..bcda65879 --- /dev/null +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -0,0 +1,44 @@ +// +// ComposeViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack + +final class ComposeViewModel { + + // input + let context: AppContext + let composeKind: ComposeKind + + // output + var diffableDataSource: UITableViewDiffableDataSource! + let title: CurrentValueSubject + let shouldDismiss = CurrentValueSubject(true) + + init( + context: AppContext, + composeKind: ComposeKind + ) { + self.context = context + self.composeKind = composeKind + switch composeKind { + case .toot: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newToot) + case .replyToot: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply) + } + // end init + } + +} + +extension ComposeViewModel { + enum ComposeKind { + case toot + case replyToot(tootObjectID: NSManagedObjectID) + } +} diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToTootContentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToTootContentTableViewCell.swift new file mode 100644 index 000000000..def777caf --- /dev/null +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToTootContentTableViewCell.swift @@ -0,0 +1,31 @@ +// +// ComposeRepliedToTootContentTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import UIKit + +final class ComposeRepliedToTootContentTableViewCell: UITableViewCell { + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ComposeRepliedToTootContentTableViewCell { + + private func _init() { + + } + +} + diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift new file mode 100644 index 000000000..f26b19c62 --- /dev/null +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift @@ -0,0 +1,40 @@ +// +// ComposeTootContentTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import UIKit + +final class ComposeTootContentTableViewCell: UITableViewCell { + + let statusView = StatusView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ComposeTootContentTableViewCell { + + private func _init() { + statusView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(statusView) + NSLayoutConstraint.activate([ + statusView.topAnchor.constraint(equalTo: contentView.topAnchor), + statusView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + statusView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + statusView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + } + +} + diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index c9498dbea..19e8c3ed4 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -166,7 +166,8 @@ extension HomeTimelineViewController { @objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - + let composeViewModel = ComposeViewModel(context: context, composeKind: .toot) + coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) } @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { diff --git a/README.md b/README.md index 0847c82e5..53e3bf498 100644 --- a/README.md +++ b/README.md @@ -53,5 +53,6 @@ arch -x86_64 pod install - [Kingfisher](https://github.com/onevcat/Kingfisher) - [SwiftGen](https://github.com/SwiftGen/SwiftGen) - [SwiftyJSON](https://github.com/SwiftyJSON/SwiftyJSON) +- [TwitterTextEditor](https://github.com/twitter/TwitterTextEditor) ## License From 2b2759c2ccd537700cc9cce81a6007d80ea5f09f Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 11 Mar 2021 19:26:10 +0800 Subject: [PATCH 02/17] feat: let text editor become first responder when compose scene appear --- .../Diffiable/Item/ComposeStatusItem.swift | 20 +------- .../Scene/Compose/ComposeViewController.swift | 30 +++++++++++ .../Compose/ComposeViewModel+Diffable.swift | 4 +- .../ComposeTootContentTableViewCell.swift | 51 +++++++++++++++++-- .../TableviewCell/StatusTableViewCell.swift | 1 - 5 files changed, 80 insertions(+), 26 deletions(-) diff --git a/Mastodon/Diffiable/Item/ComposeStatusItem.swift b/Mastodon/Diffiable/Item/ComposeStatusItem.swift index 35977f3d4..2e125f636 100644 --- a/Mastodon/Diffiable/Item/ComposeStatusItem.swift +++ b/Mastodon/Diffiable/Item/ComposeStatusItem.swift @@ -10,25 +10,7 @@ import CoreData enum ComposeStatusItem { case replyTo(tootObjectID: NSManagedObjectID) - case toot(attribute: InputAttribute) + case toot(replyToTootObjectID: NSManagedObjectID?) } extension ComposeStatusItem: Hashable { } - -extension ComposeStatusItem { - class InputAttribute: Hashable { - let hasReplyTo: Bool - - init(hasReplyTo: Bool) { - self.hasReplyTo = hasReplyTo - } - - func hash(into hasher: inout Hasher) { - hasher.combine(hasReplyTo) - } - - static func == (lhs: ComposeStatusItem.InputAttribute, rhs: ComposeStatusItem.InputAttribute) -> Bool { - return lhs.hasReplyTo == rhs.hasReplyTo - } - } -} diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index f5f2746d8..adab1dd91 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -59,6 +59,36 @@ 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.markTextViewEditorBecomeFirstResponser() + } + } + +} + +extension ComposeViewController { + private func markTextViewEditorBecomeFirstResponser() { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let items = diffableDataSource.snapshot().itemIdentifiers + for item in items { + switch item { + case .toot: + guard let indexPath = diffableDataSource.indexPath(for: item), + let cell = tableView.cellForRow(at: indexPath) as? ComposeTootContentTableViewCell else { + continue + } + cell.textEditorView.isEditing = true + return + default: + continue + } + } + } } extension ComposeViewController { diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index 65a608653..772bb97b6 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -30,9 +30,9 @@ extension ComposeViewModel { switch composeKind { case .replyToot(let tootObjectID): snapshot.appendItems([.replyTo(tootObjectID: tootObjectID)], toSection: .repliedTo) - snapshot.appendItems([.toot(attribute: ComposeStatusItem.InputAttribute(hasReplyTo: true))], toSection: .status) + snapshot.appendItems([.toot(replyToTootObjectID: tootObjectID)], toSection: .status) case .toot: - snapshot.appendItems([.toot(attribute: ComposeStatusItem.InputAttribute(hasReplyTo: false))], toSection: .status) + snapshot.appendItems([.toot(replyToTootObjectID: nil)], toSection: .status) } diffableDataSource.apply(snapshot, animatingDifferences: false) } diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift index f26b19c62..6e7a2058c 100644 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift @@ -6,10 +6,18 @@ // import UIKit +import TwitterTextEditor final class ComposeTootContentTableViewCell: UITableViewCell { let statusView = StatusView() + let textEditorView: TextEditorView = { + let textEditorView = TextEditorView() + textEditorView.font = .preferredFont(forTextStyle: .body) +// textEditorView.scrollView.isScrollEnabled = false + textEditorView.isScrollEnabled = false + return textEditorView + }() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) @@ -26,15 +34,50 @@ final class ComposeTootContentTableViewCell: UITableViewCell { extension ComposeTootContentTableViewCell { private func _init() { + selectionStyle = .none + statusView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(statusView) NSLayoutConstraint.activate([ - statusView.topAnchor.constraint(equalTo: contentView.topAnchor), - statusView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - statusView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - statusView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20), + statusView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + statusView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), ]) + statusView.statusContainerStackView.isHidden = true + statusView.actionToolbarContainer.isHidden = true + + textEditorView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(textEditorView) + NSLayoutConstraint.activate([ + textEditorView.topAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10), + textEditorView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + textEditorView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: textEditorView.bottomAnchor, constant: 20), + textEditorView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh), + ]) + + // let containerStackView = UIStackView() + // containerStackView.axis = .vertical + // containerStackView.spacing = 8 + // containerStackView.translatesAutoresizingMaskIntoConstraints = false + // contentView.addSubview(containerStackView) + // NSLayoutConstraint.activate([ + // containerStackView.topAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10), + // containerStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + // containerStackView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + // contentView.bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor, constant: 20), + // ]) + + // TODO: + } + + override func didMoveToWindow() { + super.didMoveToWindow() + } } +extension ComposeTootContentTableViewCell { + +} diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 2f4000b95..230fe3dcd 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -20,7 +20,6 @@ protocol StatusTableViewCellDelegate: class { var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { get } func statusTableViewCell(_ cell: StatusTableViewCell, playerViewControllerDidPressed playerViewController: AVPlayerViewController) - func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) From d9e24534640233ba93b48003a888f770be66f5b2 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 12 Mar 2021 14:18:07 +0800 Subject: [PATCH 03/17] feat: make text editor automatic grow height during input --- Localization/app.json | 4 +- Mastodon.xcodeproj/project.pbxproj | 4 + .../Diffiable/Item/ComposeStatusItem.swift | 25 ++++++- .../Section/ComposeStatusSection.swift | 75 ++++++++++++++++++- Mastodon/Generated/Assets.swift | 1 + Mastodon/Generated/Strings.swift | 4 + .../Button/disabled.colorset/Contents.json | 6 +- .../Button/normal.colorset/Contents.json | 20 +++++ .../Resources/en.lproj/Localizable.strings | 2 + .../Scene/Compose/ComposeViewController.swift | 26 +++++-- .../Compose/ComposeViewModel+Diffable.swift | 29 +++---- Mastodon/Scene/Compose/ComposeViewModel.swift | 41 +++++++--- .../ComposeTootContentTableViewCell.swift | 31 ++++---- .../View/Button/RoundedEdgesButton.swift | 19 +++++ .../API/Mastodon+API+Statuses.swift | 8 ++ .../MastodonSDK/API/Mastodon+API.swift | 1 + 16 files changed, 246 insertions(+), 50 deletions(-) create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Button/normal.colorset/Contents.json create mode 100644 Mastodon/Scene/Share/View/Button/RoundedEdgesButton.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift diff --git a/Localization/app.json b/Localization/app.json index ab5b3f659..8734ea00f 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -178,7 +178,9 @@ "title": { "new_toot": "New Toot", "new_reply": "New Reply" - } + }, + "content_input_placeholder": "Type or paste what's on your mind", + "compose_action": "Toot" } } } \ No newline at end of file diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index d999c0e81..532ef6cbe 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -202,6 +202,7 @@ DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */; }; DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2D25E504AC0051B173 /* Attachment.swift */; }; DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C3725E508BE0051B173 /* Attachment.swift */; }; + DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */; }; DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; }; DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; }; DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; }; @@ -464,6 +465,7 @@ DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MosaicImageViewModel.swift; sourceTree = ""; }; DB9D6C2D25E504AC0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; DB9D6C3725E508BE0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; + DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedEdgesButton.swift; sourceTree = ""; }; DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = ""; }; DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = ""; }; DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = ""; }; @@ -649,6 +651,7 @@ 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */, DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */, 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */, + DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */, ); path = Button; sourceTree = ""; @@ -1633,6 +1636,7 @@ 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, + DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */, 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */, 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */, DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */, diff --git a/Mastodon/Diffiable/Item/ComposeStatusItem.swift b/Mastodon/Diffiable/Item/ComposeStatusItem.swift index 2e125f636..812a27a6f 100644 --- a/Mastodon/Diffiable/Item/ComposeStatusItem.swift +++ b/Mastodon/Diffiable/Item/ComposeStatusItem.swift @@ -6,11 +6,34 @@ // import Foundation +import Combine import CoreData enum ComposeStatusItem { case replyTo(tootObjectID: NSManagedObjectID) - case toot(replyToTootObjectID: NSManagedObjectID?) + case toot(replyToTootObjectID: NSManagedObjectID?, attribute: ComposeTootAttribute) } extension ComposeStatusItem: Hashable { } + +extension ComposeStatusItem { + final class ComposeTootAttribute: Equatable, Hashable { + private let id = UUID() + + let avatarURL = CurrentValueSubject(nil) + let displayName = CurrentValueSubject(nil) + let username = CurrentValueSubject(nil) + let composeContent = CurrentValueSubject(nil) + + static func == (lhs: ComposeTootAttribute, rhs: ComposeTootAttribute) -> Bool { + return lhs.avatarURL.value == rhs.avatarURL.value && + lhs.displayName.value == rhs.displayName.value && + lhs.username.value == rhs.username.value && + lhs.composeContent.value == rhs.composeContent.value + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + } +} diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 56b006892..e1405309b 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -5,9 +5,82 @@ // Created by MainasuK Cirno on 2021-3-11. // -import Foundation +import UIKit +import Combine +import CoreData +import CoreDataStack enum ComposeStatusSection: Equatable, Hashable { case repliedTo case status } + +extension ComposeStatusSection { + enum ComposeKind { + case toot + case replyToot(tootObjectID: NSManagedObjectID) + } +} + +extension ComposeStatusSection { + static func tableViewDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency, + managedObjectContext: NSManagedObjectContext, + composeKind: ComposeKind + ) -> UITableViewDiffableDataSource { + UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in + switch item { + case .replyTo(let tootObjectID): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToTootContentTableViewCell.self), for: indexPath) as! ComposeRepliedToTootContentTableViewCell + // TODO: + return cell + case .toot(let replyToTootObjectID, let attribute): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeTootContentTableViewCell.self), for: indexPath) as! ComposeTootContentTableViewCell + managedObjectContext.perform { + guard let replyToTootObjectID = replyToTootObjectID, + let replyTo = managedObjectContext.object(with: replyToTootObjectID) as? Toot else { + cell.statusView.headerContainerStackView.isHidden = true + return + } + cell.statusView.headerContainerStackView.isHidden = false + cell.statusView.headerInfoLabel.text = "[TODO] \(replyTo.author.displayName)" + } + ComposeStatusSection.configureComposeTootContent(cell: cell, attribute: attribute) + // self size input cell + cell.composeContent + .receive(on: DispatchQueue.main) + .sink { text in + tableView.beginUpdates() + tableView.endUpdates() + } + .store(in: &cell.disposeBag) + return cell + } + } + } +} + +extension ComposeStatusSection { + static func configureComposeTootContent( + cell: ComposeTootContentTableViewCell, + attribute: ComposeStatusItem.ComposeTootAttribute + ) { + attribute.avatarURL + .receive(on: DispatchQueue.main) + .sink { avatarURL in + cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarURL)) + } + .store(in: &cell.disposeBag) + 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 + } + .store(in: &cell.disposeBag) + } +} diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index f68170460..f573d2d12 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -48,6 +48,7 @@ internal enum Asset { internal static let actionToolbar = ColorAsset(name: "Colors/Button/action.toolbar") internal static let disabled = ColorAsset(name: "Colors/Button/disabled") internal static let highlight = ColorAsset(name: "Colors/Button/highlight") + internal static let normal = ColorAsset(name: "Colors/Button/normal") } internal enum Icon { internal static let photo = ColorAsset(name: "Colors/Icon/photo") diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 6df84fb7d..49e4cd7c2 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -128,6 +128,10 @@ internal enum L10n { internal enum Scene { internal enum Compose { + /// Toot + internal static let composeAction = L10n.tr("Localizable", "Scene.Compose.ComposeAction") + /// Type or paste what's on your mind + internal static let contentInputPlaceholder = L10n.tr("Localizable", "Scene.Compose.ContentInputPlaceholder") internal enum Title { /// New Reply internal static let newReply = L10n.tr("Localizable", "Scene.Compose.Title.NewReply") diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Button/disabled.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Button/disabled.colorset/Contents.json index 78cde95fb..bca754614 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Button/disabled.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Button/disabled.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.784", - "green" : "0.682", - "red" : "0.608" + "blue" : "140", + "green" : "130", + "red" : "110" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Button/normal.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Button/normal.colorset/Contents.json new file mode 100644 index 000000000..d853a71aa --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Button/normal.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "217", + "green" : "144", + "red" : "43" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index a83819ddc..f9a1ffe64 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -34,6 +34,8 @@ "Common.Controls.Timeline.LoadMore" = "Load More"; "Common.Countable.Photo.Multiple" = "photos"; "Common.Countable.Photo.Single" = "photo"; +"Scene.Compose.ComposeAction" = "Toot"; +"Scene.Compose.ContentInputPlaceholder" = "Type or paste what's on your mind"; "Scene.Compose.Title.NewReply" = "New Reply"; "Scene.Compose.Title.NewToot" = "New Toot"; "Scene.ConfirmEmail.Button.DontReceiveEmail" = "I never got an email"; diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index adab1dd91..f183bb255 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -18,6 +18,20 @@ final class ComposeViewController: UIViewController, NeedsDependency { var disposeBag = Set() var viewModel: ComposeViewModel! + let composeTootBarButtonItem: UIBarButtonItem = { + let button = RoundedEdgesButton(type: .custom) + button.setTitle(L10n.Scene.Compose.composeAction, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold) + button.setBackgroundImage(.placeholder(color: Asset.Colors.Button.normal.color), for: .normal) + button.setBackgroundImage(.placeholder(color: Asset.Colors.Button.normal.color.withAlphaComponent(0.5)), for: .highlighted) + button.setBackgroundImage(.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled) + button.setTitleColor(.white, for: .normal) + button.contentEdgeInsets = UIEdgeInsets(top: 3, left: 16, bottom: 3, right: 16) + button.adjustsImageWhenHighlighted = false + let barButtonItem = UIBarButtonItem(customView: button) + return barButtonItem + }() + let tableView: UITableView = { let tableView = ControlContainableTableView() tableView.register(ComposeRepliedToTootContentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeRepliedToTootContentTableViewCell.self)) @@ -34,7 +48,6 @@ extension ComposeViewController { override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = Asset.Colors.Background.systemBackground.color viewModel.title .receive(on: DispatchQueue.main) .sink { [weak self] title in @@ -42,7 +55,10 @@ extension ComposeViewController { self.title = title } .store(in: &disposeBag) + view.backgroundColor = Asset.Colors.Background.systemBackground.color navigationItem.leftBarButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ComposeViewController.cancelBarButtonItemPressed(_:))) + navigationItem.rightBarButtonItem = composeTootBarButtonItem + tableView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tableView) @@ -54,9 +70,7 @@ extension ComposeViewController { ]) tableView.delegate = self - viewModel.setupDiffableDataSource(for: tableView) - - + viewModel.setupDiffableDataSource(for: tableView, dependency: self) } override func viewWillAppear(_ animated: Bool) { @@ -111,7 +125,9 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { // MARK: - UITableViewDelegate extension ComposeViewController: UITableViewDelegate { - + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return UITableView.automaticDimension + } } // MARK: - ComposeViewController diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index 772bb97b6..5c27bf51d 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -9,30 +9,25 @@ import UIKit extension ComposeViewModel { - func setupDiffableDataSource(for tableView: UITableView) { - diffableDataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item -> UITableViewCell? in - guard let self = self else { return nil } - - switch item { - case .replyTo(let tootObjectID): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToTootContentTableViewCell.self), for: indexPath) as! ComposeRepliedToTootContentTableViewCell - // TODO: - return cell - case .toot(let attribute): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeTootContentTableViewCell.self), for: indexPath) as! ComposeTootContentTableViewCell - // TODO: - return cell - } - } + func setupDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency + ) { + diffableDataSource = ComposeStatusSection.tableViewDiffableDataSource( + for: tableView, + dependency: dependency, + managedObjectContext: context.managedObjectContext, + composeKind: composeKind + ) var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.repliedTo, .status]) switch composeKind { case .replyToot(let tootObjectID): snapshot.appendItems([.replyTo(tootObjectID: tootObjectID)], toSection: .repliedTo) - snapshot.appendItems([.toot(replyToTootObjectID: tootObjectID)], toSection: .status) + snapshot.appendItems([.toot(replyToTootObjectID: tootObjectID, attribute: composeTootAttribute)], toSection: .status) case .toot: - snapshot.appendItems([.toot(replyToTootObjectID: nil)], toSection: .status) + snapshot.appendItems([.toot(replyToTootObjectID: nil, attribute: composeTootAttribute)], toSection: .status) } diffableDataSource.apply(snapshot, animatingDifferences: false) } diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index bcda65879..7aaadcb70 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -12,18 +12,25 @@ import CoreDataStack final class ComposeViewModel { + var disposeBag = Set() + // input let context: AppContext - let composeKind: ComposeKind + let composeKind: ComposeStatusSection.ComposeKind + let composeTootAttribute = ComposeStatusItem.ComposeTootAttribute() + let composeContent = CurrentValueSubject("") + let activeAuthentication: CurrentValueSubject // output var diffableDataSource: UITableViewDiffableDataSource! + + // UI & UX let title: CurrentValueSubject let shouldDismiss = CurrentValueSubject(true) init( context: AppContext, - composeKind: ComposeKind + composeKind: ComposeStatusSection.ComposeKind ) { self.context = context self.composeKind = composeKind @@ -31,14 +38,30 @@ final class ComposeViewModel { case .toot: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newToot) case .replyToot: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply) } + self.activeAuthentication = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value) // end init + + // bind active authentication + context.authenticationService.activeMastodonAuthentication + .assign(to: \.value, on: activeAuthentication) + .store(in: &disposeBag) + + activeAuthentication + .sink { [weak self] mastodonAuthentication in + guard let self = self else { return } + let mastodonUser = mastodonAuthentication?.user + let username = mastodonUser?.username ?? " " + + self.composeTootAttribute.avatarURL.value = mastodonUser?.avatarImageURL() + self.composeTootAttribute.displayName.value = { + guard let displayName = mastodonUser?.displayName, !displayName.isEmpty else { + return username + } + return displayName + }() + self.composeTootAttribute.username.value = username + } + .store(in: &disposeBag) } } - -extension ComposeViewModel { - enum ComposeKind { - case toot - case replyToot(tootObjectID: NSManagedObjectID) - } -} diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift index 6e7a2058c..5a7f311d1 100644 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift @@ -6,19 +6,26 @@ // import UIKit +import Combine import TwitterTextEditor final class ComposeTootContentTableViewCell: UITableViewCell { + var disposeBag = Set() + let statusView = StatusView() + let textEditorView: TextEditorView = { let textEditorView = TextEditorView() textEditorView.font = .preferredFont(forTextStyle: .body) -// textEditorView.scrollView.isScrollEnabled = false + textEditorView.scrollView.isScrollEnabled = false textEditorView.isScrollEnabled = false + textEditorView.placeholderText = L10n.Scene.Compose.contentInputPlaceholder return textEditorView }() + let composeContent = PassthroughSubject() + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) _init() @@ -56,19 +63,9 @@ extension ComposeTootContentTableViewCell { textEditorView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh), ]) - // let containerStackView = UIStackView() - // containerStackView.axis = .vertical - // containerStackView.spacing = 8 - // containerStackView.translatesAutoresizingMaskIntoConstraints = false - // contentView.addSubview(containerStackView) - // NSLayoutConstraint.activate([ - // containerStackView.topAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10), - // containerStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), - // containerStackView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), - // contentView.bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor, constant: 20), - // ]) - // TODO: + + textEditorView.changeObserver = self } override func didMoveToWindow() { @@ -81,3 +78,11 @@ extension ComposeTootContentTableViewCell { extension ComposeTootContentTableViewCell { } + +// MARK: - UITextViewDelegate +extension ComposeTootContentTableViewCell: TextEditorViewChangeObserver { + func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) { + guard changeResult.isTextChanged else { return } + composeContent.send(textEditorView.text) + } +} diff --git a/Mastodon/Scene/Share/View/Button/RoundedEdgesButton.swift b/Mastodon/Scene/Share/View/Button/RoundedEdgesButton.swift new file mode 100644 index 000000000..a38b711dd --- /dev/null +++ b/Mastodon/Scene/Share/View/Button/RoundedEdgesButton.swift @@ -0,0 +1,19 @@ +// +// RoundedEdgesButton.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-12. +// + +import UIKit + +final class RoundedEdgesButton: UIButton { + + override func layoutSubviews() { + super.layoutSubviews() + + layer.masksToBounds = true + layer.cornerRadius = bounds.height * 0.5 + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift new file mode 100644 index 000000000..f01e6cb47 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift @@ -0,0 +1,8 @@ +// +// Mastodon+API+Statuses.swift +// +// +// Created by MainasuK Cirno on 2021-3-12. +// + +import Foundation diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 073d926e9..5443fa22d 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -96,6 +96,7 @@ extension Mastodon.API { public enum Onboarding { } public enum Polls { } public enum Timeline { } + public enum Statuses { } public enum Favorites { } } From 1746c1fc777ea6682cc408999b0ff10fcec3b8cf Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 12 Mar 2021 15:23:28 +0800 Subject: [PATCH 04/17] feat: add toolbar for compose scene --- Mastodon.xcodeproj/project.pbxproj | 12 ++ .../Scene/Compose/ComposeViewController.swift | 98 +++++++++++ .../Compose/View/ComposeToolbarView.swift | 154 ++++++++++++++++++ .../MastodonRegisterViewController.swift | 2 +- .../Service/KeyboardResponderService.swift | 11 +- 5 files changed, 269 insertions(+), 8 deletions(-) create mode 100644 Mastodon/Scene/Compose/View/ComposeToolbarView.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 532ef6cbe..980271a24 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -203,6 +203,7 @@ DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2D25E504AC0051B173 /* Attachment.swift */; }; DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C3725E508BE0051B173 /* Attachment.swift */; }; DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */; }; + DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; }; DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; }; DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; }; DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; }; @@ -466,6 +467,7 @@ DB9D6C2D25E504AC0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; DB9D6C3725E508BE0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedEdgesButton.swift; sourceTree = ""; }; + DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = ""; }; DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = ""; }; DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = ""; }; DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = ""; }; @@ -984,6 +986,14 @@ path = Preference; sourceTree = ""; }; + DB55D32225FB4D320002F825 /* View */ = { + isa = PBXGroup; + children = ( + DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */, + ); + path = View; + sourceTree = ""; + }; DB68A03825E900CC00CFDF14 /* Share */ = { isa = PBXGroup; children = ( @@ -1022,6 +1032,7 @@ DB789A1025F9F29B0071ACA0 /* Compose */ = { isa = PBXGroup; children = ( + DB55D32225FB4D320002F825 /* View */, DB789A2125F9F76D0071ACA0 /* TableViewCell */, DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */, DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */, @@ -1731,6 +1742,7 @@ DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */, 2D5A3D0325CF8742002347D6 /* ControlContainableScrollViews.swift in Sources */, DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */, + DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */, 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */, DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */, 5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */, diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index f183bb255..492a69851 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -9,6 +9,7 @@ import os.log import UIKit import Combine import TwitterTextEditor +import KeyboardGuide final class ComposeViewController: UIViewController, NeedsDependency { @@ -41,6 +42,16 @@ final class ComposeViewController: UIViewController, NeedsDependency { return tableView }() + let composeToolbarView: ComposeToolbarView = { + let composeToolbarView = ComposeToolbarView() + return composeToolbarView + }() + var composeToolbarViewBottomLayoutConstraint: NSLayoutConstraint! + let composeToolbarBackgroundView: UIView = { + let backgroundView = UIView() + return backgroundView + }() + } extension ComposeViewController { @@ -69,6 +80,60 @@ extension ComposeViewController { tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) + composeToolbarView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(composeToolbarView) + composeToolbarViewBottomLayoutConstraint = view.bottomAnchor.constraint(equalTo: composeToolbarView.bottomAnchor) + NSLayoutConstraint.activate([ + composeToolbarView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + composeToolbarView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + composeToolbarViewBottomLayoutConstraint, + composeToolbarView.heightAnchor.constraint(equalToConstant: 44), + ]) + composeToolbarView.preservesSuperviewLayoutMargins = true + composeToolbarView.delegate = self + + // respond scrollView overlap change + view.layoutIfNeeded() + Publishers.CombineLatest3( + KeyboardResponderService.shared.isShow.eraseToAnyPublisher(), + KeyboardResponderService.shared.state.eraseToAnyPublisher(), + KeyboardResponderService.shared.endFrame.eraseToAnyPublisher() + ) + .sink(receiveValue: { [weak self] isShow, state, endFrame in + guard let self = self else { return } + + guard isShow, state == .dock else { + self.tableView.contentInset.bottom = 0.0 + self.tableView.verticalScrollIndicatorInsets.bottom = 0.0 + UIView.animate(withDuration: 0.3) { + self.composeToolbarViewBottomLayoutConstraint.constant = 0.0 + self.view.layoutIfNeeded() + } + return + } + + // isShow AND dock state + let contentFrame = self.view.convert(self.tableView.frame, to: nil) + let padding = contentFrame.maxY - endFrame.minY + guard padding > 0 else { + self.tableView.contentInset.bottom = 0.0 + self.tableView.verticalScrollIndicatorInsets.bottom = 0.0 + UIView.animate(withDuration: 0.3) { + self.composeToolbarViewBottomLayoutConstraint.constant = 0.0 + self.view.layoutIfNeeded() + } + return + } + + self.tableView.contentInset.bottom = padding + self.tableView.verticalScrollIndicatorInsets.bottom = padding + UIView.animate(withDuration: 0.3) { + self.composeToolbarViewBottomLayoutConstraint.constant = padding + self.view.layoutIfNeeded() + } + }) + .store(in: &disposeBag) + tableView.delegate = self viewModel.setupDiffableDataSource(for: tableView, dependency: self) } @@ -123,6 +188,31 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { } +// MARK: - ComposeToolbarViewDelegate +extension ComposeViewController: ComposeToolbarViewDelegate { + + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, gifButtonDidPressed sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, atButtonDidPressed sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, topicButtonDidPressed sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, locationButtonDidPressed sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + // MARK: - UITableViewDelegate extension ComposeViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { @@ -132,6 +222,14 @@ extension ComposeViewController: UITableViewDelegate { // MARK: - ComposeViewController extension ComposeViewController: UIAdaptivePresentationControllerDelegate { +// func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { +// switch traitCollection.userInterfaceIdiom { +// case .phone: +// return .fullScreen +// default: +// return .pageSheet +// } +// } func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { return viewModel.shouldDismiss.value diff --git a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift new file mode 100644 index 000000000..7b501bf70 --- /dev/null +++ b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift @@ -0,0 +1,154 @@ +// +// ComposeToolbarView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-12. +// + +import UIKit + +protocol ComposeToolbarViewDelegate: class { + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, gifButtonDidPressed sender: UIButton) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, atButtonDidPressed sender: UIButton) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, topicButtonDidPressed sender: UIButton) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, locationButtonDidPressed sender: UIButton) +} + +final class ComposeToolbarView: UIView { + + weak var delegate: ComposeToolbarViewDelegate? + + let mediaButton: UIButton = { + let button = UIButton(type: .custom) + button.tintColor = Asset.Colors.Button.normal.color + button.setImage(UIImage(systemName: "photo", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)), for: .normal) + return button + }() + + let pollButton: UIButton = { + let button = UIButton(type: .custom) + button.tintColor = Asset.Colors.Button.normal.color + button.setImage(UIImage(systemName: "list.bullet", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium)), for: .normal) + return button + }() + + let emojiButton: UIButton = { + let button = UIButton(type: .custom) + button.tintColor = Asset.Colors.Button.normal.color + button.setImage(UIImage(systemName: "face.smiling", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)), for: .normal) + return button + }() + + let contentWarningButton: UIButton = { + let button = UIButton(type: .custom) + button.tintColor = Asset.Colors.Button.normal.color + button.setImage(UIImage(systemName: "exclamationmark.shield", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)), for: .normal) + return button + }() + + let visibilityButton: UIButton = { + let button = UIButton(type: .custom) + button.tintColor = Asset.Colors.Button.normal.color + button.setImage(UIImage(systemName: "person.3", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)), for: .normal) + return button + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ComposeToolbarView { + private func _init() { + backgroundColor = .secondarySystemBackground + + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = 0 + stackView.distribution = .fillEqually + stackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.centerYAnchor.constraint(equalTo: centerYAnchor), + layoutMarginsGuide.leadingAnchor.constraint(equalTo: stackView.leadingAnchor, constant: 8), // tweak button margin offset + ]) + + let buttons = [ + mediaButton, + pollButton, + emojiButton, + contentWarningButton, + visibilityButton, + ] + buttons.forEach { button in + button.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubview(button) + NSLayoutConstraint.activate([ + button.widthAnchor.constraint(equalToConstant: 44), + button.heightAnchor.constraint(equalToConstant: 44), + ]) + } + + mediaButton.addTarget(self, action: #selector(ComposeToolbarView.cameraButtonDidPressed(_:)), for: .touchUpInside) + pollButton.addTarget(self, action: #selector(ComposeToolbarView.gifButtonDidPressed(_:)), for: .touchUpInside) + emojiButton.addTarget(self, action: #selector(ComposeToolbarView.atButtonDidPressed(_:)), for: .touchUpInside) + contentWarningButton.addTarget(self, action: #selector(ComposeToolbarView.topicButtonDidPressed(_:)), for: .touchUpInside) + visibilityButton.addTarget(self, action: #selector(ComposeToolbarView.locationButtonDidPressed(_:)), for: .touchUpInside) + } +} + + +extension ComposeToolbarView { + + @objc private func cameraButtonDidPressed(_ sender: UIButton) { + delegate?.composeToolbarView(self, cameraButtonDidPressed: sender) + } + + @objc private func gifButtonDidPressed(_ sender: UIButton) { + delegate?.composeToolbarView(self, gifButtonDidPressed: sender) + } + + @objc private func atButtonDidPressed(_ sender: UIButton) { + delegate?.composeToolbarView(self, atButtonDidPressed: sender) + } + + @objc private func topicButtonDidPressed(_ sender: UIButton) { + delegate?.composeToolbarView(self, topicButtonDidPressed: sender) + } + + @objc private func locationButtonDidPressed(_ sender: UIButton) { + delegate?.composeToolbarView(self, locationButtonDidPressed: sender) + } + +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct ComposeToolbarView_Previews: PreviewProvider { + + static var previews: some View { + UIViewPreview(width: 375) { + let tootbarView = ComposeToolbarView() + tootbarView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + tootbarView.widthAnchor.constraint(equalToConstant: 375).priority(.defaultHigh), + tootbarView.heightAnchor.constraint(equalToConstant: 64).priority(.defaultHigh), + ]) + return tootbarView + } + .previewLayout(.fixed(width: 375, height: 100)) + } + +} + +#endif + diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index f078e9b8d..d66f9717c 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -351,7 +351,7 @@ extension MastodonRegisterViewController { Publishers.CombineLatest( KeyboardResponderService.shared.state.eraseToAnyPublisher(), - KeyboardResponderService.shared.willEndFrame.eraseToAnyPublisher() + KeyboardResponderService.shared.endFrame.eraseToAnyPublisher() ) .sink(receiveValue: { [weak self] state, endFrame in guard let self = self else { return } diff --git a/Mastodon/Service/KeyboardResponderService.swift b/Mastodon/Service/KeyboardResponderService.swift index b21737963..d4bf9b58b 100644 --- a/Mastodon/Service/KeyboardResponderService.swift +++ b/Mastodon/Service/KeyboardResponderService.swift @@ -18,9 +18,8 @@ final class KeyboardResponderService { // output let isShow = CurrentValueSubject(false) let state = CurrentValueSubject(.none) - let didEndFrame = CurrentValueSubject(.zero) - let willEndFrame = CurrentValueSubject(.zero) - + let endFrame = CurrentValueSubject(.zero) + private init() { NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification, object: nil) .sink { notification in @@ -38,15 +37,11 @@ final class KeyboardResponderService { NotificationCenter.default.publisher(for: UIResponder.keyboardDidChangeFrameNotification, object: nil) .sink { notification in - guard let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } - self.didEndFrame.value = endFrame self.updateInternalStatus(notification: notification) } .store(in: &disposeBag) NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification, object: nil) .sink { notification in - guard let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } - self.willEndFrame.value = endFrame self.updateInternalStatus(notification: notification) } .store(in: &disposeBag) @@ -62,6 +57,8 @@ extension KeyboardResponderService { return } + self.endFrame.value = endFrame + guard isLocal else { self.state.value = .notLocal return From 36604d150f0de764fda3510a81e6dcba6a6940ee Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 12 Mar 2021 15:57:58 +0800 Subject: [PATCH 05/17] feat: show discard alert when user cancel toot composing --- Localization/app.json | 5 ++ .../Section/ComposeStatusSection.swift | 12 ++++- Mastodon/Generated/Strings.swift | 8 +++ .../Resources/en.lproj/Localizable.strings | 3 ++ .../Scene/Compose/ComposeViewController.swift | 50 +++++++++++++------ Mastodon/Scene/Compose/ComposeViewModel.swift | 19 +++++++ .../Compose/View/ComposeToolbarView.swift | 2 + 7 files changed, 82 insertions(+), 17 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index 8734ea00f..17994bb31 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -14,6 +14,10 @@ "vote_failure": { "title": "Vote Failure", "poll_expired": "The poll has expired" + }, + "discard_compose_content": { + "title": "Discard Toot", + "message": "Confirm discard composed toot content." } }, "controls": { @@ -27,6 +31,7 @@ "confirm": "Confirm", "continue": "Continue", "cancel": "Cancel", + "discard": "Discard", "take_photo": "Take photo", "save_photo": "Save photo", "sign_in": "Sign In", diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index e1405309b..be3608ef9 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -46,7 +46,7 @@ extension ComposeStatusSection { cell.statusView.headerContainerStackView.isHidden = false cell.statusView.headerInfoLabel.text = "[TODO] \(replyTo.author.displayName)" } - ComposeStatusSection.configureComposeTootContent(cell: cell, attribute: attribute) + ComposeStatusSection.configure(cell: cell, attribute: attribute) // self size input cell cell.composeContent .receive(on: DispatchQueue.main) @@ -62,16 +62,18 @@ extension ComposeStatusSection { } extension ComposeStatusSection { - static func configureComposeTootContent( + static func configure( cell: ComposeTootContentTableViewCell, attribute: ComposeStatusItem.ComposeTootAttribute ) { + // 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() @@ -82,5 +84,11 @@ extension ComposeStatusSection { cell.statusView.usernameLabel.text = username } .store(in: &cell.disposeBag) + + // bind compose content + cell.composeContent + .map { $0 as String? } + .assign(to: \.value, on: attribute.composeContent) + .store(in: &cell.disposeBag) } } diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 49e4cd7c2..105e98ef2 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -19,6 +19,12 @@ internal enum L10n { /// Please try again later. internal static let pleaseTryAgainLater = L10n.tr("Localizable", "Common.Alerts.Common.PleaseTryAgainLater") } + internal enum DiscardComposeContent { + /// Confirm discard composed toot content. + internal static let message = L10n.tr("Localizable", "Common.Alerts.DiscardComposeContent.Message") + /// Discard Toot + internal static let title = L10n.tr("Localizable", "Common.Alerts.DiscardComposeContent.Title") + } internal enum ServerError { /// Server Error internal static let title = L10n.tr("Localizable", "Common.Alerts.ServerError.Title") @@ -46,6 +52,8 @@ internal enum L10n { internal static let confirm = L10n.tr("Localizable", "Common.Controls.Actions.Confirm") /// Continue internal static let `continue` = L10n.tr("Localizable", "Common.Controls.Actions.Continue") + /// Discard + internal static let discard = L10n.tr("Localizable", "Common.Controls.Actions.Discard") /// Edit internal static let edit = L10n.tr("Localizable", "Common.Controls.Actions.Edit") /// OK diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index f9a1ffe64..5605683de 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -1,5 +1,7 @@ "Common.Alerts.Common.PleaseTryAgain" = "Please try again."; "Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later."; +"Common.Alerts.DiscardComposeContent.Message" = "Confirm discard composed toot content."; +"Common.Alerts.DiscardComposeContent.Title" = "Discard Toot"; "Common.Alerts.ServerError.Title" = "Server Error"; "Common.Alerts.SignUpFailure.Title" = "Sign Up Failure"; "Common.Alerts.VoteFailure.PollExpired" = "The poll has expired"; @@ -9,6 +11,7 @@ "Common.Controls.Actions.Cancel" = "Cancel"; "Common.Controls.Actions.Confirm" = "Confirm"; "Common.Controls.Actions.Continue" = "Continue"; +"Common.Controls.Actions.Discard" = "Discard"; "Common.Controls.Actions.Edit" = "Edit"; "Common.Controls.Actions.Ok" = "OK"; "Common.Controls.Actions.OpenInSafari" = "Open in Safari"; diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 492a69851..b1a1cf5ba 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -70,7 +70,6 @@ extension ComposeViewController { navigationItem.leftBarButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ComposeViewController.cancelBarButtonItemPressed(_:))) navigationItem.rightBarButtonItem = composeTootBarButtonItem - tableView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tableView) NSLayoutConstraint.activate([ @@ -87,13 +86,17 @@ extension ComposeViewController { composeToolbarView.leadingAnchor.constraint(equalTo: view.leadingAnchor), composeToolbarView.trailingAnchor.constraint(equalTo: view.trailingAnchor), composeToolbarViewBottomLayoutConstraint, - composeToolbarView.heightAnchor.constraint(equalToConstant: 44), + composeToolbarView.heightAnchor.constraint(equalToConstant: ComposeToolbarView.toolbarHeight), ]) composeToolbarView.preservesSuperviewLayoutMargins = true composeToolbarView.delegate = self + tableView.delegate = self + viewModel.setupDiffableDataSource(for: tableView, dependency: self) + // respond scrollView overlap change view.layoutIfNeeded() + // update layout when keyboard show/dismiss Publishers.CombineLatest3( KeyboardResponderService.shared.isShow.eraseToAnyPublisher(), KeyboardResponderService.shared.state.eraseToAnyPublisher(), @@ -125,8 +128,9 @@ extension ComposeViewController { return } - self.tableView.contentInset.bottom = padding - self.tableView.verticalScrollIndicatorInsets.bottom = padding + // add 16pt margin + self.tableView.contentInset.bottom = padding + 16 + self.tableView.verticalScrollIndicatorInsets.bottom = padding + 16 UIView.animate(withDuration: 0.3) { self.composeToolbarViewBottomLayoutConstraint.constant = padding self.view.layoutIfNeeded() @@ -134,8 +138,10 @@ extension ComposeViewController { }) .store(in: &disposeBag) - tableView.delegate = self - viewModel.setupDiffableDataSource(for: tableView, dependency: self) + viewModel.isComposeTootBarButtonItemEnabled + .receive(on: DispatchQueue.main) + .assign(to: \.isEnabled, on: composeTootBarButtonItem) + .store(in: &disposeBag) } override func viewWillAppear(_ animated: Bool) { @@ -168,12 +174,32 @@ extension ComposeViewController { } } } + + private func showDismissConfirmAlertController() { + let alertController = UIAlertController( + title: L10n.Common.Alerts.DiscardComposeContent.title, + message: L10n.Common.Alerts.DiscardComposeContent.message, + preferredStyle: .alert + ) + let discardAction = UIAlertAction(title: L10n.Common.Controls.Actions.discard, style: .destructive) { [weak self] _ in + guard let self = self else { return } + self.dismiss(animated: true, completion: nil) + } + alertController.addAction(discardAction) + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel) + alertController.addAction(cancelAction) + present(alertController, animated: true, completion: nil) + } } extension ComposeViewController { @objc private func cancelBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + guard viewModel.shouldDismiss.value else { + showDismissConfirmAlertController() + return + } dismiss(animated: true, completion: nil) } @@ -222,21 +248,15 @@ extension ComposeViewController: UITableViewDelegate { // MARK: - ComposeViewController extension ComposeViewController: UIAdaptivePresentationControllerDelegate { -// func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { -// switch traitCollection.userInterfaceIdiom { -// case .phone: -// return .fullScreen -// default: -// return .pageSheet -// } -// } - + func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { return viewModel.shouldDismiss.value } func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + showDismissConfirmAlertController() + } func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 7aaadcb70..a6228099a 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -27,6 +27,7 @@ final class ComposeViewModel { // UI & UX let title: CurrentValueSubject let shouldDismiss = CurrentValueSubject(true) + let isComposeTootBarButtonItemEnabled = CurrentValueSubject(false) init( context: AppContext, @@ -62,6 +63,24 @@ final class ComposeViewModel { self.composeTootAttribute.username.value = username } .store(in: &disposeBag) + + composeTootAttribute.composeContent + .receive(on: DispatchQueue.main) + .map { content in + let content = content?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return !content.isEmpty + } + .assign(to: \.value, on: isComposeTootBarButtonItemEnabled) + .store(in: &disposeBag) + + composeTootAttribute.composeContent + .receive(on: DispatchQueue.main) + .map { content in + let content = content ?? "" + return content.isEmpty + } + .assign(to: \.value, on: shouldDismiss) + .store(in: &disposeBag) } } diff --git a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift index 7b501bf70..7eb3ae821 100644 --- a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift +++ b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift @@ -17,6 +17,8 @@ protocol ComposeToolbarViewDelegate: class { final class ComposeToolbarView: UIView { + static let toolbarHeight: CGFloat = 44 + weak var delegate: ComposeToolbarViewDelegate? let mediaButton: UIButton = { From 92a26b2f7346e37a2646cd678cdb80d6a1f6a7fd Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 12 Mar 2021 19:25:28 +0800 Subject: [PATCH 06/17] feat: [WIP] add mention and hashtag input highlight. Add emoji token replacing logic --- Mastodon.xcodeproj/project.pbxproj | 4 + .../Section/ComposeStatusSection.swift | 7 +- .../Diffiable/Section/StatusSection.swift | 2 +- .../Scene/Compose/ComposeViewController.swift | 115 +++++++++++++++++- .../Compose/ComposeViewModel+Diffable.swift | 7 +- .../Vender/TwitterTextEditor+String.swift | 54 ++++++++ 6 files changed, 180 insertions(+), 9 deletions(-) create mode 100644 Mastodon/Vender/TwitterTextEditor+String.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 980271a24..0d7b441de 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -145,6 +145,7 @@ DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */; }; DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DB5086B725CC0D6400C2C187 /* Kingfisher */; }; DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */; }; + DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */; }; DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */; }; DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */; }; DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */; }; @@ -409,6 +410,7 @@ DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = ""; }; DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = ""; }; DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashPreference.swift; sourceTree = ""; }; + DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "TwitterTextEditor+String.swift"; sourceTree = ""; }; DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDelegate.swift"; sourceTree = ""; }; DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = ""; }; DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Poll.swift"; sourceTree = ""; }; @@ -672,6 +674,7 @@ children = ( 2D5A3D0225CF8742002347D6 /* ControlContainableScrollViews.swift */, DB2B3AE825E38850007045F9 /* UIViewPreview.swift */, + DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */, ); path = Vender; sourceTree = ""; @@ -1671,6 +1674,7 @@ 2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */, DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */, 2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */, + DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */, 2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */, DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index be3608ef9..5c2124540 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -9,6 +9,7 @@ import UIKit import Combine import CoreData import CoreDataStack +import TwitterTextEditor enum ComposeStatusSection: Equatable, Hashable { case repliedTo @@ -27,9 +28,10 @@ extension ComposeStatusSection { for tableView: UITableView, dependency: NeedsDependency, managedObjectContext: NSManagedObjectContext, - composeKind: ComposeKind + composeKind: ComposeKind, + textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate ) -> UITableViewDiffableDataSource { - UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in + UITableViewDiffableDataSource(tableView: tableView) { [weak textEditorViewTextAttributesDelegate] tableView, indexPath, item -> UITableViewCell? in switch item { case .replyTo(let tootObjectID): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToTootContentTableViewCell.self), for: indexPath) as! ComposeRepliedToTootContentTableViewCell @@ -47,6 +49,7 @@ extension ComposeStatusSection { cell.statusView.headerInfoLabel.text = "[TODO] \(replyTo.author.displayName)" } ComposeStatusSection.configure(cell: cell, attribute: attribute) + cell.textEditorView.textAttributesDelegate = textEditorViewTextAttributesDelegate // self size input cell cell.composeContent .receive(on: DispatchQueue.main) diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 1d0169ab8..517fd5a2e 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -322,7 +322,7 @@ extension StatusSection { cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.timeLeft(expiresAt.shortTimeAgoSinceNow) } } else { - assertionFailure() + // assertionFailure() cell.pollCountdownSubscription = nil cell.statusView.pollCountdownLabel.text = "-" } diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index b1a1cf5ba..22a48fb0b 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -9,7 +9,6 @@ import os.log import UIKit import Combine import TwitterTextEditor -import KeyboardGuide final class ComposeViewController: UIViewController, NeedsDependency { @@ -44,11 +43,13 @@ final class ComposeViewController: UIViewController, NeedsDependency { let composeToolbarView: ComposeToolbarView = { let composeToolbarView = ComposeToolbarView() + composeToolbarView.backgroundColor = .secondarySystemBackground return composeToolbarView }() var composeToolbarViewBottomLayoutConstraint: NSLayoutConstraint! let composeToolbarBackgroundView: UIView = { let backgroundView = UIView() + backgroundView.backgroundColor = .secondarySystemBackground return backgroundView }() @@ -91,8 +92,21 @@ extension ComposeViewController { composeToolbarView.preservesSuperviewLayoutMargins = true composeToolbarView.delegate = self + composeToolbarBackgroundView.translatesAutoresizingMaskIntoConstraints = false + view.insertSubview(composeToolbarBackgroundView, belowSubview: composeToolbarView) + NSLayoutConstraint.activate([ + composeToolbarBackgroundView.topAnchor.constraint(equalTo: composeToolbarView.topAnchor), + composeToolbarBackgroundView.leadingAnchor.constraint(equalTo: composeToolbarView.leadingAnchor), + composeToolbarBackgroundView.trailingAnchor.constraint(equalTo: composeToolbarView.trailingAnchor), + view.bottomAnchor.constraint(equalTo: composeToolbarBackgroundView.bottomAnchor), + ]) + tableView.delegate = self - viewModel.setupDiffableDataSource(for: tableView, dependency: self) + viewModel.setupDiffableDataSource( + for: tableView, + dependency: self, + textEditorViewTextAttributesDelegate: self + ) // respond scrollView overlap change view.layoutIfNeeded() @@ -208,12 +222,105 @@ extension ComposeViewController { // MARK: - TextEditorViewTextAttributesDelegate extension ComposeViewController: TextEditorViewTextAttributesDelegate { - func textEditorView(_ textEditorView: TextEditorView, updateAttributedString attributedString: NSAttributedString, completion: @escaping (NSAttributedString?) -> Void) { - // TODO: + func textEditorView( + _ textEditorView: TextEditorView, + updateAttributedString attributedString: NSAttributedString, + completion: @escaping (NSAttributedString?) -> Void + ) { + + DispatchQueue.global().async { + let string = attributedString.string + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update: %s", ((#file as NSString).lastPathComponent), #line, #function, string) + + let stringRange = NSRange(location: 0, length: string.length) + let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)|#([^\\s]+))") + // not accept :$ to force user input space to make emoji take effect + let emojiMatches = string.matches(pattern: "(?:(^:|\\s:)([a-zA-Z0-9_]+):\\s)") + + DispatchQueue.main.async { [weak self] in + guard let self = self else { + completion(nil) + return + } + + // set normal apperance + let attributedString = NSMutableAttributedString(attributedString: attributedString) + attributedString.removeAttribute(.suffixedAttachment, range: stringRange) + attributedString.removeAttribute(.underlineStyle, range: stringRange) + attributedString.addAttribute(.foregroundColor, value: Asset.Colors.Label.primary.color, range: stringRange) + attributedString.addAttribute(.font, value: UIFont.preferredFont(forTextStyle: .body), range: stringRange) + + for match in highlightMatches { + // hashtag + if let name = string.substring(with: match, at: 2) { + let attachment: TextAttributes.SuffixedAttachment? + switch name { + // FIXME: + case "person": + attachment = .init(size: CGSize(width: 20.0, height: 20.0), + attachment: .image(UIImage(systemName: "person")!)) + default: + attachment = nil + } + + if let attachment = attachment { + let index = match.range.upperBound - 1 + attributedString.addAttribute( + .suffixedAttachment, + value: attachment, + range: NSRange(location: index, length: 1) + ) + } + } + + // set highlight + var attributes = [NSAttributedString.Key: Any]() + attributes[.foregroundColor] = Asset.Colors.Label.highlight.color + // See `traitCollectionDidChange(_:)` + // set accessibility + if #available(iOS 13.0, *) { + switch self.traitCollection.accessibilityContrast { + case .high: + attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue + default: + break + } + } + attributedString.addAttributes(attributes, range: match.range) + } + for match in emojiMatches { + if let name = string.substring(with: match, at: 2) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: handle emoji: %s", ((#file as NSString).lastPathComponent), #line, #function, name) + + // set emoji token invisiable (without upper bounce space) + var attributes = [NSAttributedString.Key: Any]() + attributes[.font] = UIFont.systemFont(ofSize: 0.01) + let rangeWithoutUpperBounceSpace = NSRange(location: match.range.location, length: match.range.length - 1) + attributedString.addAttributes(attributes, range: rangeWithoutUpperBounceSpace) + + // append emoji attachment + let attachment = TextAttributes.SuffixedAttachment( + size: CGSize(width: 20, height: 20), + attachment: .image(UIImage(systemName: "circle")!) + ) + let index = match.range.upperBound - 1 + attributedString.addAttribute( + .suffixedAttachment, + value: attachment, + range: NSRange(location: index, length: 1) + ) + } + } + + completion(attributedString) + } + } } } + + // MARK: - ComposeToolbarViewDelegate extension ComposeViewController: ComposeToolbarViewDelegate { diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index 5c27bf51d..0ee3e0b32 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -6,18 +6,21 @@ // import UIKit +import TwitterTextEditor extension ComposeViewModel { func setupDiffableDataSource( for tableView: UITableView, - dependency: NeedsDependency + dependency: NeedsDependency, + textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate ) { diffableDataSource = ComposeStatusSection.tableViewDiffableDataSource( for: tableView, dependency: dependency, managedObjectContext: context.managedObjectContext, - composeKind: composeKind + composeKind: composeKind, + textEditorViewTextAttributesDelegate: textEditorViewTextAttributesDelegate ) var snapshot = NSDiffableDataSourceSnapshot() diff --git a/Mastodon/Vender/TwitterTextEditor+String.swift b/Mastodon/Vender/TwitterTextEditor+String.swift new file mode 100644 index 000000000..7abdba3a3 --- /dev/null +++ b/Mastodon/Vender/TwitterTextEditor+String.swift @@ -0,0 +1,54 @@ +// +// String.swift +// Example +// +// Copyright 2021 Twitter, Inc. +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension String { + @inlinable + var length: Int { + (self as NSString).length + } + + @inlinable + func substring(with range: NSRange) -> String { + (self as NSString).substring(with: range) + } + + func substring(with result: NSTextCheckingResult, at index: Int) -> String? { + guard index < result.numberOfRanges else { + return nil + } + let range = result.range(at: index) + guard range.location != NSNotFound else { + return nil + } + return substring(with: result.range(at: index)) + } + + func firstMatch(pattern: String, + options: NSRegularExpression.Options = [], + range: NSRange? = nil) -> NSTextCheckingResult? + { + guard let regularExpression = try? NSRegularExpression(pattern: pattern, options: options) else { + return nil + } + let range = range ?? NSRange(location: 0, length: length) + return regularExpression.firstMatch(in: self, options: [], range: range) + } + + func matches(pattern: String, + options: NSRegularExpression.Options = [], + range: NSRange? = nil) -> [NSTextCheckingResult] + { + guard let regularExpression = try? NSRegularExpression(pattern: pattern, options: options) else { + return [] + } + let range = range ?? NSRange(location: 0, length: length) + return regularExpression.matches(in: self, options: [], range: range) + } +} From 8c48bce627383c405052ebe2066ac5efabcb1cbd Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 13:42:46 +0800 Subject: [PATCH 07/17] chore: rename toot --- Localization/app.json | 28 +++++++++---------- .../Section/ComposeStatusSection.swift | 6 ++-- .../Diffiable/Section/StatusSection.swift | 2 +- Mastodon/Generated/Strings.swift | 26 ++++++++--------- .../Resources/en.lproj/Localizable.strings | 14 +++++----- .../Scene/Compose/ComposeViewController.swift | 5 ++-- .../Compose/ComposeViewModel+Diffable.swift | 4 +-- Mastodon/Scene/Compose/ComposeViewModel.swift | 4 +-- ...meTimelineViewController+DebugAction.swift | 8 +++--- .../HomeTimelineViewController.swift | 2 +- .../TableViewCell/PickServerCell.swift | 4 +-- .../Scene/Share/View/Content/StatusView.swift | 2 +- 12 files changed, 52 insertions(+), 53 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index 17994bb31..d521a98da 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -15,9 +15,9 @@ "title": "Vote Failure", "poll_expired": "The poll has expired" }, - "discard_compose_content": { - "title": "Discard Toot", - "message": "Confirm discard composed toot content." + "discard_post_content": { + "title": "Discard Publish", + "message": "Confirm discard composed post content." } }, "controls": { @@ -41,7 +41,7 @@ "open_in_safari": "Open in Safari" }, "status": { - "user_boosted": "%s boosted", + "user_reblogged": "%s reblogged", "show_post": "Show Post", "status_content_warning": "content warning", "media_content_warning": "Tap to reveal that may be sensitive", @@ -76,17 +76,17 @@ }, "server_picker": { "title": "Pick a Server,\nany server.", - "Button": { - "Category": { + "button": { + "category": { "All": "All" }, - "SeeLess": "See Less", - "SeeMore": "See More" + "see_less": "See Less", + "see_more": "See More" }, - "Label": { - "Language": "LANGUAGE", - "Users": "USERS", - "Category": "CATEGORY" + "label": { + "language": "LANGUAGE", + "users": "USERS", + "category": "CATEGORY" }, "input": { "placeholder": "Find a server or join your own..." @@ -181,11 +181,11 @@ }, "compose": { "title": { - "new_toot": "New Toot", + "new_post": "New Post", "new_reply": "New Reply" }, "content_input_placeholder": "Type or paste what's on your mind", - "compose_action": "Toot" + "compose_action": "Publish" } } } \ No newline at end of file diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 5c2124540..ce08fc009 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -18,8 +18,8 @@ enum ComposeStatusSection: Equatable, Hashable { extension ComposeStatusSection { enum ComposeKind { - case toot - case replyToot(tootObjectID: NSManagedObjectID) + case post + case reply(repliedToStatusObjectID: NSManagedObjectID) } } @@ -33,7 +33,7 @@ extension ComposeStatusSection { ) -> UITableViewDiffableDataSource { UITableViewDiffableDataSource(tableView: tableView) { [weak textEditorViewTextAttributesDelegate] tableView, indexPath, item -> UITableViewCell? in switch item { - case .replyTo(let tootObjectID): + case .replyTo(let repliedToStatusObjectID): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToTootContentTableViewCell.self), for: indexPath) as! ComposeRepliedToTootContentTableViewCell // TODO: return cell diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 517fd5a2e..3f4a4f366 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -91,7 +91,7 @@ extension StatusSection { cell.statusView.headerInfoLabel.text = { let author = toot.author let name = author.displayName.isEmpty ? author.username : author.displayName - return L10n.Common.Controls.Status.userBoosted(name) + return L10n.Common.Controls.Status.userReblogged(name) }() // set name username avatar diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 105e98ef2..dda118a04 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -19,11 +19,11 @@ internal enum L10n { /// Please try again later. internal static let pleaseTryAgainLater = L10n.tr("Localizable", "Common.Alerts.Common.PleaseTryAgainLater") } - internal enum DiscardComposeContent { - /// Confirm discard composed toot content. - internal static let message = L10n.tr("Localizable", "Common.Alerts.DiscardComposeContent.Message") - /// Discard Toot - internal static let title = L10n.tr("Localizable", "Common.Alerts.DiscardComposeContent.Title") + internal enum DiscardPostContent { + /// Confirm discard composed post content. + internal static let message = L10n.tr("Localizable", "Common.Alerts.DiscardPostContent.Message") + /// Discard Publish + internal static let title = L10n.tr("Localizable", "Common.Alerts.DiscardPostContent.Title") } internal enum ServerError { /// Server Error @@ -84,9 +84,9 @@ internal enum L10n { internal static let showPost = L10n.tr("Localizable", "Common.Controls.Status.ShowPost") /// content warning internal static let statusContentWarning = L10n.tr("Localizable", "Common.Controls.Status.StatusContentWarning") - /// %@ boosted - internal static func userBoosted(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Status.UserBoosted", String(describing: p1)) + /// %@ reblogged + internal static func userReblogged(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.UserReblogged", String(describing: p1)) } internal enum Poll { /// Closed @@ -136,15 +136,15 @@ internal enum L10n { internal enum Scene { internal enum Compose { - /// Toot + /// Publish internal static let composeAction = L10n.tr("Localizable", "Scene.Compose.ComposeAction") /// Type or paste what's on your mind internal static let contentInputPlaceholder = L10n.tr("Localizable", "Scene.Compose.ContentInputPlaceholder") internal enum Title { + /// New Post + internal static let newPost = L10n.tr("Localizable", "Scene.Compose.Title.NewPost") /// New Reply internal static let newReply = L10n.tr("Localizable", "Scene.Compose.Title.NewReply") - /// New Toot - internal static let newToot = L10n.tr("Localizable", "Scene.Compose.Title.NewToot") } } internal enum ConfirmEmail { @@ -290,9 +290,9 @@ internal enum L10n { internal static let title = L10n.tr("Localizable", "Scene.ServerPicker.Title") internal enum Button { /// See Less - internal static let seeless = L10n.tr("Localizable", "Scene.ServerPicker.Button.Seeless") + internal static let seeLess = L10n.tr("Localizable", "Scene.ServerPicker.Button.SeeLess") /// See More - internal static let seemore = L10n.tr("Localizable", "Scene.ServerPicker.Button.Seemore") + internal static let seeMore = L10n.tr("Localizable", "Scene.ServerPicker.Button.SeeMore") internal enum Category { /// All internal static let all = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.All") diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 5605683de..c2eeb4916 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -1,7 +1,7 @@ "Common.Alerts.Common.PleaseTryAgain" = "Please try again."; "Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later."; -"Common.Alerts.DiscardComposeContent.Message" = "Confirm discard composed toot content."; -"Common.Alerts.DiscardComposeContent.Title" = "Discard Toot"; +"Common.Alerts.DiscardPostContent.Message" = "Confirm discard composed post content."; +"Common.Alerts.DiscardPostContent.Title" = "Discard Publish"; "Common.Alerts.ServerError.Title" = "Server Error"; "Common.Alerts.SignUpFailure.Title" = "Sign Up Failure"; "Common.Alerts.VoteFailure.PollExpired" = "The poll has expired"; @@ -33,14 +33,14 @@ "Common.Controls.Status.Poll.VoterCount.Single" = "%d voter"; "Common.Controls.Status.ShowPost" = "Show Post"; "Common.Controls.Status.StatusContentWarning" = "content warning"; -"Common.Controls.Status.UserBoosted" = "%@ boosted"; +"Common.Controls.Status.UserReblogged" = "%@ reblogged"; "Common.Controls.Timeline.LoadMore" = "Load More"; "Common.Countable.Photo.Multiple" = "photos"; "Common.Countable.Photo.Single" = "photo"; -"Scene.Compose.ComposeAction" = "Toot"; +"Scene.Compose.ComposeAction" = "Publish"; "Scene.Compose.ContentInputPlaceholder" = "Type or paste what's on your mind"; +"Scene.Compose.Title.NewPost" = "New Post"; "Scene.Compose.Title.NewReply" = "New Reply"; -"Scene.Compose.Title.NewToot" = "New Toot"; "Scene.ConfirmEmail.Button.DontReceiveEmail" = "I never got an email"; "Scene.ConfirmEmail.Button.OpenEmailApp" = "Open Email App"; "Scene.ConfirmEmail.DontReceiveEmail.Description" = "Check if your email address is correct as well as your junk folder if you haven’t."; @@ -84,8 +84,8 @@ tap the link to confirm your account."; "Scene.Register.Input.Username.Placeholder" = "username"; "Scene.Register.Title" = "Tell us about you."; "Scene.ServerPicker.Button.Category.All" = "All"; -"Scene.ServerPicker.Button.Seeless" = "See Less"; -"Scene.ServerPicker.Button.Seemore" = "See More"; +"Scene.ServerPicker.Button.SeeLess" = "See Less"; +"Scene.ServerPicker.Button.SeeMore" = "See More"; "Scene.ServerPicker.EmptyState.BadNetwork" = "Something went wrong while loading data. Check your internet connection."; "Scene.ServerPicker.EmptyState.FindingServers" = "Finding available servers..."; "Scene.ServerPicker.Input.Placeholder" = "Find a server or join your own..."; diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 22a48fb0b..729c81e8c 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -191,8 +191,8 @@ extension ComposeViewController { private func showDismissConfirmAlertController() { let alertController = UIAlertController( - title: L10n.Common.Alerts.DiscardComposeContent.title, - message: L10n.Common.Alerts.DiscardComposeContent.message, + title: L10n.Common.Alerts.DiscardPostContent.title, + message: L10n.Common.Alerts.DiscardPostContent.message, preferredStyle: .alert ) let discardAction = UIAlertAction(title: L10n.Common.Controls.Actions.discard, style: .destructive) { [weak self] _ in @@ -227,7 +227,6 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { updateAttributedString attributedString: NSAttributedString, completion: @escaping (NSAttributedString?) -> Void ) { - DispatchQueue.global().async { let string = attributedString.string os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update: %s", ((#file as NSString).lastPathComponent), #line, #function, string) diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index 0ee3e0b32..b175aaca1 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -26,10 +26,10 @@ extension ComposeViewModel { var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.repliedTo, .status]) switch composeKind { - case .replyToot(let tootObjectID): + case .reply(let tootObjectID): snapshot.appendItems([.replyTo(tootObjectID: tootObjectID)], toSection: .repliedTo) snapshot.appendItems([.toot(replyToTootObjectID: tootObjectID, attribute: composeTootAttribute)], toSection: .status) - case .toot: + case .post: snapshot.appendItems([.toot(replyToTootObjectID: nil, attribute: composeTootAttribute)], toSection: .status) } diffableDataSource.apply(snapshot, animatingDifferences: false) diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index a6228099a..097de6ae5 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -36,8 +36,8 @@ final class ComposeViewModel { self.context = context self.composeKind = composeKind switch composeKind { - case .toot: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newToot) - case .replyToot: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply) + case .post: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newPost) + case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply) } self.activeAuthentication = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value) // end init diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 0937e1fb4..08696db9d 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -108,8 +108,8 @@ extension HomeTimelineViewController { switch item { case .homeTimelineIndex(let objectID, _): let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - let toot = homeTimelineIndex.toot.reblog ?? homeTimelineIndex.toot - return toot.poll != nil + let post = homeTimelineIndex.toot.reblog ?? homeTimelineIndex.toot + return post.poll != nil default: return false } @@ -148,8 +148,8 @@ extension HomeTimelineViewController { self.context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in guard let self = self else { return } for objectID in droppingTootObjectIDs { - guard let toot = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? Toot else { continue } - self.context.apiService.backgroundManagedObjectContext.delete(toot) + guard let post = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? Toot else { continue } + self.context.apiService.backgroundManagedObjectContext.delete(post) } } .sink { _ in diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 19e8c3ed4..e405a4a97 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -166,7 +166,7 @@ extension HomeTimelineViewController { @objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - let composeViewModel = ComposeViewModel(context: context, composeKind: .toot) + let composeViewModel = ComposeViewModel(context: context, composeKind: .post) coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) } diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift index 95a5491cc..5ff83cc70 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift @@ -88,8 +88,8 @@ class PickServerCell: UITableViewCell { let expandButton: UIButton = { let button = UIButton(type: .custom) - button.setTitle(L10n.Scene.ServerPicker.Button.seemore, for: .normal) - button.setTitle(L10n.Scene.ServerPicker.Button.seeless, for: .selected) + button.setTitle(L10n.Scene.ServerPicker.Button.seeMore, for: .normal) + button.setTitle(L10n.Scene.ServerPicker.Button.seeLess, for: .selected) button.setTitleColor(Asset.Colors.lightBrandBlue.color, for: .normal) button.titleLabel?.font = .preferredFont(forTextStyle: .footnote) button.translatesAutoresizingMaskIntoConstraints = false diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index ad7734780..324da6e26 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -350,7 +350,7 @@ extension StatusView { NSLayoutConstraint.activate([ audioView.leadingAnchor.constraint(equalTo: statusTextContainerView.leadingAnchor), audioView.trailingAnchor.constraint(equalTo: statusTextContainerView.trailingAnchor), - audioView.heightAnchor.constraint(equalToConstant: 44) + audioView.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh) ]) // video gif statusContainerStackView.addArrangedSubview(mosaicPlayerView) From 6882788cccf5ba1187eb95bc429a8cf2f2918940 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 13:43:15 +0800 Subject: [PATCH 08/17] fix: AutoLayout fail before view appear issue --- Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift index e832e5a43..d96d17baf 100644 --- a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift +++ b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift @@ -116,6 +116,7 @@ extension WelcomeViewController: OnboardingViewControllerAppearance { } // MARK: - UIAdaptivePresentationControllerDelegate extension WelcomeViewController: UIAdaptivePresentationControllerDelegate { func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { - return .fullScreen + // make underneath view controller alive to fix layout issue due to view life cycle + return .overFullScreen } } From a6e4b0bfb1cfded31de809aebf2dca8931f5e051 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 13:44:29 +0800 Subject: [PATCH 09/17] feat: set compose text editor keyboard type to supports @/# glyphs --- .../Compose/TableViewCell/ComposeTootContentTableViewCell.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift index 5a7f311d1..95e49c63a 100644 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift @@ -21,6 +21,7 @@ final class ComposeTootContentTableViewCell: UITableViewCell { textEditorView.scrollView.isScrollEnabled = false textEditorView.isScrollEnabled = false textEditorView.placeholderText = L10n.Scene.Compose.contentInputPlaceholder + textEditorView.keyboardType = .twitter return textEditorView }() From 8eb24871c5d5b6c6cf2bd290652240a017903e00 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 13:44:42 +0800 Subject: [PATCH 10/17] feat: add URL highlight for text editor --- .../Scene/Compose/ComposeViewController.swift | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 729c81e8c..8504ffc5b 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -235,6 +235,8 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)|#([^\\s]+))") // not accept :$ to force user input space to make emoji take effect let emojiMatches = string.matches(pattern: "(?:(^:|\\s:)([a-zA-Z0-9_]+):\\s)") + // only accept http/https scheme + let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)") DispatchQueue.main.async { [weak self] in guard let self = self else { @@ -311,6 +313,27 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { } } + for match in urlMatches { + if let name = string.substring(with: match, at: 0) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: handle emoji: %s", ((#file as NSString).lastPathComponent), #line, #function, name) + + // set highlight + var attributes = [NSAttributedString.Key: Any]() + attributes[.foregroundColor] = Asset.Colors.Label.highlight.color + // See `traitCollectionDidChange(_:)` + // set accessibility + if #available(iOS 13.0, *) { + switch self.traitCollection.accessibilityContrast { + case .high: + attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue + default: + break + } + } + attributedString.addAttributes(attributes, range: match.range) + } + } + completion(attributedString) } } From 9f02197873bf487e97fc1ccdd7a677f6bc8a7f15 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 14:40:10 +0800 Subject: [PATCH 11/17] feat: add custom emojis API endpoint --- Mastodon.xcodeproj/project.pbxproj | 24 ++++++ .../Scene/Compose/ComposeViewController.swift | 2 +- .../APIService/APIService+CustomEmoji.swift | 22 +++++ Mastodon/Service/AuthenticationService.swift | 2 +- .../EmojiService+CustomEmoji+LoadState.swift | 86 +++++++++++++++++++ .../EmojiService+CustomEmoji.swift | 45 ++++++++++ .../Service/EmojiService/EmojiService.swift | 26 ++++++ Mastodon/State/AppContext.swift | 8 +- .../API/Mastodon+API+CustomEmojis.swift | 48 +++++++++++ .../MastodonSDK/API/Mastodon+API.swift | 5 +- 10 files changed, 262 insertions(+), 6 deletions(-) create mode 100644 Mastodon/Service/APIService/APIService+CustomEmoji.swift create mode 100644 Mastodon/Service/EmojiService/EmojiService+CustomEmoji+LoadState.swift create mode 100644 Mastodon/Service/EmojiService/EmojiService+CustomEmoji.swift create mode 100644 Mastodon/Service/EmojiService/EmojiService.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+CustomEmojis.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 0d7b441de..e75ae17ce 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -141,6 +141,10 @@ DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */; }; DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */; }; DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */; }; + DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61325FF2C5600B98345 /* EmojiService.swift */; }; + DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmoji.swift */; }; + DB49A62525FF334C00B98345 /* EmojiService+CustomEmoji+LoadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62425FF334C00B98345 /* EmojiService+CustomEmoji+LoadState.swift */; }; + DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */; }; DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */; }; DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */; }; DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DB5086B725CC0D6400C2C187 /* Kingfisher */; }; @@ -407,6 +411,10 @@ DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+MastodonAuthentication.swift"; sourceTree = ""; }; DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = ""; }; DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HomeTimeline.swift"; sourceTree = ""; }; + DB49A61325FF2C5600B98345 /* EmojiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiService.swift; sourceTree = ""; }; + DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmoji.swift"; sourceTree = ""; }; + DB49A62425FF334C00B98345 /* EmojiService+CustomEmoji+LoadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmoji+LoadState.swift"; sourceTree = ""; }; + DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CustomEmoji.swift"; sourceTree = ""; }; DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = ""; }; DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = ""; }; DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashPreference.swift; sourceTree = ""; }; @@ -688,6 +696,7 @@ 2D206B8B25F6015000143C56 /* AudioPlayer.swift */, 2DA6054625F716A2006356F9 /* PlaybackState.swift */, 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */, + DB49A61925FF327D00B98345 /* EmojiService */, ); path = Service; sourceTree = ""; @@ -967,6 +976,7 @@ DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */, DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */, DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */, + DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */, ); path = APIService; sourceTree = ""; @@ -981,6 +991,16 @@ path = CoreData; sourceTree = ""; }; + DB49A61925FF327D00B98345 /* EmojiService */ = { + isa = PBXGroup; + children = ( + DB49A61325FF2C5600B98345 /* EmojiService.swift */, + DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmoji.swift */, + DB49A62425FF334C00B98345 /* EmojiService+CustomEmoji+LoadState.swift */, + ); + path = EmojiService; + sourceTree = ""; + }; DB5086CB25CC0DB400C2C187 /* Preference */ = { isa = PBXGroup; children = ( @@ -1632,6 +1652,7 @@ 2D206B8C25F6015000143C56 /* AudioPlayer.swift in Sources */, 2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */, DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */, + DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */, 2D7631B325C159F700929FB9 /* Item.swift in Sources */, 5DF1054125F886D400D6C0D4 /* ViedeoPlaybackService.swift in Sources */, 0FB3D2F725E4C24D00AAD544 /* MastodonPickServerViewModel.swift in Sources */, @@ -1664,6 +1685,7 @@ DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */, 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */, DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */, + DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */, 2D939AB525EDD8A90076FA61 /* String.swift in Sources */, DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */, 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */, @@ -1688,6 +1710,7 @@ 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */, 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, + DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmoji.swift in Sources */, 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */, 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */, 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, @@ -1770,6 +1793,7 @@ DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */, 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, + DB49A62525FF334C00B98345 /* EmojiService+CustomEmoji+LoadState.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */, diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 8504ffc5b..7ad5f7174 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -375,7 +375,7 @@ extension ComposeViewController: UITableViewDelegate { } } -// MARK: - ComposeViewController +// MARK: - UIAdaptivePresentationControllerDelegate extension ComposeViewController: UIAdaptivePresentationControllerDelegate { func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { diff --git a/Mastodon/Service/APIService/APIService+CustomEmoji.swift b/Mastodon/Service/APIService/APIService+CustomEmoji.swift new file mode 100644 index 000000000..96dbcb96b --- /dev/null +++ b/Mastodon/Service/APIService/APIService+CustomEmoji.swift @@ -0,0 +1,22 @@ +// +// APIService+CustomEmoji.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-15. +// + +import Foundation +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import DateToolsSwift +import MastodonSDK + +extension APIService { + + func customEmoji(domain: String) -> AnyPublisher, Error> { + return Mastodon.API.CustomEmojis.customEmojis(session: session, domain: domain) + } + +} diff --git a/Mastodon/Service/AuthenticationService.swift b/Mastodon/Service/AuthenticationService.swift index 9fa411f22..89ce7a182 100644 --- a/Mastodon/Service/AuthenticationService.swift +++ b/Mastodon/Service/AuthenticationService.swift @@ -12,7 +12,7 @@ import CoreData import CoreDataStack import MastodonSDK -class AuthenticationService: NSObject { +final class AuthenticationService: NSObject { var disposeBag = Set() // input diff --git a/Mastodon/Service/EmojiService/EmojiService+CustomEmoji+LoadState.swift b/Mastodon/Service/EmojiService/EmojiService+CustomEmoji+LoadState.swift new file mode 100644 index 000000000..3ac6a49a2 --- /dev/null +++ b/Mastodon/Service/EmojiService/EmojiService+CustomEmoji+LoadState.swift @@ -0,0 +1,86 @@ +// +// EmojiService+CustomEmoji+LoadState.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-15. +// + +import os.log +import Foundation +import GameplayKit + +extension EmojiService.CustomEmoji { + class LoadState: GKState { + weak var viewModel: EmojiService.CustomEmoji? + + init(viewModel: EmojiService.CustomEmoji) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + } + } +} + +extension EmojiService.CustomEmoji.LoadState { + + class Initial: EmojiService.CustomEmoji.LoadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } + + class Loading: EmojiService.CustomEmoji.LoadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Fail.self || stateClass == Finish.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + viewModel.context.apiService.customEmoji(domain: viewModel.domain) + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: failed to load custom emojis for %s: %s. Retry 10s later", ((#file as NSString).lastPathComponent), #line, #function, viewModel.domain, error.localizedDescription) + stateMachine.enter(Fail.self) + case .finished: + break + } + } receiveValue: { response in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: load %ld custom emojis for %s", ((#file as NSString).lastPathComponent), #line, #function, response.value.count, viewModel.domain) + stateMachine.enter(Finish.self) + viewModel.emojis.value = response.value + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: EmojiService.CustomEmoji.LoadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self || stateClass == Finish.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + guard let stateMachine = stateMachine else { return } + + // retry 10s later + DispatchQueue.main.asyncAfter(deadline: .now() + 10) { + stateMachine.enter(Loading.self) + } + } + } + + class Finish: EmojiService.CustomEmoji.LoadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // one time task + return false + } + } + +} diff --git a/Mastodon/Service/EmojiService/EmojiService+CustomEmoji.swift b/Mastodon/Service/EmojiService/EmojiService+CustomEmoji.swift new file mode 100644 index 000000000..aa253cf9a --- /dev/null +++ b/Mastodon/Service/EmojiService/EmojiService+CustomEmoji.swift @@ -0,0 +1,45 @@ +// +// EmojiService+CustomEmoji.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-15. +// + +import Foundation +import Combine +import GameplayKit +import MastodonSDK + +extension EmojiService { + final class CustomEmoji { + + var disposeBag = Set() + + // input + let domain: String + let context: AppContext + + // output + private(set) lazy var stateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + LoadState.Initial(viewModel: self), + LoadState.Loading(viewModel: self), + LoadState.Fail(viewModel: self), + LoadState.Finish(viewModel: self), + ]) + stateMachine.enter(LoadState.Initial.self) + return stateMachine + }() + let emojis = CurrentValueSubject<[Mastodon.Entity.Emoji], Never>([]) + + init(domain: String, context: AppContext) { + self.domain = domain + self.context = context + + // trigger loading + stateMachine.enter(LoadState.Loading.self) + } + + } +} diff --git a/Mastodon/Service/EmojiService/EmojiService.swift b/Mastodon/Service/EmojiService/EmojiService.swift new file mode 100644 index 000000000..468c2e6dd --- /dev/null +++ b/Mastodon/Service/EmojiService/EmojiService.swift @@ -0,0 +1,26 @@ +// +// EmojiService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-15. +// + +import os.log +import Foundation +import Combine +import MastodonSDK + +final class EmojiService { + + let workingQueue = DispatchQueue(label: "com.twidere.twiderex.video-playback-service.working-queue") + + weak var apiService: APIService? + + // ouput + + + init(apiService: APIService) { + self.apiService = apiService + } +} + diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index 30069ec30..bbb1c7952 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -23,12 +23,12 @@ class AppContext: ObservableObject { let apiService: APIService let authenticationService: AuthenticationService + let emojiService: EmojiService + let videoPlaybackService = VideoPlaybackService() let documentStore: DocumentStore private var documentStoreSubscription: AnyCancellable! - let videoPlaybackService = VideoPlaybackService() - let overrideTraitCollection = CurrentValueSubject(nil) init() { @@ -48,6 +48,10 @@ class AppContext: ObservableObject { apiService: _apiService ) + emojiService = EmojiService( + apiService: apiService + ) + documentStore = DocumentStore() documentStoreSubscription = documentStore.objectWillChange .receive(on: DispatchQueue.main) diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+CustomEmojis.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+CustomEmojis.swift new file mode 100644 index 000000000..091e12d11 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+CustomEmojis.swift @@ -0,0 +1,48 @@ +// +// Mastodon+API+CustomEmojis.swift +// +// +// Created by MainasuK Cirno on 2021-3-15. +// + +import Foundation +import Combine + +extension Mastodon.API.CustomEmojis { + + static func customEmojisEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("custom_emojis") + } + + /// Custom emoji + /// + /// Returns custom emojis that are available on the server. + /// + /// - Since: 2.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/15 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/instance/custom_emojis/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - Returns: `AnyPublisher` contains [`Emoji`] nested in the response + public static func customEmojis( + session: URLSession, + domain: String + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: customEmojisEndpointURL(domain: domain), + query: nil, + authorization: nil + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Emoji].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 5443fa22d..8b952fdb0 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -91,13 +91,14 @@ extension Mastodon.API { extension Mastodon.API { public enum Account { } public enum App { } + public enum CustomEmojis { } + public enum Favorites { } public enum Instance { } public enum OAuth { } public enum Onboarding { } public enum Polls { } - public enum Timeline { } public enum Statuses { } - public enum Favorites { } + public enum Timeline { } } extension Mastodon.API { From 1a60428f2a52cd9ab2048979456902ce40a27d6d Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 19:25:44 +0800 Subject: [PATCH 12/17] feat: implement emojis preloading logic --- Mastodon.xcodeproj/project.pbxproj | 16 ++++++------ .../Scene/MainTab/MainTabBarController.swift | 12 +++++++++ .../APIService/APIService+CustomEmoji.swift | 2 +- ...vice+CustomEmojiViewModel+LoadState.swift} | 22 ++++++++-------- ...> EmojiService+CustomEmojiViewModel.swift} | 13 ++++------ .../Service/EmojiService/EmojiService.swift | 26 ++++++++++++++++--- 6 files changed, 60 insertions(+), 31 deletions(-) rename Mastodon/Service/EmojiService/{EmojiService+CustomEmoji+LoadState.swift => EmojiService+CustomEmojiViewModel+LoadState.swift} (78%) rename Mastodon/Service/EmojiService/{EmojiService+CustomEmoji.swift => EmojiService+CustomEmojiViewModel.swift} (76%) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index e75ae17ce..feeb6e3b1 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -142,8 +142,8 @@ DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */; }; DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */; }; DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61325FF2C5600B98345 /* EmojiService.swift */; }; - DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmoji.swift */; }; - DB49A62525FF334C00B98345 /* EmojiService+CustomEmoji+LoadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62425FF334C00B98345 /* EmojiService+CustomEmoji+LoadState.swift */; }; + DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */; }; + DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */; }; DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */; }; DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */; }; DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */; }; @@ -412,8 +412,8 @@ DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = ""; }; DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HomeTimeline.swift"; sourceTree = ""; }; DB49A61325FF2C5600B98345 /* EmojiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiService.swift; sourceTree = ""; }; - DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmoji.swift"; sourceTree = ""; }; - DB49A62425FF334C00B98345 /* EmojiService+CustomEmoji+LoadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmoji+LoadState.swift"; sourceTree = ""; }; + DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel.swift"; sourceTree = ""; }; + DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel+LoadState.swift"; sourceTree = ""; }; DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CustomEmoji.swift"; sourceTree = ""; }; DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = ""; }; DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = ""; }; @@ -995,8 +995,8 @@ isa = PBXGroup; children = ( DB49A61325FF2C5600B98345 /* EmojiService.swift */, - DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmoji.swift */, - DB49A62425FF334C00B98345 /* EmojiService+CustomEmoji+LoadState.swift */, + DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */, + DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */, ); path = EmojiService; sourceTree = ""; @@ -1710,7 +1710,7 @@ 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */, 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, - DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmoji.swift in Sources */, + DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */, 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */, 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */, 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, @@ -1793,7 +1793,7 @@ DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */, 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, - DB49A62525FF334C00B98345 /* EmojiService+CustomEmoji+LoadState.swift in Sources */, + DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */, diff --git a/Mastodon/Scene/MainTab/MainTabBarController.swift b/Mastodon/Scene/MainTab/MainTabBarController.swift index a556854e5..1c8e8816b 100644 --- a/Mastodon/Scene/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/MainTab/MainTabBarController.swift @@ -123,6 +123,18 @@ extension MainTabBarController { } } .store(in: &disposeBag) + + context.authenticationService.activeMastodonAuthenticationBox + .receive(on: DispatchQueue.main) + .sink { [weak self] activeMastodonAuthenticationBox in + guard let self = self else { return } + guard let activeMastodonAuthenticationBox = activeMastodonAuthenticationBox else { return } + let domain = activeMastodonAuthenticationBox.domain + + // trigger dequeue to preload emojis + _ = self.context.emojiService.dequeueCustomEmojiViewModel(for: domain) + } + .store(in: &disposeBag) #if DEBUG // selectedIndex = 1 diff --git a/Mastodon/Service/APIService/APIService+CustomEmoji.swift b/Mastodon/Service/APIService/APIService+CustomEmoji.swift index 96dbcb96b..2a80eca4c 100644 --- a/Mastodon/Service/APIService/APIService+CustomEmoji.swift +++ b/Mastodon/Service/APIService/APIService+CustomEmoji.swift @@ -1,5 +1,5 @@ // -// APIService+CustomEmoji.swift +// APIService+CustomEmojiViewModel.swift // Mastodon // // Created by MainasuK Cirno on 2021-3-15. diff --git a/Mastodon/Service/EmojiService/EmojiService+CustomEmoji+LoadState.swift b/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel+LoadState.swift similarity index 78% rename from Mastodon/Service/EmojiService/EmojiService+CustomEmoji+LoadState.swift rename to Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel+LoadState.swift index 3ac6a49a2..4fdab0bb9 100644 --- a/Mastodon/Service/EmojiService/EmojiService+CustomEmoji+LoadState.swift +++ b/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel+LoadState.swift @@ -1,5 +1,5 @@ // -// EmojiService+CustomEmoji+LoadState.swift +// EmojiService+CustomEmojiViewModel+LoadState.swift // Mastodon // // Created by MainasuK Cirno on 2021-3-15. @@ -9,11 +9,11 @@ import os.log import Foundation import GameplayKit -extension EmojiService.CustomEmoji { +extension EmojiService.CustomEmojiViewModel { class LoadState: GKState { - weak var viewModel: EmojiService.CustomEmoji? + weak var viewModel: EmojiService.CustomEmojiViewModel? - init(viewModel: EmojiService.CustomEmoji) { + init(viewModel: EmojiService.CustomEmojiViewModel) { self.viewModel = viewModel } @@ -23,24 +23,24 @@ extension EmojiService.CustomEmoji { } } -extension EmojiService.CustomEmoji.LoadState { +extension EmojiService.CustomEmojiViewModel.LoadState { - class Initial: EmojiService.CustomEmoji.LoadState { + class Initial: EmojiService.CustomEmojiViewModel.LoadState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { return stateClass == Loading.self } } - class Loading: EmojiService.CustomEmoji.LoadState { + class Loading: EmojiService.CustomEmojiViewModel.LoadState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { return stateClass == Fail.self || stateClass == Finish.self } override func didEnter(from previousState: GKState?) { super.didEnter(from: previousState) - guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let viewModel = viewModel, let apiService = viewModel.service?.apiService, let stateMachine = stateMachine else { return } - viewModel.context.apiService.customEmoji(domain: viewModel.domain) + apiService.customEmoji(domain: viewModel.domain) .receive(on: DispatchQueue.main) .sink { completion in switch completion { @@ -59,7 +59,7 @@ extension EmojiService.CustomEmoji.LoadState { } } - class Fail: EmojiService.CustomEmoji.LoadState { + class Fail: EmojiService.CustomEmojiViewModel.LoadState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { return stateClass == Loading.self || stateClass == Finish.self } @@ -76,7 +76,7 @@ extension EmojiService.CustomEmoji.LoadState { } } - class Finish: EmojiService.CustomEmoji.LoadState { + class Finish: EmojiService.CustomEmojiViewModel.LoadState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { // one time task return false diff --git a/Mastodon/Service/EmojiService/EmojiService+CustomEmoji.swift b/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel.swift similarity index 76% rename from Mastodon/Service/EmojiService/EmojiService+CustomEmoji.swift rename to Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel.swift index aa253cf9a..f866f4a02 100644 --- a/Mastodon/Service/EmojiService/EmojiService+CustomEmoji.swift +++ b/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel.swift @@ -1,5 +1,5 @@ // -// EmojiService+CustomEmoji.swift +// EmojiService+CustomEmojiViewModel.swift // Mastodon // // Created by MainasuK Cirno on 2021-3-15. @@ -11,13 +11,13 @@ import GameplayKit import MastodonSDK extension EmojiService { - final class CustomEmoji { + final class CustomEmojiViewModel { var disposeBag = Set() // input let domain: String - let context: AppContext + weak var service: EmojiService? // output private(set) lazy var stateMachine: GKStateMachine = { @@ -33,12 +33,9 @@ extension EmojiService { }() let emojis = CurrentValueSubject<[Mastodon.Entity.Emoji], Never>([]) - init(domain: String, context: AppContext) { + init(domain: String, service: EmojiService) { self.domain = domain - self.context = context - - // trigger loading - stateMachine.enter(LoadState.Loading.self) + self.service = service } } diff --git a/Mastodon/Service/EmojiService/EmojiService.swift b/Mastodon/Service/EmojiService/EmojiService.swift index 468c2e6dd..3883d4bab 100644 --- a/Mastodon/Service/EmojiService/EmojiService.swift +++ b/Mastodon/Service/EmojiService/EmojiService.swift @@ -12,15 +12,35 @@ import MastodonSDK final class EmojiService { - let workingQueue = DispatchQueue(label: "com.twidere.twiderex.video-playback-service.working-queue") weak var apiService: APIService? - // ouput - + let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.EmojiService.working-queue") + private(set) var customEmojiViewModelDict: [String: CustomEmojiViewModel] = [:] init(apiService: APIService) { self.apiService = apiService } + +} + +extension EmojiService { + + func dequeueCustomEmojiViewModel(for domain: String) -> CustomEmojiViewModel? { + var _customEmojiViewModel: CustomEmojiViewModel? + workingQueue.sync { + if let viewModel = customEmojiViewModelDict[domain] { + _customEmojiViewModel = viewModel + } else { + let viewModel = CustomEmojiViewModel(domain: domain, service: self) + _customEmojiViewModel = viewModel + + // trigger loading + viewModel.stateMachine.enter(CustomEmojiViewModel.LoadState.Loading.self) + } + } + return _customEmojiViewModel + } + } From 7705e54e679f5e57ccd9d548381a9313334fba8a Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 16 Mar 2021 11:23:19 +0800 Subject: [PATCH 13/17] chore: renaming --- Mastodon/Diffiable/Item/ComposeStatusItem.swift | 8 ++++---- Mastodon/Diffiable/Section/ComposeStatusSection.swift | 4 ++-- Mastodon/Scene/Compose/ComposeViewController.swift | 2 +- Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift | 8 ++++---- Mastodon/Scene/Compose/ComposeViewModel.swift | 2 +- .../HomeTimelineViewController+DebugAction.swift | 8 ++++---- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Mastodon/Diffiable/Item/ComposeStatusItem.swift b/Mastodon/Diffiable/Item/ComposeStatusItem.swift index 812a27a6f..79655b946 100644 --- a/Mastodon/Diffiable/Item/ComposeStatusItem.swift +++ b/Mastodon/Diffiable/Item/ComposeStatusItem.swift @@ -10,14 +10,14 @@ import Combine import CoreData enum ComposeStatusItem { - case replyTo(tootObjectID: NSManagedObjectID) - case toot(replyToTootObjectID: NSManagedObjectID?, attribute: ComposeTootAttribute) + case replyTo(statusObjectID: NSManagedObjectID) + case input(replyToStatusObjectID: NSManagedObjectID?, attribute: ComposeStatusAttribute) } extension ComposeStatusItem: Hashable { } extension ComposeStatusItem { - final class ComposeTootAttribute: Equatable, Hashable { + final class ComposeStatusAttribute: Equatable, Hashable { private let id = UUID() let avatarURL = CurrentValueSubject(nil) @@ -25,7 +25,7 @@ extension ComposeStatusItem { let username = CurrentValueSubject(nil) let composeContent = CurrentValueSubject(nil) - static func == (lhs: ComposeTootAttribute, rhs: ComposeTootAttribute) -> Bool { + static func == (lhs: ComposeStatusAttribute, rhs: ComposeStatusAttribute) -> Bool { return lhs.avatarURL.value == rhs.avatarURL.value && lhs.displayName.value == rhs.displayName.value && lhs.username.value == rhs.username.value && diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index ce08fc009..835007dcc 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -37,7 +37,7 @@ extension ComposeStatusSection { let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToTootContentTableViewCell.self), for: indexPath) as! ComposeRepliedToTootContentTableViewCell // TODO: return cell - case .toot(let replyToTootObjectID, let attribute): + case .input(let replyToTootObjectID, let attribute): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeTootContentTableViewCell.self), for: indexPath) as! ComposeTootContentTableViewCell managedObjectContext.perform { guard let replyToTootObjectID = replyToTootObjectID, @@ -67,7 +67,7 @@ extension ComposeStatusSection { extension ComposeStatusSection { static func configure( cell: ComposeTootContentTableViewCell, - attribute: ComposeStatusItem.ComposeTootAttribute + attribute: ComposeStatusItem.ComposeStatusAttribute ) { // set avatar attribute.avatarURL diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 7ad5f7174..84531b114 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -176,7 +176,7 @@ extension ComposeViewController { let items = diffableDataSource.snapshot().itemIdentifiers for item in items { switch item { - case .toot: + case .input: guard let indexPath = diffableDataSource.indexPath(for: item), let cell = tableView.cellForRow(at: indexPath) as? ComposeTootContentTableViewCell else { continue diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index b175aaca1..a3a0515e6 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -26,11 +26,11 @@ extension ComposeViewModel { var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.repliedTo, .status]) switch composeKind { - case .reply(let tootObjectID): - snapshot.appendItems([.replyTo(tootObjectID: tootObjectID)], toSection: .repliedTo) - snapshot.appendItems([.toot(replyToTootObjectID: tootObjectID, attribute: composeTootAttribute)], toSection: .status) + case .reply(let statusObjectID): + snapshot.appendItems([.replyTo(statusObjectID: statusObjectID)], toSection: .repliedTo) + snapshot.appendItems([.input(replyToStatusObjectID: statusObjectID, attribute: composeStatusAttribute)], toSection: .repliedTo) case .post: - snapshot.appendItems([.toot(replyToTootObjectID: nil, attribute: composeTootAttribute)], toSection: .status) + snapshot.appendItems([.input(replyToStatusObjectID: nil, attribute: composeStatusAttribute)], toSection: .status) } diffableDataSource.apply(snapshot, animatingDifferences: false) } diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 097de6ae5..a357b8740 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -17,7 +17,7 @@ final class ComposeViewModel { // input let context: AppContext let composeKind: ComposeStatusSection.ComposeKind - let composeTootAttribute = ComposeStatusItem.ComposeTootAttribute() + let composeStatusAttribute = ComposeStatusItem.ComposeStatusAttribute() let composeContent = CurrentValueSubject("") let activeAuthentication: CurrentValueSubject diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 08696db9d..548409fbc 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -76,9 +76,9 @@ extension HomeTimelineViewController { identifier: nil, options: [], children: [50, 100, 150, 200, 250, 300].map { count in - UIAction(title: "Drop Recent \(count) Toots", image: nil, attributes: [], handler: { [weak self] action in + UIAction(title: "Drop Recent \(count) Statuses", image: nil, attributes: [], handler: { [weak self] action in guard let self = self else { return } - self.dropRecentTootsAction(action, count: count) + self.dropRecentStatusAction(action, count: count) }) } ) @@ -118,11 +118,11 @@ extension HomeTimelineViewController { tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) tableView.blinkRow(at: IndexPath(row: index, section: 0)) } else { - print("Not found poll toot") + print("Not found status contains poll") } } - @objc private func dropRecentTootsAction(_ sender: UIAction, count: Int) { + @objc private func dropRecentStatusAction(_ sender: UIAction, count: Int) { guard let diffableDataSource = viewModel.diffableDataSource else { return } let snapshotTransitioning = diffableDataSource.snapshot() From f69086e6c3b6cfdc77d09c63e7f6f6e84ee4d1f1 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 16 Mar 2021 11:23:34 +0800 Subject: [PATCH 14/17] chore: move emoji preload to compose scene --- Mastodon/Scene/Compose/ComposeViewModel.swift | 30 +++++++++++++++---- .../Scene/MainTab/MainTabBarController.swift | 12 -------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index a357b8740..8084ab53b 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -29,6 +29,10 @@ final class ComposeViewModel { let shouldDismiss = CurrentValueSubject(true) let isComposeTootBarButtonItemEnabled = CurrentValueSubject(false) + // custom emojis + let customEmojiViewModel = CurrentValueSubject(nil) + + init( context: AppContext, composeKind: ComposeStatusSection.ComposeKind @@ -47,24 +51,26 @@ final class ComposeViewModel { .assign(to: \.value, on: activeAuthentication) .store(in: &disposeBag) + // bind avatar and names activeAuthentication .sink { [weak self] mastodonAuthentication in guard let self = self else { return } let mastodonUser = mastodonAuthentication?.user let username = mastodonUser?.username ?? " " - self.composeTootAttribute.avatarURL.value = mastodonUser?.avatarImageURL() - self.composeTootAttribute.displayName.value = { + self.composeStatusAttribute.avatarURL.value = mastodonUser?.avatarImageURL() + self.composeStatusAttribute.displayName.value = { guard let displayName = mastodonUser?.displayName, !displayName.isEmpty else { return username } return displayName }() - self.composeTootAttribute.username.value = username + self.composeStatusAttribute.username.value = username } .store(in: &disposeBag) - composeTootAttribute.composeContent + // bind compose bar button item UI state + composeStatusAttribute.composeContent .receive(on: DispatchQueue.main) .map { content in let content = content?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" @@ -73,7 +79,8 @@ final class ComposeViewModel { .assign(to: \.value, on: isComposeTootBarButtonItemEnabled) .store(in: &disposeBag) - composeTootAttribute.composeContent + // bind modal dismiss state + composeStatusAttribute.composeContent .receive(on: DispatchQueue.main) .map { content in let content = content ?? "" @@ -81,6 +88,19 @@ final class ComposeViewModel { } .assign(to: \.value, on: shouldDismiss) .store(in: &disposeBag) + + // bind custom emojis + context.authenticationService.activeMastodonAuthenticationBox + .receive(on: DispatchQueue.main) + .sink { [weak self] activeMastodonAuthenticationBox in + guard let self = self else { return } + guard let activeMastodonAuthenticationBox = activeMastodonAuthenticationBox else { return } + let domain = activeMastodonAuthenticationBox.domain + + // trigger dequeue to preload emojis + _ = self.context.emojiService.dequeueCustomEmojiViewModel(for: domain) + } + .store(in: &disposeBag) } } diff --git a/Mastodon/Scene/MainTab/MainTabBarController.swift b/Mastodon/Scene/MainTab/MainTabBarController.swift index 1c8e8816b..a556854e5 100644 --- a/Mastodon/Scene/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/MainTab/MainTabBarController.swift @@ -123,18 +123,6 @@ extension MainTabBarController { } } .store(in: &disposeBag) - - context.authenticationService.activeMastodonAuthenticationBox - .receive(on: DispatchQueue.main) - .sink { [weak self] activeMastodonAuthenticationBox in - guard let self = self else { return } - guard let activeMastodonAuthenticationBox = activeMastodonAuthenticationBox else { return } - let domain = activeMastodonAuthenticationBox.domain - - // trigger dequeue to preload emojis - _ = self.context.emojiService.dequeueCustomEmojiViewModel(for: domain) - } - .store(in: &disposeBag) #if DEBUG // selectedIndex = 1 From fdcd1ffcd057fe498ae30165d15f3e77c22c2b5d Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 16 Mar 2021 14:19:12 +0800 Subject: [PATCH 15/17] feat: implement inline emoji for text editor --- .../Scene/Compose/ComposeViewController.swift | 79 ++++++++++++++++--- Mastodon/Scene/Compose/ComposeViewModel.swift | 2 +- 2 files changed, 67 insertions(+), 14 deletions(-) diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 84531b114..22c0a24ae 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -9,6 +9,7 @@ import os.log import UIKit import Combine import TwitterTextEditor +import Kingfisher final class ComposeViewController: UIViewController, NeedsDependency { @@ -18,6 +19,8 @@ final class ComposeViewController: UIViewController, NeedsDependency { var disposeBag = Set() var viewModel: ComposeViewModel! + private var suffixedAttachmentViews: [UIView] = [] + let composeTootBarButtonItem: UIBarButtonItem = { let button = RoundedEdgesButton(type: .custom) button.setTitle(L10n.Scene.Compose.composeAction, for: .normal) @@ -156,6 +159,20 @@ extension ComposeViewController { .receive(on: DispatchQueue.main) .assign(to: \.isEnabled, on: composeTootBarButtonItem) .store(in: &disposeBag) + + // bind custom emojis + viewModel.customEmojiViewModel + .compactMap { $0?.emojis } + .switchToLatest() + .sink(receiveValue: { [weak self] emojis in + guard let self = self else { return } + for emoji in emojis { + UITextChecker.learnWord(emoji.shortcode) + UITextChecker.learnWord(":" + emoji.shortcode + ":") + } + self.textEditorView()?.setNeedsUpdateTextAttributes() + }) + .store(in: &disposeBag) } override func viewWillAppear(_ animated: Bool) { @@ -164,15 +181,16 @@ extension ComposeViewController { // Fix AutoLayout conflict issue DispatchQueue.main.async { [weak self] in guard let self = self else { return } - self.markTextViewEditorBecomeFirstResponser() + self.markTextEditorViewBecomeFirstResponser() } } } extension ComposeViewController { - private func markTextViewEditorBecomeFirstResponser() { - guard let diffableDataSource = viewModel.diffableDataSource else { return } + + private func textEditorView() -> TextEditorView? { + guard let diffableDataSource = viewModel.diffableDataSource else { return nil } let items = diffableDataSource.snapshot().itemIdentifiers for item in items { switch item { @@ -181,12 +199,17 @@ extension ComposeViewController { let cell = tableView.cellForRow(at: indexPath) as? ComposeTootContentTableViewCell else { continue } - cell.textEditorView.isEditing = true - return + return cell.textEditorView default: continue } } + + return nil + } + + private func markTextEditorViewBecomeFirstResponser() { + textEditorView()?.isEditing = true } private func showDismissConfirmAlertController() { @@ -233,8 +256,9 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { let stringRange = NSRange(location: 0, length: string.length) let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)|#([^\\s]+))") - // not accept :$ to force user input space to make emoji take effect - let emojiMatches = string.matches(pattern: "(?:(^:|\\s:)([a-zA-Z0-9_]+):\\s)") + // accept ^\B: or \s: but not accept \B: to force user input a space to make emoji take effect + // precondition :\B with following space + let emojiMatches = string.matches(pattern: "(?:(^\\B:|\\s:)([a-zA-Z0-9_]+)(:\\B(?=\\s)))") // only accept http/https scheme let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)") @@ -243,6 +267,11 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { completion(nil) return } + let customEmojiViewModel = self.viewModel.customEmojiViewModel.value + for view in self.suffixedAttachmentViews { + view.removeFromSuperview() + } + self.suffixedAttachmentViews.removeAll() // set normal apperance let attributedString = NSMutableAttributedString(attributedString: attributedString) @@ -289,20 +318,44 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { } attributedString.addAttributes(attributes, range: match.range) } - for match in emojiMatches { - if let name = string.substring(with: match, at: 2) { + + let emojis = customEmojiViewModel?.emojis.value ?? [] + if !emojis.isEmpty { + for match in emojiMatches { + guard let name = string.substring(with: match, at: 2) else { continue } + guard let emoji = emojis.first(where: { $0.shortcode == name }) else { continue } os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: handle emoji: %s", ((#file as NSString).lastPathComponent), #line, #function, name) // set emoji token invisiable (without upper bounce space) var attributes = [NSAttributedString.Key: Any]() attributes[.font] = UIFont.systemFont(ofSize: 0.01) - let rangeWithoutUpperBounceSpace = NSRange(location: match.range.location, length: match.range.length - 1) - attributedString.addAttributes(attributes, range: rangeWithoutUpperBounceSpace) + attributedString.addAttributes(attributes, range: match.range) // append emoji attachment + let imageViewSize = CGSize(width: 20, height: 20) + let imageView = UIImageView(frame: CGRect(origin: .zero, size: imageViewSize)) + textEditorView.textContentView.addSubview(imageView) + self.suffixedAttachmentViews.append(imageView) + let processor = DownsamplingImageProcessor(size: imageViewSize) + imageView.kf.setImage( + with: URL(string: emoji.url), + placeholder: UIImage.placeholder(size: imageViewSize, color: .systemFill), + options: [ + .processor(processor), + .scaleFactor(textEditorView.traitCollection.displayScale), + ], completionHandler: nil + ) + let layoutInTextContainer = { [weak textEditorView] (view: UIView, frame: CGRect) in + // `textEditorView` retains `textStorage`, which retains this block as a part of attributes. + guard let textEditorView = textEditorView else { + return + } + let insets = textEditorView.textContentInsets + view.frame = frame.offsetBy(dx: insets.left, dy: insets.top) + } let attachment = TextAttributes.SuffixedAttachment( - size: CGSize(width: 20, height: 20), - attachment: .image(UIImage(systemName: "circle")!) + size: imageViewSize, + attachment: .view(view: imageView, layoutInTextContainer: layoutInTextContainer) ) let index = match.range.upperBound - 1 attributedString.addAttribute( diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 8084ab53b..743f385e5 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -98,7 +98,7 @@ final class ComposeViewModel { let domain = activeMastodonAuthenticationBox.domain // trigger dequeue to preload emojis - _ = self.context.emojiService.dequeueCustomEmojiViewModel(for: domain) + self.customEmojiViewModel.value = self.context.emojiService.dequeueCustomEmojiViewModel(for: domain) } .store(in: &disposeBag) } From 96c148882079633339298f1018ad6ba60cd75e7a Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 16 Mar 2021 16:16:44 +0800 Subject: [PATCH 16/17] fix: hashtag regex exclude list issue --- Mastodon/Scene/Compose/ComposeViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 22c0a24ae..df04b8d23 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -255,7 +255,7 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update: %s", ((#file as NSString).lastPathComponent), #line, #function, string) let stringRange = NSRange(location: 0, length: string.length) - let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)|#([^\\s]+))") + let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)|#([^\\s.]+))") // accept ^\B: or \s: but not accept \B: to force user input a space to make emoji take effect // precondition :\B with following space let emojiMatches = string.matches(pattern: "(?:(^\\B:|\\s:)([a-zA-Z0-9_]+)(:\\B(?=\\s)))") From 07f3cc7a77382f3abf4a69c245ca99b722902864 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 16 Mar 2021 16:17:11 +0800 Subject: [PATCH 17/17] fix: author info UI layout issue --- .../ComposeTootContentTableViewCell.swift | 2 ++ .../Scene/Share/View/Content/StatusView.swift | 21 ++++++++++--------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift index 95e49c63a..9f39f1989 100644 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift @@ -53,6 +53,8 @@ extension ComposeTootContentTableViewCell { ]) statusView.statusContainerStackView.isHidden = true statusView.actionToolbarContainer.isHidden = true + statusView.nameTrialingDotLabel.isHidden = true + statusView.dateLabel.isHidden = true textEditorView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(textEditorView) diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 5e501b92f..3987aa5fc 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -71,6 +71,14 @@ final class StatusView: UIView { return label }() + let nameTrialingDotLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.secondary.color + label.font = .systemFont(ofSize: 17) + label.text = "·" + return label + }() + let usernameLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 15, weight: .regular) @@ -268,18 +276,11 @@ extension StatusView { nameLabel.heightAnchor.constraint(equalToConstant: 22).priority(.defaultHigh), ]) titleContainerStackView.alignment = .firstBaseline - let dotLabel: UILabel = { - let label = UILabel() - label.textColor = Asset.Colors.Label.secondary.color - label.font = .systemFont(ofSize: 17) - label.text = "·" - return label - }() - titleContainerStackView.addArrangedSubview(dotLabel) + titleContainerStackView.addArrangedSubview(nameTrialingDotLabel) titleContainerStackView.addArrangedSubview(dateLabel) nameLabel.setContentHuggingPriority(.defaultHigh + 1, for: .horizontal) - dotLabel.setContentHuggingPriority(.defaultHigh + 2, for: .horizontal) - dotLabel.setContentCompressionResistancePriority(.required - 2, 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)