diff --git a/Localization/app.json b/Localization/app.json index 535630384..0cd2e7f83 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -194,8 +194,20 @@ "new_post": "New Post", "new_reply": "New Reply" }, + "media_selection": { + "camera": "Take Photo", + "photo_library": "Photo Library", + "browse": "Browse" + }, "content_input_placeholder": "Type or paste what's on your mind", - "compose_action": "Publish" + "compose_action": "Publish", + "attachment": { + "photo": "photo", + "video": "video", + "attachment_broken": "This %s is broken and can't be\nuploaded to Mastodon.", + "description_photo": "Describe photo for low vision people...", + "description_video": "Describe what’s happening for low vision people..." + } } } -} +} \ No newline at end of file diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 9dac11620..0a3c58b9c 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -171,6 +171,8 @@ DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */; }; DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; }; DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A06225E905E000CFDF14 /* UIApplication.swift */; }; + DB6B35182601FA3400DC1E11 /* MastodonAttachmentService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */; }; + DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift */; }; DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; }; DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */; }; DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */; }; @@ -182,8 +184,9 @@ 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 */; }; + DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */; }; + DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift */; }; + DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8190C52601FF0400020C08 /* AttachmentContainerView.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, ); }; }; @@ -214,6 +217,13 @@ DB98338725C945ED00AD9700 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98338525C945ED00AD9700 /* Strings.swift */; }; DB98338825C945ED00AD9700 /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98338625C945ED00AD9700 /* Assets.swift */; }; DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98339B25C96DE600AD9700 /* APIService+Account.swift */; }; + DB9A485C2603010E008B817C /* PHPickerResultLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */; }; + DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */; }; + DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */ = {isa = PBXBuildFile; productRef = DB9A487D2603456B008B817C /* UITextView+Placeholder */; }; + DB9A488426034BD7008B817C /* APIService+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488326034BD7008B817C /* APIService+Status.swift */; }; + DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */; }; + DB9A489026035963008B817C /* APIService+Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488F26035963008B817C /* APIService+Media.swift */; }; + DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A48952603685D008B817C /* MastodonAttachmentService+UploadState.swift */; }; DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BE825E4F5340051B173 /* SearchViewController.swift */; }; DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */; }; DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */; }; @@ -455,6 +465,8 @@ 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 = ""; }; DB68A06225E905E000CFDF14 /* UIApplication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = ""; }; + DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAttachmentService.swift; sourceTree = ""; }; + DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentTableViewCell.swift; sourceTree = ""; }; DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = ""; }; DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.swift; sourceTree = ""; }; DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistMemo.swift"; sourceTree = ""; }; @@ -466,8 +478,9 @@ 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 = ""; }; + DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentCollectionViewCell.swift; sourceTree = ""; }; + DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToTootContentCollectionViewCell.swift; sourceTree = ""; }; + DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentContainerView.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 = ""; }; @@ -500,6 +513,12 @@ DB98338525C945ED00AD9700 /* Strings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = ""; }; DB98338625C945ED00AD9700 /* Assets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Assets.swift; sourceTree = ""; }; DB98339B25C96DE600AD9700 /* APIService+Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Account.swift"; sourceTree = ""; }; + DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHPickerResultLoader.swift; sourceTree = ""; }; + DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttachmentContainerView+EmptyStateView.swift"; sourceTree = ""; }; + DB9A488326034BD7008B817C /* APIService+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Status.swift"; sourceTree = ""; }; + DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+PublishState.swift"; sourceTree = ""; }; + DB9A488F26035963008B817C /* APIService+Media.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Media.swift"; sourceTree = ""; }; + DB9A48952603685D008B817C /* MastodonAttachmentService+UploadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonAttachmentService+UploadState.swift"; sourceTree = ""; }; DB9D6BE825E4F5340051B173 /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = ""; }; DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = ""; }; DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = ""; }; @@ -530,6 +549,7 @@ DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */, DB89BA0325C10FD0008580ED /* CoreDataStack.framework in Frameworks */, 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */, + DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */, 2D939AC825EE14620076FA61 /* CropViewController in Frameworks */, 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */, DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */, @@ -723,6 +743,7 @@ 2D5A3D0225CF8742002347D6 /* ControlContainableScrollViews.swift */, DB2B3AE825E38850007045F9 /* UIViewPreview.swift */, DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */, + DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */, ); path = Vender; sourceTree = ""; @@ -731,6 +752,8 @@ isa = PBXGroup; children = ( DB45FB0425CA87B4005A8AC7 /* APIService */, + DB49A61925FF327D00B98345 /* EmojiService */, + DB9A489B26036E19008B817C /* MastodonAttachmentService */, DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */, DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */, 2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */, @@ -1032,6 +1055,8 @@ DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */, DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */, DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */, + DB9A488326034BD7008B817C /* APIService+Status.swift */, + DB9A488F26035963008B817C /* APIService+Media.swift */, ); path = APIService; sourceTree = ""; @@ -1068,6 +1093,8 @@ isa = PBXGroup; children = ( DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */, + DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */, + DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */, ); path = View; sourceTree = ""; @@ -1111,21 +1138,23 @@ isa = PBXGroup; children = ( DB55D32225FB4D320002F825 /* View */, - DB789A2125F9F76D0071ACA0 /* TableViewCell */, + DB789A2125F9F76D0071ACA0 /* CollectionViewCell */, DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */, DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */, + DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */, DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */, ); path = Compose; sourceTree = ""; }; - DB789A2125F9F76D0071ACA0 /* TableViewCell */ = { + DB789A2125F9F76D0071ACA0 /* CollectionViewCell */ = { isa = PBXGroup; children = ( - DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentTableViewCell.swift */, - DB789A1B25F9F76A0071ACA0 /* ComposeTootContentTableViewCell.swift */, + DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift */, + DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */, + DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift */, ); - path = TableViewCell; + path = CollectionViewCell; sourceTree = ""; }; DB89B9EF25C10FD0008580ED /* CoreDataStack */ = { @@ -1284,6 +1313,15 @@ path = Generated; sourceTree = ""; }; + DB9A489B26036E19008B817C /* MastodonAttachmentService */ = { + isa = PBXGroup; + children = ( + DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */, + DB9A48952603685D008B817C /* MastodonAttachmentService+UploadState.swift */, + ); + path = MastodonAttachmentService; + sourceTree = ""; + }; DB9D6BEE25E4F5370051B173 /* Search */ = { isa = PBXGroup; children = ( @@ -1407,6 +1445,7 @@ 2D5981B925E4D7F8000FB903 /* ThirdPartyMailer */, 2D939AC725EE14620076FA61 /* CropViewController */, DB6672A225F9FDE500D60309 /* TwitterTextEditor */, + DB9A487D2603456B008B817C /* UITextView+Placeholder */, ); productName = Mastodon; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; @@ -1537,6 +1576,7 @@ 2D5981B825E4D7F8000FB903 /* XCRemoteSwiftPackageReference "ThirdPartyMailer" */, 2D939AC625EE14620076FA61 /* XCRemoteSwiftPackageReference "TOCropViewController" */, DB6672A125F9FDE500D60309 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */, + DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -1730,6 +1770,7 @@ DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */, 2D7631B325C159F700929FB9 /* Item.swift in Sources */, 5DF1054125F886D400D6C0D4 /* ViedeoPlaybackService.swift in Sources */, + DB6B35182601FA3400DC1E11 /* MastodonAttachmentService.swift in Sources */, 0FB3D2F725E4C24D00AAD544 /* MastodonPickServerViewModel.swift in Sources */, 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */, DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */, @@ -1748,6 +1789,7 @@ 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */, 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift in Sources */, + DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, 2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */, 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarState.swift in Sources */, @@ -1774,6 +1816,7 @@ 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */, 2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */, DB98338825C945ED00AD9700 /* Assets.swift in Sources */, + DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift in Sources */, 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */, DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */, 2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */, @@ -1787,6 +1830,7 @@ 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */, DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */, + DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */, DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */, DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */, @@ -1807,6 +1851,7 @@ DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, 2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */, 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */, + DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */, DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */, DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */, DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */, @@ -1817,8 +1862,9 @@ DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, - DB789A1C25F9F76A0071ACA0 /* ComposeTootContentTableViewCell.swift in Sources */, + DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */, DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */, + DB9A488426034BD7008B817C /* APIService+Status.swift in Sources */, 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, @@ -1842,6 +1888,7 @@ DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */, 2D206B8625F5FB0900143C56 /* Double.swift in Sources */, + DB9A485C2603010E008B817C /* PHPickerResultLoader.swift in Sources */, 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */, @@ -1879,10 +1926,12 @@ 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */, 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */, DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */, + DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */, DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */, DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */, DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */, + DB9A489026035963008B817C /* APIService+Media.swift in Sources */, 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, @@ -1894,7 +1943,7 @@ 5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */, DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */, - DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToTootContentTableViewCell.swift in Sources */, + DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2489,6 +2538,14 @@ minimumVersion = 1.0.0; }; }; + DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/MainasuK/UITextView-Placeholder"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.4.1; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -2536,6 +2593,11 @@ package = DB6672A125F9FDE500D60309 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */; productName = TwitterTextEditor; }; + DB9A487D2603456B008B817C /* UITextView+Placeholder */ = { + isa = XCSwiftPackageProductDependency; + package = DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */; + productName = "UITextView+Placeholder"; + }; /* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index c57f2d22e..696ac7070 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -108,6 +108,15 @@ "revision": "8aa914134c5b6aa46e862de63f239ec0e3b52a91", "version": "1.0.0" } + }, + { + "package": "UITextView+Placeholder", + "repositoryURL": "https://github.com/MainasuK/UITextView-Placeholder", + "state": { + "branch": null, + "revision": "20f513ded04a040cdf5467f0891849b1763ede3b", + "version": "1.4.1" + } } ] }, diff --git a/Mastodon/Diffiable/Item/ComposeStatusItem.swift b/Mastodon/Diffiable/Item/ComposeStatusItem.swift index 79655b946..d49203c33 100644 --- a/Mastodon/Diffiable/Item/ComposeStatusItem.swift +++ b/Mastodon/Diffiable/Item/ComposeStatusItem.swift @@ -12,6 +12,7 @@ import CoreData enum ComposeStatusItem { case replyTo(statusObjectID: NSManagedObjectID) case input(replyToStatusObjectID: NSManagedObjectID?, attribute: ComposeStatusAttribute) + case attachment(attachmentService: MastodonAttachmentService) } extension ComposeStatusItem: Hashable { } diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 835007dcc..33ef0f268 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -10,10 +10,12 @@ import Combine import CoreData import CoreDataStack import TwitterTextEditor +import AlamofireImage enum ComposeStatusSection: Equatable, Hashable { case repliedTo case status + case attachment } extension ComposeStatusSection { @@ -24,21 +26,23 @@ extension ComposeStatusSection { } extension ComposeStatusSection { - static func tableViewDiffableDataSource( - for tableView: UITableView, + + static func collectionViewDiffableDataSource( + for collectionView: UICollectionView, dependency: NeedsDependency, managedObjectContext: NSManagedObjectContext, composeKind: ComposeKind, - textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate - ) -> UITableViewDiffableDataSource { - UITableViewDiffableDataSource(tableView: tableView) { [weak textEditorViewTextAttributesDelegate] tableView, indexPath, item -> UITableViewCell? in + textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate, + composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate + ) -> UICollectionViewDiffableDataSource { + UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in switch item { case .replyTo(let repliedToStatusObjectID): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToTootContentTableViewCell.self), for: indexPath) as! ComposeRepliedToTootContentTableViewCell - // TODO: + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeRepliedToTootContentCollectionViewCell.self), for: indexPath) as! ComposeRepliedToTootContentCollectionViewCell return cell case .input(let replyToTootObjectID, let attribute): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeTootContentTableViewCell.self), for: indexPath) as! ComposeTootContentTableViewCell + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self), for: indexPath) as! ComposeStatusContentCollectionViewCell + cell.textEditorView.text = attribute.composeContent.value ?? "" managedObjectContext.perform { guard let replyToTootObjectID = replyToTootObjectID, let replyTo = managedObjectContext.object(with: replyToTootObjectID) as? Toot else { @@ -52,13 +56,74 @@ extension ComposeStatusSection { cell.textEditorView.textAttributesDelegate = textEditorViewTextAttributesDelegate // self size input cell cell.composeContent + .removeDuplicates() .receive(on: DispatchQueue.main) .sink { text in - tableView.beginUpdates() - tableView.endUpdates() + collectionView.collectionViewLayout.invalidateLayout() + // bind input data + attribute.composeContent.value = text } .store(in: &cell.disposeBag) return cell + case .attachment(let attachmentService): + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self), for: indexPath) as! ComposeStatusAttachmentCollectionViewCell + cell.attachmentContainerView.descriptionTextView.text = attachmentService.description.value + cell.delegate = composeStatusAttachmentTableViewCellDelegate + attachmentService.imageData + .receive(on: DispatchQueue.main) + .sink { imageData in + let size = cell.attachmentContainerView.previewImageView.frame.size != .zero ? cell.attachmentContainerView.previewImageView.frame.size : CGSize(width: 1, height: 1) + guard let imageData = imageData, + let image = UIImage(data: imageData) else { + let placeholder = UIImage.placeholder( + size: size, + color: Asset.Colors.Background.systemGroupedBackground.color + ) + .af.imageRounded( + withCornerRadius: AttachmentContainerView.containerViewCornerRadius + ) + cell.attachmentContainerView.previewImageView.image = placeholder + return + } + cell.attachmentContainerView.previewImageView.image = image + .af.imageAspectScaled(toFill: size) + .af.imageRounded(withCornerRadius: AttachmentContainerView.containerViewCornerRadius) + } + .store(in: &cell.disposeBag) + Publishers.CombineLatest( + attachmentService.uploadStateMachineSubject.eraseToAnyPublisher(), + attachmentService.error.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { uploadState, error in + cell.attachmentContainerView.emptyStateView.isHidden = error == nil + cell.attachmentContainerView.descriptionBackgroundView.isHidden = error != nil + if let _ = error { + cell.attachmentContainerView.activityIndicatorView.stopAnimating() + } else { + guard let uploadState = uploadState else { return } + switch uploadState { + case is MastodonAttachmentService.UploadState.Finish, + is MastodonAttachmentService.UploadState.Fail: + cell.attachmentContainerView.activityIndicatorView.stopAnimating() + default: + break + } + } + } + .store(in: &cell.disposeBag) + NotificationCenter.default.publisher( + for: UITextView.textDidChangeNotification, + object: cell.attachmentContainerView.descriptionTextView + ) + .receive(on: DispatchQueue.main) + .sink { notification in + guard let textField = notification.object as? UITextView else { return } + let text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) + attachmentService.description.value = text + } + .store(in: &cell.disposeBag) + return cell } } } @@ -66,7 +131,7 @@ extension ComposeStatusSection { extension ComposeStatusSection { static func configure( - cell: ComposeTootContentTableViewCell, + cell: ComposeStatusContentCollectionViewCell, attribute: ComposeStatusItem.ComposeStatusAttribute ) { // set avatar diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 2133c5aa3..ba58cb3f1 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -30,11 +30,16 @@ internal enum Asset { } internal enum Colors { internal enum Background { + internal enum AudioPlayer { + internal static let highlight = ColorAsset(name: "Colors/Background/AudioPlayer/highlight") + } internal enum Poll { internal static let disabled = ColorAsset(name: "Colors/Background/Poll/disabled") internal static let highlight = ColorAsset(name: "Colors/Background/Poll/highlight") } - internal static let mediaTypeIndicotor = ColorAsset(name: "Colors/Background/mediaTypeIndicotor") + internal static let dangerBorder = ColorAsset(name: "Colors/Background/danger.border") + internal static let danger = ColorAsset(name: "Colors/Background/danger") + internal static let mediaTypeIndicotor = ColorAsset(name: "Colors/Background/media.type.indicotor") internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background") internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background") internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background") @@ -45,7 +50,6 @@ internal enum Asset { internal enum Button { 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 { @@ -79,10 +83,12 @@ internal enum Asset { internal static let lightSecondaryText = ColorAsset(name: "Colors/lightSecondaryText") internal static let lightSuccessGreen = ColorAsset(name: "Colors/lightSuccessGreen") internal static let lightWhite = ColorAsset(name: "Colors/lightWhite") - internal static let plusCircleFill = ImageAsset(name: "Colors/plus.circle.fill") internal static let systemGreen = ColorAsset(name: "Colors/system.green") internal static let systemOrange = ColorAsset(name: "Colors/system.orange") } + internal enum Connectivity { + internal static let photoFillSplit = ImageAsset(name: "Connectivity/photo.fill.split") + } internal enum Welcome { internal enum Illustration { internal static let backgroundCyan = ColorAsset(name: "Welcome/illustration/background.cyan") diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index f4d3a8a56..7f142cb99 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -148,6 +148,28 @@ internal enum L10n { 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 Attachment { + /// This %@ is broken and can't be\nuploaded to Mastodon. + internal static func attachmentBroken(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Compose.Attachment.AttachmentBroken", String(describing: p1)) + } + /// Describe photo for low vision people... + internal static let descriptionPhoto = L10n.tr("Localizable", "Scene.Compose.Attachment.DescriptionPhoto") + /// Describe what’s happening for low vision people... + internal static let descriptionVideo = L10n.tr("Localizable", "Scene.Compose.Attachment.DescriptionVideo") + /// photo + internal static let photo = L10n.tr("Localizable", "Scene.Compose.Attachment.Photo") + /// video + internal static let video = L10n.tr("Localizable", "Scene.Compose.Attachment.Video") + } + internal enum MediaSelection { + /// Browse + internal static let browse = L10n.tr("Localizable", "Scene.Compose.MediaSelection.Browse") + /// Take Photo + internal static let camera = L10n.tr("Localizable", "Scene.Compose.MediaSelection.Camera") + /// Photo Library + internal static let photoLibrary = L10n.tr("Localizable", "Scene.Compose.MediaSelection.PhotoLibrary") + } internal enum Title { /// New Post internal static let newPost = L10n.tr("Localizable", "Scene.Compose.Title.NewPost") diff --git a/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.fill.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.fill.imageset/Contents.json index 580a3f7a0..40480a161 100644 --- a/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.fill.imageset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.fill.imageset/Contents.json @@ -2,20 +2,14 @@ "images" : [ { "filename" : "plus.circle.fill.pdf", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" + "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true } } diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/AudioPlayer/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/AudioPlayer/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/AudioPlayer/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Button/highlight.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/AudioPlayer/highlight.colorset/Contents.json similarity index 74% rename from Mastodon/Resources/Assets.xcassets/Colors/Button/highlight.colorset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Colors/Background/AudioPlayer/highlight.colorset/Contents.json index 03a422b00..2e1ce5f3a 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Button/highlight.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/AudioPlayer/highlight.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.839", - "green" : "0.573", - "red" : "0.204" + "blue" : "0.851", + "green" : "0.565", + "red" : "0.169" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/danger.border.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/danger.border.colorset/Contents.json new file mode 100644 index 000000000..bc9f94fcc --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/danger.border.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "66", + "green" : "46", + "red" : "163" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/danger.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/danger.colorset/Contents.json new file mode 100644 index 000000000..b77cb3c75 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/danger.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "90", + "green" : "64", + "red" : "223" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/mediaTypeIndicotor.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/media.type.indicotor.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/Background/mediaTypeIndicotor.colorset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Colors/Background/media.type.indicotor.colorset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json index d097fec40..edc0dce9a 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xE8", - "green" : "0xE1", - "red" : "0xD9" + "blue" : "232", + "green" : "225", + "red" : "217" } }, "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 index d853a71aa..cd9b7c5ba 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Button/normal.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Button/normal.colorset/Contents.json @@ -11,6 +11,24 @@ } }, "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0x84", + "red" : "0x0A" + } + }, + "idiom" : "universal" } ], "info" : { diff --git a/Mastodon/Resources/Assets.xcassets/Colors/plus.circle.fill.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/plus.circle.fill.imageset/Contents.json deleted file mode 100644 index 580a3f7a0..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/plus.circle.fill.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "plus.circle.fill.pdf", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/plus.circle.fill.imageset/plus.circle.fill.pdf b/Mastodon/Resources/Assets.xcassets/Colors/plus.circle.fill.imageset/plus.circle.fill.pdf deleted file mode 100644 index f4a613417..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/plus.circle.fill.imageset/plus.circle.fill.pdf +++ /dev/null @@ -1,101 +0,0 @@ -%PDF-1.7 - -1 0 obj - << >> -endobj - -2 0 obj - << /Length 3 0 R >> -stream -/DeviceRGB CS -/DeviceRGB cs -q -1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm -1.000000 1.000000 1.000000 scn -30.000000 15.000000 m -30.000000 6.715729 23.284271 0.000000 15.000000 0.000000 c -6.715729 0.000000 0.000000 6.715729 0.000000 15.000000 c -0.000000 23.284271 6.715729 30.000000 15.000000 30.000000 c -23.284271 30.000000 30.000000 23.284271 30.000000 15.000000 c -h -f -n -Q -q -1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm -0.000000 0.000000 0.000000 scn -15.009004 0.000000 m -23.233341 0.000000 30.000000 6.766640 30.000000 15.009003 c -30.000000 23.233379 23.233341 30.000000 14.991017 30.000000 c -6.766642 30.000000 0.000000 23.233379 0.000000 15.009003 c -0.000000 6.766640 6.766643 0.000000 15.009004 0.000000 c -h -8.098384 15.009003 m -8.098384 16.034798 8.836217 16.790653 9.844025 16.790653 c -13.209368 16.790653 l -13.209368 20.155996 l -13.209368 21.163769 13.965223 21.919624 14.991017 21.919624 c -16.016811 21.919624 16.772667 21.163769 16.772667 20.155996 c -16.772667 16.790653 l -20.137974 16.790653 l -21.163769 16.790653 21.901638 16.034798 21.901638 15.009003 c -21.901638 13.983208 21.163769 13.227352 20.137974 13.227352 c -16.772667 13.227352 l -16.772667 9.862047 l -16.772667 8.854239 16.016811 8.098381 14.991017 8.098381 c -13.965223 8.098381 13.209368 8.854239 13.209368 9.862047 c -13.209368 13.227352 l -9.844025 13.227352 l -8.836217 13.227352 8.098384 13.983208 8.098384 15.009003 c -h -f -n -Q - -endstream -endobj - -3 0 obj - 1426 -endobj - -4 0 obj - << /Annots [] - /Type /Page - /MediaBox [ 0.000000 0.000000 30.000000 30.000000 ] - /Resources 1 0 R - /Contents 2 0 R - /Parent 5 0 R - >> -endobj - -5 0 obj - << /Kids [ 4 0 R ] - /Count 1 - /Type /Pages - >> -endobj - -6 0 obj - << /Type /Catalog - /Pages 5 0 R - >> -endobj - -xref -0 7 -0000000000 65535 f -0000000010 00000 n -0000000034 00000 n -0000001516 00000 n -0000001539 00000 n -0000001712 00000 n -0000001786 00000 n -trailer -<< /ID [ (some) (id) ] - /Root 6 0 R - /Size 7 ->> -startxref -1845 -%%EOF \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Connectivity/Contents.json b/Mastodon/Resources/Assets.xcassets/Connectivity/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Connectivity/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Connectivity/photo.fill.split.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Connectivity/photo.fill.split.imageset/Contents.json new file mode 100644 index 000000000..9c640adf5 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Connectivity/photo.fill.split.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Frame 2.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Connectivity/photo.fill.split.imageset/Frame 2.pdf b/Mastodon/Resources/Assets.xcassets/Connectivity/photo.fill.split.imageset/Frame 2.pdf new file mode 100644 index 000000000..4ce898753 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Connectivity/photo.fill.split.imageset/Frame 2.pdf @@ -0,0 +1,114 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +0.992546 -0.121869 0.121869 0.992546 42.624641 7.462139 cm +0.000000 0.000000 0.000000 scn +29.841717 4.404652 m +10.813622 4.404652 l +5.729721 9.441498 l +29.810324 9.441498 l +32.782593 9.441498 34.628483 11.256016 34.628483 14.259354 c +34.628483 19.077240 l +20.237179 32.404518 l +18.766808 33.781090 16.983574 34.438072 15.262939 34.438072 c +13.481962 34.438072 11.763362 33.813934 10.231857 32.441067 c +0.000000 39.493633 l +11.853184 50.706104 l +1.586006 62.000000 l +29.841717 62.000000 l +36.411587 62.000000 39.665127 58.746330 39.665127 52.301659 c +39.665127 14.102936 l +39.665127 7.658268 36.411587 4.404652 29.841717 4.404652 c +h +f* +n +Q +q +1.000000 0.000000 -0.000000 1.000000 6.000000 11.404663 cm +0.000000 0.000000 0.000000 scn +35.690556 57.595337 m +9.823408 57.595337 l +3.284870 57.595337 0.000000 54.372997 0.000000 47.896996 c +0.000000 9.698273 l +0.000000 3.222317 3.284870 -0.000011 9.823408 -0.000011 c +44.918179 -0.000011 l +39.834278 5.036835 l +9.886006 5.036835 l +6.851334 5.036835 5.036836 6.851357 5.036836 9.917267 c +5.036836 11.825638 l +14.641250 20.209938 l +16.017820 21.430046 17.519461 22.055767 18.896032 22.055767 c +20.428938 22.055767 22.024504 21.430050 23.401012 20.147408 c +29.376427 14.766380 l +44.330532 28.031185 l +44.332489 28.032942 44.334446 28.034697 44.336403 28.036451 c +34.104435 35.089096 l +45.957619 46.301567 l +35.690556 57.595337 l +h +15.736227 35.758499 m +15.736227 31.503782 19.208826 28.031185 23.463608 28.031185 c +27.687059 28.031185 31.159658 31.503782 31.159658 35.758499 c +31.159658 39.981949 27.687059 43.485878 23.463608 43.485878 c +19.208826 43.485878 15.736227 39.981949 15.736227 35.758499 c +h +f* +n +Q + +endstream +endobj + +3 0 obj + 1681 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 92.000000 76.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Type /Catalog + /Pages 5 0 R + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001771 00000 n +0000001794 00000 n +0000001967 00000 n +0000002041 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +2100 +%%EOF \ No newline at end of file diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index e051aa9ec..87649a3e0 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -39,8 +39,17 @@ "Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts..."; "Common.Countable.Photo.Multiple" = "photos"; "Common.Countable.Photo.Single" = "photo"; +"Scene.Compose.Attachment.AttachmentBroken" = "This %@ is broken and can't be +uploaded to Mastodon."; +"Scene.Compose.Attachment.DescriptionPhoto" = "Describe photo for low vision people..."; +"Scene.Compose.Attachment.DescriptionVideo" = "Describe what’s happening for low vision people..."; +"Scene.Compose.Attachment.Photo" = "photo"; +"Scene.Compose.Attachment.Video" = "video"; "Scene.Compose.ComposeAction" = "Publish"; "Scene.Compose.ContentInputPlaceholder" = "Type or paste what's on your mind"; +"Scene.Compose.MediaSelection.Browse" = "Browse"; +"Scene.Compose.MediaSelection.Camera" = "Take Photo"; +"Scene.Compose.MediaSelection.PhotoLibrary" = "Photo Library"; "Scene.Compose.Title.NewPost" = "New Post"; "Scene.Compose.Title.NewReply" = "New Reply"; "Scene.ConfirmEmail.Button.DontReceiveEmail" = "I never got an email"; diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToTootContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToTootContentCollectionViewCell.swift new file mode 100644 index 000000000..fe00563df --- /dev/null +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToTootContentCollectionViewCell.swift @@ -0,0 +1,31 @@ +// +// ComposeRepliedToTootContentCollectionViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import UIKit + +final class ComposeRepliedToTootContentCollectionViewCell: UICollectionViewCell { + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ComposeRepliedToTootContentCollectionViewCell { + + private func _init() { + + } + +} + diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusAttachmentTableViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusAttachmentTableViewCell.swift new file mode 100644 index 000000000..bc087c990 --- /dev/null +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusAttachmentTableViewCell.swift @@ -0,0 +1,99 @@ +// +// ComposeStatusAttachmentTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-17. +// + +import os.log +import UIKit +import Combine + +protocol ComposeStatusAttachmentCollectionViewCellDelegate: class { + func composeStatusAttachmentCollectionViewCell(_ cell: ComposeStatusAttachmentCollectionViewCell, removeButtonDidPressed button: UIButton) +} + +final class ComposeStatusAttachmentCollectionViewCell: UICollectionViewCell { + + var disposeBag = Set() + + static let verticalMarginHeight: CGFloat = ComposeStatusAttachmentCollectionViewCell.removeButtonSize.height * 0.5 + static let removeButtonSize = CGSize(width: 22, height: 22) + + weak var delegate: ComposeStatusAttachmentCollectionViewCellDelegate? + + let attachmentContainerView = AttachmentContainerView() + let removeButton: UIButton = { + let button = HighlightDimmableButton() + button.expandEdgeInsets = UIEdgeInsets(top: -10, left: -10, bottom: -10, right: -10) + let image = UIImage(systemName: "minus")!.withConfiguration(UIImage.SymbolConfiguration(pointSize: 14, weight: .bold)) + button.tintColor = .white + button.setImage(image, for: .normal) + button.setBackgroundImage(.placeholder(color: Asset.Colors.Background.danger.color), for: .normal) + button.layer.masksToBounds = true + button.layer.cornerRadius = ComposeStatusAttachmentCollectionViewCell.removeButtonSize.width * 0.5 + button.layer.borderColor = Asset.Colors.Background.dangerBorder.color.cgColor + button.layer.borderWidth = 1 + return button + }() + + override func prepareForReuse() { + super.prepareForReuse() + + attachmentContainerView.activityIndicatorView.startAnimating() + attachmentContainerView.previewImageView.af.cancelImageRequest() + attachmentContainerView.previewImageView.image = .placeholder(color: .systemFill) + delegate = nil + disposeBag.removeAll() + } + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ComposeStatusAttachmentCollectionViewCell { + + private func _init() { + // selectionStyle = .none + + attachmentContainerView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(attachmentContainerView) + NSLayoutConstraint.activate([ + attachmentContainerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: ComposeStatusAttachmentCollectionViewCell.verticalMarginHeight), + attachmentContainerView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + attachmentContainerView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: attachmentContainerView.bottomAnchor, constant: ComposeStatusAttachmentCollectionViewCell.verticalMarginHeight), + attachmentContainerView.heightAnchor.constraint(equalToConstant: 205).priority(.defaultHigh), + ]) + + removeButton.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(removeButton) + NSLayoutConstraint.activate([ + removeButton.centerXAnchor.constraint(equalTo: attachmentContainerView.trailingAnchor), + removeButton.centerYAnchor.constraint(equalTo: attachmentContainerView.topAnchor), + removeButton.widthAnchor.constraint(equalToConstant: ComposeStatusAttachmentCollectionViewCell.removeButtonSize.width).priority(.defaultHigh), + removeButton.heightAnchor.constraint(equalToConstant: ComposeStatusAttachmentCollectionViewCell.removeButtonSize.height).priority(.defaultHigh), + ]) + + removeButton.addTarget(self, action: #selector(ComposeStatusAttachmentCollectionViewCell.removeButtonDidPressed(_:)), for: .touchUpInside) + } + +} + + +extension ComposeStatusAttachmentCollectionViewCell { + + @objc private func removeButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.composeStatusAttachmentCollectionViewCell(self, removeButtonDidPressed: sender) + } + +} diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift similarity index 80% rename from Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift rename to Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift index 9f39f1989..80e8cf875 100644 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift @@ -1,5 +1,5 @@ // -// ComposeTootContentTableViewCell.swift +// ComposeStatusContentCollectionViewCell.swift // Mastodon // // Created by MainasuK Cirno on 2021-3-11. @@ -9,7 +9,7 @@ import UIKit import Combine import TwitterTextEditor -final class ComposeTootContentTableViewCell: UITableViewCell { +final class ComposeStatusContentCollectionViewCell: UICollectionViewCell { var disposeBag = Set() @@ -27,8 +27,8 @@ final class ComposeTootContentTableViewCell: UITableViewCell { let composeContent = PassthroughSubject() - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) + override init(frame: CGRect) { + super.init(frame: frame) _init() } @@ -39,10 +39,11 @@ final class ComposeTootContentTableViewCell: UITableViewCell { } -extension ComposeTootContentTableViewCell { +extension ComposeStatusContentCollectionViewCell { private func _init() { - selectionStyle = .none + // selectionStyle = .none + preservesSuperviewLayoutMargins = true statusView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(statusView) @@ -56,6 +57,9 @@ extension ComposeTootContentTableViewCell { statusView.nameTrialingDotLabel.isHidden = true statusView.dateLabel.isHidden = true + statusView.setContentHuggingPriority(.defaultHigh, for: .vertical) + statusView.setContentCompressionResistancePriority(.required - 1, for: .vertical) + textEditorView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(textEditorView) NSLayoutConstraint.activate([ @@ -65,6 +69,7 @@ extension ComposeTootContentTableViewCell { contentView.bottomAnchor.constraint(equalTo: textEditorView.bottomAnchor, constant: 20), textEditorView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh), ]) + textEditorView.setContentCompressionResistancePriority(.required - 2, for: .vertical) // TODO: @@ -78,12 +83,8 @@ extension ComposeTootContentTableViewCell { } -extension ComposeTootContentTableViewCell { - -} - // MARK: - UITextViewDelegate -extension ComposeTootContentTableViewCell: TextEditorViewChangeObserver { +extension ComposeStatusContentCollectionViewCell: TextEditorViewChangeObserver { func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) { guard changeResult.isTextChanged else { return } composeContent.send(textEditorView.text) diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index df04b8d23..760496026 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -8,8 +8,9 @@ import os.log import UIKit import Combine -import TwitterTextEditor +import PhotosUI import Kingfisher +import TwitterTextEditor final class ComposeViewController: UIViewController, NeedsDependency { @@ -21,7 +22,7 @@ final class ComposeViewController: UIViewController, NeedsDependency { private var suffixedAttachmentViews: [UIView] = [] - let composeTootBarButtonItem: UIBarButtonItem = { + let publishButton: UIButton = { let button = RoundedEdgesButton(type: .custom) button.setTitle(L10n.Scene.Compose.composeAction, for: .normal) button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold) @@ -31,17 +32,21 @@ final class ComposeViewController: UIViewController, NeedsDependency { 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 button + }() + private(set) lazy var publishBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem(customView: publishButton) 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 collectionView: UICollectionView = { + let collectionViewLayout = ComposeViewController.createLayout() + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout) + collectionView.register(ComposeRepliedToTootContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeRepliedToTootContentCollectionViewCell.self)) + collectionView.register(ComposeStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self)) + collectionView.register(ComposeStatusAttachmentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self)) + collectionView.backgroundColor = Asset.Colors.Background.systemBackground.color + return collectionView }() let composeToolbarView: ComposeToolbarView = { @@ -56,6 +61,42 @@ final class ComposeViewController: UIViewController, NeedsDependency { return backgroundView }() + private(set) lazy var imagePicker: PHPickerViewController = { + var configuration = PHPickerConfiguration() + configuration.filter = .images + configuration.selectionLimit = 4 + + let imagePicker = PHPickerViewController(configuration: configuration) + imagePicker.delegate = self + return imagePicker + }() + private(set) lazy var imagePickerController: UIImagePickerController = { + let imagePickerController = UIImagePickerController() + imagePickerController.sourceType = .camera + imagePickerController.delegate = self + return imagePickerController + }() + + private(set) lazy var documentPickerController: UIDocumentPickerViewController = { + let documentPickerController = UIDocumentPickerViewController(documentTypes: ["public.image"], in: .open) + documentPickerController.delegate = self + return documentPickerController + }() + +} + +extension ComposeViewController { + private static func createLayout() -> UICollectionViewLayout { + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(100)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(100)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + let section = NSCollectionLayoutSection(group: group) + section.contentInsetsReference = .readableContent + // section.interGroupSpacing = 10 + // section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10) + return UICollectionViewCompositionalLayout(section: section) + } } extension ComposeViewController { @@ -72,15 +113,16 @@ extension ComposeViewController { .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 + navigationItem.rightBarButtonItem = publishBarButtonItem + publishButton.addTarget(self, action: #selector(ComposeViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside) - tableView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(tableView) + collectionView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(collectionView) 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), + collectionView.topAnchor.constraint(equalTo: view.topAnchor), + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) composeToolbarView.translatesAutoresizingMaskIntoConstraints = false @@ -104,11 +146,15 @@ extension ComposeViewController { view.bottomAnchor.constraint(equalTo: composeToolbarBackgroundView.bottomAnchor), ]) - tableView.delegate = self + collectionView.delegate = self + // Note: do not allow reorder due to the images display order following the upload time + // let longPressReorderGesture = UILongPressGestureRecognizer(target: self, action: #selector(ComposeViewController.longPressReorderGestureHandler(_:))) + // collectionView.addGestureRecognizer(longPressReorderGesture) viewModel.setupDiffableDataSource( - for: tableView, + for: collectionView, dependency: self, - textEditorViewTextAttributesDelegate: self + textEditorViewTextAttributesDelegate: self, + composeStatusAttachmentTableViewCellDelegate: self ) // respond scrollView overlap change @@ -121,45 +167,45 @@ extension ComposeViewController { ) .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 + self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom + self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom UIView.animate(withDuration: 0.3) { - self.composeToolbarViewBottomLayoutConstraint.constant = 0.0 + self.composeToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom self.view.layoutIfNeeded() } return } // isShow AND dock state - let contentFrame = self.view.convert(self.tableView.frame, to: nil) + let contentFrame = self.view.convert(self.collectionView.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 + self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom + self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom UIView.animate(withDuration: 0.3) { - self.composeToolbarViewBottomLayoutConstraint.constant = 0.0 + self.composeToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom self.view.layoutIfNeeded() } return } // add 16pt margin - self.tableView.contentInset.bottom = padding + 16 - self.tableView.verticalScrollIndicatorInsets.bottom = padding + 16 + self.collectionView.contentInset.bottom = padding + 16 + self.collectionView.verticalScrollIndicatorInsets.bottom = padding + 16 UIView.animate(withDuration: 0.3) { self.composeToolbarViewBottomLayoutConstraint.constant = padding self.view.layoutIfNeeded() } }) .store(in: &disposeBag) - - viewModel.isComposeTootBarButtonItemEnabled + + viewModel.isPublishBarButtonItemEnabled .receive(on: DispatchQueue.main) - .assign(to: \.isEnabled, on: composeTootBarButtonItem) + .assign(to: \.isEnabled, on: publishBarButtonItem) .store(in: &disposeBag) - + // bind custom emojis viewModel.customEmojiViewModel .compactMap { $0?.emojis } @@ -173,6 +219,16 @@ extension ComposeViewController { self.textEditorView()?.setNeedsUpdateTextAttributes() }) .store(in: &disposeBag) + + // bind image picker toolbar state + viewModel.attachmentServices + .receive(on: DispatchQueue.main) + .sink { [weak self] attachmentServices in + guard let self = self else { return } + self.composeToolbarView.mediaButton.isEnabled = attachmentServices.count < 4 + self.resetImagePicker() + } + .store(in: &disposeBag) } override func viewWillAppear(_ animated: Bool) { @@ -196,7 +252,7 @@ extension ComposeViewController { switch item { case .input: guard let indexPath = diffableDataSource.indexPath(for: item), - let cell = tableView.cellForRow(at: indexPath) as? ComposeTootContentTableViewCell else { + let cell = collectionView.cellForItem(at: indexPath) as? ComposeStatusContentCollectionViewCell else { continue } return cell.textEditorView @@ -227,6 +283,22 @@ extension ComposeViewController { alertController.addAction(cancelAction) present(alertController, animated: true, completion: nil) } + + private func resetImagePicker() { + var configuration = PHPickerConfiguration() + configuration.filter = .images + let selectionLimit = max(1, 4 - viewModel.attachmentServices.value.count) + configuration.selectionLimit = selectionLimit + + imagePicker = createImagePicker(configuration: configuration) + } + + private func createImagePicker(configuration: PHPickerConfiguration) -> PHPickerViewController { + let imagePicker = PHPickerViewController(configuration: configuration) + imagePicker.delegate = self + return imagePicker + } + } extension ComposeViewController { @@ -240,6 +312,44 @@ extension ComposeViewController { dismiss(animated: true, completion: nil) } + @objc private func publishBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + guard viewModel.publishStateMachine.enter(ComposeViewModel.PublishState.Publishing.self) else { + // TODO: handle error + return + } + + dismiss(animated: true, completion: nil) + } + + /* Do not allow reorder image due to image display order following the update time + @objc private func longPressReorderGestureHandler(_ sender: UILongPressGestureRecognizer) { + switch(sender.state) { + case .began: + guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)) else { + break + } + collectionView.beginInteractiveMovementForItem(at: selectedIndexPath) + case .changed: + guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)), + let diffableDataSource = viewModel.diffableDataSource else { + break + } + guard let item = diffableDataSource.itemIdentifier(for: selectedIndexPath), + case .attachment = item else { + collectionView.cancelInteractiveMovement() + return + } + + collectionView.updateInteractiveMovementTargetPosition(sender.location(in: collectionView)) + case .ended: + collectionView.endInteractiveMovement() + default: + collectionView.cancelInteractiveMovement() + } + } + */ + } // MARK: - TextEditorViewTextAttributesDelegate @@ -280,32 +390,12 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { attributedString.addAttribute(.foregroundColor, value: Asset.Colors.Label.primary.color, range: stringRange) attributedString.addAttribute(.font, value: UIFont.preferredFont(forTextStyle: .body), range: stringRange) + // hashtag 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, *) { @@ -319,6 +409,7 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { attributedString.addAttributes(attributes, range: match.range) } + // emoji let emojis = customEmojiViewModel?.emojis.value ?? [] if !emojis.isEmpty { for match in emojiMatches { @@ -366,25 +457,26 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { } } + // url 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 - } + guard let name = string.substring(with: match, at: 0) else { continue } + 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) } + attributedString.addAttributes(attributes, range: match.range) } completion(attributedString) @@ -394,13 +486,19 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { } - - // MARK: - ComposeToolbarViewDelegate extension ComposeViewController: ComposeToolbarViewDelegate { - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton, mediaSelectionType: ComposeToolbarView.MediaSelectionType) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: %s", ((#file as NSString).lastPathComponent), #line, #function, mediaSelectionType.rawValue) + switch mediaSelectionType { + case .photoLibrary: + present(imagePicker, animated: true, completion: nil) + case .camera: + present(imagePickerController, animated: true, completion: nil) + case .browse: + present(documentPickerController, animated: true, completion: nil) + } } func composeToolbarView(_ composeToolbarView: ComposeToolbarView, gifButtonDidPressed sender: UIButton) { @@ -422,10 +520,8 @@ extension ComposeViewController: ComposeToolbarViewDelegate { } // MARK: - UITableViewDelegate -extension ComposeViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return UITableView.automaticDimension - } +extension ComposeViewController: UICollectionViewDelegate { + } // MARK: - UIAdaptivePresentationControllerDelegate @@ -446,3 +542,83 @@ extension ComposeViewController: UIAdaptivePresentationControllerDelegate { } } + +// MARK: - PHPickerViewControllerDelegate +extension ComposeViewController: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true, completion: nil) + + let attachmentServices: [MastodonAttachmentService] = results.map { result in + let service = MastodonAttachmentService( + context: context, + pickerResult: result, + initalAuthenticationBox: viewModel.activeAuthenticationBox.value + ) + service.delegate = viewModel + return service + } + viewModel.attachmentServices.value = viewModel.attachmentServices.value + attachmentServices + } +} + +// MARK: - UIImagePickerControllerDelegate +extension ComposeViewController: UIImagePickerControllerDelegate & UINavigationControllerDelegate { + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + picker.dismiss(animated: true, completion: nil) + + guard let image = info[.originalImage] as? UIImage else { return } + + let attachmentService = MastodonAttachmentService( + context: context, + image: image, + initalAuthenticationBox: viewModel.activeAuthenticationBox.value + ) + attachmentService.delegate = viewModel + viewModel.attachmentServices.value = viewModel.attachmentServices.value + [attachmentService] + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + picker.dismiss(animated: true, completion: nil) + } +} + +// MARK: - UIDocumentPickerDelegate +extension ComposeViewController: UIDocumentPickerDelegate { + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let url = urls.first else { return } + + do { + guard url.startAccessingSecurityScopedResource() else { return } + defer { url.stopAccessingSecurityScopedResource() } + let imageData = try Data(contentsOf: url) + let attachmentService = MastodonAttachmentService( + context: context, + imageData: imageData, + initalAuthenticationBox: viewModel.activeAuthenticationBox.value + ) + attachmentService.delegate = viewModel + viewModel.attachmentServices.value = viewModel.attachmentServices.value + [attachmentService] + } catch { + os_log("%{public}s[%{public}ld], %{public}s: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + } + } +} + +// MARK: - ComposeStatusAttachmentTableViewCellDelegate +extension ComposeViewController: ComposeStatusAttachmentCollectionViewCellDelegate { + + func composeStatusAttachmentCollectionViewCell(_ cell: ComposeStatusAttachmentCollectionViewCell, removeButtonDidPressed button: UIButton) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let indexPath = collectionView.indexPath(for: cell) else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + guard case let .attachment(attachmentService) = item else { return } + + var attachmentServices = viewModel.attachmentServices.value + guard let index = attachmentServices.firstIndex(of: attachmentService) else { return } + attachmentServices.remove(at: index) + viewModel.attachmentServices.value = attachmentServices + } + +} diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index a3a0515e6..389d23edb 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -11,20 +11,44 @@ import TwitterTextEditor extension ComposeViewModel { func setupDiffableDataSource( - for tableView: UITableView, + for collectionView: UICollectionView, dependency: NeedsDependency, - textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate + textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate, + composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate ) { - diffableDataSource = ComposeStatusSection.tableViewDiffableDataSource( - for: tableView, + let diffableDataSource = ComposeStatusSection.collectionViewDiffableDataSource( + for: collectionView, dependency: dependency, managedObjectContext: context.managedObjectContext, composeKind: composeKind, - textEditorViewTextAttributesDelegate: textEditorViewTextAttributesDelegate + textEditorViewTextAttributesDelegate: textEditorViewTextAttributesDelegate, + composeStatusAttachmentTableViewCellDelegate: composeStatusAttachmentTableViewCellDelegate ) + + // Note: do not allow reorder due to the images display order following the upload time + // diffableDataSource.reorderingHandlers.canReorderItem = { item in + // switch item { + // case .attachment: return true + // default: return false + // } + // + // } + // diffableDataSource.reorderingHandlers.didReorder = { [weak self] transaction in + // guard let self = self else { return } + // + // let items = transaction.finalSnapshot.itemIdentifiers + // var attachmentServices: [MastodonAttachmentService] = [] + // for item in items { + // guard case let .attachment(attachmentService) = item else { continue } + // attachmentServices.append(attachmentService) + // } + // self.attachmentServices.value = attachmentServices + // } + // + self.diffableDataSource = diffableDataSource var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.repliedTo, .status]) + snapshot.appendSections([.repliedTo, .status, .attachment]) switch composeKind { case .reply(let statusObjectID): snapshot.appendItems([.replyTo(statusObjectID: statusObjectID)], toSection: .repliedTo) diff --git a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift new file mode 100644 index 000000000..2da46b655 --- /dev/null +++ b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift @@ -0,0 +1,121 @@ +// +// ComposeViewModel+PublishState.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-18. +// + +import os.log +import Foundation +import Combine +import GameplayKit +import MastodonSDK + +extension ComposeViewModel { + class PublishState: GKState { + weak var viewModel: ComposeViewModel? + + init(viewModel: ComposeViewModel) { + 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 ComposeViewModel.PublishState { + class Initial: ComposeViewModel.PublishState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Publishing.self + } + } + + class Publishing: ComposeViewModel.PublishState { + + var publishingSubscription: AnyCancellable? + + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Fail.self || stateClass == Finish.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let mastodonAuthenticationBox = viewModel.activeAuthenticationBox.value else { + stateMachine.enter(Fail.self) + return + } + + let domain = mastodonAuthenticationBox.domain + let attachmentServices = viewModel.attachmentServices.value + let mediaIDs = attachmentServices.compactMap { attachmentService in + attachmentService.attachment.value?.id + } + let updateMediaQuerySubscriptions: [AnyPublisher, Error>] = { + var subscriptions: [AnyPublisher, Error>] = [] + for attachmentService in attachmentServices { + guard let attachmentID = attachmentService.attachment.value?.id else { continue } + let description = attachmentService.description.value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !description.isEmpty else { continue } + let query = Mastodon.API.Media.UpdateMediaQuery( + file: nil, + thumbnail: nil, + description: description, + focus: nil + ) + let subscription = viewModel.context.apiService.updateMedia( + domain: domain, + attachmentID: attachmentID, + query: query, + mastodonAuthenticationBox: mastodonAuthenticationBox + ) + subscriptions.append(subscription) + } + return subscriptions + }() + + publishingSubscription = Publishers.MergeMany(updateMediaQuerySubscriptions) + .collect() + .flatMap { attachments -> AnyPublisher, Error> in + let query = Mastodon.API.Statuses.PublishStatusQuery( + status: viewModel.composeStatusAttribute.composeContent.value, + mediaIDs: mediaIDs + ) + return viewModel.context.apiService.publishStatus( + domain: domain, + query: query, + mastodonAuthenticationBox: mastodonAuthenticationBox + ) + } + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: publish status %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + stateMachine.enter(Fail.self) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: publish status success", ((#file as NSString).lastPathComponent), #line, #function) + stateMachine.enter(Finish.self) + } + } receiveValue: { response in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: status %s published: %s", ((#file as NSString).lastPathComponent), #line, #function, response.value.id, response.value.uri) + } + } + } + + class Fail: ComposeViewModel.PublishState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // allow discard publishing + return stateClass == Publishing.self || stateClass == Finish.self + } + } + + class Finish: ComposeViewModel.PublishState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return false + } + } + +} diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 743f385e5..ea998b778 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -9,6 +9,7 @@ import UIKit import Combine import CoreData import CoreDataStack +import GameplayKit final class ComposeViewModel { @@ -18,20 +19,34 @@ final class ComposeViewModel { let context: AppContext let composeKind: ComposeStatusSection.ComposeKind let composeStatusAttribute = ComposeStatusItem.ComposeStatusAttribute() - let composeContent = CurrentValueSubject("") let activeAuthentication: CurrentValueSubject + let activeAuthenticationBox: CurrentValueSubject // output - var diffableDataSource: UITableViewDiffableDataSource! + //var diffableDataSource: UITableViewDiffableDataSource! + var diffableDataSource: UICollectionViewDiffableDataSource! + private(set) lazy var publishStateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + PublishState.Initial(viewModel: self), + PublishState.Publishing(viewModel: self), + PublishState.Fail(viewModel: self), + PublishState.Finish(viewModel: self), + ]) + stateMachine.enter(PublishState.Initial.self) + return stateMachine + }() // UI & UX let title: CurrentValueSubject let shouldDismiss = CurrentValueSubject(true) - let isComposeTootBarButtonItemEnabled = CurrentValueSubject(false) + let isPublishBarButtonItemEnabled = CurrentValueSubject(false) // custom emojis let customEmojiViewModel = CurrentValueSubject(nil) + // attachment + let attachmentServices = CurrentValueSubject<[MastodonAttachmentService], Never>([]) init( context: AppContext, @@ -44,12 +59,16 @@ final class ComposeViewModel { case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply) } self.activeAuthentication = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value) + self.activeAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value) // end init // bind active authentication context.authenticationService.activeMastodonAuthentication .assign(to: \.value, on: activeAuthentication) .store(in: &disposeBag) + context.authenticationService.activeMastodonAuthenticationBox + .assign(to: \.value, on: activeAuthenticationBox) + .store(in: &disposeBag) // bind avatar and names activeAuthentication @@ -70,14 +89,30 @@ final class ComposeViewModel { .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 + let isComposeContentEmpty = composeStatusAttribute.composeContent + .map { ($0 ?? "").isEmpty } + let isComposeContentValid = Just(true).eraseToAnyPublisher() + let isMediaEmpty = attachmentServices + .map { $0.isEmpty } + let isMediaUploadAllSuccess = attachmentServices + .map { services in + services.allSatisfy { $0.uploadStateMachineSubject.value is MastodonAttachmentService.UploadState.Finish } } - .assign(to: \.value, on: isComposeTootBarButtonItemEnabled) - .store(in: &disposeBag) + Publishers.CombineLatest4( + isComposeContentEmpty.eraseToAnyPublisher(), + isComposeContentValid.eraseToAnyPublisher(), + isMediaEmpty.eraseToAnyPublisher(), + isMediaUploadAllSuccess.eraseToAnyPublisher() + ) + .map { isComposeContentEmpty, isComposeContentValid, isMediaEmpty, isMediaUploadAllSuccess in + if isMediaEmpty { + return isComposeContentValid && !isComposeContentEmpty + } else { + return isComposeContentValid && isMediaUploadAllSuccess + } + } + .assign(to: \.value, on: isPublishBarButtonItemEnabled) + .store(in: &disposeBag) // bind modal dismiss state composeStatusAttribute.composeContent @@ -101,6 +136,54 @@ final class ComposeViewModel { self.customEmojiViewModel.value = self.context.emojiService.dequeueCustomEmojiViewModel(for: domain) } .store(in: &disposeBag) + + // bind snapshot and drive service upload state + attachmentServices + .receive(on: DispatchQueue.main) + .sink { [weak self] attachmentServices in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + var snapshot = diffableDataSource.snapshot() + + snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .attachment)) + var items: [ComposeStatusItem] = [] + for attachmentService in attachmentServices { + let item = ComposeStatusItem.attachment(attachmentService: attachmentService) + items.append(item) + } + snapshot.appendItems(items, toSection: .attachment) + + diffableDataSource.apply(snapshot) + + // make image upload in the queue + for attachmentService in attachmentServices { + // skip when prefix N task when task finish OR fail OR uploading + guard let currentState = attachmentService.uploadStateMachine.currentState else { break } + if currentState is MastodonAttachmentService.UploadState.Fail { + continue + } + if currentState is MastodonAttachmentService.UploadState.Finish { + continue + } + if currentState is MastodonAttachmentService.UploadState.Uploading { + break + } + // trigger uploading one by one + if currentState is MastodonAttachmentService.UploadState.Initial { + attachmentService.uploadStateMachine.enter(MastodonAttachmentService.UploadState.Uploading.self) + break + } + } + } + .store(in: &disposeBag) } } + +// MARK: - MastodonAttachmentServiceDelegate +extension ComposeViewModel: MastodonAttachmentServiceDelegate { + func mastodonAttachmentService(_ service: MastodonAttachmentService, uploadStateDidChange state: MastodonAttachmentService.UploadState?) { + // trigger new output event + attachmentServices.value = attachmentServices.value + } +} diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToTootContentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToTootContentTableViewCell.swift deleted file mode 100644 index def777caf..000000000 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToTootContentTableViewCell.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// 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/View/AttachmentContainerView+EmptyStateView.swift b/Mastodon/Scene/Compose/View/AttachmentContainerView+EmptyStateView.swift new file mode 100644 index 000000000..353fe7497 --- /dev/null +++ b/Mastodon/Scene/Compose/View/AttachmentContainerView+EmptyStateView.swift @@ -0,0 +1,132 @@ +// +// AttachmentContainerView+EmptyStateView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-18. +// + +import UIKit + +extension AttachmentContainerView { + final class EmptyStateView: UIView { + + static let photoFillSplitImage = Asset.Connectivity.photoFillSplit.image.withRenderingMode(.alwaysTemplate) + static let videoSplashImage: UIImage = { + let image = UIImage(systemName: "video.slash")!.withConfiguration(UIImage.SymbolConfiguration(pointSize: 64)) + return image + }() + + let imageView: UIImageView = { + let imageView = UIImageView() + imageView.tintColor = Asset.Colors.Label.secondary.color + imageView.image = AttachmentContainerView.EmptyStateView.photoFillSplitImage + return imageView + }() + let label: UILabel = { + let label = UILabel() + label.font = .preferredFont(forTextStyle: .body) + label.textColor = Asset.Colors.Label.secondary.color + label.textAlignment = .center + label.text = L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo) + label.numberOfLines = 2 + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + } +} + +extension AttachmentContainerView.EmptyStateView { + private func _init() { + layer.masksToBounds = true + layer.cornerRadius = AttachmentContainerView.containerViewCornerRadius + layer.cornerCurve = .continuous + backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + + let stackView = UIStackView() + stackView.axis = .vertical + stackView.alignment = .center + stackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: topAnchor), + stackView.leadingAnchor.constraint(equalTo: leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + let topPaddingView = UIView() + let middlePaddingView = UIView() + let bottomPaddingView = UIView() + + topPaddingView.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubview(topPaddingView) + imageView.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubview(imageView) + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: 92).priority(.defaultHigh), + imageView.heightAnchor.constraint(equalToConstant: 76).priority(.defaultHigh), + ]) + imageView.setContentHuggingPriority(.required - 1, for: .vertical) + middlePaddingView.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubview(middlePaddingView) + stackView.addArrangedSubview(label) + bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubview(bottomPaddingView) + NSLayoutConstraint.activate([ + topPaddingView.heightAnchor.constraint(equalTo: middlePaddingView.heightAnchor, multiplier: 1.5), + bottomPaddingView.heightAnchor.constraint(equalTo: middlePaddingView.heightAnchor, multiplier: 1.5), + ]) + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct AttachmentContainerView_EmptyStateView_Previews: PreviewProvider { + + static var previews: some View { + Group { + UIViewPreview(width: 375) { + let emptyStateView = AttachmentContainerView.EmptyStateView() + NSLayoutConstraint.activate([ + emptyStateView.heightAnchor.constraint(equalToConstant: 205) + ]) + return emptyStateView + } + .previewLayout(.fixed(width: 375, height: 205)) + UIViewPreview(width: 375) { + let emptyStateView = AttachmentContainerView.EmptyStateView() + NSLayoutConstraint.activate([ + emptyStateView.heightAnchor.constraint(equalToConstant: 205) + ]) + return emptyStateView + } + .preferredColorScheme(.dark) + .previewLayout(.fixed(width: 375, height: 205)) + UIViewPreview(width: 375) { + let emptyStateView = AttachmentContainerView.EmptyStateView() + emptyStateView.imageView.image = AttachmentContainerView.EmptyStateView.videoSplashImage + emptyStateView.label.text = L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.video) + + NSLayoutConstraint.activate([ + emptyStateView.heightAnchor.constraint(equalToConstant: 205) + ]) + return emptyStateView + } + .previewLayout(.fixed(width: 375, height: 205)) + } + } + +} + +#endif + diff --git a/Mastodon/Scene/Compose/View/AttachmentContainerView.swift b/Mastodon/Scene/Compose/View/AttachmentContainerView.swift new file mode 100644 index 000000000..cbad76830 --- /dev/null +++ b/Mastodon/Scene/Compose/View/AttachmentContainerView.swift @@ -0,0 +1,140 @@ +// +// AttachmentContainerView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-17. +// + +import UIKit +import UITextView_Placeholder + +final class AttachmentContainerView: UIView { + + static let containerViewCornerRadius: CGFloat = 4 + + var descriptionBackgroundViewFrameObservation: NSKeyValueObservation? + + let activityIndicatorView = UIActivityIndicatorView(style: .large) + + let previewImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.layer.masksToBounds = true + return imageView + }() + + let emptyStateView = AttachmentContainerView.EmptyStateView() + let descriptionBackgroundView: UIView = { + let view = UIView() + view.layer.masksToBounds = true + view.layer.cornerRadius = AttachmentContainerView.containerViewCornerRadius + view.layer.cornerCurve = .continuous + view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] + view.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 5, right: 8) + return view + }() + let descriptionBackgroundGradientLayer: CAGradientLayer = { + let gradientLayer = CAGradientLayer() + gradientLayer.colors = [UIColor.black.withAlphaComponent(0.0).cgColor, UIColor.black.withAlphaComponent(0.69).cgColor] + gradientLayer.locations = [0.0, 1.0] + gradientLayer.startPoint = CGPoint(x: 0.5, y: 0) + gradientLayer.endPoint = CGPoint(x: 0.5, y: 1) + gradientLayer.frame = CGRect(x: 0, y: 0, width: 100, height: 100) + return gradientLayer + }() + let descriptionTextView: UITextView = { + let textView = UITextView() + textView.showsVerticalScrollIndicator = false + textView.backgroundColor = .clear + textView.textColor = .white + textView.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15)) + textView.placeholder = L10n.Scene.Compose.Attachment.descriptionPhoto + textView.placeholderColor = UIColor.white.withAlphaComponent(0.6) // force white with alpha for Light/Dark mode + textView.returnKeyType = .done + return textView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension AttachmentContainerView { + + private func _init() { + previewImageView.translatesAutoresizingMaskIntoConstraints = false + addSubview(previewImageView) + NSLayoutConstraint.activate([ + previewImageView.topAnchor.constraint(equalTo: topAnchor), + previewImageView.leadingAnchor.constraint(equalTo: leadingAnchor), + previewImageView.trailingAnchor.constraint(equalTo: trailingAnchor), + previewImageView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + descriptionBackgroundView.translatesAutoresizingMaskIntoConstraints = false + addSubview(descriptionBackgroundView) + NSLayoutConstraint.activate([ + descriptionBackgroundView.leadingAnchor.constraint(equalTo: leadingAnchor), + descriptionBackgroundView.trailingAnchor.constraint(equalTo: trailingAnchor), + descriptionBackgroundView.bottomAnchor.constraint(equalTo: bottomAnchor), + descriptionBackgroundView.heightAnchor.constraint(equalTo: heightAnchor, multiplier: 0.3), + ]) + descriptionBackgroundView.layer.addSublayer(descriptionBackgroundGradientLayer) + descriptionBackgroundViewFrameObservation = descriptionBackgroundView.observe(\.bounds, options: [.initial, .new]) { [weak self] _, _ in + guard let self = self else { return } + self.descriptionBackgroundGradientLayer.frame = self.descriptionBackgroundView.bounds + } + + descriptionTextView.translatesAutoresizingMaskIntoConstraints = false + descriptionBackgroundView.addSubview(descriptionTextView) + NSLayoutConstraint.activate([ + descriptionTextView.leadingAnchor.constraint(equalTo: descriptionBackgroundView.layoutMarginsGuide.leadingAnchor), + descriptionTextView.trailingAnchor.constraint(equalTo: descriptionBackgroundView.layoutMarginsGuide.trailingAnchor), + descriptionBackgroundView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: descriptionTextView.bottomAnchor), + descriptionTextView.heightAnchor.constraint(lessThanOrEqualToConstant: 36), + ]) + + emptyStateView.translatesAutoresizingMaskIntoConstraints = false + addSubview(emptyStateView) + NSLayoutConstraint.activate([ + emptyStateView.topAnchor.constraint(equalTo: topAnchor), + emptyStateView.leadingAnchor.constraint(equalTo: leadingAnchor), + emptyStateView.trailingAnchor.constraint(equalTo: trailingAnchor), + emptyStateView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false + addSubview(activityIndicatorView) + NSLayoutConstraint.activate([ + activityIndicatorView.centerXAnchor.constraint(equalTo: previewImageView.centerXAnchor), + activityIndicatorView.centerYAnchor.constraint(equalTo: previewImageView.centerYAnchor), + ]) + + emptyStateView.isHidden = true + activityIndicatorView.hidesWhenStopped = true + activityIndicatorView.startAnimating() + + descriptionTextView.delegate = self + } + +} + +// MARK: - UITextViewDelegate +extension AttachmentContainerView: UITextViewDelegate { + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + // let keyboard dismiss when input description with "done" type return key + if textView === descriptionTextView, text == "\n" { + textView.resignFirstResponder() + return false + } + + return true + } +} diff --git a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift index 7eb3ae821..dfbc70cb9 100644 --- a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift +++ b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift @@ -8,7 +8,7 @@ import UIKit protocol ComposeToolbarViewDelegate: class { - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton, mediaSelectionType: ComposeToolbarView.MediaSelectionType) func composeToolbarView(_ composeToolbarView: ComposeToolbarView, gifButtonDidPressed sender: UIButton) func composeToolbarView(_ composeToolbarView: ComposeToolbarView, atButtonDidPressed sender: UIButton) func composeToolbarView(_ composeToolbarView: ComposeToolbarView, topicButtonDidPressed sender: UIButton) @@ -17,41 +17,42 @@ protocol ComposeToolbarViewDelegate: class { final class ComposeToolbarView: UIView { + static let toolbarButtonSize: CGSize = CGSize(width: 44, height: 44) static let toolbarHeight: CGFloat = 44 weak var delegate: ComposeToolbarViewDelegate? let mediaButton: UIButton = { - let button = UIButton(type: .custom) - button.tintColor = Asset.Colors.Button.normal.color + let button = HighlightDimmableButton() + ComposeToolbarView.configureToolbarButtonAppearance(button: button) 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 + let button = HighlightDimmableButton(type: .custom) + ComposeToolbarView.configureToolbarButtonAppearance(button: button) 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 + let button = HighlightDimmableButton() + ComposeToolbarView.configureToolbarButtonAppearance(button: button) 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 + let button = HighlightDimmableButton() + ComposeToolbarView.configureToolbarButtonAppearance(button: button) 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 + let button = HighlightDimmableButton() + ComposeToolbarView.configureToolbarButtonAppearance(button: button) button.setImage(UIImage(systemName: "person.3", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)), for: .normal) return button }() @@ -99,7 +100,8 @@ extension ComposeToolbarView { ]) } - mediaButton.addTarget(self, action: #selector(ComposeToolbarView.cameraButtonDidPressed(_:)), for: .touchUpInside) + mediaButton.menu = createMediaContextMenu() + mediaButton.showsMenuAsPrimaryAction = true 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) @@ -107,13 +109,52 @@ extension ComposeToolbarView { } } +extension ComposeToolbarView { + enum MediaSelectionType: String { + case camera + case photoLibrary + case browse + } +} extension ComposeToolbarView { - - @objc private func cameraButtonDidPressed(_ sender: UIButton) { - delegate?.composeToolbarView(self, cameraButtonDidPressed: sender) + + private static func configureToolbarButtonAppearance(button: UIButton) { + button.tintColor = Asset.Colors.Button.normal.color + button.setBackgroundImage(.placeholder(size: ComposeToolbarView.toolbarButtonSize, color: .systemFill), for: .highlighted) + button.layer.masksToBounds = true + button.layer.cornerRadius = 5 + button.layer.cornerCurve = .continuous } + private func createMediaContextMenu() -> UIMenu { + var children: [UIMenuElement] = [] + let photoLibraryAction = UIAction(title: L10n.Scene.Compose.MediaSelection.photoLibrary, image: UIImage(systemName: "rectangle.on.rectangle"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + guard let self = self else { return } + self.delegate?.composeToolbarView(self, cameraButtonDidPressed: self.mediaButton, mediaSelectionType: .photoLibrary) + } + children.append(photoLibraryAction) + if UIImagePickerController.isSourceTypeAvailable(.camera) { + let cameraAction = UIAction(title: L10n.Scene.Compose.MediaSelection.camera, image: UIImage(systemName: "camera"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] _ in + guard let self = self else { return } + self.delegate?.composeToolbarView(self, cameraButtonDidPressed: self.mediaButton, mediaSelectionType: .camera) + }) + children.append(cameraAction) + } + let browseAction = UIAction(title: L10n.Scene.Compose.MediaSelection.browse, image: UIImage(systemName: "ellipsis"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + guard let self = self else { return } + self.delegate?.composeToolbarView(self, cameraButtonDidPressed: self.mediaButton, mediaSelectionType: .browse) + } + children.append(browseAction) + + return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children) + } + +} + + +extension ComposeToolbarView { + @objc private func gifButtonDidPressed(_ sender: UIButton) { delegate?.composeToolbarView(self, gifButtonDidPressed: sender) } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift index c19d45e47..dc7b8a47b 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift @@ -22,7 +22,7 @@ final class HomeTimelineNavigationBarView { }() static let newPostsView: UIView = { - let view = HomeTimelineNavigationBarView.backgroundViewWithColor(color: Asset.Colors.Button.highlight.color) + let view = HomeTimelineNavigationBarView.backgroundViewWithColor(color: Asset.Colors.Button.normal.color) let label = HomeTimelineNavigationBarView.contentLabel(text: L10n.Scene.HomeTimeline.NavigationBarState.newPosts) HomeTimelineNavigationBarView.addLabelToView(label: label, view: view) return view diff --git a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift index 63c1e4217..e415c5737 100644 --- a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift +++ b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift @@ -37,10 +37,10 @@ final class WelcomeViewController: UIViewController, NeedsDependency { private(set) lazy var signUpButton: PrimaryActionButton = { let button = PrimaryActionButton() button.setTitle(L10n.Common.Controls.Actions.signUp, for: .normal) - let backgroundImageColor: UIColor = traitCollection.userInterfaceIdiom == .phone ? .white : Asset.Colors.Button.highlight.color + let backgroundImageColor: UIColor = traitCollection.userInterfaceIdiom == .phone ? .white : Asset.Colors.Button.normal.color button.setBackgroundImage(.placeholder(color: backgroundImageColor), for: .normal) button.setBackgroundImage(.placeholder(color: backgroundImageColor.withAlphaComponent(0.9)), for: .highlighted) - let titleColor: UIColor = traitCollection.userInterfaceIdiom == .phone ? Asset.Colors.Button.highlight.color : UIColor.white + let titleColor: UIColor = traitCollection.userInterfaceIdiom == .phone ? Asset.Colors.Button.normal.color : UIColor.white button.setTitleColor(titleColor, for: .normal) button.translatesAutoresizingMaskIntoConstraints = false return button @@ -50,7 +50,7 @@ final class WelcomeViewController: UIViewController, NeedsDependency { let button = UIButton(type: .system) button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)) button.setTitle(L10n.Common.Controls.Actions.signIn, for: .normal) - let titleColor: UIColor = traitCollection.userInterfaceIdiom == .phone ? UIColor.white.withAlphaComponent(0.8) : Asset.Colors.Button.highlight.color + let titleColor: UIColor = traitCollection.userInterfaceIdiom == .phone ? UIColor.white.withAlphaComponent(0.8) : Asset.Colors.Button.normal.color button.setTitleColor(titleColor, for: .normal) button.translatesAutoresizingMaskIntoConstraints = false return button diff --git a/Mastodon/Scene/Share/View/Button/HighlightDimmableButton.swift b/Mastodon/Scene/Share/View/Button/HighlightDimmableButton.swift index 3eb916f26..5202d376a 100644 --- a/Mastodon/Scene/Share/View/Button/HighlightDimmableButton.swift +++ b/Mastodon/Scene/Share/View/Button/HighlightDimmableButton.swift @@ -9,6 +9,8 @@ import UIKit final class HighlightDimmableButton: UIButton { + var expandEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + override init(frame: CGRect) { super.init(frame: frame) _init() @@ -19,6 +21,9 @@ final class HighlightDimmableButton: UIButton { _init() } + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + return bounds.inset(by: expandEdgeInsets).contains(point) + } override var isHighlighted: Bool { didSet { diff --git a/Mastodon/Scene/Share/View/Button/PrimaryActionButton.swift b/Mastodon/Scene/Share/View/Button/PrimaryActionButton.swift index 0d68cd74d..aa36fd237 100644 --- a/Mastodon/Scene/Share/View/Button/PrimaryActionButton.swift +++ b/Mastodon/Scene/Share/View/Button/PrimaryActionButton.swift @@ -38,8 +38,8 @@ extension PrimaryActionButton { private func _init() { titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)) setTitleColor(.white, for: .normal) - setBackgroundImage(UIImage.placeholder(color: Asset.Colors.Button.highlight.color), for: .normal) - setBackgroundImage(UIImage.placeholder(color: Asset.Colors.Button.highlight.color.withAlphaComponent(0.5)), for: .highlighted) + setBackgroundImage(UIImage.placeholder(color: Asset.Colors.Button.normal.color), for: .normal) + setBackgroundImage(UIImage.placeholder(color: Asset.Colors.Button.normal.color.withAlphaComponent(0.5)), for: .highlighted) setBackgroundImage(UIImage.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled) applyCornerRadius(radius: 10) } diff --git a/Mastodon/Scene/Share/View/Container/AudioContainerView.swift b/Mastodon/Scene/Share/View/Container/AudioContainerView.swift index 980e5ae87..336fade8f 100644 --- a/Mastodon/Scene/Share/View/Container/AudioContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/AudioContainerView.swift @@ -22,7 +22,7 @@ final class AudioContainerView: UIView { stackView.isLayoutMarginsRelativeArrangement = true stackView.layer.cornerRadius = AudioContainerView.cornerRadius stackView.clipsToBounds = true - stackView.backgroundColor = Asset.Colors.Button.highlight.color + stackView.backgroundColor = Asset.Colors.Background.AudioPlayer.highlight.color stackView.translatesAutoresizingMaskIntoConstraints = false return stackView }() @@ -31,7 +31,7 @@ final class AudioContainerView: UIView { let view = UIView() view.layer.cornerRadius = 16 view.clipsToBounds = true - view.backgroundColor = Asset.Colors.Button.highlight.color + view.backgroundColor = Asset.Colors.Background.AudioPlayer.highlight.color view.translatesAutoresizingMaskIntoConstraints = false return view }() @@ -109,3 +109,20 @@ extension AudioContainerView { ]) } } + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct AudioContainerView_Previews: PreviewProvider { + + static var previews: some View { + UIViewPreview(width: 375) { + AudioContainerView() + } + .previewLayout(.fixed(width: 375, height: 100)) + } + +} + +#endif + diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift index 3a42560a9..2c2229466 100644 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift @@ -66,8 +66,8 @@ extension PlayerContainerView { NSLayoutConstraint.activate([ mediaTypeIndicotorView.bottomAnchor.constraint(equalTo: playerViewController.contentOverlayView!.bottomAnchor), mediaTypeIndicotorView.rightAnchor.constraint(equalTo: playerViewController.contentOverlayView!.rightAnchor), - mediaTypeIndicotorView.heightAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.height).priority(.defaultHigh), - mediaTypeIndicotorView.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.defaultHigh), + mediaTypeIndicotorView.heightAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.height).priority(.required - 1), + mediaTypeIndicotorView.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.required - 1), ]) addSubview(contentWarningOverlayView) @@ -84,8 +84,8 @@ extension PlayerContainerView { NSLayoutConstraint.activate([ mediaTypeIndicotorViewInContentWarningOverlay.bottomAnchor.constraint(equalTo: contentWarningOverlayView.bottomAnchor), mediaTypeIndicotorViewInContentWarningOverlay.rightAnchor.constraint(equalTo: contentWarningOverlayView.rightAnchor), - mediaTypeIndicotorViewInContentWarningOverlay.heightAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.height).priority(.defaultHigh), - mediaTypeIndicotorViewInContentWarningOverlay.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.defaultHigh), + mediaTypeIndicotorViewInContentWarningOverlay.heightAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.height).priority(.required - 1), + mediaTypeIndicotorViewInContentWarningOverlay.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.required - 1), ]) } } diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 9b3db273a..4c03d1baa 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -167,8 +167,8 @@ final class StatusView: UIView { let button = HitTestExpandedButton() button.titleLabel?.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14, weight: .semibold)) button.setTitle(L10n.Common.Controls.Status.Poll.vote, for: .normal) - button.setTitleColor(Asset.Colors.Button.highlight.color, for: .normal) - button.setTitleColor(Asset.Colors.Button.highlight.color.withAlphaComponent(0.8), for: .highlighted) + button.setTitleColor(Asset.Colors.Button.normal.color, for: .normal) + button.setTitleColor(Asset.Colors.Button.normal.color.withAlphaComponent(0.8), for: .highlighted) button.setTitleColor(Asset.Colors.Button.disabled.color, for: .disabled) button.isEnabled = false return button diff --git a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift index 7aa7ef41d..2fd3a023d 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift @@ -35,7 +35,7 @@ final class PollOptionTableViewCell: UITableViewCell { let imageView = UIImageView() let image = UIImage(systemName: "checkmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 14, weight: .bold))! imageView.image = image.withRenderingMode(.alwaysTemplate) - imageView.tintColor = Asset.Colors.Button.highlight.color + imageView.tintColor = Asset.Colors.Button.normal.color return imageView }() diff --git a/Mastodon/Service/APIService/APIService+Media.swift b/Mastodon/Service/APIService/APIService+Media.swift new file mode 100644 index 000000000..03e333424 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Media.swift @@ -0,0 +1,46 @@ +// +// APIService+Media.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-18. +// + +import Foundation +import Combine +import MastodonSDK + +extension APIService { + + func uploadMedia( + domain: String, + query: Mastodon.API.Media.UploadMeidaQuery, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Media.uploadMedia( + session: session, + domain: domain, + query: query, + authorization: authorization + ) + } + + func updateMedia( + domain: String, + attachmentID: Mastodon.Entity.Attachment.ID, + query: Mastodon.API.Media.UpdateMediaQuery, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Media.updateMedia( + session: session, + domain: domain, + attachmentID: attachmentID, + query: query, + authorization: authorization + ) + } + +} diff --git a/Mastodon/Service/APIService/APIService+Status.swift b/Mastodon/Service/APIService/APIService+Status.swift index d02b04796..08806c886 100644 --- a/Mastodon/Service/APIService/APIService+Status.swift +++ b/Mastodon/Service/APIService/APIService+Status.swift @@ -14,7 +14,44 @@ import DateToolsSwift import MastodonSDK extension APIService { - + + func publishStatus( + domain: String, + query: Mastodon.API.Statuses.PublishStatusQuery, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Statuses.publishStatus( + session: session, + domain: domain, + query: query, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + return APIService.Persist.persistStatus( + managedObjectContext: self.backgroundManagedObjectContext, + domain: domain, + query: nil, + response: response.map { [$0] }, + persistType: .lookUp, + requestMastodonUserID: nil, + log: OSLog.api + ) + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + func status( domain: String, statusID: Mastodon.Entity.Status.ID, diff --git a/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift new file mode 100644 index 000000000..9fd4b1298 --- /dev/null +++ b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift @@ -0,0 +1,111 @@ +// +// MastodonAttachmentService+UploadState.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-18. +// + +import os.log +import Foundation +import GameplayKit +import Kingfisher +import MastodonSDK + +extension MastodonAttachmentService { + class UploadState: GKState { + weak var service: MastodonAttachmentService? + + init(service: MastodonAttachmentService) { + self.service = service + } + + 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) + service?.uploadStateMachineSubject.send(self) + } + } +} + +extension MastodonAttachmentService.UploadState { + + class Initial: MastodonAttachmentService.UploadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + guard service?.authenticationBox != nil else { return false } + if stateClass == Initial.self { + return true + } + + if service?.imageData.value != nil { + return stateClass == Uploading.self + } else { + return stateClass == Fail.self + } + } + } + + class Uploading: MastodonAttachmentService.UploadState { + 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 service = service, let stateMachine = stateMachine else { return } + guard let authenticationBox = service.authenticationBox else { return } + guard let imageData = service.imageData.value else { return } + + let file: Mastodon.Query.MediaAttachment = { + if imageData.kf.imageFormat == .PNG { + return .png(imageData) + } else { + return .jpeg(imageData) + } + }() + let description = service.description.value + let query = Mastodon.API.Media.UploadMeidaQuery( + file: file, + thumbnail: nil, + description: description, + focus: nil + ) + + service.context.apiService.uploadMedia( + domain: authenticationBox.domain, + query: query, + mastodonAuthenticationBox: authenticationBox + ) + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: upload attachment fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + service.error.send(error) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: upload attachment success", ((#file as NSString).lastPathComponent), #line, #function) + break + } + } receiveValue: { response in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: upload attachment %s success: %s", ((#file as NSString).lastPathComponent), #line, #function, response.value.id, response.value.url) + service.attachment.value = response.value + stateMachine.enter(Finish.self) + } + .store(in: &service.disposeBag) + } + } + + class Fail: MastodonAttachmentService.UploadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // allow discard publishing + return stateClass == Uploading.self || stateClass == Finish.self + } + } + + class Finish: MastodonAttachmentService.UploadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return false + } + } + +} + diff --git a/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift new file mode 100644 index 000000000..3a57a9d98 --- /dev/null +++ b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift @@ -0,0 +1,138 @@ +// +// MastodonAttachmentService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-17. +// + +import UIKit +import Combine +import PhotosUI +import Kingfisher +import GameplayKit +import MastodonSDK + +protocol MastodonAttachmentServiceDelegate: class { + func mastodonAttachmentService(_ service: MastodonAttachmentService, uploadStateDidChange state: MastodonAttachmentService.UploadState?) +} + +final class MastodonAttachmentService { + + var disposeBag = Set() + weak var delegate: MastodonAttachmentServiceDelegate? + + let identifier = UUID() + + // input + let context: AppContext + var authenticationBox: AuthenticationService.MastodonAuthenticationBox? + + // output + // TODO: handle video/GIF/Audio data + let imageData = CurrentValueSubject(nil) + let attachment = CurrentValueSubject(nil) + let description = CurrentValueSubject(nil) + let error = CurrentValueSubject(nil) + + private(set) lazy var uploadStateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + UploadState.Initial(service: self), + UploadState.Uploading(service: self), + UploadState.Fail(service: self), + UploadState.Finish(service: self), + ]) + stateMachine.enter(UploadState.Initial.self) + return stateMachine + }() + lazy var uploadStateMachineSubject = CurrentValueSubject(nil) + + init( + context: AppContext, + pickerResult: PHPickerResult, + initalAuthenticationBox: AuthenticationService.MastodonAuthenticationBox? + ) { + self.context = context + self.authenticationBox = initalAuthenticationBox + // end init + + setupServiceObserver() + + PHPickerResultLoader.loadImageData(from: pickerResult) + .sink { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + self.error.value = error + self.uploadStateMachine.enter(UploadState.Fail.self) + case .finished: + break + } + } receiveValue: { [weak self] imageData in + guard let self = self else { return } + self.imageData.value = imageData + self.uploadStateMachine.enter(UploadState.Initial.self) + } + .store(in: &disposeBag) + } + + init( + context: AppContext, + image: UIImage, + initalAuthenticationBox: AuthenticationService.MastodonAuthenticationBox? + ) { + self.context = context + self.authenticationBox = initalAuthenticationBox + // end init + + setupServiceObserver() + + imageData.value = image.jpegData(compressionQuality: 0.75) + uploadStateMachine.enter(UploadState.Initial.self) + } + + init( + context: AppContext, + imageData: Data, + initalAuthenticationBox: AuthenticationService.MastodonAuthenticationBox? + ) { + self.context = context + self.authenticationBox = initalAuthenticationBox + // end init + + setupServiceObserver() + + self.imageData.value = imageData + uploadStateMachine.enter(UploadState.Initial.self) + } + + private func setupServiceObserver() { + uploadStateMachineSubject + .sink { [weak self] state in + guard let self = self else { return } + self.delegate?.mastodonAttachmentService(self, uploadStateDidChange: state) + } + .store(in: &disposeBag) + } + +} + +extension MastodonAttachmentService { + // FIXME: needs reset state for multiple account posting support + func uploading(mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox) -> Bool { + authenticationBox = mastodonAuthenticationBox + return uploadStateMachine.enter(UploadState.self) + } +} + +extension MastodonAttachmentService: Equatable, Hashable { + + static func == (lhs: MastodonAttachmentService, rhs: MastodonAttachmentService) -> Bool { + return lhs.identifier == rhs.identifier + } + + func hash(into hasher: inout Hasher) { + hasher.combine(identifier) + } + +} diff --git a/Mastodon/Vender/PHPickerResultLoader.swift b/Mastodon/Vender/PHPickerResultLoader.swift new file mode 100644 index 000000000..7e083001c --- /dev/null +++ b/Mastodon/Vender/PHPickerResultLoader.swift @@ -0,0 +1,72 @@ +// +// PHPickerResultLoader.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-18. +// + +import os.log +import Foundation +import Combine +import MobileCoreServices +import PhotosUI + +// load image with low memory usage +// Refs: https://christianselig.com/2020/09/phpickerviewcontroller-efficiently/ +enum PHPickerResultLoader { + + static func loadImageData(from result: PHPickerResult) -> Future { + Future { promise in + result.itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { url, error in + if let error = error { + promise(.failure(error)) + return + } + + guard let url = url else { + promise(.success(nil)) + return + } + + let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary + guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions) else { + return + } + + let downsampleOptions = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: 4096, + ] as CFDictionary + + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions) else { + return + } + + let data = NSMutableData() + guard let imageDestination = CGImageDestinationCreateWithData(data, kUTTypeJPEG, 1, nil) else { + promise(.success(nil)) + return + } + + let isPNG: Bool = { + guard let utType = cgImage.utType else { return false } + return (utType as String) == UTType.png.identifier + }() + + let destinationProperties = [ + kCGImageDestinationLossyCompressionQuality: isPNG ? 1.0 : 0.75 + ] as CFDictionary + + CGImageDestinationAddImage(imageDestination, cgImage, destinationProperties) + CGImageDestinationFinalize(imageDestination) + + let dataSize = ByteCountFormatter.string(fromByteCount: Int64(data.length), countStyle: .memory) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: load image %s", ((#file as NSString).lastPathComponent), #line, #function, dataSize) + + promise(.success(data as Data)) + } + } + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift index 04273188b..6f324627b 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift @@ -198,6 +198,10 @@ extension Mastodon.API.Account { return Self.multipartContentType() } + var queryItems: [URLQueryItem]? { + return nil + } + var body: Data? { var data = Data() @@ -219,6 +223,7 @@ extension Mastodon.API.Account { data.append(Data.multipart(key: "fields_attributes[value][]", value: fieldsAttribute.value)) } } + data.append(Data.multipartEnd()) return data } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift new file mode 100644 index 000000000..5ae344b3d --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift @@ -0,0 +1,136 @@ +// +// Mastodon+API+Media.swift +// +// +// Created by MainasuK Cirno on 2021-3-18. +// + +import Foundation +import Combine + +extension Mastodon.API.Media { + + static func uploadMediaEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("media") + } + + /// Upload media as attachment + /// + /// Creates an attachment to be used with a new status. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/18 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/media/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `UploadMediaQuery` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Attachment` nested in the response + public static func uploadMedia( + session: URLSession, + domain: String, + query: UploadMeidaQuery, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + var request = Mastodon.API.post( + url: uploadMediaEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + request.timeoutInterval = 180 // should > 200 Kb/s for 40 MiB media attachment + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Attachment.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct UploadMeidaQuery: PostQuery, PutQuery { + public let file: Mastodon.Query.MediaAttachment? + public let thumbnail: Mastodon.Query.MediaAttachment? + public let description: String? + public let focus: String? + + public init( + file: Mastodon.Query.MediaAttachment?, + thumbnail: Mastodon.Query.MediaAttachment?, + description: String?, + focus: String? + ) { + self.file = file + self.thumbnail = thumbnail + self.description = description + self.focus = focus + } + + var contentType: String? { + return Self.multipartContentType() + } + + var body: Data? { + var data = Data() + + file.flatMap { data.append(Data.multipart(key: "file", value: $0)) } + thumbnail.flatMap { data.append(Data.multipart(key: "thumbnail", value: $0)) } + description.flatMap { data.append(Data.multipart(key: "description", value: $0)) } + focus.flatMap { data.append(Data.multipart(key: "focus", value: $0)) } + + data.append(Data.multipartEnd()) + return data + } + } + +} + +extension Mastodon.API.Media { + + static func updateMediaEndpointURL(domain: String, attachmentID: Mastodon.Entity.Attachment.ID) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("media").appendingPathComponent(attachmentID) + } + + /// Update attachment + /// + /// Update an Attachment, before it is attached to a status and posted.. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/18 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/media/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `UploadMediaQuery` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Attachment` nested in the response + public static func updateMedia( + session: URLSession, + domain: String, + attachmentID: Mastodon.Entity.Attachment.ID, + query: UpdateMediaQuery, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + var request = Mastodon.API.put( + url: updateMediaEndpointURL(domain: domain, attachmentID: attachmentID), + query: query, + authorization: authorization + ) + request.timeoutInterval = 180 // should > 200 Kb/s for 40 MiB media attachment + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Attachment.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public typealias UpdateMediaQuery = UploadMeidaQuery + +} + diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift index cb33bc09b..6a7642609 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift @@ -14,7 +14,7 @@ extension Mastodon.API.Statuses { let pathComponent = "statuses/" + statusID return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) } - + /// View specific status /// /// View information about a status @@ -49,5 +49,63 @@ extension Mastodon.API.Statuses { } .eraseToAnyPublisher() } - + +} + +extension Mastodon.API.Statuses { + + static func publishNewStatusEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("statuses") + } + + /// Publish new status + /// + /// Post a new status. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/18 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `PublishStatusQuery` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Status` nested in the response + public static func publishStatus( + session: URLSession, + domain: String, + query: PublishStatusQuery, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: publishNewStatusEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct PublishStatusQuery: Codable, PostQuery { + public let status: String? + public let mediaIDs: [String]? + + enum CodingKeys: String, CodingKey { + case status + case mediaIDs = "media_ids" + } + + public init(status: String?, mediaIDs: [String]?) { + self.status = status + self.mediaIDs = mediaIDs + } + } + } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index b8efcdee2..b86cc50e8 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -94,6 +94,7 @@ extension Mastodon.API { public enum CustomEmojis { } public enum Favorites { } public enum Instance { } + public enum Media { } public enum OAuth { } public enum Onboarding { } public enum Polls { } @@ -127,6 +128,14 @@ extension Mastodon.API { ) -> URLRequest { return buildRequest(url: url, method: .PATCH, query: query, authorization: authorization) } + + static func put( + url: URL, + query: PutQuery?, + authorization: OAuth.Authorization? + ) -> URLRequest { + return buildRequest(url: url, method: .PUT, query: query, authorization: authorization) + } private static func buildRequest( url: URL, diff --git a/MastodonSDK/Sources/MastodonSDK/Query/Query.swift b/MastodonSDK/Sources/MastodonSDK/Query/Query.swift index a0a5e4eae..39f6e3ec4 100644 --- a/MastodonSDK/Sources/MastodonSDK/Query/Query.swift +++ b/MastodonSDK/Sources/MastodonSDK/Query/Query.swift @@ -35,6 +35,7 @@ extension RequestQuery where Self: Encodable { } } +// GET protocol GetQuery: RequestQuery { } extension GetQuery { @@ -43,6 +44,7 @@ extension GetQuery { var contentType: String? { nil } } +// POST protocol PostQuery: RequestQuery { } extension PostQuery { @@ -50,10 +52,9 @@ extension PostQuery { var queryItems: [URLQueryItem]? { nil } } +// PATCH protocol PatchQuery: RequestQuery { } -extension PatchQuery { - // By default a `PatchQuery` does not has query items - var queryItems: [URLQueryItem]? { nil } -} +// PUT +protocol PutQuery: RequestQuery { } diff --git a/README.md b/README.md index 53e3bf498..61f142bb8 100644 --- a/README.md +++ b/README.md @@ -54,5 +54,6 @@ arch -x86_64 pod install - [SwiftGen](https://github.com/SwiftGen/SwiftGen) - [SwiftyJSON](https://github.com/SwiftyJSON/SwiftyJSON) - [TwitterTextEditor](https://github.com/twitter/TwitterTextEditor) +- [UITextView-Placeholder](https://github.com/devxoul/UITextView-Placeholder) ## License