diff --git a/Localization/app.json b/Localization/app.json index 6170c000..b85f6732 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_post_content": { + "title": "Discard Publish", + "message": "Confirm discard composed post 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", @@ -71,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..." @@ -179,6 +184,14 @@ }, "public_timeline": { "title": "Public" + }, + "compose": { + "title": { + "new_post": "New Post", + "new_reply": "New Reply" + }, + "content_input_placeholder": "Type or paste what's on your mind", + "compose_action": "Publish" } } } diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index e894ba1f..4cbdb48b 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -148,14 +148,24 @@ DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */; }; DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */; }; DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */; }; + DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61325FF2C5600B98345 /* EmojiService.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 */; }; 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 */; }; 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 */; }; @@ -164,6 +174,10 @@ DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.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, ); }; }; @@ -201,6 +215,8 @@ 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 */; }; + DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; }; DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; }; DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */; }; DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */; }; @@ -255,6 +271,7 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( + DB6672A425F9FDE500D60309 /* TwitterTextEditor in Embed Frameworks */, DB89BA0425C10FD0008580ED /* CoreDataStack.framework in Embed Frameworks */, ); name = "Embed Frameworks"; @@ -412,13 +429,21 @@ DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HomeTimeline.swift"; sourceTree = ""; }; DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSManagedObjectContext.swift; sourceTree = ""; }; DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlayerContainerView+MediaTypeIndicotorView.swift"; sourceTree = ""; }; + DB49A61325FF2C5600B98345 /* EmojiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiService.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 = ""; }; 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 = ""; }; 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 = ""; }; @@ -427,6 +452,10 @@ DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.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 = ""; }; @@ -466,6 +495,8 @@ 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 = ""; }; + DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = ""; }; DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = ""; }; DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Reblog.swift"; sourceTree = ""; }; DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIInterpolatingMotionEffect.swift; sourceTree = ""; }; @@ -483,6 +514,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DB6672A325F9FDE500D60309 /* TwitterTextEditor in Frameworks */, DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */, DB89BA0325C10FD0008580ED /* CoreDataStack.framework in Frameworks */, 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */, @@ -658,6 +690,7 @@ 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */, DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */, 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */, + DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */, ); path = Button; sourceTree = ""; @@ -676,6 +709,7 @@ children = ( 2D5A3D0225CF8742002347D6 /* ControlContainableScrollViews.swift */, DB2B3AE825E38850007045F9 /* UIViewPreview.swift */, + DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */, ); path = Vender; sourceTree = ""; @@ -689,6 +723,7 @@ 2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */, 2DA6054625F716A2006356F9 /* PlaybackState.swift */, 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */, + DB49A61925FF327D00B98345 /* EmojiService */, ); path = Service; sourceTree = ""; @@ -745,6 +780,7 @@ DB4481C525EE2ADA00BEFB67 /* PollSection.swift */, DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */, DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */, + DB66729525F9F91600D60309 /* ComposeStatusSection.swift */, ); path = Section; sourceTree = ""; @@ -792,6 +828,7 @@ DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */, DB1E347725F519300079D7DF /* PickServerItem.swift */, DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */, + DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */, ); path = Item; sourceTree = ""; @@ -968,6 +1005,7 @@ DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */, DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */, DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */, + DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */, ); path = APIService; sourceTree = ""; @@ -982,6 +1020,16 @@ path = CoreData; sourceTree = ""; }; + DB49A61925FF327D00B98345 /* EmojiService */ = { + isa = PBXGroup; + children = ( + DB49A61325FF2C5600B98345 /* EmojiService.swift */, + DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */, + DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */, + ); + path = EmojiService; + sourceTree = ""; + }; DB5086CB25CC0DB400C2C187 /* Preference */ = { isa = PBXGroup; children = ( @@ -990,6 +1038,14 @@ path = Preference; sourceTree = ""; }; + DB55D32225FB4D320002F825 /* View */ = { + isa = PBXGroup; + children = ( + DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */, + ); + path = View; + sourceTree = ""; + }; DB68A03825E900CC00CFDF14 /* Share */ = { isa = PBXGroup; children = ( @@ -1025,6 +1081,27 @@ path = ServerRules; sourceTree = ""; }; + DB789A1025F9F29B0071ACA0 /* Compose */ = { + isa = PBXGroup; + children = ( + DB55D32225FB4D320002F825 /* View */, + 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 = ( @@ -1126,6 +1203,7 @@ DB9D6BEE25E4F5370051B173 /* Search */, DB9D6BFD25E4F57B0051B173 /* Notification */, DB9D6C0825E4F5A60051B173 /* Profile */, + DB789A1025F9F29B0071ACA0 /* Compose */, ); path = Scene; sourceTree = ""; @@ -1309,6 +1387,7 @@ DB5086B725CC0D6400C2C187 /* Kingfisher */, 2D5981B925E4D7F8000FB903 /* ThirdPartyMailer */, 2D939AC725EE14620076FA61 /* CropViewController */, + DB6672A225F9FDE500D60309 /* TwitterTextEditor */, ); productName = Mastodon; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; @@ -1438,6 +1517,7 @@ DB5086B625CC0D6400C2C187 /* XCRemoteSwiftPackageReference "Kingfisher" */, 2D5981B825E4D7F8000FB903 /* XCRemoteSwiftPackageReference "ThirdPartyMailer" */, 2D939AC625EE14620076FA61 /* XCRemoteSwiftPackageReference "TOCropViewController" */, + DB6672A125F9FDE500D60309 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -1628,10 +1708,12 @@ 2D206B8C25F6015000143C56 /* AudioPlaybackService.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 */, 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 */, @@ -1648,11 +1730,13 @@ 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarState.swift in Sources */, + DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */, DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */, 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */, 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 */, @@ -1661,6 +1745,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 */, @@ -1671,6 +1756,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 */, @@ -1678,6 +1764,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 */, @@ -1685,6 +1772,7 @@ 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, 2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */, + DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */, 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */, 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */, 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, @@ -1705,6 +1793,7 @@ DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.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 */, @@ -1745,6 +1834,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 */, @@ -1769,13 +1859,17 @@ 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */, DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */, 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.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 */, 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, + DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */, 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */, 5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */, DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */, + DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToTootContentTableViewCell.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2362,6 +2456,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 */ @@ -2404,6 +2506,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 3183f10d..21afdd4c 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 b274f2b9..6ed0b18f 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 00000000..79655b94 --- /dev/null +++ b/Mastodon/Diffiable/Item/ComposeStatusItem.swift @@ -0,0 +1,39 @@ +// +// ComposeStatusItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import Foundation +import Combine +import CoreData + +enum ComposeStatusItem { + case replyTo(statusObjectID: NSManagedObjectID) + case input(replyToStatusObjectID: NSManagedObjectID?, attribute: ComposeStatusAttribute) +} + +extension ComposeStatusItem: Hashable { } + +extension ComposeStatusItem { + final class ComposeStatusAttribute: 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: ComposeStatusAttribute, rhs: ComposeStatusAttribute) -> 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 new file mode 100644 index 00000000..835007dc --- /dev/null +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -0,0 +1,97 @@ +// +// ComposeStatusSection.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import TwitterTextEditor + +enum ComposeStatusSection: Equatable, Hashable { + case repliedTo + case status +} + +extension ComposeStatusSection { + enum ComposeKind { + case post + case reply(repliedToStatusObjectID: NSManagedObjectID) + } +} + +extension ComposeStatusSection { + static func tableViewDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency, + managedObjectContext: NSManagedObjectContext, + composeKind: ComposeKind, + textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate + ) -> UITableViewDiffableDataSource { + UITableViewDiffableDataSource(tableView: tableView) { [weak textEditorViewTextAttributesDelegate] tableView, indexPath, item -> UITableViewCell? in + switch item { + case .replyTo(let repliedToStatusObjectID): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToTootContentTableViewCell.self), for: indexPath) as! ComposeRepliedToTootContentTableViewCell + // TODO: + return cell + 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, + 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.configure(cell: cell, attribute: attribute) + cell.textEditorView.textAttributesDelegate = textEditorViewTextAttributesDelegate + // 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 configure( + cell: ComposeTootContentTableViewCell, + attribute: ComposeStatusItem.ComposeStatusAttribute + ) { + // set avatar + attribute.avatarURL + .receive(on: DispatchQueue.main) + .sink { avatarURL in + cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarURL)) + } + .store(in: &cell.disposeBag) + // set display name and username + Publishers.CombineLatest( + attribute.displayName.eraseToAnyPublisher(), + attribute.username.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { displayName, username in + cell.statusView.nameLabel.text = displayName + cell.statusView.usernameLabel.text = username + } + .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/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 4b0532d5..e0620b10 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -376,7 +376,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/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 25738948..884a7b34 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -49,6 +49,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 166a6122..63259503 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 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 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 @@ -127,6 +135,18 @@ internal enum L10n { } internal enum Scene { + internal enum Compose { + /// 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") + } + } internal enum ConfirmEmail { /// We just sent an email to %@,\ntap the link to confirm your account. internal static func subtitle(_ p1: Any) -> String { @@ -280,9 +300,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/Assets.xcassets/Colors/Button/disabled.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Button/disabled.colorset/Contents.json index 78cde95f..bca75461 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 00000000..d853a71a --- /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 5ed3c68a..3515da67 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.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"; @@ -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"; @@ -34,6 +37,10 @@ "Common.Controls.Timeline.LoadMore" = "Load More"; "Common.Countable.Photo.Multiple" = "photos"; "Common.Countable.Photo.Single" = "photo"; +"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.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."; @@ -81,8 +88,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 new file mode 100644 index 00000000..df04b8d2 --- /dev/null +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -0,0 +1,448 @@ +// +// ComposeViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import os.log +import UIKit +import Combine +import TwitterTextEditor +import Kingfisher + +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! + + private var suffixedAttachmentViews: [UIView] = [] + + 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)) + tableView.register(ComposeTootContentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeTootContentTableViewCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + return tableView + }() + + 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 + }() + +} + +extension ComposeViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + viewModel.title + .receive(on: DispatchQueue.main) + .sink { [weak self] title in + guard let self = self else { return } + 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) + 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), + ]) + + 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: ComposeToolbarView.toolbarHeight), + ]) + 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, + textEditorViewTextAttributesDelegate: self + ) + + // respond scrollView overlap change + view.layoutIfNeeded() + // update layout when keyboard show/dismiss + 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 + } + + // 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() + } + }) + .store(in: &disposeBag) + + viewModel.isComposeTootBarButtonItemEnabled + .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) { + super.viewWillAppear(animated) + + // Fix AutoLayout conflict issue + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.markTextEditorViewBecomeFirstResponser() + } + } + +} + +extension ComposeViewController { + + private func textEditorView() -> TextEditorView? { + guard let diffableDataSource = viewModel.diffableDataSource else { return nil } + let items = diffableDataSource.snapshot().itemIdentifiers + for item in items { + switch item { + case .input: + guard let indexPath = diffableDataSource.indexPath(for: item), + let cell = tableView.cellForRow(at: indexPath) as? ComposeTootContentTableViewCell else { + continue + } + return cell.textEditorView + default: + continue + } + } + + return nil + } + + private func markTextEditorViewBecomeFirstResponser() { + textEditorView()?.isEditing = true + } + + private func showDismissConfirmAlertController() { + let alertController = UIAlertController( + 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 + 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) + } + +} + +// MARK: - TextEditorViewTextAttributesDelegate +extension ComposeViewController: TextEditorViewTextAttributesDelegate { + + 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.]+))") + // 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)") + + DispatchQueue.main.async { [weak self] in + guard let self = self else { + 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) + 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) + } + + 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) + 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: imageViewSize, + attachment: .view(view: imageView, layoutInTextContainer: layoutInTextContainer) + ) + let index = match.range.upperBound - 1 + attributedString.addAttribute( + .suffixedAttachment, + value: attachment, + range: NSRange(location: index, length: 1) + ) + } + } + + 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) + } + } + } + +} + + + +// 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 { + return UITableView.automaticDimension + } +} + +// MARK: - UIAdaptivePresentationControllerDelegate +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) + showDismissConfirmAlertController() + + } + + 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 00000000..a3a0515e --- /dev/null +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -0,0 +1,38 @@ +// +// ComposeViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import UIKit +import TwitterTextEditor + +extension ComposeViewModel { + + func setupDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency, + textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate + ) { + diffableDataSource = ComposeStatusSection.tableViewDiffableDataSource( + for: tableView, + dependency: dependency, + managedObjectContext: context.managedObjectContext, + composeKind: composeKind, + textEditorViewTextAttributesDelegate: textEditorViewTextAttributesDelegate + ) + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.repliedTo, .status]) + switch composeKind { + case .reply(let statusObjectID): + snapshot.appendItems([.replyTo(statusObjectID: statusObjectID)], toSection: .repliedTo) + snapshot.appendItems([.input(replyToStatusObjectID: statusObjectID, attribute: composeStatusAttribute)], toSection: .repliedTo) + case .post: + 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 new file mode 100644 index 00000000..743f385e --- /dev/null +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -0,0 +1,106 @@ +// +// ComposeViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack + +final class ComposeViewModel { + + var disposeBag = Set() + + // input + let context: AppContext + let composeKind: ComposeStatusSection.ComposeKind + let composeStatusAttribute = ComposeStatusItem.ComposeStatusAttribute() + let composeContent = CurrentValueSubject("") + let activeAuthentication: CurrentValueSubject + + // output + var diffableDataSource: UITableViewDiffableDataSource! + + // UI & UX + let title: CurrentValueSubject + let shouldDismiss = CurrentValueSubject(true) + let isComposeTootBarButtonItemEnabled = CurrentValueSubject(false) + + // custom emojis + let customEmojiViewModel = CurrentValueSubject(nil) + + + init( + context: AppContext, + composeKind: ComposeStatusSection.ComposeKind + ) { + self.context = context + self.composeKind = composeKind + switch composeKind { + 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 + + // bind active authentication + context.authenticationService.activeMastodonAuthentication + .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.composeStatusAttribute.avatarURL.value = mastodonUser?.avatarImageURL() + self.composeStatusAttribute.displayName.value = { + guard let displayName = mastodonUser?.displayName, !displayName.isEmpty else { + return username + } + return displayName + }() + self.composeStatusAttribute.username.value = username + } + .store(in: &disposeBag) + + // bind compose bar button item UI state + composeStatusAttribute.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) + + // bind modal dismiss state + composeStatusAttribute.composeContent + .receive(on: DispatchQueue.main) + .map { content in + let content = content ?? "" + return content.isEmpty + } + .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.customEmojiViewModel.value = self.context.emojiService.dequeueCustomEmojiViewModel(for: domain) + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToTootContentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToTootContentTableViewCell.swift new file mode 100644 index 00000000..def777ca --- /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 00000000..9f39f198 --- /dev/null +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift @@ -0,0 +1,91 @@ +// +// ComposeTootContentTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +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.isScrollEnabled = false + textEditorView.placeholderText = L10n.Scene.Compose.contentInputPlaceholder + textEditorView.keyboardType = .twitter + return textEditorView + }() + + let composeContent = PassthroughSubject() + + 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() { + selectionStyle = .none + + statusView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(statusView) + NSLayoutConstraint.activate([ + 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 + statusView.nameTrialingDotLabel.isHidden = true + statusView.dateLabel.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), + ]) + + // TODO: + + textEditorView.changeObserver = self + } + + override func didMoveToWindow() { + super.didMoveToWindow() + + } + +} + +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/Compose/View/ComposeToolbarView.swift b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift new file mode 100644 index 00000000..7eb3ae82 --- /dev/null +++ b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift @@ -0,0 +1,156 @@ +// +// 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 { + + static let toolbarHeight: CGFloat = 44 + + 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/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 9f2b4e72..2b6694b8 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -84,9 +84,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) }) } ) @@ -136,8 +136,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 } @@ -146,7 +146,7 @@ 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 poll status") } } @@ -171,7 +171,7 @@ extension HomeTimelineViewController { } } - @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() @@ -197,8 +197,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 fe273241..2dd7a3ad 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -164,7 +164,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: .post) + coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) } @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift index 95a5491c..5ff83cc7 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/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index f078e9b8..d66f9717 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/Scene/Onboarding/Welcome/WelcomeViewController.swift b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift index 98b221ad..63c1e421 100644 --- a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift +++ b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift @@ -217,6 +217,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 } } diff --git a/Mastodon/Scene/Share/View/Button/RoundedEdgesButton.swift b/Mastodon/Scene/Share/View/Button/RoundedEdgesButton.swift new file mode 100644 index 00000000..a38b711d --- /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/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 27aa3ecf..3987aa5f 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) @@ -360,7 +361,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(playerContainerView) diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 75845d6a..5f9bdf65 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, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) diff --git a/Mastodon/Service/APIService/APIService+CustomEmoji.swift b/Mastodon/Service/APIService/APIService+CustomEmoji.swift new file mode 100644 index 00000000..2a80eca4 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+CustomEmoji.swift @@ -0,0 +1,22 @@ +// +// APIService+CustomEmojiViewModel.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 9fa411f2..89ce7a18 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+CustomEmojiViewModel+LoadState.swift b/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel+LoadState.swift new file mode 100644 index 00000000..4fdab0bb --- /dev/null +++ b/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel+LoadState.swift @@ -0,0 +1,86 @@ +// +// EmojiService+CustomEmojiViewModel+LoadState.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-15. +// + +import os.log +import Foundation +import GameplayKit + +extension EmojiService.CustomEmojiViewModel { + class LoadState: GKState { + weak var viewModel: EmojiService.CustomEmojiViewModel? + + init(viewModel: EmojiService.CustomEmojiViewModel) { + 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.CustomEmojiViewModel.LoadState { + + class Initial: EmojiService.CustomEmojiViewModel.LoadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } + + 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 apiService = viewModel.service?.apiService, let stateMachine = stateMachine else { return } + + 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.CustomEmojiViewModel.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.CustomEmojiViewModel.LoadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // one time task + return false + } + } + +} diff --git a/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel.swift b/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel.swift new file mode 100644 index 00000000..f866f4a0 --- /dev/null +++ b/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel.swift @@ -0,0 +1,42 @@ +// +// EmojiService+CustomEmojiViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-15. +// + +import Foundation +import Combine +import GameplayKit +import MastodonSDK + +extension EmojiService { + final class CustomEmojiViewModel { + + var disposeBag = Set() + + // input + let domain: String + weak var service: EmojiService? + + // 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, service: EmojiService) { + self.domain = domain + self.service = service + } + + } +} diff --git a/Mastodon/Service/EmojiService/EmojiService.swift b/Mastodon/Service/EmojiService/EmojiService.swift new file mode 100644 index 00000000..3883d4ba --- /dev/null +++ b/Mastodon/Service/EmojiService/EmojiService.swift @@ -0,0 +1,46 @@ +// +// EmojiService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-15. +// + +import os.log +import Foundation +import Combine +import MastodonSDK + +final class EmojiService { + + + weak var apiService: APIService? + + 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 + } + +} + diff --git a/Mastodon/Service/KeyboardResponderService.swift b/Mastodon/Service/KeyboardResponderService.swift index b2173796..d4bf9b58 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 diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index c5330fc0..fe8cd583 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -23,13 +23,13 @@ class AppContext: ObservableObject { let apiService: APIService let authenticationService: AuthenticationService + let emojiService: EmojiService + let audioPlaybackService = AudioPlaybackService() + let videoPlaybackService = VideoPlaybackService() let documentStore: DocumentStore private var documentStoreSubscription: AnyCancellable! - let videoPlaybackService = VideoPlaybackService() - let audioPlaybackService = AudioPlaybackService() - let overrideTraitCollection = CurrentValueSubject(nil) init() { @@ -49,6 +49,10 @@ class AppContext: ObservableObject { apiService: _apiService ) + emojiService = EmojiService( + apiService: apiService + ) + documentStore = DocumentStore() documentStoreSubscription = documentStore.objectWillChange .receive(on: DispatchQueue.main) diff --git a/Mastodon/Vender/TwitterTextEditor+String.swift b/Mastodon/Vender/TwitterTextEditor+String.swift new file mode 100644 index 00000000..7abdba3a --- /dev/null +++ b/Mastodon/Vender/TwitterTextEditor+String.swift @@ -0,0 +1,54 @@ +// +// String.swift +// Example +// +// Copyright 2021 Twitter, Inc. +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension String { + @inlinable + var length: Int { + (self as NSString).length + } + + @inlinable + func substring(with range: NSRange) -> String { + (self as NSString).substring(with: range) + } + + func substring(with result: NSTextCheckingResult, at index: Int) -> String? { + guard index < result.numberOfRanges else { + return nil + } + let range = result.range(at: index) + guard range.location != NSNotFound else { + return nil + } + return substring(with: result.range(at: index)) + } + + func firstMatch(pattern: String, + options: NSRegularExpression.Options = [], + range: NSRange? = nil) -> NSTextCheckingResult? + { + guard let regularExpression = try? NSRegularExpression(pattern: pattern, options: options) else { + return nil + } + let range = range ?? NSRange(location: 0, length: length) + return regularExpression.firstMatch(in: self, options: [], range: range) + } + + func matches(pattern: String, + options: NSRegularExpression.Options = [], + range: NSRange? = nil) -> [NSTextCheckingResult] + { + guard let regularExpression = try? NSRegularExpression(pattern: pattern, options: options) else { + return [] + } + let range = range ?? NSRange(location: 0, length: length) + return regularExpression.matches(in: self, options: [], range: range) + } +} 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 00000000..091e12d1 --- /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+Status.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status.swift deleted file mode 100644 index 09dd07c4..00000000 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status.swift +++ /dev/null @@ -1,10 +0,0 @@ -// -// Mastodon+API+Status.swift -// -// -// Created by MainasuK Cirno on 2021-3-9. -// - -import Foundation - -extension Mastodon.API.Status { } 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 00000000..f01e6cb4 --- /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 4a76a540..dfba19bf 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -91,14 +91,15 @@ 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 Statuses { } public enum Reblog { } - public enum Status { } public enum Timeline { } - public enum Favorites { } } extension Mastodon.API { diff --git a/README.md b/README.md index 0847c82e..53e3bf49 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