diff --git a/Documentation/Snapshot.md b/Documentation/Snapshot.md index 7140f7a0b..857c1b586 100644 --- a/Documentation/Snapshot.md +++ b/Documentation/Snapshot.md @@ -14,7 +14,7 @@ We use `xcodebuild` CLI tool to trigger UITest. Set the `name` in `-destination` option to add device for snapshot. For example: `-destination 'platform=iOS Simulator,name=iPad Pro (12.9-inch) (5th generation)' \` -You can list the avaiable simulator: +You can list the available simulators: ```zsh # list the destinations xcodebuild \ diff --git a/Localization/Localizable.stringsdict b/Localization/Localizable.stringsdict index 93d566f70..f8964ca5d 100644 --- a/Localization/Localizable.stringsdict +++ b/Localization/Localizable.stringsdict @@ -68,6 +68,28 @@ %ld characters + a11y.plural.count.characters_left + + NSStringLocalizedFormatKey + %#@character_count@ left + character_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + zero + no characters + one + 1 character + few + %ld characters + many + %ld characters + other + %ld characters + + plural.count.followed_by_and_mutual NSStringLocalizedFormatKey diff --git a/Localization/StringsConvertor/input/en.lproj/Localizable.stringsdict b/Localization/StringsConvertor/input/en.lproj/Localizable.stringsdict index bdcae6ac9..297e6675a 100644 --- a/Localization/StringsConvertor/input/en.lproj/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/en.lproj/Localizable.stringsdict @@ -50,6 +50,28 @@ %ld characters + a11y.plural.count.characters_left + + NSStringLocalizedFormatKey + %#@character_count@ left + character_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + zero + no characters + one + 1 character + few + %ld characters + many + %ld characters + other + %ld characters + + plural.count.followed_by_and_mutual NSStringLocalizedFormatKey diff --git a/Localization/StringsConvertor/input/en.lproj/app.json b/Localization/StringsConvertor/input/en.lproj/app.json index 4c64d28f3..a70c480f9 100644 --- a/Localization/StringsConvertor/input/en.lproj/app.json +++ b/Localization/StringsConvertor/input/en.lproj/app.json @@ -136,6 +136,12 @@ "vote": "Vote", "closed": "Closed" }, + "meta_entity": { + "url": "Link: %s", + "hashtag": "Hastag %s", + "mention": "Show Profile: %s", + "email": "Email address: %s" + }, "actions": { "reply": "Reply", "reblog": "Reblog", @@ -407,7 +413,9 @@ "custom_emoji_picker": "Custom Emoji Picker", "enable_content_warning": "Enable Content Warning", "disable_content_warning": "Disable Content Warning", - "post_visibility_menu": "Post Visibility Menu" + "post_visibility_menu": "Post Visibility Menu", + "post_options": "Post Options", + "posting_as": "Posting as %s" }, "keyboard": { "discard_post": "Discard Post", diff --git a/Localization/app.json b/Localization/app.json index 8fb7933c4..349dd387b 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -136,6 +136,12 @@ "vote": "Vote", "closed": "Closed" }, + "meta_entity": { + "url": "Link: %s", + "hashtag": "Hashtag: %s", + "mention": "Show Profile: %s", + "email": "Email address: %s" + }, "actions": { "reply": "Reply", "reblog": "Reblog", @@ -376,7 +382,11 @@ "video": "video", "attachment_broken": "This %s is broken and can’t be\nuploaded to Mastodon.", "description_photo": "Describe the photo for the visually-impaired...", - "description_video": "Describe the video for the visually-impaired..." + "description_video": "Describe the video for the visually-impaired...", + "load_failed": "Load Failed", + "upload_failed": "Upload Failed", + "can_not_recognize_this_media_attachment": "Can not regonize this media attachment", + "attachment_too_large": "Attachment too large" }, "poll": { "duration_time": "Duration: %s", @@ -407,7 +417,9 @@ "custom_emoji_picker": "Custom Emoji Picker", "enable_content_warning": "Enable Content Warning", "disable_content_warning": "Disable Content Warning", - "post_visibility_menu": "Post Visibility Menu" + "post_visibility_menu": "Post Visibility Menu", + "post_options": "Post Options", + "posting_as": "Posting as %s" }, "keyboard": { "discard_post": "Discard Post", diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 380f21eac..434926d94 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -151,7 +151,6 @@ DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */; }; DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */; }; DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */; }; - DB221B16260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB221B15260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift */; }; DB22C92228E700A10082A9E9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = DB22C92128E700A10082A9E9 /* MastodonSDK */; }; DB22C92428E700A80082A9E9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = DB22C92328E700A80082A9E9 /* MastodonSDK */; }; DB22C92628E700AF0082A9E9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = DB22C92528E700AF0082A9E9 /* MastodonSDK */; }; @@ -185,9 +184,6 @@ DB427DED25BAA00100D1B89D /* MastodonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DEC25BAA00100D1B89D /* MastodonTests.swift */; }; DB427DF825BAA00100D1B89D /* MastodonUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DF725BAA00100D1B89D /* MastodonUITests.swift */; }; DB443CD42694627B00159B29 /* AppearanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB443CD32694627B00159B29 /* AppearanceView.swift */; }; - DB44767B260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB44767A260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift */; }; - DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB447690260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift */; }; - DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB447696260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift */; }; DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481B825EE289600BEFB67 /* UITableView.swift */; }; DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */; }; DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */; }; @@ -257,7 +253,6 @@ DB64BA452851F23000ADF1B7 /* MastodonAuthentication+Fetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB64BA442851F23000ADF1B7 /* MastodonAuthentication+Fetch.swift */; }; DB64BA482851F29300ADF1B7 /* Account+Fetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB64BA472851F29300ADF1B7 /* Account+Fetch.swift */; }; DB65C63727A2AF6C008BAC2E /* ReportItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB65C63627A2AF6C008BAC2E /* ReportItem.swift */; }; - DB66728C25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66728B25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift */; }; DB6746EB278ED8B0008A6B94 /* PollOptionView+Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6746EA278ED8B0008A6B94 /* PollOptionView+Configuration.swift */; }; DB6746ED278F45F0008A6B94 /* AutoGenerateProtocolRelayDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6746EC278F45F0008A6B94 /* AutoGenerateProtocolRelayDelegate.swift */; }; DB6746F0278F463B008A6B94 /* AutoGenerateProtocolDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6746EF278F463B008A6B94 /* AutoGenerateProtocolDelegate.swift */; }; @@ -337,7 +332,6 @@ DB98EB6927B21A7C0082E365 /* ReportResultActionTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98EB6827B21A7C0082E365 /* ReportResultActionTableViewCell.swift */; }; DB98EB6B27B243470082E365 /* SettingsAppearanceTableViewCell+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98EB6A27B243470082E365 /* SettingsAppearanceTableViewCell+ViewModel.swift */; }; DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */; }; - DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488926034D40008B817C /* ComposeViewModel+PublishState.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 */; }; @@ -376,12 +370,11 @@ DBB8AB4626AECDE200F6D281 /* SendPostIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB8AB4526AECDE200F6D281 /* SendPostIntentHandler.swift */; }; DBB8AB4F26AED13F00F6D281 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB427DDE25BAA00100D1B89D /* Assets.xcassets */; }; DBB9759C262462E1004620BD /* ThreadMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB9759B262462E1004620BD /* ThreadMetaView.swift */; }; - DBBC24DC26A54BCB00398BB9 /* MastodonRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */; }; DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; }; - DBC6461526A170AB00B0E31B /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC6461426A170AB00B0E31B /* ComposeViewController.swift */; }; + DBC3872429214121001EC0FD /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC3872329214121001EC0FD /* ShareViewController.swift */; }; DBC6461826A170AB00B0E31B /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DBC6461626A170AB00B0E31B /* MainInterface.storyboard */; }; DBC6461C26A170AB00B0E31B /* ShareActionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = DBC6461226A170AB00B0E31B /* ShareActionExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - DBC6462326A1712000B0E31B /* ComposeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC6462226A1712000B0E31B /* ComposeViewModel.swift */; }; + DBC6462326A1712000B0E31B /* ShareViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC6462226A1712000B0E31B /* ShareViewModel.swift */; }; DBC6462826A1736300B0E31B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB427DDE25BAA00100D1B89D /* Assets.xcassets */; }; DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */; }; DBCA0EBC282BB38A0029E2B0 /* PageboyNavigateable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCA0EBB282BB38A0029E2B0 /* PageboyNavigateable.swift */; }; @@ -681,7 +674,6 @@ DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonPickServerViewModel+Diffable.swift"; sourceTree = ""; }; DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerItem.swift; sourceTree = ""; }; DB1FD45F25F278AF004CFCFC /* CategoryPickerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerSection.swift; sourceTree = ""; }; - DB221B15260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerInputViewModel.swift; sourceTree = ""; }; DB2B3ABD25E37E15007045F9 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = ""; }; DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollExpiresOptionCollectionViewCell.swift; sourceTree = ""; }; @@ -718,9 +710,6 @@ DB427DF725BAA00100D1B89D /* MastodonUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonUITests.swift; sourceTree = ""; }; DB427DF925BAA00100D1B89D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; DB443CD32694627B00159B29 /* AppearanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceView.swift; sourceTree = ""; }; - DB44767A260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerInputView.swift; sourceTree = ""; }; - DB447690260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerItemCollectionViewCell.swift; sourceTree = ""; }; - DB447696260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerHeaderCollectionReusableView.swift; sourceTree = ""; }; DB4481B825EE289600BEFB67 /* UITableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UITableView.swift; sourceTree = ""; }; DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIAlertController.swift; sourceTree = ""; }; DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIBarButtonItem.swift; sourceTree = ""; }; @@ -820,7 +809,6 @@ DB64BA442851F23000ADF1B7 /* MastodonAuthentication+Fetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonAuthentication+Fetch.swift"; sourceTree = ""; }; DB64BA472851F29300ADF1B7 /* Account+Fetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Account+Fetch.swift"; sourceTree = ""; }; DB65C63627A2AF6C008BAC2E /* ReportItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportItem.swift; sourceTree = ""; }; - DB66728B25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+DataSource.swift"; sourceTree = ""; }; DB6746EA278ED8B0008A6B94 /* PollOptionView+Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PollOptionView+Configuration.swift"; sourceTree = ""; }; DB6746EC278F45F0008A6B94 /* AutoGenerateProtocolRelayDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoGenerateProtocolRelayDelegate.swift; sourceTree = ""; }; DB6746EF278F463B008A6B94 /* AutoGenerateProtocolDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoGenerateProtocolDelegate.swift; sourceTree = ""; }; @@ -912,7 +900,6 @@ DB98EB6827B21A7C0082E365 /* ReportResultActionTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportResultActionTableViewCell.swift; sourceTree = ""; }; DB98EB6A27B243470082E365 /* SettingsAppearanceTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsAppearanceTableViewCell+ViewModel.swift"; sourceTree = ""; }; DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttachmentContainerView+EmptyStateView.swift"; sourceTree = ""; }; - DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+PublishState.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 = ""; }; @@ -961,14 +948,12 @@ DBB525632612C988002F1F29 /* MeProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeProfileViewModel.swift; sourceTree = ""; }; DBB8AB4526AECDE200F6D281 /* SendPostIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendPostIntentHandler.swift; sourceTree = ""; }; DBB9759B262462E1004620BD /* ThreadMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadMetaView.swift; sourceTree = ""; }; - DBBC24A726A52F9000398BB9 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = ""; }; - DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonRegex.swift; sourceTree = ""; }; DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = ""; }; + DBC3872329214121001EC0FD /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; DBC6461226A170AB00B0E31B /* ShareActionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareActionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; - DBC6461426A170AB00B0E31B /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = ""; }; DBC6461726A170AB00B0E31B /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; DBC6461926A170AB00B0E31B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - DBC6462226A1712000B0E31B /* ComposeViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = ""; }; + DBC6462226A1712000B0E31B /* ShareViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShareViewModel.swift; sourceTree = ""; }; DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentWarningEditorView.swift; sourceTree = ""; }; DBC9E3A3282E13BB0063A4D9 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = eu.lproj/Intents.strings; sourceTree = ""; }; DBC9E3A4282E13BB0063A4D9 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = eu.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1042,15 +1027,7 @@ DBFEEC98279BDCDE004F81DD /* ProfileAboutViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileAboutViewModel.swift; sourceTree = ""; }; DBFEEC9A279BDDD9004F81DD /* ProfileAboutViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileAboutViewModel+Diffable.swift"; sourceTree = ""; }; DBFEEC9C279C12C1004F81DD /* ProfileFieldEditCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldEditCollectionViewCell.swift; sourceTree = ""; }; - DBFEF05526A576EE006D7ED1 /* StatusEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditorView.swift; sourceTree = ""; }; - DBFEF05626A576EE006D7ED1 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; - DBFEF05726A576EE006D7ED1 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = ""; }; - DBFEF05826A576EE006D7ED1 /* ContentWarningEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningEditorView.swift; sourceTree = ""; }; - DBFEF05926A576EE006D7ED1 /* StatusAuthorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusAuthorView.swift; sourceTree = ""; }; - DBFEF05A26A576EE006D7ED1 /* StatusAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusAttachmentView.swift; sourceTree = ""; }; - DBFEF06226A577F2006D7ED1 /* StatusAttachmentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusAttachmentViewModel.swift; sourceTree = ""; }; DBFEF06726A58D07006D7ED1 /* ShareActionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareActionExtension.entitlements; sourceTree = ""; }; - DBFEF06C26A67FB7006D7ED1 /* StatusAttachmentViewModel+UploadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusAttachmentViewModel+UploadState.swift"; sourceTree = ""; }; DDB1B139FA8EA26F510D58B6 /* Pods-AppShared.asdk - release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AppShared.asdk - release.xcconfig"; path = "Target Support Files/Pods-AppShared/Pods-AppShared.asdk - release.xcconfig"; sourceTree = ""; }; DF65937EC1FF64462BC002EE /* Pods-MastodonTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.profile.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.profile.xcconfig"; sourceTree = ""; }; E5C7236E58D14A0322FE00F2 /* Pods-Mastodon-MastodonUITests.asdk - debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.asdk - debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.asdk - debug.xcconfig"; sourceTree = ""; }; @@ -1342,7 +1319,7 @@ path = Protocol; sourceTree = ""; }; - 2D76319C25C151DE00929FB9 /* Diffiable */ = { + 2D76319C25C151DE00929FB9 /* Diffable */ = { isa = PBXGroup; children = ( DB4F097826A039B400D62E92 /* Onboarding */, @@ -1357,7 +1334,7 @@ DB3E6FE52806A5BA00B035AE /* Discovery */, DB0617FA27855B660030EE79 /* Settings */, ); - path = Diffiable; + path = Diffable; sourceTree = ""; }; 2D7631A425C1532200929FB9 /* Share */ = { @@ -1762,7 +1739,7 @@ children = ( DB89BA1025C10FF5008580ED /* Mastodon.entitlements */, DB427DE325BAA00100D1B89D /* Info.plist */, - 2D76319C25C151DE00929FB9 /* Diffiable */, + 2D76319C25C151DE00929FB9 /* Diffable */, DB8AF55525C1379F002E6C99 /* Scene */, DB8AF54125C13647002E6C99 /* Coordinator */, DB8AF56225C138BC002E6C99 /* Extension */, @@ -1878,8 +1855,6 @@ DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */, DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */, DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */, - DB44767A260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift */, - DB221B15260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift */, DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */, ); path = View; @@ -2146,8 +2121,6 @@ DB789A2125F9F76D0071ACA0 /* CollectionViewCell */, DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */, DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */, - DB66728B25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift */, - DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */, ); path = Compose; sourceTree = ""; @@ -2159,8 +2132,6 @@ DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */, DB87D4502609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift */, DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */, - DB447690260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift */, - DB447696260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift */, ); path = CollectionViewCell; sourceTree = ""; @@ -2520,7 +2491,6 @@ DBBC24D526A54BCB00398BB9 /* Helper */ = { isa = PBXGroup; children = ( - DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */, DBF3B7402733EB9400E21627 /* MastodonLocalCode.swift */, ); path = Helper; @@ -2687,28 +2657,11 @@ path = Cell; sourceTree = ""; }; - DBFEF05426A576EE006D7ED1 /* View */ = { - isa = PBXGroup; - children = ( - DBBC24A726A52F9000398BB9 /* ComposeToolbarView.swift */, - DBFEF05526A576EE006D7ED1 /* StatusEditorView.swift */, - DBFEF05626A576EE006D7ED1 /* ComposeView.swift */, - DBFEF05726A576EE006D7ED1 /* ComposeViewModel.swift */, - DBFEF05826A576EE006D7ED1 /* ContentWarningEditorView.swift */, - DBFEF05926A576EE006D7ED1 /* StatusAuthorView.swift */, - DBFEF05A26A576EE006D7ED1 /* StatusAttachmentView.swift */, - DBFEF06226A577F2006D7ED1 /* StatusAttachmentViewModel.swift */, - DBFEF06C26A67FB7006D7ED1 /* StatusAttachmentViewModel+UploadState.swift */, - ); - path = View; - sourceTree = ""; - }; DBFEF06126A57721006D7ED1 /* Scene */ = { isa = PBXGroup; children = ( - DBFEF05426A576EE006D7ED1 /* View */, - DBC6462226A1712000B0E31B /* ComposeViewModel.swift */, - DBC6461426A170AB00B0E31B /* ComposeViewController.swift */, + DBC6462226A1712000B0E31B /* ShareViewModel.swift */, + DBC3872329214121001EC0FD /* ShareViewController.swift */, ); path = Scene; sourceTree = ""; @@ -3198,7 +3151,6 @@ 0F1E2D0B2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift in Sources */, DBDFF1902805543100557A48 /* DiscoveryPostsViewController.swift in Sources */, DB697DD9278F4CED004EF2F7 /* HomeTimelineViewController+DataSourceProvider.swift in Sources */, - DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */, DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */, @@ -3246,7 +3198,6 @@ DBFEEC9D279C12C1004F81DD /* ProfileFieldEditCollectionViewCell.swift in Sources */, DB3E6FEC2806D7F100B035AE /* DiscoveryNewsViewController.swift in Sources */, DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */, - DBBC24DC26A54BCB00398BB9 /* MastodonRegex.swift in Sources */, DBCBED1726132DB500B49291 /* UserTimelineViewModel+Diffable.swift in Sources */, 2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */, 2DAC9E3E262FC2400062E1A6 /* SuggestionAccountViewModel.swift in Sources */, @@ -3301,7 +3252,6 @@ DB63F7492799126300455B82 /* FollowerListViewController+DataSourceProvider.swift in Sources */, 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */, 2DAC9E38262FC2320062E1A6 /* SuggestionAccountViewController.swift in Sources */, - DB66728C25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift in Sources */, DB6180E02639194B0018D199 /* MediaPreviewPagingViewController.swift in Sources */, DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, 5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */, @@ -3339,7 +3289,6 @@ DB98EB6227B215EB0082E365 /* ReportResultViewController.swift in Sources */, DB6B74F4272FBAE700C70B6E /* FollowerListViewModel+Diffable.swift in Sources */, DB6B74F2272FB67600C70B6E /* FollowerListViewModel.swift in Sources */, - DB44767B260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift in Sources */, 0F20222D261457EE000C64BF /* HashtagTimelineViewModel+State.swift in Sources */, DB0009A626AEE5DC009B9D2D /* Intents.intentdefinition in Sources */, 5B90C462262599800002E742 /* SettingsSectionHeader.swift in Sources */, @@ -3353,7 +3302,6 @@ DBFEEC9B279BDDD9004F81DD /* ProfileAboutViewModel+Diffable.swift in Sources */, DBB525562611EDCA002F1F29 /* UserTimelineViewModel.swift in Sources */, DB0618012785732C0030EE79 /* ServerRulesTableViewCell.swift in Sources */, - DB221B16260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift in Sources */, DB98EB5C27B10A730082E365 /* ReportSupplementaryViewModel.swift in Sources */, DB0617EF277F12720030EE79 /* NavigationActionView.swift in Sources */, DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */, @@ -3437,7 +3385,6 @@ 2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */, 2D4AD89C263165B500613EFC /* SuggestionAccountCollectionViewCell.swift in Sources */, DB98EB6927B21A7C0082E365 /* ReportResultActionTableViewCell.swift in Sources */, - DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */, DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */, DB697DDF278F524F004EF2F7 /* DataSourceFacade+Profile.swift in Sources */, DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */, @@ -3461,7 +3408,6 @@ DB63F74D27993F5B00455B82 /* SearchHistoryUserCollectionViewCell.swift in Sources */, DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */, DB1D84382657B275000346B3 /* SegmentedControlNavigateable.swift in Sources */, - DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */, 0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */, DB7A9F932818F33C0016AF98 /* MastodonServerRulesViewController+Debug.swift in Sources */, 2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */, @@ -3567,9 +3513,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - DBC6462326A1712000B0E31B /* ComposeViewModel.swift in Sources */, + DBC6462326A1712000B0E31B /* ShareViewModel.swift in Sources */, DBB3BA2B26A81D060004F2D4 /* FLAnimatedImageView.swift in Sources */, - DBC6461526A170AB00B0E31B /* ComposeViewController.swift in Sources */, + DBC3872429214121001EC0FD /* ShareViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 979c8c0e6..78a3a9e70 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -117,7 +117,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 18 + 16 ShareActionExtension.xcscheme_^#shared#^_ diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 64dc691bb..409b8820d 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -90,6 +90,15 @@ "version" : "2.2.5" } }, + { + "identity" : "nextlevelsessionexporter", + "kind" : "remoteSourceControl", + "location" : "https://github.com/NextLevel/NextLevelSessionExporter.git", + "state" : { + "revision" : "b6c0cce1aa37fe1547d694f958fac3c3524b74da", + "version" : "0.4.6" + } + }, { "identity" : "nuke", "kind" : "remoteSourceControl", diff --git a/Mastodon/Diffiable/Account/SelectedAccountItem.swift b/Mastodon/Diffable/Account/SelectedAccountItem.swift similarity index 100% rename from Mastodon/Diffiable/Account/SelectedAccountItem.swift rename to Mastodon/Diffable/Account/SelectedAccountItem.swift diff --git a/Mastodon/Diffiable/Account/SelectedAccountSection.swift b/Mastodon/Diffable/Account/SelectedAccountSection.swift similarity index 100% rename from Mastodon/Diffiable/Account/SelectedAccountSection.swift rename to Mastodon/Diffable/Account/SelectedAccountSection.swift diff --git a/Mastodon/Diffiable/Discovery/DiscoveryItem.swift b/Mastodon/Diffable/Discovery/DiscoveryItem.swift similarity index 100% rename from Mastodon/Diffiable/Discovery/DiscoveryItem.swift rename to Mastodon/Diffable/Discovery/DiscoveryItem.swift diff --git a/Mastodon/Diffiable/Discovery/DiscoverySection.swift b/Mastodon/Diffable/Discovery/DiscoverySection.swift similarity index 100% rename from Mastodon/Diffiable/Discovery/DiscoverySection.swift rename to Mastodon/Diffable/Discovery/DiscoverySection.swift diff --git a/Mastodon/Diffiable/Notification/NotificationItem.swift b/Mastodon/Diffable/Notification/NotificationItem.swift similarity index 100% rename from Mastodon/Diffiable/Notification/NotificationItem.swift rename to Mastodon/Diffable/Notification/NotificationItem.swift diff --git a/Mastodon/Diffiable/Notification/NotificationSection.swift b/Mastodon/Diffable/Notification/NotificationSection.swift similarity index 100% rename from Mastodon/Diffiable/Notification/NotificationSection.swift rename to Mastodon/Diffable/Notification/NotificationSection.swift diff --git a/Mastodon/Diffiable/Onboarding/CategoryPickerItem.swift b/Mastodon/Diffable/Onboarding/CategoryPickerItem.swift similarity index 100% rename from Mastodon/Diffiable/Onboarding/CategoryPickerItem.swift rename to Mastodon/Diffable/Onboarding/CategoryPickerItem.swift diff --git a/Mastodon/Diffiable/Onboarding/CategoryPickerSection.swift b/Mastodon/Diffable/Onboarding/CategoryPickerSection.swift similarity index 100% rename from Mastodon/Diffiable/Onboarding/CategoryPickerSection.swift rename to Mastodon/Diffable/Onboarding/CategoryPickerSection.swift diff --git a/Mastodon/Diffiable/Onboarding/PickServerItem.swift b/Mastodon/Diffable/Onboarding/PickServerItem.swift similarity index 100% rename from Mastodon/Diffiable/Onboarding/PickServerItem.swift rename to Mastodon/Diffable/Onboarding/PickServerItem.swift diff --git a/Mastodon/Diffiable/Onboarding/PickServerSection.swift b/Mastodon/Diffable/Onboarding/PickServerSection.swift similarity index 100% rename from Mastodon/Diffiable/Onboarding/PickServerSection.swift rename to Mastodon/Diffable/Onboarding/PickServerSection.swift diff --git a/Mastodon/Diffiable/Onboarding/RegisterItem.swift b/Mastodon/Diffable/Onboarding/RegisterItem.swift similarity index 100% rename from Mastodon/Diffiable/Onboarding/RegisterItem.swift rename to Mastodon/Diffable/Onboarding/RegisterItem.swift diff --git a/Mastodon/Diffiable/Onboarding/RegisterSection.swift b/Mastodon/Diffable/Onboarding/RegisterSection.swift similarity index 100% rename from Mastodon/Diffiable/Onboarding/RegisterSection.swift rename to Mastodon/Diffable/Onboarding/RegisterSection.swift diff --git a/Mastodon/Diffiable/Onboarding/ServerRuleItem.swift b/Mastodon/Diffable/Onboarding/ServerRuleItem.swift similarity index 100% rename from Mastodon/Diffiable/Onboarding/ServerRuleItem.swift rename to Mastodon/Diffable/Onboarding/ServerRuleItem.swift diff --git a/Mastodon/Diffiable/Onboarding/ServerRuleSection.swift b/Mastodon/Diffable/Onboarding/ServerRuleSection.swift similarity index 100% rename from Mastodon/Diffiable/Onboarding/ServerRuleSection.swift rename to Mastodon/Diffable/Onboarding/ServerRuleSection.swift diff --git a/Mastodon/Diffiable/Profile/ProfileFieldItem.swift b/Mastodon/Diffable/Profile/ProfileFieldItem.swift similarity index 100% rename from Mastodon/Diffiable/Profile/ProfileFieldItem.swift rename to Mastodon/Diffable/Profile/ProfileFieldItem.swift diff --git a/Mastodon/Diffiable/Profile/ProfileFieldSection.swift b/Mastodon/Diffable/Profile/ProfileFieldSection.swift similarity index 100% rename from Mastodon/Diffiable/Profile/ProfileFieldSection.swift rename to Mastodon/Diffable/Profile/ProfileFieldSection.swift diff --git a/Mastodon/Diffiable/RecommandAccount/RecommendAccountItem.swift b/Mastodon/Diffable/RecommandAccount/RecommendAccountItem.swift similarity index 100% rename from Mastodon/Diffiable/RecommandAccount/RecommendAccountItem.swift rename to Mastodon/Diffable/RecommandAccount/RecommendAccountItem.swift diff --git a/Mastodon/Diffiable/RecommandAccount/RecommendAccountSection.swift b/Mastodon/Diffable/RecommandAccount/RecommendAccountSection.swift similarity index 100% rename from Mastodon/Diffiable/RecommandAccount/RecommendAccountSection.swift rename to Mastodon/Diffable/RecommandAccount/RecommendAccountSection.swift diff --git a/Mastodon/Diffiable/Report/ReportItem.swift b/Mastodon/Diffable/Report/ReportItem.swift similarity index 100% rename from Mastodon/Diffiable/Report/ReportItem.swift rename to Mastodon/Diffable/Report/ReportItem.swift diff --git a/Mastodon/Diffiable/Report/ReportSection.swift b/Mastodon/Diffable/Report/ReportSection.swift similarity index 100% rename from Mastodon/Diffiable/Report/ReportSection.swift rename to Mastodon/Diffable/Report/ReportSection.swift diff --git a/Mastodon/Diffiable/Search/SearchHistoryItem.swift b/Mastodon/Diffable/Search/SearchHistoryItem.swift similarity index 100% rename from Mastodon/Diffiable/Search/SearchHistoryItem.swift rename to Mastodon/Diffable/Search/SearchHistoryItem.swift diff --git a/Mastodon/Diffiable/Search/SearchHistorySection.swift b/Mastodon/Diffable/Search/SearchHistorySection.swift similarity index 100% rename from Mastodon/Diffiable/Search/SearchHistorySection.swift rename to Mastodon/Diffable/Search/SearchHistorySection.swift diff --git a/Mastodon/Diffiable/Search/SearchItem.swift b/Mastodon/Diffable/Search/SearchItem.swift similarity index 100% rename from Mastodon/Diffiable/Search/SearchItem.swift rename to Mastodon/Diffable/Search/SearchItem.swift diff --git a/Mastodon/Diffiable/Search/SearchResultItem.swift b/Mastodon/Diffable/Search/SearchResultItem.swift similarity index 100% rename from Mastodon/Diffiable/Search/SearchResultItem.swift rename to Mastodon/Diffable/Search/SearchResultItem.swift diff --git a/Mastodon/Diffiable/Search/SearchResultSection.swift b/Mastodon/Diffable/Search/SearchResultSection.swift similarity index 100% rename from Mastodon/Diffiable/Search/SearchResultSection.swift rename to Mastodon/Diffable/Search/SearchResultSection.swift diff --git a/Mastodon/Diffiable/Search/SearchSection.swift b/Mastodon/Diffable/Search/SearchSection.swift similarity index 100% rename from Mastodon/Diffiable/Search/SearchSection.swift rename to Mastodon/Diffable/Search/SearchSection.swift diff --git a/Mastodon/Diffiable/Settings/SettingsItem.swift b/Mastodon/Diffable/Settings/SettingsItem.swift similarity index 100% rename from Mastodon/Diffiable/Settings/SettingsItem.swift rename to Mastodon/Diffable/Settings/SettingsItem.swift diff --git a/Mastodon/Diffiable/Settings/SettingsSection.swift b/Mastodon/Diffable/Settings/SettingsSection.swift similarity index 100% rename from Mastodon/Diffiable/Settings/SettingsSection.swift rename to Mastodon/Diffable/Settings/SettingsSection.swift diff --git a/Mastodon/Diffiable/Status/StatusItem.swift b/Mastodon/Diffable/Status/StatusItem.swift similarity index 100% rename from Mastodon/Diffiable/Status/StatusItem.swift rename to Mastodon/Diffable/Status/StatusItem.swift diff --git a/Mastodon/Diffiable/Status/StatusSection.swift b/Mastodon/Diffable/Status/StatusSection.swift similarity index 100% rename from Mastodon/Diffiable/Status/StatusSection.swift rename to Mastodon/Diffable/Status/StatusSection.swift diff --git a/Mastodon/Diffiable/User/UserItem.swift b/Mastodon/Diffable/User/UserItem.swift similarity index 100% rename from Mastodon/Diffiable/User/UserItem.swift rename to Mastodon/Diffable/User/UserItem.swift diff --git a/Mastodon/Diffiable/User/UserSection.swift b/Mastodon/Diffable/User/UserSection.swift similarity index 100% rename from Mastodon/Diffiable/User/UserSection.swift rename to Mastodon/Diffable/User/UserSection.swift diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index bf9145d6c..6de17e31f 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -86,34 +86,7 @@ final class ComposeViewController: UIViewController, NeedsDependency { publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled) publishButton.setTitleColor(Asset.Colors.Label.primaryReverse.color, for: .normal) } - -// var systemKeyboardHeight: CGFloat = .zero { -// didSet { -// // note: some system AutoLayout warning here -// let height = max(300, systemKeyboardHeight) -// customEmojiPickerInputView.frame.size.height = height -// } -// } -// -// // CustomEmojiPickerView -// let customEmojiPickerInputView: CustomEmojiPickerInputView = { -// let view = CustomEmojiPickerInputView(frame: CGRect(x: 0, y: 0, width: 0, height: 300), inputViewStyle: .keyboard) -// return view -// }() -// -// let composeToolbarView = ComposeToolbarView() -// var composeToolbarViewBottomLayoutConstraint: NSLayoutConstraint! -// let composeToolbarBackgroundView = UIView() -// -// -// private(set) lazy var autoCompleteViewController: AutoCompleteViewController = { -// let viewController = AutoCompleteViewController() -// viewController.viewModel = AutoCompleteViewModel(context: context, authContext: viewModel.authContext) -// viewController.delegate = self -// viewController.viewModel.customEmojiViewModel.value = viewModel.customEmojiViewModel -// return viewController -// }() - + deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) } @@ -147,9 +120,9 @@ extension ComposeViewController { guard let self = self else { return } guard self.traitCollection.userInterfaceIdiom == .pad else { return } var items = [self.publishBarButtonItem] - if self.traitCollection.horizontalSizeClass == .regular { - items.append(self.characterCountBarButtonItem) - } + // if self.traitCollection.horizontalSizeClass == .regular { + // items.append(self.characterCountBarButtonItem) + // } self.navigationItem.rightBarButtonItems = items } .store(in: &disposeBag) @@ -166,380 +139,56 @@ extension ComposeViewController { ]) composeContentViewController.didMove(toParent: self) -// configureNavigationBarTitleStyle() -// viewModel.traitCollectionDidChangePublisher -// .receive(on: DispatchQueue.main) -// .sink { [weak self] _ in -// guard let self = self else { return } -// self.configureNavigationBarTitleStyle() -// } -// .store(in: &disposeBag) -// -// viewModel.$title -// .receive(on: DispatchQueue.main) -// .sink { [weak self] title in -// guard let self = self else { return } -// self.title = title -// } -// .store(in: &disposeBag) -// -// composeToolbarView.translatesAutoresizingMaskIntoConstraints = false -// view.addSubview(composeToolbarView) -// composeToolbarViewBottomLayoutConstraint = view.bottomAnchor.constraint(equalTo: composeToolbarView.bottomAnchor) -// NSLayoutConstraint.activate([ -// composeToolbarView.leadingAnchor.constraint(equalTo: view.leadingAnchor), -// composeToolbarView.trailingAnchor.constraint(equalTo: view.trailingAnchor), -// composeToolbarViewBottomLayoutConstraint, -// composeToolbarView.heightAnchor.constraint(equalToConstant: ComposeToolbarView.toolbarHeight), -// ]) -// composeToolbarView.preservesSuperviewLayoutMargins = true -// composeToolbarView.delegate = self -// -// composeToolbarBackgroundView.translatesAutoresizingMaskIntoConstraints = false -// view.insertSubview(composeToolbarBackgroundView, belowSubview: composeToolbarView) -// NSLayoutConstraint.activate([ -// composeToolbarBackgroundView.topAnchor.constraint(equalTo: composeToolbarView.topAnchor), -// composeToolbarBackgroundView.leadingAnchor.constraint(equalTo: composeToolbarView.leadingAnchor), -// composeToolbarBackgroundView.trailingAnchor.constraint(equalTo: composeToolbarView.trailingAnchor), -// view.bottomAnchor.constraint(equalTo: composeToolbarBackgroundView.bottomAnchor), -// ]) + // bind navigation bar style + // configureNavigationBarTitleStyle() + viewModel.traitCollectionDidChangePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.configureNavigationBarTitleStyle() + } + .store(in: &disposeBag) -// tableView.delegate = self -// viewModel.setupDataSource( -// tableView: tableView, -// metaTextDelegate: self, -// metaTextViewDelegate: self, -// customEmojiPickerInputViewModel: viewModel.customEmojiPickerInputViewModel, -// composeStatusAttachmentCollectionViewCellDelegate: self, -// composeStatusPollOptionCollectionViewCellDelegate: self, -// composeStatusPollOptionAppendEntryCollectionViewCellDelegate: self, -// composeStatusPollExpiresOptionCollectionViewCellDelegate: self -// ) + // bind title + viewModel.$title + .receive(on: DispatchQueue.main) + .sink { [weak self] title in + guard let self = self else { return } + self.title = title + } + .store(in: &disposeBag) -// viewModel.composeStatusAttribute.$composeContent -// .removeDuplicates() -// .receive(on: DispatchQueue.main) -// .sink { [weak self] _ in -// guard let self = self else { return } -// guard self.view.window != nil else { return } -// UIView.performWithoutAnimation { -// self.tableView.beginUpdates() -// self.tableView.setNeedsLayout() -// self.tableView.layoutIfNeeded() -// self.tableView.endUpdates() -// } -// } -// .store(in: &disposeBag) - -// customEmojiPickerInputView.collectionView.delegate = self -// viewModel.customEmojiPickerInputViewModel.customEmojiPickerInputView = customEmojiPickerInputView -// viewModel.setupCustomEmojiPickerDiffableDataSource( -// for: customEmojiPickerInputView.collectionView, -// dependency: self -// ) - -// viewModel.composeStatusContentTableViewCell.delegate = self -// -// // update layout when keyboard show/dismiss -// view.layoutIfNeeded() -// - -// -// // bind auto-complete -// viewModel.$autoCompleteInfo -// .receive(on: DispatchQueue.main) -// .sink { [weak self] info in -// guard let self = self else { return } -// let textEditorView = self.textEditorView -// if self.autoCompleteViewController.view.superview == nil { -// self.autoCompleteViewController.view.frame = self.view.bounds -// // add to container view. seealso: `viewDidLayoutSubviews()` -// self.viewModel.composeStatusContentTableViewCell.textEditorViewContainerView.addSubview(self.autoCompleteViewController.view) -// self.addChild(self.autoCompleteViewController) -// self.autoCompleteViewController.didMove(toParent: self) -// self.autoCompleteViewController.view.isHidden = true -// self.tableView.autoCompleteViewController = self.autoCompleteViewController -// } -// self.updateAutoCompleteViewControllerLayout() -// self.autoCompleteViewController.view.isHidden = info == nil -// guard let info = info else { return } -// let symbolBoundingRectInContainer = textEditorView.textView.convert(info.symbolBoundingRect, to: self.autoCompleteViewController.chevronView) -// self.autoCompleteViewController.view.frame.origin.y = info.textBoundingRect.maxY -// self.autoCompleteViewController.viewModel.symbolBoundingRect.value = symbolBoundingRectInContainer -// self.autoCompleteViewController.viewModel.inputText.value = String(info.inputText) -// } -// .store(in: &disposeBag) -// -// // bind publish bar button state -// viewModel.$isPublishBarButtonItemEnabled -// .receive(on: DispatchQueue.main) -// .assign(to: \.isEnabled, on: publishButton) -// .store(in: &disposeBag) -// -// // bind media button toolbar state -// viewModel.$isMediaToolbarButtonEnabled -// .receive(on: DispatchQueue.main) -// .sink { [weak self] isMediaToolbarButtonEnabled in -// guard let self = self else { return } -// self.composeToolbarView.mediaBarButtonItem.isEnabled = isMediaToolbarButtonEnabled -// self.composeToolbarView.mediaButton.isEnabled = isMediaToolbarButtonEnabled -// } -// .store(in: &disposeBag) -// -// // bind poll button toolbar state -// viewModel.$isPollToolbarButtonEnabled -// .receive(on: DispatchQueue.main) -// .sink { [weak self] isPollToolbarButtonEnabled in -// guard let self = self else { return } -// self.composeToolbarView.pollBarButtonItem.isEnabled = isPollToolbarButtonEnabled -// self.composeToolbarView.pollButton.isEnabled = isPollToolbarButtonEnabled -// } -// .store(in: &disposeBag) -// -// Publishers.CombineLatest( -// viewModel.$isPollComposing, -// viewModel.$isPollToolbarButtonEnabled -// ) -// .receive(on: DispatchQueue.main) -// .sink { [weak self] isPollComposing, isPollToolbarButtonEnabled in -// guard let self = self else { return } -// guard isPollToolbarButtonEnabled else { -// let accessibilityLabel = L10n.Scene.Compose.Accessibility.appendPoll -// self.composeToolbarView.pollBarButtonItem.accessibilityLabel = accessibilityLabel -// self.composeToolbarView.pollButton.accessibilityLabel = accessibilityLabel -// return -// } -// let accessibilityLabel = isPollComposing ? L10n.Scene.Compose.Accessibility.removePoll : L10n.Scene.Compose.Accessibility.appendPoll -// self.composeToolbarView.pollBarButtonItem.accessibilityLabel = accessibilityLabel -// self.composeToolbarView.pollButton.accessibilityLabel = accessibilityLabel -// } -// .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 } -// let isEnabled = attachmentServices.count < self.viewModel.maxMediaAttachments -// self.composeToolbarView.mediaBarButtonItem.isEnabled = isEnabled -// self.composeToolbarView.mediaButton.isEnabled = isEnabled -// self.resetImagePicker() -// } -// .store(in: &disposeBag) -// -// // bind content warning button state -// viewModel.$isContentWarningComposing -// .receive(on: DispatchQueue.main) -// .sink { [weak self] isContentWarningComposing in -// guard let self = self else { return } -// let accessibilityLabel = isContentWarningComposing ? L10n.Scene.Compose.Accessibility.disableContentWarning : L10n.Scene.Compose.Accessibility.enableContentWarning -// self.composeToolbarView.contentWarningBarButtonItem.accessibilityLabel = accessibilityLabel -// self.composeToolbarView.contentWarningButton.accessibilityLabel = accessibilityLabel -// } -// .store(in: &disposeBag) -// -// // bind visibility toolbar UI -// Publishers.CombineLatest( -// viewModel.$selectedStatusVisibility, -// viewModel.traitCollectionDidChangePublisher -// ) -// .receive(on: DispatchQueue.main) -// .sink { [weak self] type, _ in -// guard let self = self else { return } -// let image = type.image(interfaceStyle: self.traitCollection.userInterfaceStyle) -// self.composeToolbarView.visibilityBarButtonItem.image = image -// self.composeToolbarView.visibilityButton.setImage(image, for: .normal) -// self.composeToolbarView.activeVisibilityType.value = type -// } -// .store(in: &disposeBag) -// -// viewModel.$characterCount -// .receive(on: DispatchQueue.main) -// .sink { [weak self] characterCount in -// guard let self = self else { return } -// let count = self.viewModel.composeContentLimit - characterCount -// self.composeToolbarView.characterCountLabel.text = "\(count)" -// self.characterCountLabel.text = "\(count)" -// let font: UIFont -// let textColor: UIColor -// let accessibilityLabel: String -// switch count { -// case _ where count < 0: -// font = .monospacedDigitSystemFont(ofSize: 24, weight: .bold) -// textColor = Asset.Colors.danger.color -// accessibilityLabel = L10n.A11y.Plural.Count.inputLimitExceeds(abs(count)) -// default: -// font = .monospacedDigitSystemFont(ofSize: 15, weight: .regular) -// textColor = Asset.Colors.Label.secondary.color -// accessibilityLabel = L10n.A11y.Plural.Count.inputLimitRemains(count) -// } -// self.composeToolbarView.characterCountLabel.font = font -// self.composeToolbarView.characterCountLabel.textColor = textColor -// self.composeToolbarView.characterCountLabel.accessibilityLabel = accessibilityLabel -// self.characterCountLabel.font = font -// self.characterCountLabel.textColor = textColor -// self.characterCountLabel.accessibilityLabel = accessibilityLabel -// self.characterCountLabel.sizeToFit() -// } -// .store(in: &disposeBag) -// -// // bind custom emoji picker UI -// viewModel.customEmojiViewModel?.emojis -// .receive(on: DispatchQueue.main) -// .sink(receiveValue: { [weak self] emojis in -// guard let self = self else { return } -// if emojis.isEmpty { -// self.customEmojiPickerInputView.activityIndicatorView.startAnimating() -// } else { -// self.customEmojiPickerInputView.activityIndicatorView.stopAnimating() -// } -// }) -// .store(in: &disposeBag) -// -// configureToolbarDisplay(keyboardHasShortcutBar: keyboardHasShortcutBar.value) -// Publishers.CombineLatest( -// keyboardHasShortcutBar, -// viewModel.traitCollectionDidChangePublisher -// ) -// .receive(on: DispatchQueue.main) -// .sink { [weak self] keyboardHasShortcutBar, _ in -// guard let self = self else { return } -// self.configureToolbarDisplay(keyboardHasShortcutBar: keyboardHasShortcutBar) -// } -// .store(in: &disposeBag) - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - -// // update MetaText without trigger call underlaying `UITextStorage.processEditing` -// _ = textEditorView.processEditing(textEditorView.textStorage) - -// markTextEditorViewBecomeFirstResponser() - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - -// viewModel.isViewAppeared = true + // bind publish bar button state + composeContentViewModel.$isPublishBarButtonItemEnabled + .receive(on: DispatchQueue.main) + .assign(to: \.isEnabled, on: publishButton) + .store(in: &disposeBag) } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) -// configurePublishButtonApperance() -// viewModel.traitCollectionDidChangePublisher.send() - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - - updateAutoCompleteViewControllerLayout() - } - - private func updateAutoCompleteViewControllerLayout() { - // pin autoCompleteViewController frame to current view -// if let containerView = autoCompleteViewController.view.superview { -// let viewFrameInWindow = containerView.convert(autoCompleteViewController.view.frame, to: view) -// if viewFrameInWindow.origin.x != 0 { -// autoCompleteViewController.view.frame.origin.x = -viewFrameInWindow.origin.x -// } -// autoCompleteViewController.view.frame.size.width = view.frame.width -// } + configurePublishButtonApperance() + viewModel.traitCollectionDidChangePublisher.send() } } -//extension ComposeViewController { -// -// private var textEditorView: MetaText { -// return viewModel.composeStatusContentTableViewCell.metaText -// } -// -// private func markTextEditorViewBecomeFirstResponser() { -// textEditorView.textView.becomeFirstResponder() -// } -// -// private func contentWarningEditorTextView() -> UITextView? { -// viewModel.composeStatusContentTableViewCell.statusContentWarningEditorView.textView -// } -// -// private func pollOptionCollectionViewCell(of item: ComposeStatusPollItem) -> ComposeStatusPollOptionCollectionViewCell? { -// guard case .pollOption = item else { return nil } -// guard let dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return nil } -// guard let indexPath = dataSource.indexPath(for: item), -// let cell = viewModel.composeStatusPollTableViewCell.collectionView.cellForItem(at: indexPath) as? ComposeStatusPollOptionCollectionViewCell else { -// return nil -// } -// -// return cell -// } -// -// private func firstPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? { -// guard let dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return nil } -// let items = dataSource.snapshot().itemIdentifiers(inSection: .main) -// let firstPollItem = items.first { item -> Bool in -// guard case .pollOption = item else { return false } -// return true -// } -// -// guard let item = firstPollItem else { -// return nil -// } -// -// return pollOptionCollectionViewCell(of: item) -// } -// -// private func lastPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? { -// guard let dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return nil } -// let items = dataSource.snapshot().itemIdentifiers(inSection: .main) -// let lastPollItem = items.last { item -> Bool in -// guard case .pollOption = item else { return false } -// return true -// } -// -// guard let item = lastPollItem else { -// return nil -// } -// -// return pollOptionCollectionViewCell(of: item) -// } -// -// private func markFirstPollOptionCollectionViewCellBecomeFirstResponser() { -// guard let cell = firstPollOptionCollectionViewCell() else { return } -// cell.pollOptionView.optionTextField.becomeFirstResponder() -// } -// -// private func markLastPollOptionCollectionViewCellBecomeFirstResponser() { -// guard let cell = lastPollOptionCollectionViewCell() else { return } -// cell.pollOptionView.optionTextField.becomeFirstResponder() -// } -// -// private func showDismissConfirmAlertController() { -// let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) -// let discardAction = UIAlertAction(title: L10n.Common.Controls.Actions.discard, style: .destructive) { [weak self] _ in -// guard let self = self else { return } -// self.dismiss(animated: true, completion: nil) -// } -// alertController.addAction(discardAction) -// let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel) -// alertController.addAction(cancelAction) -// alertController.popoverPresentationController?.barButtonItem = cancelBarButtonItem -// present(alertController, animated: true, completion: nil) -// } -// -// private func resetImagePicker() { -// let selectionLimit = max(1, viewModel.maxMediaAttachments - viewModel.attachmentServices.count) -// let configuration = ComposeViewController.createPhotoLibraryPickerConfiguration(selectionLimit: selectionLimit) -// photoLibraryPicker = createImagePicker(configuration: configuration) -// } -// -// private func createImagePicker(configuration: PHPickerConfiguration) -> PHPickerViewController { -// let imagePicker = PHPickerViewController(configuration: configuration) -// imagePicker.delegate = self -// return imagePicker -// } -// +extension ComposeViewController { + + private func showDismissConfirmAlertController() { + let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + let discardAction = UIAlertAction(title: L10n.Common.Controls.Actions.discard, style: .destructive) { [weak self] _ in + guard let self = self else { return } + self.dismiss(animated: true, completion: nil) + } + alertController.addAction(discardAction) + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel) + alertController.addAction(cancelAction) + alertController.popoverPresentationController?.barButtonItem = cancelBarButtonItem + present(alertController, animated: true, completion: nil) + } + // private func setupBackgroundColor(theme: Theme) { // let backgroundColor = UIColor(dynamicProvider: { traitCollection in // switch traitCollection.userInterfaceStyle { @@ -578,46 +227,40 @@ extension ComposeViewController { // } // } // -// private func configureNavigationBarTitleStyle() { -// switch traitCollection.userInterfaceIdiom { -// case .pad: -// navigationController?.navigationBar.prefersLargeTitles = traitCollection.horizontalSizeClass == .regular -// default: -// break -// } -// } -// -//} -// + private func configureNavigationBarTitleStyle() { + switch traitCollection.userInterfaceIdiom { + case .pad: + navigationController?.navigationBar.prefersLargeTitles = traitCollection.horizontalSizeClass == .regular + default: + break + } + } + +} + extension ComposeViewController { @objc private func cancelBarButtonItemPressed(_ sender: UIBarButtonItem) { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") -// guard viewModel.shouldDismiss else { -// showDismissConfirmAlertController() -// return -// } + guard composeContentViewModel.shouldDismiss else { + showDismissConfirmAlertController() + return + } 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) -// do { -// try viewModel.checkAttachmentPrecondition() -// } catch { -// let alertController = UIAlertController(for: error, title: nil, preferredStyle: .alert) -// let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil) -// alertController.addAction(okAction) -// coordinator.present(scene: .alertController(alertController: alertController), from: nil, transition: .alertController(animated: true, completion: nil)) -// return -// } -// guard viewModel.publishStateMachine.enter(ComposeViewModel.PublishState.Publishing.self) else { -// // TODO: handle error -// return -// } - - // context.statusPublishService.publish(composeViewModel: viewModel) + do { + try composeContentViewModel.checkAttachmentPrecondition() + } catch { + let alertController = UIAlertController(for: error, title: nil, preferredStyle: .alert) + let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil) + alertController.addAction(okAction) + coordinator.present(scene: .alertController(alertController: alertController), from: nil, transition: .alertController(animated: true, completion: nil)) + return + } do { let statusPublisher = try composeContentViewModel.statusPublisher() @@ -640,249 +283,35 @@ extension ComposeViewController { } -//// MARK: - MetaTextDelegate -//extension ComposeViewController: MetaTextDelegate { -// func metaText(_ metaText: MetaText, processEditing textStorage: MetaTextStorage) -> MetaContent? { -// let string = metaText.textStorage.string -// let content = MastodonContent( -// content: string, -// emojis: viewModel.customEmojiViewModel?.emojiMapping.value ?? [:] -// ) -// let metaContent = MastodonMetaContent.convert(text: content) -// return metaContent -// } -//} -// -//// MARK: - UITextViewDelegate -//extension ComposeViewController: UITextViewDelegate { -// -// func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { -// setupInputAssistantItem(item: textView.inputAssistantItem) -// return true -// } -// -// func textViewDidChange(_ textView: UITextView) { -// switch textView { -// case textEditorView.textView: -// // update model -// let metaText = self.textEditorView -// let backedString = metaText.backedString -// viewModel.composeStatusAttribute.composeContent = backedString -// logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(backedString)") -// -// // configure auto completion -// setupAutoComplete(for: textView) -// default: -// assertionFailure() -// } -// } -// -// struct AutoCompleteInfo { -// // model -// let inputText: Substring -// // range -// let symbolRange: Range -// let symbolString: Substring -// let toCursorRange: Range -// let toCursorString: Substring -// let toHighlightEndRange: Range -// let toHighlightEndString: Substring -// // geometry -// var textBoundingRect: CGRect = .zero -// var symbolBoundingRect: CGRect = .zero -// } -// -// private func setupAutoComplete(for textView: UITextView) { -// guard var autoCompletion = ComposeViewController.scanAutoCompleteInfo(textView: textView) else { -// viewModel.autoCompleteInfo = nil -// return -// } -// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: auto complete %s (%s)", ((#file as NSString).lastPathComponent), #line, #function, String(autoCompletion.toHighlightEndString), String(autoCompletion.toCursorString)) -// -// // get layout text bounding rect -// var glyphRange = NSRange() -// textView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompletion.toCursorRange, in: textView.text), actualGlyphRange: &glyphRange) -// let textContainer = textView.layoutManager.textContainers[0] -// let textBoundingRect = textView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) -// -// let retryLayoutTimes = viewModel.autoCompleteRetryLayoutTimes -// guard textBoundingRect.size != .zero else { -// viewModel.autoCompleteRetryLayoutTimes += 1 -// // avoid infinite loop -// guard retryLayoutTimes < 3 else { return } -// // needs retry calculate layout when the rect position changing -// DispatchQueue.main.async { -// self.setupAutoComplete(for: textView) -// } -// return -// } -// viewModel.autoCompleteRetryLayoutTimes = 0 -// -// // get symbol bounding rect -// textView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompletion.symbolRange, in: textView.text), actualGlyphRange: &glyphRange) -// let symbolBoundingRect = textView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) -// -// // set bounding rect and trigger layout -// autoCompletion.textBoundingRect = textBoundingRect -// autoCompletion.symbolBoundingRect = symbolBoundingRect -// viewModel.autoCompleteInfo = autoCompletion -// } -// -// private static func scanAutoCompleteInfo(textView: UITextView) -> AutoCompleteInfo? { -// guard let text = textView.text, -// textView.selectedRange.location > 0, !text.isEmpty, -// let selectedRange = Range(textView.selectedRange, in: text) else { -// return nil -// } -// let cursorIndex = selectedRange.upperBound -// let _highlightStartIndex: String.Index? = { -// var index = text.index(before: cursorIndex) -// while index > text.startIndex { -// let char = text[index] -// if char == "@" || char == "#" || char == ":" { -// return index -// } -// index = text.index(before: index) -// } -// assert(index == text.startIndex) -// let char = text[index] -// if char == "@" || char == "#" || char == ":" { -// return index -// } else { -// return nil -// } -// }() -// -// guard let highlightStartIndex = _highlightStartIndex else { return nil } -// let scanRange = NSRange(highlightStartIndex..= cursorIndex else { return nil } -// let symbolRange = highlightStartIndex.. Bool { -// switch textView { -// case textEditorView.textView: -// return false -// default: -// return true -// } -// } -// -// func textView(_ textView: UITextView, shouldInteractWith textAttachment: NSTextAttachment, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { -// switch textView { -// case textEditorView.textView: -// return false -// default: -// return true -// } -// } -// -//} -// -//// MARK: - ComposeToolbarViewDelegate -//extension ComposeViewController: ComposeToolbarViewDelegate { +extension ComposeViewController { + public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + + // Enable pasting images + if (action == #selector(UIResponderStandardEditActions.paste(_:))) { + return UIPasteboard.general.hasStrings || UIPasteboard.general.hasImages; + } -// func composeToolbarView(_ composeToolbarView: ComposeToolbarView, pollButtonDidPressed sender: Any) { -// // toggle poll composing state -// viewModel.isPollComposing.toggle() -// -// // cancel custom picker input -// viewModel.isCustomEmojiComposing = false -// -// // setup initial poll option if needs -// if viewModel.isPollComposing, viewModel.pollOptionAttributes.isEmpty { -// viewModel.pollOptionAttributes = [ComposeStatusPollItem.PollOptionAttribute(), ComposeStatusPollItem.PollOptionAttribute()] -// } -// -// if viewModel.isPollComposing { -// // Magic RunLoop -// DispatchQueue.main.async { -// self.markFirstPollOptionCollectionViewCellBecomeFirstResponser() -// } -// } else { -// markTextEditorViewBecomeFirstResponser() -// } -// } -// -// func composeToolbarView(_ composeToolbarView: ComposeToolbarView, emojiButtonDidPressed sender: Any) { -// viewModel.isCustomEmojiComposing.toggle() -// } -// -// func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: Any) { -// // cancel custom picker input -// viewModel.isCustomEmojiComposing = false -// -// // restore first responder for text editor when content warning dismiss -// if viewModel.isContentWarningComposing { -// if contentWarningEditorTextView()?.isFirstResponder == true { -// markTextEditorViewBecomeFirstResponser() -// } -// } -// -// // toggle composing status -// viewModel.isContentWarningComposing.toggle() -// -// // active content warning after toggled -// if viewModel.isContentWarningComposing { -// contentWarningEditorTextView()?.becomeFirstResponder() -// } -// } -// -// func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: Any, visibilitySelectionType type: ComposeToolbarView.VisibilitySelectionType) { -// viewModel.selectedStatusVisibility = type -// } -// -//} + return super.canPerformAction(action, withSender: sender); + } + + override func paste(_ sender: Any?) { + logger.debug("Paste event received") -//// MARK: - UITableViewDelegate -//extension ComposeViewController: UITableViewDelegate { } -// -//// MARK: - UICollectionViewDelegate -//extension ComposeViewController: UICollectionViewDelegate { -// -// func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { -// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: select %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription) -// -// if collectionView === customEmojiPickerInputView.collectionView { -// guard let diffableDataSource = viewModel.customEmojiPickerDiffableDataSource else { return } -// let item = diffableDataSource.itemIdentifier(for: indexPath) -// guard case let .emoji(attribute) = item else { return } -// let emoji = attribute.emoji -// -// // make click sound -// UIDevice.current.playInputClick() -// -// // retrieve active text input and insert emoji -// // the trailing space is REQUIRED to make regex happy -// _ = viewModel.customEmojiPickerInputViewModel.insertText(":\(emoji.shortcode): ") -// } else { -// // do nothing -// } -// } -//} + // Look for images on the clipboard + if UIPasteboard.general.hasImages, let images = UIPasteboard.general.images { + logger.warning("Got image paste event, however attachments are not yet re-implemented."); + let attachmentViewModels = images.map { image in + return AttachmentViewModel( + api: viewModel.context.apiService, + authContext: viewModel.authContext, + input: .image(image), + delegate: composeContentViewModel + ) + } + composeContentViewModel.attachmentViewModels += attachmentViewModels + } + } +} // MARK: - UIAdaptivePresentationControllerDelegate extension ComposeViewController: UIAdaptivePresentationControllerDelegate { @@ -895,15 +324,15 @@ extension ComposeViewController: UIAdaptivePresentationControllerDelegate { return .pageSheet } } - -// func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { -// return viewModel.shouldDismiss -// } -// func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { -// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) -// showDismissConfirmAlertController() -// } + func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { + return composeContentViewModel.shouldDismiss + } + + func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + showDismissConfirmAlertController() + } func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) @@ -911,180 +340,6 @@ extension ComposeViewController: UIAdaptivePresentationControllerDelegate { } -//// MARK: - ComposeStatusAttachmentTableViewCellDelegate -//extension ComposeViewController: ComposeStatusAttachmentCollectionViewCellDelegate { -// -// func composeStatusAttachmentCollectionViewCell(_ cell: ComposeStatusAttachmentCollectionViewCell, removeButtonDidPressed button: UIButton) { -// guard let diffableDataSource = viewModel.composeStatusAttachmentTableViewCell.dataSource else { return } -// guard let indexPath = viewModel.composeStatusAttachmentTableViewCell.collectionView.indexPath(for: cell) else { return } -// guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } -// guard case let .attachment(attachmentService) = item else { return } -// -// var attachmentServices = viewModel.attachmentServices -// guard let index = attachmentServices.firstIndex(of: attachmentService) else { return } -// let removedItem = attachmentServices[index] -// attachmentServices.remove(at: index) -// viewModel.attachmentServices = attachmentServices -// -// // cancel task -// removedItem.disposeBag.removeAll() -// } -// -//} -// -//// MARK: - ComposeStatusPollOptionCollectionViewCellDelegate -//extension ComposeViewController: ComposeStatusPollOptionCollectionViewCellDelegate { -// -// func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textFieldDidBeginEditing textField: UITextField) { -// -// setupInputAssistantItem(item: textField.inputAssistantItem) -// -// // FIXME: make poll section visible -// // DispatchQueue.main.async { -// // self.collectionView.scroll(to: .bottom, animated: true) -// // } -// } -// -// -// // handle delete backward event for poll option input -// func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textBeforeDeleteBackward text: String?) { -// guard (text ?? "").isEmpty else { return } -// guard let dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return } -// guard let indexPath = viewModel.composeStatusPollTableViewCell.collectionView.indexPath(for: cell) else { return } -// guard let item = dataSource.itemIdentifier(for: indexPath) else { return } -// guard case let .pollOption(attribute) = item else { return } -// -// var pollAttributes = viewModel.pollOptionAttributes -// guard let index = pollAttributes.firstIndex(of: attribute) else { return } -// -// // mark previous (fallback to next) item of removed middle poll option become first responder -// let pollItems = dataSource.snapshot().itemIdentifiers(inSection: .main) -// if let indexOfItem = pollItems.firstIndex(of: item), index > 0 { -// func cellBeforeRemoved() -> ComposeStatusPollOptionCollectionViewCell? { -// guard index > 0 else { return nil } -// let indexBeforeRemoved = pollItems.index(before: indexOfItem) -// let itemBeforeRemoved = pollItems[indexBeforeRemoved] -// return pollOptionCollectionViewCell(of: itemBeforeRemoved) -// } -// -// func cellAfterRemoved() -> ComposeStatusPollOptionCollectionViewCell? { -// guard index < pollItems.count - 1 else { return nil } -// let indexAfterRemoved = pollItems.index(after: index) -// let itemAfterRemoved = pollItems[indexAfterRemoved] -// return pollOptionCollectionViewCell(of: itemAfterRemoved) -// } -// -// var cell: ComposeStatusPollOptionCollectionViewCell? = cellBeforeRemoved() -// if cell == nil { -// cell = cellAfterRemoved() -// } -// cell?.pollOptionView.optionTextField.becomeFirstResponder() -// } -// -// guard pollAttributes.count > 2 else { -// return -// } -// pollAttributes.remove(at: index) -// -// // update data source -// viewModel.pollOptionAttributes = pollAttributes -// } -// -// // handle keyboard return event for poll option input -// func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, pollOptionTextFieldDidReturn: UITextField) { -// guard let dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return } -// guard let indexPath = viewModel.composeStatusPollTableViewCell.collectionView.indexPath(for: cell) else { return } -// let pollItems = dataSource.snapshot().itemIdentifiers(inSection: .main).filter { item in -// guard case .pollOption = item else { return false } -// return true -// } -// guard let item = dataSource.itemIdentifier(for: indexPath) else { return } -// guard let index = pollItems.firstIndex(of: item) else { return } -// -// if index == pollItems.count - 1 { -// // is the last -// viewModel.createNewPollOptionIfPossible() -// DispatchQueue.main.async { -// self.markLastPollOptionCollectionViewCellBecomeFirstResponser() -// } -// } else { -// // not the last -// let indexAfter = pollItems.index(after: index) -// let itemAfter = pollItems[indexAfter] -// let cell = pollOptionCollectionViewCell(of: itemAfter) -// cell?.pollOptionView.optionTextField.becomeFirstResponder() -// } -// } -// -//} -// -//// MARK: - ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate -//extension ComposeViewController: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate { -// func composeStatusPollOptionAppendEntryCollectionViewCellDidPressed(_ cell: ComposeStatusPollOptionAppendEntryCollectionViewCell) { -// viewModel.createNewPollOptionIfPossible() -// DispatchQueue.main.async { -// self.markLastPollOptionCollectionViewCellBecomeFirstResponser() -// } -// } -//} -// -//// MARK: - ComposeStatusPollExpiresOptionCollectionViewCellDelegate -//extension ComposeViewController: ComposeStatusPollExpiresOptionCollectionViewCellDelegate { -// func composeStatusPollExpiresOptionCollectionViewCell(_ cell: ComposeStatusPollExpiresOptionCollectionViewCell, didSelectExpiresOption expiresOption: ComposeStatusPollItem.PollExpiresOptionAttribute.ExpiresOption) { -// viewModel.pollExpiresOptionAttribute.expiresOption.value = expiresOption -// } -//} -// -//// MARK: - ComposeStatusContentTableViewCellDelegate -//extension ComposeViewController: ComposeStatusContentTableViewCellDelegate { -// func composeStatusContentTableViewCell(_ cell: ComposeStatusContentTableViewCell, textViewShouldBeginEditing textView: UITextView) -> Bool { -// setupInputAssistantItem(item: textView.inputAssistantItem) -// return true -// } -//} -// -//// MARK: - AutoCompleteViewControllerDelegate -//extension ComposeViewController: AutoCompleteViewControllerDelegate { -// func autoCompleteViewController(_ viewController: AutoCompleteViewController, didSelectItem item: AutoCompleteItem) { -// guard let info = viewModel.autoCompleteInfo else { return } -// let _replacedText: String? = { -// var text: String -// switch item { -// case .hashtag(let hashtag): -// text = "#" + hashtag.name -// case .hashtagV1(let hashtagName): -// text = "#" + hashtagName -// case .account(let account): -// text = "@" + account.acct -// case .emoji(let emoji): -// text = ":" + emoji.shortcode + ":" -// case .bottomLoader: -// return nil -// } -// return text -// }() -// guard let replacedText = _replacedText else { return } -// guard let text = textEditorView.textView.text else { return } -// -// let range = NSRange(info.toHighlightEndRange, in: text) -// textEditorView.textStorage.replaceCharacters(in: range, with: replacedText) -// DispatchQueue.main.async { -// self.textEditorView.textView.insertText(" ") // trigger textView delegate update -// } -// viewModel.autoCompleteInfo = nil -// -// switch item { -// case .emoji, .bottomLoader: -// break -// default: -// // set selected range except emoji -// let newRange = NSRange(location: range.location + (replacedText as NSString).length, length: 0) -// guard textEditorView.textStorage.length <= newRange.location else { return } -// textEditorView.textView.selectedRange = newRange -// } -// } -//} -// //extension ComposeViewController { // override var keyCommands: [UIKeyCommand]? { // composeKeyCommands diff --git a/Mastodon/Scene/Compose/ComposeViewModel+DataSource.swift b/Mastodon/Scene/Compose/ComposeViewModel+DataSource.swift deleted file mode 100644 index b3d8f52dc..000000000 --- a/Mastodon/Scene/Compose/ComposeViewModel+DataSource.swift +++ /dev/null @@ -1,490 +0,0 @@ -// -// ComposeViewModel+Diffable.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-11. -// - -import os.log -import UIKit -import Combine -import CoreDataStack -import MetaTextKit -import MastodonMeta -import MastodonAsset -import MastodonCore -import MastodonLocalization -import MastodonSDK - -extension ComposeViewModel { - -// func setupDataSource( -// tableView: UITableView, -// metaTextDelegate: MetaTextDelegate, -// metaTextViewDelegate: UITextViewDelegate, -// customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel, -// composeStatusAttachmentCollectionViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate, -// composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate, -// composeStatusPollOptionAppendEntryCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate, -// composeStatusPollExpiresOptionCollectionViewCellDelegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate -// ) { -// // UI -// bind() -// -// // content -// bind(cell: composeStatusContentTableViewCell, tableView: tableView) -// composeStatusContentTableViewCell.metaText.delegate = metaTextDelegate -// composeStatusContentTableViewCell.metaText.textView.delegate = metaTextViewDelegate -// -// // attachment -// bind(cell: composeStatusAttachmentTableViewCell, tableView: tableView) -// composeStatusAttachmentTableViewCell.composeStatusAttachmentCollectionViewCellDelegate = composeStatusAttachmentCollectionViewCellDelegate -// -// // poll -// bind(cell: composeStatusPollTableViewCell, tableView: tableView) -// composeStatusPollTableViewCell.delegate = self -// composeStatusPollTableViewCell.customEmojiPickerInputViewModel = customEmojiPickerInputViewModel -// composeStatusPollTableViewCell.composeStatusPollOptionCollectionViewCellDelegate = composeStatusPollOptionCollectionViewCellDelegate -// composeStatusPollTableViewCell.composeStatusPollOptionAppendEntryCollectionViewCellDelegate = composeStatusPollOptionAppendEntryCollectionViewCellDelegate -// composeStatusPollTableViewCell.composeStatusPollExpiresOptionCollectionViewCellDelegate = composeStatusPollExpiresOptionCollectionViewCellDelegate -// -// // setup data source -// tableView.dataSource = self -// } -// -// func setupCustomEmojiPickerDiffableDataSource( -// for collectionView: UICollectionView, -// dependency: NeedsDependency -// ) { -// let diffableDataSource = CustomEmojiPickerSection.collectionViewDiffableDataSource( -// for: collectionView, -// dependency: dependency -// ) -// self.customEmojiPickerDiffableDataSource = diffableDataSource -// -// let _domain = customEmojiViewModel?.domain -// customEmojiViewModel?.emojis -// .receive(on: DispatchQueue.main) -// .sink { [weak self, weak diffableDataSource] emojis in -// guard let _ = self else { return } -// guard let diffableDataSource = diffableDataSource else { return } -// -// var snapshot = NSDiffableDataSourceSnapshot() -// let domain = _domain?.uppercased() ?? " " -// let customEmojiSection = CustomEmojiPickerSection.emoji(name: domain) -// snapshot.appendSections([customEmojiSection]) -// let items: [CustomEmojiPickerItem] = { -// var items = [CustomEmojiPickerItem]() -// for emoji in emojis where emoji.visibleInPicker { -// let attribute = CustomEmojiPickerItem.CustomEmojiAttribute(emoji: emoji) -// let item = CustomEmojiPickerItem.emoji(attribute: attribute) -// items.append(item) -// } -// return items -// }() -// snapshot.appendItems(items, toSection: customEmojiSection) -// -// diffableDataSource.apply(snapshot) -// } -// .store(in: &disposeBag) -// } - -} - -//// MARK: - UITableViewDataSource -//extension ComposeViewModel: UITableViewDataSource { - -// func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { -// switch Section.allCases[indexPath.section] { -// case .repliedTo: -// let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToStatusContentTableViewCell.self), for: indexPath) as! ComposeRepliedToStatusContentTableViewCell -// guard case let .reply(record) = composeKind else { return cell } -// -// // bind frame publisher -// cell.framePublisher -// .receive(on: DispatchQueue.main) -// .assign(to: \.repliedToCellFrame, on: self) -// .store(in: &cell.disposeBag) -// -// // set initial width -// if cell.statusView.frame.width == .zero { -// cell.statusView.frame.size.width = tableView.frame.width -// } -// -// // configure status -// context.managedObjectContext.performAndWait { -// guard let replyTo = record.object(in: context.managedObjectContext) else { return } -// cell.statusView.configure(status: replyTo) -// } -// -// return cell -// case .status: -// return composeStatusContentTableViewCell -// case .attachment: -// return composeStatusAttachmentTableViewCell -// case .poll: -// return composeStatusPollTableViewCell -// } -// } -//} - -//// MARK: - ComposeStatusPollTableViewCellDelegate -//extension ComposeViewModel: ComposeStatusPollTableViewCellDelegate { -// func composeStatusPollTableViewCell(_ cell: ComposeStatusPollTableViewCell, pollOptionAttributesDidReorder options: [ComposeStatusPollItem.PollOptionAttribute]) { -// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) -// -// self.pollOptionAttributes = options -// } -//} -// -//extension ComposeViewModel { -// private func bind() { -// $isCustomEmojiComposing -// .assign(to: \.value, on: customEmojiPickerInputViewModel.isCustomEmojiComposing) -// .store(in: &disposeBag) -// -// $isContentWarningComposing -// .assign(to: \.isContentWarningComposing, on: composeStatusAttribute) -// .store(in: &disposeBag) -// -// // bind compose toolbar UI state -// Publishers.CombineLatest( -// $isPollComposing, -// $attachmentServices -// ) -// .receive(on: DispatchQueue.main) -// .sink(receiveValue: { [weak self] isPollComposing, attachmentServices in -// guard let self = self else { return } -// let shouldMediaDisable = isPollComposing || attachmentServices.count >= self.maxMediaAttachments -// let shouldPollDisable = attachmentServices.count > 0 -// -// self.isMediaToolbarButtonEnabled = !shouldMediaDisable -// self.isPollToolbarButtonEnabled = !shouldPollDisable -// }) -// .store(in: &disposeBag) -// -// // calculate `Idempotency-Key` -// let content = Publishers.CombineLatest3( -// composeStatusAttribute.$isContentWarningComposing, -// composeStatusAttribute.$contentWarningContent, -// composeStatusAttribute.$composeContent -// ) -// .map { isContentWarningComposing, contentWarningContent, composeContent -> String in -// if isContentWarningComposing { -// return contentWarningContent + (composeContent ?? "") -// } else { -// return composeContent ?? "" -// } -// } -// let attachmentIDs = $attachmentServices.map { attachments -> String in -// let attachmentIDs = attachments.compactMap { $0.attachment.value?.id } -// return attachmentIDs.joined(separator: ",") -// } -// let pollOptionsAndDuration = Publishers.CombineLatest3( -// $isPollComposing, -// $pollOptionAttributes, -// pollExpiresOptionAttribute.expiresOption -// ) -// .map { isPollComposing, pollOptionAttributes, expiresOption -> String in -// guard isPollComposing else { -// return "" -// } -// -// let pollOptions = pollOptionAttributes.map { $0.option.value }.joined(separator: ",") -// return pollOptions + expiresOption.rawValue -// } -// -// Publishers.CombineLatest4( -// content, -// attachmentIDs, -// pollOptionsAndDuration, -// $selectedStatusVisibility -// ) -// .map { content, attachmentIDs, pollOptionsAndDuration, selectedStatusVisibility -> String in -// var hasher = Hasher() -// hasher.combine(content) -// hasher.combine(attachmentIDs) -// hasher.combine(pollOptionsAndDuration) -// hasher.combine(selectedStatusVisibility.visibility.rawValue) -// let hashValue = hasher.finalize() -// return "\(hashValue)" -// } -// .assign(to: \.value, on: idempotencyKey) -// .store(in: &disposeBag) -// -// // bind modal dismiss state -// composeStatusAttribute.$composeContent -// .receive(on: DispatchQueue.main) -// .map { [weak self] content in -// let content = content ?? "" -// if content.isEmpty { -// return true -// } -// // if preInsertedContent plus a space is equal to the content, simply dismiss the modal -// if let preInsertedContent = self?.preInsertedContent { -// return content == preInsertedContent -// } -// return false -// } -// .assign(to: &$shouldDismiss) -// -// // bind compose bar button item UI state -// let isComposeContentEmpty = composeStatusAttribute.$composeContent -// .map { ($0 ?? "").isEmpty } -// let isComposeContentValid = $characterCount -// .compactMap { [weak self] characterCount -> Bool in -// guard let self = self else { return characterCount <= 500 } -// return characterCount <= self.composeContentLimit -// } -// let isMediaEmpty = $attachmentServices -// .map { $0.isEmpty } -// let isMediaUploadAllSuccess = $attachmentServices -// .map { services in -// services.allSatisfy { $0.uploadStateMachineSubject.value is MastodonAttachmentService.UploadState.Finish } -// } -// let isPollAttributeAllValid = $pollOptionAttributes -// .map { pollAttributes in -// pollAttributes.allSatisfy { attribute -> Bool in -// !attribute.option.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty -// } -// } -// -// let isPublishBarButtonItemEnabledPrecondition1 = Publishers.CombineLatest4( -// isComposeContentEmpty, -// isComposeContentValid, -// isMediaEmpty, -// isMediaUploadAllSuccess -// ) -// .map { isComposeContentEmpty, isComposeContentValid, isMediaEmpty, isMediaUploadAllSuccess -> Bool in -// if isMediaEmpty { -// return isComposeContentValid && !isComposeContentEmpty -// } else { -// return isComposeContentValid && isMediaUploadAllSuccess -// } -// } -// .eraseToAnyPublisher() -// -// let isPublishBarButtonItemEnabledPrecondition2 = Publishers.CombineLatest4( -// isComposeContentEmpty, -// isComposeContentValid, -// $isPollComposing, -// isPollAttributeAllValid -// ) -// .map { isComposeContentEmpty, isComposeContentValid, isPollComposing, isPollAttributeAllValid -> Bool in -// if isPollComposing { -// return isComposeContentValid && !isComposeContentEmpty && isPollAttributeAllValid -// } else { -// return isComposeContentValid && !isComposeContentEmpty -// } -// } -// .eraseToAnyPublisher() -// -// Publishers.CombineLatest( -// isPublishBarButtonItemEnabledPrecondition1, -// isPublishBarButtonItemEnabledPrecondition2 -// ) -// .map { $0 && $1 } -// .assign(to: &$isPublishBarButtonItemEnabled) -// } -//} -// -//extension ComposeViewModel { -// private func bind( -// cell: ComposeStatusContentTableViewCell, -// tableView: UITableView -// ) { -// // bind status content character count -// Publishers.CombineLatest3( -// composeStatusAttribute.$composeContent, -// composeStatusAttribute.$isContentWarningComposing, -// composeStatusAttribute.$contentWarningContent -// ) -// .map { composeContent, isContentWarningComposing, contentWarningContent -> Int in -// let composeContent = composeContent ?? "" -// var count = composeContent.count -// if isContentWarningComposing { -// count += contentWarningContent.count -// } -// return count -// } -// .assign(to: &$characterCount) -// -// // bind content warning -// composeStatusAttribute.$isContentWarningComposing -// .receive(on: DispatchQueue.main) -// .sink { [weak cell, weak tableView] isContentWarningComposing in -// guard let cell = cell else { return } -// guard let tableView = tableView else { return } -// -// // self size input cell -// cell.statusContentWarningEditorView.isHidden = !isContentWarningComposing -// cell.statusContentWarningEditorView.alpha = 0 -// UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseOut]) { -// cell.statusContentWarningEditorView.alpha = 1 -// tableView.beginUpdates() -// tableView.endUpdates() -// } completion: { _ in -// // do nothing -// } -// } -// .store(in: &disposeBag) -// -// cell.contentWarningContent -// .removeDuplicates() -// .receive(on: DispatchQueue.main) -// .sink { [weak tableView, weak self] text in -// guard let self = self else { return } -// // bind input data -// self.composeStatusAttribute.contentWarningContent = text -// -// // self size input cell -// guard let tableView = tableView else { return } -// UIView.performWithoutAnimation { -// tableView.beginUpdates() -// tableView.endUpdates() -// } -// } -// .store(in: &cell.disposeBag) -// -// // configure custom emoji picker -// ComposeStatusSection.configureCustomEmojiPicker( -// viewModel: customEmojiPickerInputViewModel, -// customEmojiReplaceableTextInput: cell.metaText.textView, -// disposeBag: &disposeBag -// ) -// ComposeStatusSection.configureCustomEmojiPicker( -// viewModel: customEmojiPickerInputViewModel, -// customEmojiReplaceableTextInput: cell.statusContentWarningEditorView.textView, -// disposeBag: &disposeBag -// ) -// } -//} -// -//extension ComposeViewModel { -// private func bind( -// cell: ComposeStatusPollTableViewCell, -// tableView: UITableView -// ) { -// Publishers.CombineLatest( -// $isPollComposing, -// $pollOptionAttributes -// ) -// .receive(on: DispatchQueue.main) -// .sink { [weak self] isPollComposing, pollOptionAttributes in -// guard let self = self else { return } -// guard self.isViewAppeared else { return } -// -// let cell = self.composeStatusPollTableViewCell -// guard let dataSource = cell.dataSource else { return } -// -// var snapshot = NSDiffableDataSourceSnapshot() -// snapshot.appendSections([.main]) -// var items: [ComposeStatusPollItem] = [] -// if isPollComposing { -// for attribute in pollOptionAttributes { -// items.append(.pollOption(attribute: attribute)) -// } -// if pollOptionAttributes.count < self.maxPollOptions { -// items.append(.pollOptionAppendEntry) -// } -// items.append(.pollExpiresOption(attribute: self.pollExpiresOptionAttribute)) -// } -// snapshot.appendItems(items, toSection: .main) -// -// tableView.performBatchUpdates { -// if #available(iOS 15.0, *) { -// dataSource.apply(snapshot, animatingDifferences: false) -// } else { -// dataSource.apply(snapshot, animatingDifferences: true) -// } -// } -// } -// .store(in: &disposeBag) -// -// // bind delegate -// $pollOptionAttributes -// .sink { [weak self] pollAttributes in -// guard let self = self else { return } -// pollAttributes.forEach { $0.delegate = self } -// } -// .store(in: &disposeBag) -// } -//} -// -//extension ComposeViewModel { -// private func bind( -// cell: ComposeStatusAttachmentTableViewCell, -// tableView: UITableView -// ) { -// cell.collectionViewHeightDidUpdate -// .receive(on: DispatchQueue.main) -// .sink { [weak self] _ in -// guard let _ = self else { return } -// tableView.beginUpdates() -// tableView.endUpdates() -// } -// .store(in: &disposeBag) -// -// $attachmentServices -// .removeDuplicates() -// .receive(on: DispatchQueue.main) -// .sink { [weak self] attachmentServices in -// guard let self = self else { return } -// guard self.isViewAppeared else { return } -// -// let cell = self.composeStatusAttachmentTableViewCell -// guard let dataSource = cell.dataSource else { return } -// -// var snapshot = NSDiffableDataSourceSnapshot() -// snapshot.appendSections([.main]) -// let items = attachmentServices.map { ComposeStatusAttachmentItem.attachment(attachmentService: $0) } -// snapshot.appendItems(items, toSection: .main) -// -// if #available(iOS 15.0, *) { -// dataSource.applySnapshotUsingReloadData(snapshot) -// } else { -// dataSource.apply(snapshot, animatingDifferences: false) -// } -// } -// .store(in: &disposeBag) -// -// // setup attribute updater -// $attachmentServices -// .receive(on: DispatchQueue.main) -// .debounce(for: 0.3, scheduler: DispatchQueue.main) -// .sink { attachmentServices in -// // drive service upload state -// // make image upload in the queue -// for attachmentService in attachmentServices { -// // skip when prefix N task when task finish OR fail OR uploading -// guard let currentState = attachmentService.uploadStateMachine.currentState else { break } -// if currentState is MastodonAttachmentService.UploadState.Fail { -// continue -// } -// if currentState is MastodonAttachmentService.UploadState.Finish { -// continue -// } -// if currentState is MastodonAttachmentService.UploadState.Processing { -// 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) -// -// // bind delegate -// $attachmentServices -// .sink { [weak self] attachmentServices in -// guard let self = self else { return } -// attachmentServices.forEach { $0.delegate = self } -// } -// .store(in: &disposeBag) -// } -//} diff --git a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift deleted file mode 100644 index b9ed18c45..000000000 --- a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift +++ /dev/null @@ -1,164 +0,0 @@ -// -// ComposeViewModel+PublishState.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-18. -// - -import os.log -import Foundation -import Combine -import CoreDataStack -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) -// viewModel?.publishStateMachinePublisher.value = self -// } -// } -//} - -//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 } -// -// viewModel.updatePublishDate() -// -// let authenticationBox = viewModel.authenticationBox -// let domain = authenticationBox.domain -// let attachmentServices = viewModel.attachmentServices -// let mediaIDs = attachmentServices.compactMap { attachmentService in -// attachmentService.attachment.value?.id -// } -// let pollOptions: [String]? = { -// guard viewModel.isPollComposing else { return nil } -// return viewModel.pollOptionAttributes.map { attribute in attribute.option.value } -// }() -// let pollExpiresIn: Int? = { -// guard viewModel.isPollComposing else { return nil } -// return viewModel.pollExpiresOptionAttribute.expiresOption.value.seconds -// }() -// let inReplyToID: Mastodon.Entity.Status.ID? = { -// guard case let .reply(status) = viewModel.composeKind else { return nil } -// var id: Mastodon.Entity.Status.ID? -// viewModel.context.managedObjectContext.performAndWait { -// guard let replyTo = status.object(in: viewModel.context.managedObjectContext) else { return } -// id = replyTo.id -// } -// return id -// }() -// let sensitive: Bool = viewModel.isContentWarningComposing -// let spoilerText: String? = { -// let text = viewModel.composeStatusAttribute.contentWarningContent.trimmingCharacters(in: .whitespacesAndNewlines) -// guard !text.isEmpty else { -// return nil -// } -// return text -// }() -// let visibility = viewModel.selectedStatusVisibility.visibility -// -// 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: authenticationBox -// ) -// subscriptions.append(subscription) -// } -// return subscriptions -// }() -// -// let idempotencyKey = viewModel.idempotencyKey.value -// -// publishingSubscription = Publishers.MergeMany(updateMediaQuerySubscriptions) -// .collect() -// .asyncMap { attachments -> Mastodon.Response.Content in -// let query = Mastodon.API.Statuses.PublishStatusQuery( -// status: viewModel.composeStatusAttribute.composeContent, -// mediaIDs: mediaIDs.isEmpty ? nil : mediaIDs, -// pollOptions: pollOptions, -// pollExpiresIn: pollExpiresIn, -// inReplyToID: inReplyToID, -// sensitive: sensitive, -// spoilerText: spoilerText, -// visibility: visibility -// ) -// return try await viewModel.context.apiService.publishStatus( -// domain: domain, -// idempotencyKey: idempotencyKey, -// query: query, -// authenticationBox: 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: 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 == Discard.self -// } -// } -// -// class Discard: ComposeViewModel.PublishState { -// override func isValidNextState(_ stateClass: AnyClass) -> Bool { -// return false -// } -// } -// -// 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 45c9f1e93..bf234b095 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -18,7 +18,7 @@ import MastodonLocalization import MastodonMeta import MastodonUI -final class ComposeViewModel: NSObject { +final class ComposeViewModel { let logger = Logger(subsystem: "ComposeViewModel", category: "ViewModel") @@ -30,91 +30,13 @@ final class ComposeViewModel: NSObject { let context: AppContext let authContext: AuthContext let kind: ComposeContentViewModel.Kind - -// var authenticationBox: MastodonAuthenticationBox { -// authContext.mastodonAuthenticationBox -// } -// -// @Published var isPollComposing = false -// @Published var isCustomEmojiComposing = false -// @Published var isContentWarningComposing = false -// -// @Published var selectedStatusVisibility: ComposeToolbarView.VisibilitySelectionType -// @Published var repliedToCellFrame: CGRect = .zero -// @Published var autoCompleteRetryLayoutTimes = 0 -// @Published var autoCompleteInfo: ComposeViewController.AutoCompleteInfo? = nil let traitCollectionDidChangePublisher = CurrentValueSubject(Void()) // use CurrentValueSubject to make initial event emit -// var isViewAppeared = false // output -// let instanceConfiguration: Mastodon.Entity.Instance.Configuration? -// var composeContentLimit: Int { -// guard let maxCharacters = instanceConfiguration?.statuses?.maxCharacters else { return 500 } -// return max(1, maxCharacters) -// } -// var maxMediaAttachments: Int { -// guard let maxMediaAttachments = instanceConfiguration?.statuses?.maxMediaAttachments else { -// return 4 -// } -// // FIXME: update timeline media preview UI -// return min(4, max(1, maxMediaAttachments)) -// // return max(1, maxMediaAttachments) -// } -// var maxPollOptions: Int { -// guard let maxOptions = instanceConfiguration?.polls?.maxOptions else { return 4 } -// return max(2, maxOptions) -// } -// -// let composeStatusAttribute = ComposeStatusItem.ComposeStatusAttribute() -// let composeStatusContentTableViewCell = ComposeStatusContentTableViewCell() -// let composeStatusAttachmentTableViewCell = ComposeStatusAttachmentTableViewCell() -// let composeStatusPollTableViewCell = ComposeStatusPollTableViewCell() -// -// // var dataSource: UITableViewDiffableDataSource? -// var customEmojiPickerDiffableDataSource: 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.Discard(viewModel: self), -// PublishState.Finish(viewModel: self), -// ]) -// stateMachine.enter(PublishState.Initial.self) -// return stateMachine -// }() -// private(set) lazy var publishStateMachinePublisher = CurrentValueSubject(nil) -// private(set) var publishDate = Date() // update it when enter Publishing state -// -// // TODO: group post material into Hashable class -// var idempotencyKey = CurrentValueSubject(UUID().uuidString) -// -// // UI & UX -// @Published var title: String -// @Published var shouldDismiss = true -// @Published var isPublishBarButtonItemEnabled = false -// @Published var isMediaToolbarButtonEnabled = true -// @Published var isPollToolbarButtonEnabled = true -// @Published var characterCount = 0 -// @Published var collectionViewState: CollectionViewState = .fold -// -// // for hashtag: "# " -// // for mention: "@ " -// var preInsertedContent: String? -// -// // custom emojis -// let customEmojiViewModel: EmojiService.CustomEmojiViewModel? -// let customEmojiPickerInputViewModel = CustomEmojiPickerInputViewModel() -// @Published var isLoadingCustomEmoji = false -// -// // attachment -// @Published var attachmentServices: [MastodonAttachmentService] = [] -// -// // polls -// @Published var pollOptionAttributes: [ComposeStatusPollItem.PollOptionAttribute] = [] -// let pollExpiresOptionAttribute = ComposeStatusPollItem.PollExpiresOptionAttribute() + + // UI & UX + @Published var title: String init( context: AppContext, @@ -124,63 +46,14 @@ final class ComposeViewModel: NSObject { self.context = context self.authContext = authContext self.kind = kind + // end init -// self.title = { -// switch composeKind { -// case .post, .hashtag, .mention: return L10n.Scene.Compose.Title.newPost -// case .reply: return L10n.Scene.Compose.Title.newReply -// } -// }() -// self.selectedStatusVisibility = { -// // default private when user locked -// var visibility: ComposeToolbarView.VisibilitySelectionType = { -// guard let author = authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user -// else { -// return .public -// } -// return author.locked ? .private : .public -// }() -// // set visibility for reply post -// switch composeKind { -// case .reply(let record): -// context.managedObjectContext.performAndWait { -// guard let status = record.object(in: context.managedObjectContext) else { -// assertionFailure() -// return -// } -// let repliedStatusVisibility = status.visibility -// switch repliedStatusVisibility { -// case .public, .unlisted: -// // keep default -// break -// case .private: -// visibility = .private -// case .direct: -// visibility = .direct -// case ._other: -// assertionFailure() -// break -// } -// } -// default: -// break -// } -// return visibility -// }() -// // set limit -// self.instanceConfiguration = { -// var configuration: Mastodon.Entity.Instance.Configuration? = nil -// context.managedObjectContext.performAndWait { -// guard let authentication = authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext) else { return } -// configuration = authentication.instance?.configuration -// } -// return configuration -// }() -// self.customEmojiViewModel = context.emojiService.dequeueCustomEmojiViewModel(for: authContext.mastodonAuthenticationBox.domain) -// super.init() -// // end init -// -// setup(cell: composeStatusContentTableViewCell) + self.title = { + switch kind { + case .post, .hashtag, .mention: return L10n.Scene.Compose.Title.newPost + case .reply: return L10n.Scene.Compose.Title.newReply + } + }() } deinit { @@ -188,194 +61,3 @@ final class ComposeViewModel: NSObject { } } - -extension ComposeViewModel { -// func createNewPollOptionIfPossible() { -// guard pollOptionAttributes.count < maxPollOptions else { return } -// -// let attribute = ComposeStatusPollItem.PollOptionAttribute() -// pollOptionAttributes = pollOptionAttributes + [attribute] -// } -// -// func updatePublishDate() { -// publishDate = Date() -// } -} - -//extension ComposeViewModel { -// -// enum AttachmentPrecondition: Error, LocalizedError { -// case videoAttachWithPhoto -// case moreThanOneVideo -// -// var errorDescription: String? { -// return L10n.Common.Alerts.PublishPostFailure.title -// } -// -// var failureReason: String? { -// switch self { -// case .videoAttachWithPhoto: -// return L10n.Common.Alerts.PublishPostFailure.AttachmentsMessage.videoAttachWithPhoto -// case .moreThanOneVideo: -// return L10n.Common.Alerts.PublishPostFailure.AttachmentsMessage.moreThanOneVideo -// } -// } -// } -// -// // check exclusive limit: -// // - up to 1 video -// // - up to N photos -// func checkAttachmentPrecondition() throws { -// let attachmentServices = self.attachmentServices -// guard !attachmentServices.isEmpty else { return } -// var photoAttachmentServices: [MastodonAttachmentService] = [] -// var videoAttachmentServices: [MastodonAttachmentService] = [] -// attachmentServices.forEach { service in -// guard let file = service.file.value else { -// assertionFailure() -// return -// } -// switch file { -// case .jpeg, .png, .gif: -// photoAttachmentServices.append(service) -// case .other: -// videoAttachmentServices.append(service) -// } -// } -// -// if !videoAttachmentServices.isEmpty { -// guard videoAttachmentServices.count == 1 else { -// throw AttachmentPrecondition.moreThanOneVideo -// } -// guard photoAttachmentServices.isEmpty else { -// throw AttachmentPrecondition.videoAttachWithPhoto -// } -// } -// } -// -//} -// -//// MARK: - MastodonAttachmentServiceDelegate -//extension ComposeViewModel: MastodonAttachmentServiceDelegate { -// func mastodonAttachmentService(_ service: MastodonAttachmentService, uploadStateDidChange state: MastodonAttachmentService.UploadState?) { -// // trigger new output event -// attachmentServices = attachmentServices -// } -//} -// -//// MARK: - ComposePollAttributeDelegate -//extension ComposeViewModel: ComposePollAttributeDelegate { -// func composePollAttribute(_ attribute: ComposeStatusPollItem.PollOptionAttribute, pollOptionDidChange: String?) { -// // trigger update -// pollOptionAttributes = pollOptionAttributes -// } -//} -// -//extension ComposeViewModel { -// private func setup( -// cell: ComposeStatusContentTableViewCell -// ) { -// setupStatusHeader(cell: cell) -// setupStatusAuthor(cell: cell) -// setupStatusContent(cell: cell) -// } -// -// private func setupStatusHeader( -// cell: ComposeStatusContentTableViewCell -// ) { -// // configure header -// let managedObjectContext = context.managedObjectContext -// managedObjectContext.performAndWait { -// guard case let .reply(record) = self.composeKind, -// let replyTo = record.object(in: managedObjectContext) -// else { -// cell.statusView.viewModel.header = .none -// return -// } -// -// let info: StatusView.ViewModel.Header.ReplyInfo -// do { -// let content = MastodonContent( -// content: replyTo.author.displayNameWithFallback, -// emojis: replyTo.author.emojis.asDictionary -// ) -// let metaContent = try MastodonMetaContent.convert(document: content) -// info = .init(header: metaContent) -// } catch { -// let metaContent = PlaintextMetaContent(string: replyTo.author.displayNameWithFallback) -// info = .init(header: metaContent) -// } -// cell.statusView.viewModel.header = .reply(info: info) -// } -// } -// -// private func setupStatusAuthor( -// cell: ComposeStatusContentTableViewCell -// ) { -// self.context.managedObjectContext.performAndWait { -// guard let author = authenticationBox.authenticationRecord.object(in: self.context.managedObjectContext)?.user else { return } -// cell.statusView.configureAuthor(author: author) -// } -// } -// -// private func setupStatusContent( -// cell: ComposeStatusContentTableViewCell -// ) { -// switch composeKind { -// case .reply(let record): -// context.managedObjectContext.performAndWait { -// guard let status = record.object(in: context.managedObjectContext) else { return } -// let author = self.authenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user -// -// var mentionAccts: [String] = [] -// if author?.id != status.author.id { -// mentionAccts.append("@" + status.author.acct) -// } -// let mentions = status.mentions -// .filter { author?.id != $0.id } -// for mention in mentions { -// let acct = "@" + mention.acct -// guard !mentionAccts.contains(acct) else { continue } -// mentionAccts.append(acct) -// } -// for acct in mentionAccts { -// UITextChecker.learnWord(acct) -// } -// if let spoilerText = status.spoilerText, !spoilerText.isEmpty { -// self.isContentWarningComposing = true -// self.composeStatusAttribute.contentWarningContent = spoilerText -// } -// -// let initialComposeContent = mentionAccts.joined(separator: " ") -// let preInsertedContent: String? = initialComposeContent.isEmpty ? nil : initialComposeContent + " " -// self.preInsertedContent = preInsertedContent -// self.composeStatusAttribute.composeContent = preInsertedContent -// } -// case .hashtag(let hashtag): -// let initialComposeContent = "#" + hashtag -// UITextChecker.learnWord(initialComposeContent) -// let preInsertedContent = initialComposeContent + " " -// self.preInsertedContent = preInsertedContent -// self.composeStatusAttribute.composeContent = preInsertedContent -// case .mention(let record): -// context.managedObjectContext.performAndWait { -// guard let user = record.object(in: context.managedObjectContext) else { return } -// let initialComposeContent = "@" + user.acct -// UITextChecker.learnWord(initialComposeContent) -// let preInsertedContent = initialComposeContent + " " -// self.preInsertedContent = preInsertedContent -// self.composeStatusAttribute.composeContent = preInsertedContent -// } -// case .post: -// self.preInsertedContent = nil -// } -// -// // configure content warning -// if let composeContent = composeStatusAttribute.composeContent { -// cell.metaText.textView.text = composeContent -// } -// -// // configure content warning -// cell.statusContentWarningEditorView.textView.text = composeStatusAttribute.contentWarningContent -// } -//} diff --git a/Mastodon/Scene/Profile/Follower/FollowerListViewModel.swift b/Mastodon/Scene/Profile/Follower/FollowerListViewModel.swift index 1b0d505b5..f96a02ddc 100644 --- a/Mastodon/Scene/Profile/Follower/FollowerListViewModel.swift +++ b/Mastodon/Scene/Profile/Follower/FollowerListViewModel.swift @@ -7,7 +7,6 @@ import Foundation import Combine -import Combine import CoreData import CoreDataStack import GameplayKit diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 3ce1fd33a..07d72c0b6 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -552,6 +552,9 @@ extension ProfileViewController { userTimelineViewController.viewModel.stateMachine.enter(UserTimelineViewModel.State.Reloading.self) } + // trigger authenticated user account update + viewModel.context.authenticationService.updateActiveUserAccountPublisher.send() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { sender.endRefreshing() } diff --git a/Mastodon/Scene/Report/ReportResult/ReportResultView.swift b/Mastodon/Scene/Report/ReportResult/ReportResultView.swift index 75021934b..361f5db24 100644 --- a/Mastodon/Scene/Report/ReportResult/ReportResultView.swift +++ b/Mastodon/Scene/Report/ReportResult/ReportResultView.swift @@ -8,7 +8,6 @@ import UIKit import SwiftUI import MastodonSDK -import MastodonUI import MastodonAsset import MastodonCore import MastodonUI diff --git a/Mastodon/Scene/Root/MainTab/MainTabBarController.swift b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift index e2510071e..48d974358 100644 --- a/Mastodon/Scene/Root/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift @@ -311,6 +311,12 @@ extension MainTabBarController { let _profileTabItem = self.tabBar.items?.first { item in item.tag == Tab.me.tag } guard let profileTabItem = _profileTabItem else { return } profileTabItem.accessibilityHint = L10n.Scene.AccountList.tabBarHint(user.displayNameWithFallback) + + context.authenticationService.updateActiveUserAccountPublisher + .sink { [weak self] in + self?.updateUserAccount() + } + .store(in: &disposeBag) } else { self.avatarURLObserver = nil } @@ -485,6 +491,26 @@ extension MainTabBarController { avatarButton.setNeedsLayout() } + private func updateUserAccount() { + guard let authContext = authContext else { return } + + Task { @MainActor in + let profileResponse = try await context.apiService.authenticatedUserInfo( + authenticationBox: authContext.mastodonAuthenticationBox + ) + + if let user = authContext.mastodonAuthenticationBox.authenticationRecord.object( + in: context.managedObjectContext + )?.user { + user.update( + property: .init( + entity: profileResponse.value, + domain: authContext.mastodonAuthenticationBox.domain + ) + ) + } + } + } } extension MainTabBarController { diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index a3315211e..c9850a0d3 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -99,6 +99,10 @@ extension StatusTableViewCell { return true } + override var accessibilityCustomActions: [UIAccessibilityCustomAction]? { + get { statusView.accessibilityCustomActions } + set { } + } } // MARK: - AdaptiveContainerMarginTableViewCell diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift index 1e97fb179..bf02b8031 100644 --- a/Mastodon/Supporting Files/SceneDelegate.swift +++ b/Mastodon/Supporting Files/SceneDelegate.swift @@ -109,6 +109,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // trigger status filter update AppContext.shared.statusFilterService.filterUpdatePublisher.send() + + // trigger authenticated user account update + AppContext.shared.authenticationService.updateActiveUserAccountPublisher.send() if let shortcutItem = savedShortCutItem { Task { diff --git a/MastodonSDK/Package.swift b/MastodonSDK/Package.swift index ca241038b..7a4201894 100644 --- a/MastodonSDK/Package.swift +++ b/MastodonSDK/Package.swift @@ -25,9 +25,9 @@ let package = Package( ], dependencies: [ .package(name: "ArkanaKeys", path: "../dependencies/ArkanaKeys"), - .package(name: "FaviconFinder", url: "https://github.com/will-lumley/FaviconFinder.git", from: "3.2.2"), - .package(name: "Introspect", url: "https://github.com/siteline/SwiftUI-Introspect.git", from: "0.1.3"), - .package(name: "UITextView+Placeholder", url: "https://github.com/MainasuK/UITextView-Placeholder.git", from: "1.4.1"), + .package(url: "https://github.com/will-lumley/FaviconFinder.git", from: "3.2.2"), + .package(url: "https://github.com/siteline/SwiftUI-Introspect.git", from: "0.1.3"), + .package(url: "https://github.com/MainasuK/UITextView-Placeholder.git", from: "1.4.1"), .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.4.0"), .package(url: "https://github.com/Alamofire/AlamofireImage.git", from: "4.1.0"), .package(url: "https://github.com/apple/swift-collections.git", from: "1.0.3"), @@ -49,6 +49,7 @@ let package = Package( .package(url: "https://github.com/SDWebImage/SDWebImage.git", from: "5.12.0"), .package(url: "https://github.com/eneko/Stripes.git", from: "0.2.0"), .package(url: "https://github.com/onevcat/Kingfisher.git", from: "7.4.1"), + .package(url: "https://github.com/NextLevel/NextLevelSessionExporter.git", from: "0.4.6"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -112,8 +113,8 @@ let package = Package( .product(name: "FLAnimatedImage", package: "FLAnimatedImage"), .product(name: "FaviconFinder", package: "FaviconFinder"), .product(name: "Nuke", package: "Nuke"), - .product(name: "Introspect", package: "Introspect"), - .product(name: "UITextView+Placeholder", package: "UITextView+Placeholder"), + .product(name: "Introspect", package: "SwiftUI-Introspect"), + .product(name: "UITextView+Placeholder", package: "UITextView-Placeholder"), .product(name: "UIHostingConfigurationBackport", package: "UIHostingConfigurationBackport"), .product(name: "TabBarPager", package: "TabBarPager"), .product(name: "ThirdPartyMailer", package: "ThirdPartyMailer"), @@ -124,6 +125,7 @@ let package = Package( .product(name: "PanModal", package: "PanModal"), .product(name: "Stripes", package: "Stripes"), .product(name: "Kingfisher", package: "Kingfisher"), + .product(name: "NextLevelSessionExporter", package: "NextLevelSessionExporter"), ] ), .testTarget( diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/indicator.button.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/indicator.button.background.colorset/Contents.json new file mode 100644 index 000000000..7a1c8d9e2 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/indicator.button.background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.400", + "green" : "0.275", + "red" : "0.275" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.400", + "green" : "0.275", + "red" : "0.275" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/retry.imageset/Arrow Clockwise.pdf b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/retry.imageset/Arrow Clockwise.pdf new file mode 100644 index 000000000..a15c522d8 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/retry.imageset/Arrow Clockwise.pdf @@ -0,0 +1,91 @@ +%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 2.750000 2.750000 cm +0.000000 0.000000 0.000000 scn +9.250000 16.500000 m +5.245935 16.500000 2.000000 13.254065 2.000000 9.250000 c +2.000000 5.245935 5.245935 2.000000 9.250000 2.000000 c +13.254065 2.000000 16.500000 5.245935 16.500000 9.250000 c +16.500000 9.535608 16.483484 9.817360 16.451357 10.094351 c +16.383255 10.681498 16.809317 11.250000 17.400400 11.250000 c +17.916018 11.250000 18.369314 10.891933 18.431660 10.380100 c +18.476776 10.009713 18.500000 9.632568 18.500000 9.250000 c +18.500000 4.141366 14.358634 0.000000 9.250000 0.000000 c +4.141366 0.000000 0.000000 4.141366 0.000000 9.250000 c +0.000000 14.358634 4.141366 18.500000 9.250000 18.500000 c +11.423139 18.500000 13.421247 17.750608 15.000000 16.496151 c +15.000000 17.000000 l +15.000000 17.552284 15.447716 18.000000 16.000000 18.000000 c +16.552284 18.000000 17.000000 17.552284 17.000000 17.000000 c +17.000000 14.301708 l +17.011232 14.284512 17.022409 14.267276 17.033529 14.250000 c +17.000000 14.250000 l +17.000000 14.000000 l +17.000000 13.447716 16.552284 13.000000 16.000000 13.000000 c +13.000000 13.000000 l +12.447715 13.000000 12.000000 13.447716 12.000000 14.000000 c +12.000000 14.552284 12.447715 15.000000 13.000000 15.000000 c +13.666476 15.000000 l +12.443584 15.940684 10.912110 16.500000 9.250000 16.500000 c +h +f +n +Q + +endstream +endobj + +3 0 obj + 1365 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.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 + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001455 00000 n +0000001478 00000 n +0000001651 00000 n +0000001725 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +1784 +%%EOF \ No newline at end of file diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/retry.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/retry.imageset/Contents.json new file mode 100644 index 000000000..92bff3aca --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/retry.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Arrow Clockwise.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/stop.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/stop.imageset/Contents.json new file mode 100644 index 000000000..b2b588d4d --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/stop.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Dismiss.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/stop.imageset/Dismiss.pdf b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/stop.imageset/Dismiss.pdf new file mode 100644 index 000000000..0616f6275 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Compose/Attachment/stop.imageset/Dismiss.pdf @@ -0,0 +1,89 @@ +%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 4.000000 3.804749 cm +0.000000 0.000000 0.000000 scn +0.209704 15.808150 m +0.292893 15.902358 l +0.653377 16.262842 1.220608 16.290571 1.612899 15.985547 c +1.707107 15.902358 l +8.000000 9.610251 l +14.292892 15.902358 l +14.683416 16.292883 15.316584 16.292883 15.707108 15.902358 c +16.097631 15.511834 16.097631 14.878669 15.707108 14.488145 c +9.415000 8.195251 l +15.707108 1.902359 l +16.067591 1.541875 16.095320 0.974643 15.790295 0.582352 c +15.707108 0.488144 l +15.346623 0.127661 14.779391 0.099932 14.387100 0.404957 c +14.292892 0.488144 l +8.000000 6.780252 l +1.707107 0.488144 l +1.316582 0.097620 0.683418 0.097620 0.292893 0.488144 c +-0.097631 0.878668 -0.097631 1.511835 0.292893 1.902359 c +6.585000 8.195251 l +0.292893 14.488145 l +-0.067591 14.848629 -0.095320 15.415859 0.209704 15.808150 c +0.292893 15.902358 l +0.209704 15.808150 l +h +f +n +Q + +endstream +endobj + +3 0 obj + 914 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.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 + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001004 00000 n +0000001026 00000 n +0000001199 00000 n +0000001273 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +1332 +%%EOF \ No newline at end of file diff --git a/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift b/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift index 5cd0059d8..fc47acdfd 100644 --- a/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift +++ b/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift @@ -130,6 +130,11 @@ public enum Asset { } public enum Scene { public enum Compose { + public enum Attachment { + public static let indicatorButtonBackground = ColorAsset(name: "Scene/Compose/Attachment/indicator.button.background") + public static let retry = ImageAsset(name: "Scene/Compose/Attachment/retry") + public static let stop = ImageAsset(name: "Scene/Compose/Attachment/stop") + } public static let earth = ImageAsset(name: "Scene/Compose/Earth") public static let mention = ImageAsset(name: "Scene/Compose/Mention") public static let more = ImageAsset(name: "Scene/Compose/More") diff --git a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/MastodonUser+Property.swift b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/MastodonUser+Property.swift index cebe7f8af..8d2f77ba7 100644 --- a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/MastodonUser+Property.swift +++ b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/MastodonUser+Property.swift @@ -10,6 +10,10 @@ import CoreDataStack import MastodonSDK extension MastodonUser.Property { + public init(entity: Mastodon.Entity.Account, domain: String) { + self.init(entity: entity, domain: domain, networkDate: Date()) + } + init(entity: Mastodon.Entity.Account, domain: String, networkDate: Date) { self.init( identifier: entity.id + "@" + domain, diff --git a/MastodonSDK/Sources/MastodonCore/Model/Compose/CustomEmojiPickerItem.swift b/MastodonSDK/Sources/MastodonCore/Model/Compose/CustomEmojiPickerItem.swift index 52f522703..6174f4687 100644 --- a/MastodonSDK/Sources/MastodonCore/Model/Compose/CustomEmojiPickerItem.swift +++ b/MastodonSDK/Sources/MastodonCore/Model/Compose/CustomEmojiPickerItem.swift @@ -8,28 +8,28 @@ import Foundation import MastodonSDK -enum CustomEmojiPickerItem { +public enum CustomEmojiPickerItem { case emoji(attribute: CustomEmojiAttribute) } extension CustomEmojiPickerItem: Equatable, Hashable { } extension CustomEmojiPickerItem { - final class CustomEmojiAttribute: Equatable, Hashable { - let id = UUID() + public final class CustomEmojiAttribute: Equatable, Hashable { + public let id = UUID() - let emoji: Mastodon.Entity.Emoji + public let emoji: Mastodon.Entity.Emoji - init(emoji: Mastodon.Entity.Emoji) { + public init(emoji: Mastodon.Entity.Emoji) { self.emoji = emoji } - static func == (lhs: CustomEmojiPickerItem.CustomEmojiAttribute, rhs: CustomEmojiPickerItem.CustomEmojiAttribute) -> Bool { + public static func == (lhs: CustomEmojiPickerItem.CustomEmojiAttribute, rhs: CustomEmojiPickerItem.CustomEmojiAttribute) -> Bool { return lhs.id == rhs.id && lhs.emoji.shortcode == rhs.emoji.shortcode } - func hash(into hasher: inout Hasher) { + public func hash(into hasher: inout Hasher) { hasher.combine(id) } } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift index 68649d24c..1b6a57a83 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift @@ -13,6 +13,15 @@ import MastodonCommon import MastodonSDK extension APIService { + public func authenticatedUserInfo( + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content { + try await accountInfo( + domain: authenticationBox.domain, + userID: authenticationBox.userID, + authorization: authenticationBox.userAuthorization + ) + } public func accountInfo( domain: String, diff --git a/MastodonSDK/Sources/MastodonCore/Service/AuthenticationService.swift b/MastodonSDK/Sources/MastodonCore/Service/AuthenticationService.swift index 48da254c6..0e5160679 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/AuthenticationService.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/AuthenticationService.swift @@ -25,6 +25,7 @@ public final class AuthenticationService: NSObject { // output @Published public var mastodonAuthentications: [ManagedObjectRecord] = [] @Published public var mastodonAuthenticationBoxes: [MastodonAuthenticationBox] = [] + public let updateActiveUserAccountPublisher = PassthroughSubject() init( managedObjectContext: NSManagedObjectContext, diff --git a/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift b/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift index c63e965bd..4cd804036 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift @@ -24,7 +24,7 @@ public final class InstanceService { weak var authenticationService: AuthenticationService? // output - + init( apiService: APIService, authenticationService: AuthenticationService diff --git a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift index 4628cc99f..7321c3980 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift +++ b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift @@ -314,6 +314,24 @@ public enum L10n { /// Undo reblog public static let unreblog = L10n.tr("Localizable", "Common.Controls.Status.Actions.Unreblog") } + public enum MetaEntity { + /// Email address: %@ + public static func email(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.MetaEntity.Email", String(describing: p1)) + } + /// Hastag %@ + public static func hashtag(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.MetaEntity.Hashtag", String(describing: p1)) + } + /// Show Profile: %@ + public static func mention(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.MetaEntity.Mention", String(describing: p1)) + } + /// Link: %@ + public static func url(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.MetaEntity.Url", String(describing: p1)) + } + } public enum Poll { /// Closed public static let closed = L10n.tr("Localizable", "Common.Controls.Status.Poll.Closed") @@ -429,6 +447,12 @@ public enum L10n { public static let disableContentWarning = L10n.tr("Localizable", "Scene.Compose.Accessibility.DisableContentWarning") /// Enable Content Warning public static let enableContentWarning = L10n.tr("Localizable", "Scene.Compose.Accessibility.EnableContentWarning") + /// Posting as %@ + public static func postingAs(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Compose.Accessibility.PostingAs", String(describing: p1)) + } + /// Post Options + public static let postOptions = L10n.tr("Localizable", "Scene.Compose.Accessibility.PostOptions") /// Post Visibility Menu public static let postVisibilityMenu = L10n.tr("Localizable", "Scene.Compose.Accessibility.PostVisibilityMenu") /// Remove Poll @@ -1256,6 +1280,10 @@ public enum L10n { public enum A11y { public enum Plural { public enum Count { + /// Plural format key: "%#@character_count@ left" + public static func charactersLeft(_ p1: Int) -> String { + return L10n.tr("Localizable", "a11y.plural.count.characters_left", p1) + } /// Plural format key: "Input limit exceeds %#@character_count@" public static func inputLimitExceeds(_ p1: Int) -> String { return L10n.tr("Localizable", "a11y.plural.count.input_limit_exceeds", p1) diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings index e05098682..2f767bf63 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings @@ -108,6 +108,10 @@ Please check your internet connection."; "Common.Controls.Status.Actions.Unreblog" = "Undo reblog"; "Common.Controls.Status.ContentWarning" = "Content Warning"; "Common.Controls.Status.MediaContentWarning" = "Tap anywhere to reveal"; +"Common.Controls.Status.MetaEntity.Email" = "Email address: %@"; +"Common.Controls.Status.MetaEntity.Hashtag" = "Hastag %@"; +"Common.Controls.Status.MetaEntity.Mention" = "Show Profile: %@"; +"Common.Controls.Status.MetaEntity.Url" = "Link: %@"; "Common.Controls.Status.Poll.Closed" = "Closed"; "Common.Controls.Status.Poll.Vote" = "Vote"; "Common.Controls.Status.SensitiveContent" = "Sensitive Content"; @@ -157,7 +161,9 @@ Your profile looks like this to them."; "Scene.Compose.Accessibility.CustomEmojiPicker" = "Custom Emoji Picker"; "Scene.Compose.Accessibility.DisableContentWarning" = "Disable Content Warning"; "Scene.Compose.Accessibility.EnableContentWarning" = "Enable Content Warning"; +"Scene.Compose.Accessibility.PostOptions" = "Post Options"; "Scene.Compose.Accessibility.PostVisibilityMenu" = "Post Visibility Menu"; +"Scene.Compose.Accessibility.PostingAs" = "Posting as %@"; "Scene.Compose.Accessibility.RemovePoll" = "Remove Poll"; "Scene.Compose.Attachment.AttachmentBroken" = "This %@ is broken and can’t be uploaded to Mastodon."; diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.stringsdict b/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.stringsdict index bdcae6ac9..297e6675a 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.stringsdict +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.stringsdict @@ -50,6 +50,28 @@ %ld characters + a11y.plural.count.characters_left + + NSStringLocalizedFormatKey + %#@character_count@ left + character_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + zero + no characters + one + 1 character + few + %ld characters + many + %ld characters + other + %ld characters + + plural.count.followed_by_and_mutual NSStringLocalizedFormatKey diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Media.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Media.swift index 4f8ac71d5..6c905438c 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Media.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Media.swift @@ -43,6 +43,20 @@ extension Mastodon.API.V2.Media { request.timeoutInterval = 180 // should > 200 Kb/s for 40 MiB media attachment let serialStream = query.serialStream request.httpBodyStream = serialStream.boundStreams.input + + // total unit count in bytes count + // will small than actally count due to multipart protocol meta + serialStream.progress.totalUnitCount = { + var size = 0 + size += query.file?.sizeInByte ?? 0 + size += query.thumbnail?.sizeInByte ?? 0 + return Int64(size) + }() + query.progress.addChild( + serialStream.progress, + withPendingUnitCount: query.progress.totalUnitCount + ) + return session.dataTaskPublisher(for: request) .tryMap { data, response in let value = try Mastodon.API.decode(type: Mastodon.Entity.Attachment.self, from: data, response: response) diff --git a/MastodonSDK/Sources/MastodonSDK/Query/MediaAttachment.swift b/MastodonSDK/Sources/MastodonSDK/Query/MediaAttachment.swift index f1fdac8bb..05639964e 100644 --- a/MastodonSDK/Sources/MastodonSDK/Query/MediaAttachment.swift +++ b/MastodonSDK/Sources/MastodonSDK/Query/MediaAttachment.swift @@ -54,7 +54,7 @@ extension Mastodon.Query.MediaAttachment { return data.map { "data:" + mimeType + ";base64," + $0.base64EncodedString() } } - var sizeInByte: Int? { + public var sizeInByte: Int? { switch self { case .jpeg(let data), .gif(let data), .png(let data): return data?.count diff --git a/MastodonSDK/Sources/MastodonSDK/Query/SerialStream.swift b/MastodonSDK/Sources/MastodonSDK/Query/SerialStream.swift index 5d806b6ba..5808b9f6d 100644 --- a/MastodonSDK/Sources/MastodonSDK/Query/SerialStream.swift +++ b/MastodonSDK/Sources/MastodonSDK/Query/SerialStream.swift @@ -82,6 +82,10 @@ final class SerialStream: NSObject { self.progress.completedUnitCount += Int64(writeResult) self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): estimate progress: \(self.progress.completedUnitCount)/\(self.progress.totalUnitCount)") + + if writeResult == -1 { + break + } } } diff --git a/MastodonSDK/Sources/MastodonUI/DataSource/CustomEmojiPickerSection+Diffable.swift b/MastodonSDK/Sources/MastodonUI/DataSource/CustomEmojiPickerSection+Diffable.swift index ca3658e95..4c142b532 100644 --- a/MastodonSDK/Sources/MastodonUI/DataSource/CustomEmojiPickerSection+Diffable.swift +++ b/MastodonSDK/Sources/MastodonUI/DataSource/CustomEmojiPickerSection+Diffable.swift @@ -5,55 +5,55 @@ // Created by MainasuK on 22/10/10. // -import Foundation +import UIKit import MastodonCore extension CustomEmojiPickerSection { -// static func collectionViewDiffableDataSource( -// collectionView: UICollectionView, -// dependency: NeedsDependency -// ) -> UICollectionViewDiffableDataSource { -// let dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak dependency] collectionView, indexPath, item -> UICollectionViewCell? in -// guard let _ = dependency else { return nil } -// switch item { -// case .emoji(let attribute): -// let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: CustomEmojiPickerItemCollectionViewCell.self), for: indexPath) as! CustomEmojiPickerItemCollectionViewCell -// let placeholder = UIImage.placeholder(size: CustomEmojiPickerItemCollectionViewCell.itemSize, color: .systemFill) -// .af.imageRounded(withCornerRadius: 4) -// -// let isAnimated = !UserDefaults.shared.preferredStaticEmoji -// let url = URL(string: isAnimated ? attribute.emoji.url : attribute.emoji.staticURL) -// cell.emojiImageView.sd_setImage( -// with: url, -// placeholderImage: placeholder, -// options: [], -// context: nil -// ) -// cell.accessibilityLabel = attribute.emoji.shortcode -// return cell -// } -// } -// -// dataSource.supplementaryViewProvider = { [weak dataSource] collectionView, kind, indexPath -> UICollectionReusableView? in -// guard let dataSource = dataSource else { return nil } -// let sections = dataSource.snapshot().sectionIdentifiers -// guard indexPath.section < sections.count else { return nil } -// let section = sections[indexPath.section] -// -// switch kind { -// case String(describing: CustomEmojiPickerHeaderCollectionReusableView.self): -// let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: String(describing: CustomEmojiPickerHeaderCollectionReusableView.self), for: indexPath) as! CustomEmojiPickerHeaderCollectionReusableView -// switch section { -// case .emoji(let name): -// header.titleLabel.text = name -// } -// return header -// default: -// assertionFailure() -// return nil -// } -// } -// -// return dataSource -// } + static func collectionViewDiffableDataSource( + collectionView: UICollectionView, + context: AppContext + ) -> UICollectionViewDiffableDataSource { + let dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak context] collectionView, indexPath, item -> UICollectionViewCell? in + guard let _ = context else { return nil } + switch item { + case .emoji(let attribute): + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: CustomEmojiPickerItemCollectionViewCell.self), for: indexPath) as! CustomEmojiPickerItemCollectionViewCell + let placeholder = UIImage.placeholder(size: CustomEmojiPickerItemCollectionViewCell.itemSize, color: .systemFill) + .af.imageRounded(withCornerRadius: 4) + + let isAnimated = !UserDefaults.shared.preferredStaticEmoji + let url = URL(string: isAnimated ? attribute.emoji.url : attribute.emoji.staticURL) + cell.emojiImageView.sd_setImage( + with: url, + placeholderImage: placeholder, + options: [], + context: nil + ) + cell.accessibilityLabel = attribute.emoji.shortcode + return cell + } + } + + dataSource.supplementaryViewProvider = { [weak dataSource] collectionView, kind, indexPath -> UICollectionReusableView? in + guard let dataSource = dataSource else { return nil } + let sections = dataSource.snapshot().sectionIdentifiers + guard indexPath.section < sections.count else { return nil } + let section = sections[indexPath.section] + + switch kind { + case String(describing: CustomEmojiPickerHeaderCollectionReusableView.self): + let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: String(describing: CustomEmojiPickerHeaderCollectionReusableView.self), for: indexPath) as! CustomEmojiPickerHeaderCollectionReusableView + switch section { + case .emoji(let name): + header.titleLabel.text = name + } + return header + default: + assertionFailure() + return nil + } + } + + return dataSource + } } diff --git a/MastodonSDK/Sources/MastodonUI/Extension/MetaEntity+Accessibility.swift b/MastodonSDK/Sources/MastodonUI/Extension/MetaEntity+Accessibility.swift new file mode 100644 index 000000000..b604a3870 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Extension/MetaEntity+Accessibility.swift @@ -0,0 +1,27 @@ +// +// MetaEntity+Accessibility.swift +// +// +// Created by Jed Fox on 2022-11-03. +// + +import Meta +import MastodonLocalization + +extension Meta.Entity { + var accessibilityCustomActionLabel: String? { + switch meta { + case .url(_, trimmed: _, url: let url, userInfo: _): + return L10n.Common.Controls.Status.MetaEntity.url(url) + case .hashtag(_, hashtag: let hashtag, userInfo: _): + return L10n.Common.Controls.Status.MetaEntity.hashtag(hashtag) + case .mention(_, mention: let mention, userInfo: _): + return L10n.Common.Controls.Status.MetaEntity.mention(mention) + case .email(let email, userInfo: _): + return L10n.Common.Controls.Status.MetaEntity.email(email) + // emoji are not actionable + case .emoji: + return nil + } + } +} diff --git a/MastodonSDK/Sources/MastodonUI/Extension/View.swift b/MastodonSDK/Sources/MastodonUI/Extension/View.swift new file mode 100644 index 000000000..756e51b64 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Extension/View.swift @@ -0,0 +1,21 @@ +// +// View.swift +// +// +// Created by MainasuK on 2022/11/8. +// + +import SwiftUI + +extension View { + public func badgeView(_ content: Content) -> some View where Content: View { + overlay( + ZStack { + content + } + .alignmentGuide(.top) { $0.height / 2 } + .alignmentGuide(.trailing) { $0.width / 2 } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + ) + } +} diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentView.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentView.swift index f4d1397a9..a2567d0b6 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentView.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentView.swift @@ -10,237 +10,194 @@ import UIKit import SwiftUI import Introspect import AVKit +import MastodonAsset +import MastodonLocalization +import Introspect public struct AttachmentView: View { - static let size = CGSize(width: 56, height: 56) - static let cornerRadius: CGFloat = 8 - @ObservedObject var viewModel: AttachmentViewModel - - let action: (Action) -> Void - - @State var isCaptionEditorPresented = false - @State var caption = "" + + var blurEffect: UIBlurEffect { + UIBlurEffect(style: .systemUltraThinMaterialDark) + } public var body: some View { - Text("Hello") -// Menu { -// menu -// } label: { -// let image = viewModel.thumbnail ?? .placeholder(color: .systemGray3) -// Image(uiImage: image) -// .resizable() -// .aspectRatio(contentMode: .fill) -// .frame(width: AttachmentView.size.width, height: AttachmentView.size.height) -// .overlay { -// ZStack { -// // spinner -// if viewModel.output == nil { -// Color.clear -// .background(.ultraThinMaterial) -// ProgressView() -// .progressViewStyle(CircularProgressViewStyle()) -// .foregroundStyle(.regularMaterial) -// } -// // border -// RoundedRectangle(cornerRadius: AttachmentView.cornerRadius) -// .stroke(Color.black.opacity(0.05)) -// } -// .transition(.opacity) -// } -// .overlay(alignment: .bottom) { -// HStack(alignment: .bottom) { -// // alt -// VStack(spacing: 2) { -// switch viewModel.output { -// case .video: -// Image(uiImage: Asset.Media.playerRectangle.image) -// .resizable() -// .frame(width: 16, height: 12) -// default: -// EmptyView() -// } -// if !viewModel.caption.isEmpty { -// Image(uiImage: Asset.Media.altRectangle.image) -// .resizable() -// .frame(width: 16, height: 12) -// } -// } -// Spacer() -// // option -// Image(systemName: "ellipsis") -// .resizable() -// .frame(width: 12, height: 12) -// .symbolVariant(.circle) -// .symbolVariant(.fill) -// .symbolRenderingMode(.palette) -// .foregroundStyle(.white, .black) -// } -// .padding(6) -// } -// .cornerRadius(AttachmentView.cornerRadius) -// } // end Menu -// .sheet(isPresented: $isCaptionEditorPresented) { -// captionSheet -// } // end caption sheet -// .sheet(isPresented: $viewModel.isPreviewPresented) { -// previewSheet -// } // end preview sheet - + Color.clear.aspectRatio(358.0/232.0, contentMode: .fill) + .overlay( + ZStack { + let image = viewModel.thumbnail ?? .placeholder(color: .secondarySystemFill) + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + } + ) + .overlay( + ZStack { + Color.clear + .overlay( + VStack(alignment: .leading) { + let placeholder: String = { + switch viewModel.output { + case .image: return L10n.Scene.Compose.Attachment.descriptionPhoto + case .video: return L10n.Scene.Compose.Attachment.descriptionVideo + case nil: return "" + } + }() + Spacer() + TextField(placeholder, text: $viewModel.caption) + .lineLimit(1) + .textFieldStyle(.plain) + .foregroundColor(.white) + .placeholder(placeholder, when: viewModel.caption.isEmpty) + .padding(8) + } + ) + + // loading… + if viewModel.output == nil, viewModel.error == nil { + ProgressView() + .progressViewStyle(.circular) + } + + // load failed + // cannot re-entry + if viewModel.output == nil, let error = viewModel.error { + VisualEffectView(effect: blurEffect) + VStack { + Text("Load Failed") // TODO: i18n + .font(.system(size: 13, weight: .semibold)) + Text(error.localizedDescription) + .font(.system(size: 12, weight: .regular)) + } + } + + // loaded + // uploading… or upload failed + // could retry upload when error emit + if viewModel.output != nil, viewModel.uploadState != .finish { + VisualEffectView(effect: blurEffect) + VStack { + let action: AttachmentViewModel.Action = { + if let _ = viewModel.error { + return .retry + } else { + return .remove + } + }() + Button { + viewModel.delegate?.attachmentViewModel(viewModel, actionButtonDidPressed: action) + } label: { + let image: UIImage = { + switch action { + case .remove: + return Asset.Scene.Compose.Attachment.stop.image.withRenderingMode(.alwaysTemplate) + case .retry: + return Asset.Scene.Compose.Attachment.retry.image.withRenderingMode(.alwaysTemplate) + } + }() + Image(uiImage: image) + .foregroundColor(.white) + .padding() + .background(Color(Asset.Scene.Compose.Attachment.indicatorButtonBackground.color)) + .overlay( + Group { + switch viewModel.uploadState { + case .compressing: + CircleProgressView(progress: viewModel.videoCompressProgress) + .animation(.default, value: viewModel.videoCompressProgress) + case .uploading: + CircleProgressView(progress: viewModel.fractionCompleted) + .animation(.default, value: viewModel.fractionCompleted) + default: + EmptyView() + } + } + ) + .clipShape(Circle()) + .padding() + } + + let title: String = { + switch action { + case .remove: + switch viewModel.uploadState { + case .compressing: + return "Comporessing..." // TODO: i18n + default: + if viewModel.fractionCompleted < 0.9 { + let totalSizeInByte = viewModel.outputSizeInByte + let uploadSizeInByte = Double(totalSizeInByte) * min(1.0, viewModel.fractionCompleted + 0.1) // 9:1 + let total = viewModel.byteCountFormatter.string(fromByteCount: Int64(totalSizeInByte)) + let upload = viewModel.byteCountFormatter.string(fromByteCount: Int64(uploadSizeInByte)) + return "\(upload) / \(total)" + } else { + return "Server Processing..." // TODO: i18n + } + } + case .retry: + return "Upload Failed" // TODO: i18n + } + }() + let subtitle: String = { + switch action { + case .remove: + if viewModel.progress.fractionCompleted < 1, viewModel.uploadState == .uploading { + if viewModel.progress.fractionCompleted < 0.9 { + return viewModel.remainTimeLocalizedString ?? "" + } else { + return "" + } + } else if viewModel.videoCompressProgress < 1, viewModel.uploadState == .compressing { + return viewModel.percentageFormatter.string(from: NSNumber(floatLiteral: viewModel.videoCompressProgress)) ?? "" + } else { + return "" + } + case .retry: + return viewModel.error?.localizedDescription ?? "" + } + }() + Text(title) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal) + Text(subtitle) + .font(.system(size: 12, weight: .regular)) + .foregroundColor(.white) + .padding(.horizontal) + .lineLimit(nil) + .multilineTextAlignment(.center) + .frame(maxWidth: 240) + } + } + } // end ZStack + ) } // end body -// var menu: some View { -// Group { -// Button( -// action: { -// action(.preview) -// }, -// label: { -// Label(L10n.Scene.Compose.Media.preview, systemImage: "photo") -// } -// ) -// // caption -// let canAddCaption: Bool = { -// switch viewModel.output { -// case .image: return true -// case .video: return false -// case .none: return false -// } -// }() -// if canAddCaption { -// Button( -// action: { -// action(.caption) -// caption = viewModel.caption -// isCaptionEditorPresented.toggle() -// }, -// label: { -// let title = viewModel.caption.isEmpty ? L10n.Scene.Compose.Media.Caption.add : L10n.Scene.Compose.Media.Caption.update -// Label(title, systemImage: "text.bubble") -// // FIXME: https://stackoverflow.com/questions/72318730/how-to-customize-swiftui-menu -// // add caption subtitle -// } -// ) -// } -// Divider() -// // remove -// Button( -// role: .destructive, -// action: { -// action(.remove) -// }, -// label: { -// Label(L10n.Scene.Compose.Media.remove, systemImage: "minus.circle") -// } -// ) -// } -// } - -// var captionSheet: some View { -// NavigationView { -// ScrollView(.vertical) { -// VStack { -// // preview -// switch viewModel.output { -// case .image: -// let image = viewModel.thumbnail ?? .placeholder(color: .systemGray3) -// Image(uiImage: image) -// .resizable() -// .aspectRatio(contentMode: .fill) -// case .video(let url, _): -// let player = AVPlayer(url: url) -// VideoPlayer(player: player) -// .frame(height: 300) -// case .none: -// EmptyView() -// } -// // caption textField -// TextField( -// text: $caption, -// prompt: Text(L10n.Scene.Compose.Media.Caption.addADescriptionForThisImage) -// ) { -// Text(L10n.Scene.Compose.Media.Caption.update) -// } -// .padding() -// .introspectTextField { textField in -// textField.becomeFirstResponder() -// } -// } -// } -// .navigationTitle(L10n.Scene.Compose.Media.Caption.update) -// .navigationBarTitleDisplayMode(.inline) -// .toolbar { -// ToolbarItem(placement: .navigationBarLeading) { -// Button { -// isCaptionEditorPresented.toggle() -// } label: { -// Image(systemName: "xmark.circle.fill") -// .resizable() -// .frame(width: 30, height: 30, alignment: .center) -// .symbolRenderingMode(.hierarchical) -// .foregroundStyle(Color(uiColor: .secondaryLabel), Color(uiColor: .tertiaryLabel)) -// } -// } -// ToolbarItem(placement: .navigationBarTrailing) { -// Button { -// viewModel.caption = caption.trimmingCharacters(in: .whitespacesAndNewlines) -// isCaptionEditorPresented.toggle() -// } label: { -// Text(L10n.Common.Controls.Actions.save) -// } -// } -// } -// } // end NavigationView -// } - - // design for share extension - // preferred UIKit preview in app -// var previewSheet: some View { -// NavigationView { -// ScrollView(.vertical) { -// VStack { -// // preview -// switch viewModel.output { -// case .image: -// let image = viewModel.thumbnail ?? .placeholder(color: .systemGray3) -// Image(uiImage: image) -// .resizable() -// .aspectRatio(contentMode: .fill) -// case .video(let url, _): -// let player = AVPlayer(url: url) -// VideoPlayer(player: player) -// .frame(height: 300) -// case .none: -// EmptyView() -// } -// Spacer() -// } -// } -// .navigationTitle(L10n.Scene.Compose.Media.preview) -// .navigationBarTitleDisplayMode(.inline) -// .toolbar { -// ToolbarItem(placement: .navigationBarLeading) { -// Button { -// viewModel.isPreviewPresented.toggle() -// } label: { -// Image(systemName: "xmark.circle.fill") -// .resizable() -// .frame(width: 30, height: 30, alignment: .center) -// .symbolRenderingMode(.hierarchical) -// .foregroundStyle(Color(uiColor: .secondaryLabel), Color(uiColor: .tertiaryLabel)) -// } -// } -// } -// } // end NavigationView -// } - } -extension AttachmentView { - public enum Action: Hashable { - case preview - case caption - case remove +// https://stackoverflow.com/a/57715771/3797903 +extension View { + fileprivate func placeholder( + when shouldShow: Bool, + alignment: Alignment = .leading, + @ViewBuilder placeholder: () -> Content) -> some View { + + ZStack(alignment: alignment) { + placeholder().opacity(shouldShow ? 1 : 0) + self + } + } + + fileprivate func placeholder( + _ text: String, + when shouldShow: Bool, + alignment: Alignment = .leading) -> some View { + + placeholder(when: shouldShow, alignment: alignment) { + Text(text) + .foregroundColor(.white.opacity(0.7)) + .lineLimit(1) + } } } diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Compress.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Compress.swift new file mode 100644 index 000000000..ac1811a06 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Compress.swift @@ -0,0 +1,94 @@ +// +// AttachmentViewModel+Compress.swift +// +// +// Created by MainasuK on 2022/11/11. +// + +import os.log +import UIKit +import AVKit +import SessionExporter +import MastodonCore + +extension AttachmentViewModel { + func comporessVideo(url: URL) async throws -> URL { + let urlAsset = AVURLAsset(url: url) + let exporter = NextLevelSessionExporter(withAsset: urlAsset) + exporter.outputFileType = .mp4 + + var isLandscape: Bool = { + guard let track = urlAsset.tracks(withMediaType: .video).first else { + return true + } + + let size = track.naturalSize.applying(track.preferredTransform) + return abs(size.width) >= abs(size.height) + }() + + let outputURL = try FileManager.default.createTemporaryFileURL( + filename: UUID().uuidString, + pathExtension: url.pathExtension + ) + exporter.outputURL = outputURL + + let compressionDict: [String: Any] = [ + AVVideoAverageBitRateKey: NSNumber(integerLiteral: 3000000), // 3000k + AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel as String, + AVVideoAverageNonDroppableFrameRateKey: NSNumber(floatLiteral: 30), // 30 FPS + ] + exporter.videoOutputConfiguration = [ + AVVideoCodecKey: AVVideoCodecType.h264, + AVVideoWidthKey: NSNumber(integerLiteral: isLandscape ? 1280 : 720), + AVVideoHeightKey: NSNumber(integerLiteral: isLandscape ? 720 : 1280), + AVVideoScalingModeKey: AVVideoScalingModeResizeAspectFill, + AVVideoCompressionPropertiesKey: compressionDict + ] + exporter.audioOutputConfiguration = [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVEncoderBitRateKey: NSNumber(integerLiteral: 128000), // 128k + AVNumberOfChannelsKey: NSNumber(integerLiteral: 2), + AVSampleRateKey: NSNumber(value: Float(44100)) + ] + + // needs set to LOW priority to prevent priority inverse issue + let task = Task(priority: .utility) { + _ = try await exportVideo(by: exporter) + } + _ = try await task.value + + return outputURL + } + + private func exportVideo(by exporter: NextLevelSessionExporter) async throws -> URL { + guard let outputURL = exporter.outputURL else { + throw AppError.badRequest + } + return try await withCheckedThrowingContinuation { continuation in + exporter.export(progressHandler: { progress in + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.videoCompressProgress = Double(progress) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: export progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress) + } + }, completionHandler: { result in + switch result { + case .success(let status): + switch status { + case .completed: + print("NextLevelSessionExporter, export completed, \(exporter.outputURL?.description ?? "")") + continuation.resume(with: .success(outputURL)) + default: + if Task.isCancelled { + exporter.cancelExport() + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: cancel export", ((#file as NSString).lastPathComponent), #line, #function) + } + print("NextLevelSessionExporter, did not complete") + } + case .failure(let error): + continuation.resume(with: .failure(error)) + } + }) + } + } // end func +} diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+DragAndDrop.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+DragAndDrop.swift new file mode 100644 index 000000000..269b836bc --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+DragAndDrop.swift @@ -0,0 +1,144 @@ +// +// AttachmentViewModel+DragAndDrop.swift +// +// +// Created by MainasuK on 2022/11/8. +// + +import os.log +import UIKit +import Combine +import UniformTypeIdentifiers + +// MARK: - TypeIdentifiedItemProvider +extension AttachmentViewModel: TypeIdentifiedItemProvider { + public static var typeIdentifier: String { + // must in UTI format + // https://developer.apple.com/library/archive/qa/qa1796/_index.html + return "org.joinmastodon.app.AttachmentViewModel" + } +} + +// MARK: - NSItemProviderWriting +extension AttachmentViewModel: NSItemProviderWriting { + + + /// Attachment uniform type idendifiers + /// + /// The latest one for in-app drag and drop. + /// And use generic `image` and `movie` type to + /// allows transformable media in different formats + public static var writableTypeIdentifiersForItemProvider: [String] { + return [ + UTType.image.identifier, + UTType.movie.identifier, + AttachmentViewModel.typeIdentifier, + ] + } + + public var writableTypeIdentifiersForItemProvider: [String] { + // should append elements in priority order from high to low + var typeIdentifiers: [String] = [] + + // FIXME: check jpg or png + switch input { + case .image: + typeIdentifiers.append(UTType.png.identifier) + case .url(let url): + let _uti = UTType(filenameExtension: url.pathExtension) + if let uti = _uti { + if uti.conforms(to: .image) { + typeIdentifiers.append(UTType.png.identifier) + } else if uti.conforms(to: .movie) { + typeIdentifiers.append(UTType.mpeg4Movie.identifier) + } + } + case .pickerResult(let item): + if item.itemProvider.isImage() { + typeIdentifiers.append(UTType.png.identifier) + } else if item.itemProvider.isMovie() { + typeIdentifiers.append(UTType.mpeg4Movie.identifier) + } + case .itemProvider(let itemProvider): + if itemProvider.isImage() { + typeIdentifiers.append(UTType.png.identifier) + } else if itemProvider.isMovie() { + typeIdentifiers.append(UTType.mpeg4Movie.identifier) + } + } + + typeIdentifiers.append(AttachmentViewModel.typeIdentifier) + + return typeIdentifiers + } + + public func loadData( + withTypeIdentifier typeIdentifier: String, + forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void + ) -> Progress? { + switch typeIdentifier { + case AttachmentViewModel.typeIdentifier: + do { + let archiver = NSKeyedArchiver(requiringSecureCoding: false) + try archiver.encodeEncodable(id, forKey: NSKeyedArchiveRootObjectKey) + archiver.finishEncoding() + let data = archiver.encodedData + completionHandler(data, nil) + } catch { + assertionFailure() + completionHandler(nil, nil) + } + default: + break + } + + let loadingProgress = Progress(totalUnitCount: 100) + + Publishers.CombineLatest( + $output, + $error + ) + .sink { [weak self] output, error in + guard let self = self else { return } + + // continue when load completed + guard output != nil || error != nil else { return } + + switch output { + case .image(let data, _): + switch typeIdentifier { + case UTType.png.identifier: + loadingProgress.completedUnitCount = 100 + completionHandler(data, nil) + default: + completionHandler(nil, nil) + } + case .video(let url, _): + switch typeIdentifier { + case UTType.png.identifier: + let _image = AttachmentViewModel.createThumbnailForVideo(url: url) + let _data = _image?.pngData() + loadingProgress.completedUnitCount = 100 + completionHandler(_data, nil) + case UTType.mpeg4Movie.identifier: + let task = URLSession.shared.dataTask(with: url) { data, response, error in + completionHandler(data, error) + } + task.progress.observe(\.fractionCompleted) { progress, change in + loadingProgress.completedUnitCount = Int64(100 * progress.fractionCompleted) + } + .store(in: &self.observations) + task.resume() + default: + completionHandler(nil, nil) + } + case nil: + completionHandler(nil, error) + } + } + .store(in: &disposeBag) + + return loadingProgress + } + +} diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Load.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Load.swift new file mode 100644 index 000000000..a259485f1 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Load.swift @@ -0,0 +1,148 @@ +// +// AttachmentViewModel+Load.swift +// +// +// Created by MainasuK on 2022/11/8. +// + +import os.log +import UIKit +import AVKit +import UniformTypeIdentifiers + +extension AttachmentViewModel { + + @MainActor + func load(input: Input) async throws -> Output { + switch input { + case .image(let image): + guard let data = image.pngData() else { + throw AttachmentError.invalidAttachmentType + } + return .image(data, imageKind: .png) + case .url(let url): + do { + let output = try await AttachmentViewModel.load(url: url) + return output + } catch { + throw error + } + case .pickerResult(let pickerResult): + do { + let output = try await AttachmentViewModel.load(itemProvider: pickerResult.itemProvider) + return output + } catch { + throw error + } + case .itemProvider(let itemProvider): + do { + let output = try await AttachmentViewModel.load(itemProvider: itemProvider) + return output + } catch { + throw error + } + } + } + + private static func load(url: URL) async throws -> Output { + guard let uti = UTType(filenameExtension: url.pathExtension) else { + throw AttachmentError.invalidAttachmentType + } + + if uti.conforms(to: .image) { + guard url.startAccessingSecurityScopedResource() else { + throw AttachmentError.invalidAttachmentType + } + defer { url.stopAccessingSecurityScopedResource() } + let imageData = try Data(contentsOf: url) + return .image(imageData, imageKind: imageData.kf.imageFormat == .PNG ? .png : .jpg) + } else if uti.conforms(to: .movie) { + guard url.startAccessingSecurityScopedResource() else { + throw AttachmentError.invalidAttachmentType + } + defer { url.stopAccessingSecurityScopedResource() } + + let fileName = UUID().uuidString + let tempDirectoryURL = FileManager.default.temporaryDirectory + let fileURL = tempDirectoryURL.appendingPathComponent(fileName).appendingPathExtension(url.pathExtension) + try FileManager.default.createDirectory(at: tempDirectoryURL, withIntermediateDirectories: true, attributes: nil) + try FileManager.default.copyItem(at: url, to: fileURL) + return .video(fileURL, mimeType: UTType.movie.preferredMIMEType ?? "video/mp4") + } else { + throw AttachmentError.invalidAttachmentType + } + } + + private static func load(itemProvider: NSItemProvider) async throws -> Output { + if itemProvider.isImage() { + guard let result = try await itemProvider.loadImageData() else { + throw AttachmentError.invalidAttachmentType + } + let imageKind: Output.ImageKind = { + if let type = result.type { + if type == UTType.png { + return .png + } + if type == UTType.jpeg { + return .jpg + } + } + + let imageData = result.data + + if imageData.kf.imageFormat == .PNG { + return .png + } + if imageData.kf.imageFormat == .JPEG { + return .jpg + } + + assertionFailure("unknown image kind") + return .jpg + }() + return .image(result.data, imageKind: imageKind) + } else if itemProvider.isMovie() { + guard let result = try await itemProvider.loadVideoData() else { + throw AttachmentError.invalidAttachmentType + } + return .video(result.url, mimeType: "video/mp4") + } else { + assertionFailure() + throw AttachmentError.invalidAttachmentType + } + } + +} + +extension AttachmentViewModel { + static func createThumbnailForVideo(url: URL) -> UIImage? { + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + let asset = AVURLAsset(url: url) + let assetImageGenerator = AVAssetImageGenerator(asset: asset) + assetImageGenerator.appliesPreferredTrackTransform = true // fix orientation + do { + let cgImage = try assetImageGenerator.copyCGImage(at: CMTimeMake(value: 0, timescale: 1), actualTime: nil) + let image = UIImage(cgImage: cgImage) + return image + } catch { + AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): thumbnail generate fail: \(error.localizedDescription)") + return nil + } + } +} + +extension NSItemProvider { + func isImage() -> Bool { + return hasRepresentationConforming( + toTypeIdentifier: UTType.image.identifier, + fileOptions: [] + ) + } + + func isMovie() -> Bool { + return hasRepresentationConforming( + toTypeIdentifier: UTType.movie.identifier, + fileOptions: [] + ) + } +} diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Upload.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Upload.swift index 0a4aadec3..e26e97d35 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Upload.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Upload.swift @@ -52,153 +52,65 @@ extension Data { } } -// Twitter Only -//extension AttachmentViewModel { -// class SliceResult { -// -// let fileURL: URL -// let chunks: Chunked -// let chunkCount: Int -// let type: UTType -// let sizeInBytes: UInt64 -// -// public init?( -// url: URL, -// type: UTType -// ) { -// guard let chunks = try? FileHandle(forReadingFrom: url).bytes.chunked else { return nil } -// let _sizeInBytes: UInt64? = { -// let attribute = try? FileManager.default.attributesOfItem(atPath: url.path) -// return attribute?[.size] as? UInt64 -// }() -// guard let sizeInBytes = _sizeInBytes else { return nil } -// -// self.fileURL = url -// self.chunks = chunks -// self.chunkCount = SliceResult.chunkCount(chunkSize: UInt64(chunks.chunkSize), sizeInBytes: sizeInBytes) -// self.type = type -// self.sizeInBytes = sizeInBytes -// } -// -// public init?( -// imageData: Data, -// type: UTType -// ) { -// let _fileURL = try? FileManager.default.createTemporaryFileURL( -// filename: UUID().uuidString, -// pathExtension: imageData.kf.imageFormat == .PNG ? "png" : "jpeg" -// ) -// guard let fileURL = _fileURL else { return nil } -// -// do { -// try imageData.write(to: fileURL) -// } catch { -// return nil -// } -// -// guard let chunks = try? FileHandle(forReadingFrom: fileURL).bytes.chunked else { -// return nil -// } -// let sizeInBytes = UInt64(imageData.count) -// -// self.fileURL = fileURL -// self.chunks = chunks -// self.chunkCount = SliceResult.chunkCount(chunkSize: UInt64(chunks.chunkSize), sizeInBytes: sizeInBytes) -// self.type = type -// self.sizeInBytes = sizeInBytes -// } -// -// static func chunkCount(chunkSize: UInt64, sizeInBytes: UInt64) -> Int { -// guard sizeInBytes > 0 else { return 0 } -// let count = sizeInBytes / chunkSize -// let remains = sizeInBytes % chunkSize -// let result = remains > 0 ? count + 1 : count -// return Int(result) -// } -// -// } -// -// static func slice(output: Output, sizeLimit: SizeLimit) -> SliceResult? { -// // needs execute in background -// assert(!Thread.isMainThread) -// -// // try png then use JPEG compress with Q=0.8 -// // then slice into 1MiB chunks -// switch output { -// case .image(let data, _): -// let maxPayloadSizeInBytes = sizeLimit.image -// -// // use processed imageData to remove EXIF -// guard let image = UIImage(data: data), -// var imageData = image.pngData() -// else { return nil } -// -// var didRemoveEXIF = false -// repeat { -// guard let image = KFCrossPlatformImage(data: imageData) else { return nil } -// if imageData.kf.imageFormat == .PNG { -// // A. png image -// guard let pngData = image.pngData() else { return nil } -// didRemoveEXIF = true -// if pngData.count > maxPayloadSizeInBytes { -// guard let compressedJpegData = image.jpegData(compressionQuality: 0.8) else { return nil } -// os_log("%{public}s[%{public}ld], %{public}s: compress png %.2fMiB -> jpeg %.2fMiB", ((#file as NSString).lastPathComponent), #line, #function, Double(imageData.count) / 1024 / 1024, Double(compressedJpegData.count) / 1024 / 1024) -// imageData = compressedJpegData -// } else { -// os_log("%{public}s[%{public}ld], %{public}s: png %.2fMiB", ((#file as NSString).lastPathComponent), #line, #function, Double(pngData.count) / 1024 / 1024) -// imageData = pngData -// } -// } else { -// // B. other image -// if !didRemoveEXIF { -// guard let jpegData = image.jpegData(compressionQuality: 0.8) else { return nil } -// os_log("%{public}s[%{public}ld], %{public}s: compress jpeg %.2fMiB -> jpeg %.2fMiB", ((#file as NSString).lastPathComponent), #line, #function, Double(imageData.count) / 1024 / 1024, Double(jpegData.count) / 1024 / 1024) -// imageData = jpegData -// didRemoveEXIF = true -// } else { -// let targetSize = CGSize(width: image.size.width * 0.8, height: image.size.height * 0.8) -// let scaledImage = image.af.imageScaled(to: targetSize) -// guard let compressedJpegData = scaledImage.jpegData(compressionQuality: 0.8) else { return nil } -// os_log("%{public}s[%{public}ld], %{public}s: compress jpeg %.2fMiB -> jpeg %.2fMiB", ((#file as NSString).lastPathComponent), #line, #function, Double(imageData.count) / 1024 / 1024, Double(compressedJpegData.count) / 1024 / 1024) -// imageData = compressedJpegData -// } -// } -// } while (imageData.count > maxPayloadSizeInBytes) -// -// return SliceResult( -// imageData: imageData, -// type: imageData.kf.imageFormat == .PNG ? UTType.png : UTType.jpeg -// ) -// -//// case .gif(let url): -//// fatalError() -// case .video(let url, _): -// return SliceResult( -// url: url, -// type: .movie -// ) -// } -// } -//} - extension AttachmentViewModel { + public enum UploadState { + case none + case compressing + case ready + case uploading + case fail + case finish + } + struct UploadContext { let apiService: APIService let authContext: AuthContext } - enum UploadResult { - case mastodon(Mastodon.Response.Content) - } + public typealias UploadResult = Mastodon.Entity.Attachment } extension AttachmentViewModel { - func upload(context: UploadContext) async throws -> UploadResult { - return try await uploadMastodonMedia( - context: context - ) + @MainActor + func upload(isRetry: Bool = false) async throws { + do { + let result = try await upload( + context: .init( + apiService: self.api, + authContext: self.authContext + ), + isRetry: isRetry + ) + update(uploadResult: result) + } catch { + self.error = error + } } + @MainActor + private func upload(context: UploadContext, isRetry: Bool) async throws -> UploadResult { + if isRetry { + guard uploadState == .fail else { throw AppError.badRequest } + self.error = nil + self.fractionCompleted = 0 + } else { + guard uploadState == .ready else { throw AppError.badRequest } + } + do { + update(uploadState: .uploading) + let result = try await uploadMastodonMedia( + context: context + ) + update(uploadState: .finish) + return result + } catch { + update(uploadState: .fail) + throw error + } + } + + // MainActor is required here to trigger stream upload task + @MainActor private func uploadMastodonMedia( context: UploadContext ) async throws -> UploadResult { @@ -260,7 +172,7 @@ extension AttachmentViewModel { if attachmentUploadResponse.statusCode == 202 { // note: // the Mastodon server append the attachments in order by upload time - // can not upload concurrency + // can not upload parallels let waitProcessRetryLimit = checkUploadTaskRetryLimit var waitProcessRetryCount: Int64 = 0 @@ -283,7 +195,7 @@ extension AttachmentViewModel { // escape here progress.completedUnitCount = progress.totalUnitCount - return .mastodon(attachmentStatusResponse) + return attachmentStatusResponse.value } else { AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): attachment processing. Retry \(waitProcessRetryCount)/\(waitProcessRetryLimit)") @@ -296,7 +208,7 @@ extension AttachmentViewModel { } else { AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): upload attachment success: \(attachmentUploadResponse.value.url ?? "")") - return .mastodon(attachmentUploadResponse) + return attachmentUploadResponse.value } } } diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift index 7d0e8c859..9a0f58f47 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift @@ -11,36 +11,112 @@ import Combine import PhotosUI import Kingfisher import MastodonCore +import func QuartzCore.CACurrentMediaTime + +public protocol AttachmentViewModelDelegate: AnyObject { + func attachmentViewModel(_ viewModel: AttachmentViewModel, uploadStateValueDidChange state: AttachmentViewModel.UploadState) + func attachmentViewModel(_ viewModel: AttachmentViewModel, actionButtonDidPressed action: AttachmentViewModel.Action) +} final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable { static let logger = Logger(subsystem: "AttachmentViewModel", category: "ViewModel") + let logger = Logger(subsystem: "AttachmentViewModel", category: "ViewModel") public let id = UUID() var disposeBag = Set() var observations = Set() + + weak var delegate: AttachmentViewModelDelegate? + + let byteCountFormatter: ByteCountFormatter = { + let formatter = ByteCountFormatter() + formatter.allowsNonnumericFormatting = true + formatter.countStyle = .memory + return formatter + }() + + let percentageFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .percent + return formatter + }() // input + public let api: APIService + public let authContext: AuthContext public let input: Input @Published var caption = "" @Published var sizeLimit = SizeLimit() - @Published public var isPreviewPresented = false + + // var compressVideoTask: Task? // output @Published public private(set) var output: Output? @Published public private(set) var thumbnail: UIImage? // original size image thumbnail - @Published var error: Error? - let progress = Progress() // upload progress + @Published public private(set) var outputSizeInByte: Int64 = 0 - public init(input: Input) { + @Published public private(set) var uploadState: UploadState = .none + @Published public private(set) var uploadResult: UploadResult? + @Published var error: Error? + + var uploadTask: Task<(), Never>? + + @Published var videoCompressProgress: Double = 0 + + let progress = Progress() // upload progress + @Published var fractionCompleted: Double = 0 + + private var lastTimestamp: TimeInterval? + private var lastUploadSizeInByte: Int64 = 0 + private var averageUploadSpeedInByte: Int64 = 0 + private var remainTimeInterval: Double? + @Published var remainTimeLocalizedString: String? + + public init( + api: APIService, + authContext: AuthContext, + input: Input, + delegate: AttachmentViewModelDelegate + ) { + self.api = api + self.authContext = authContext self.input = input + self.delegate = delegate super.init() // end init - defer { - load(input: input) - } + Timer.publish(every: 1.0 / 60.0, on: .main, in: .common) // 60 FPS + .autoconnect() + .share() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.step() + } + .store(in: &disposeBag) + + progress + .observe(\.fractionCompleted, options: [.initial, .new]) { [weak self] progress, _ in + guard let self = self else { return } + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): publish progress \(progress.fractionCompleted)") + DispatchQueue.main.async { + self.fractionCompleted = progress.fractionCompleted + } + } + .store(in: &observations) + + // Note: this observation is redundant if .fractionCompleted listener always emit event when reach 1.0 progress + // progress + // .observe(\.isFinished, options: [.initial, .new]) { [weak self] progress, _ in + // guard let self = self else { return } + // self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): publish progress \(progress.fractionCompleted)") + // DispatchQueue.main.async { + // self.objectWillChange.send() + // } + // } + // .store(in: &observations) $output .map { output -> UIImage? in @@ -53,22 +129,121 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable return nil } } + .receive(on: DispatchQueue.main) .assign(to: &$thumbnail) + + defer { + let uploadTask = Task { @MainActor in + do { + var output = try await load(input: input) + + switch output { + case .video(let fileURL, let mimeType): + self.output = output + self.update(uploadState: .compressing) + let compressedFileURL = try await comporessVideo(url: fileURL) + output = .video(compressedFileURL, mimeType: mimeType) + try? FileManager.default.removeItem(at: fileURL) // remove old file + default: + break + } + + self.outputSizeInByte = output.asAttachment.sizeInByte.flatMap { Int64($0) } ?? 0 + self.output = output + + self.update(uploadState: .ready) + self.delegate?.attachmentViewModel(self, uploadStateValueDidChange: self.uploadState) + } catch { + self.error = error + } + } // end Task + self.uploadTask = uploadTask + Task { + await uploadTask.value + } + } } deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + uploadTask?.cancel() + switch output { case .image: // FIXME: break case .video(let url, _): try? FileManager.default.removeItem(at: url) - case nil : + case nil: break } } } +// calculate the upload speed +// ref: https://stackoverflow.com/a/3841706/3797903 +extension AttachmentViewModel { + + static var SpeedSmoothingFactor = 0.4 + static let remainsTimeFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + return formatter + }() + + @objc private func step() { + + let uploadProgress = min(progress.fractionCompleted + 0.1, 1) // the progress split into 9:1 blocks (download : waiting) + + guard let lastTimestamp = self.lastTimestamp else { + self.lastTimestamp = CACurrentMediaTime() + self.lastUploadSizeInByte = Int64(Double(outputSizeInByte) * uploadProgress) + return + } + + let duration = CACurrentMediaTime() - lastTimestamp + guard duration >= 1.0 else { return } // update every 1 sec + + let old = self.lastUploadSizeInByte + self.lastUploadSizeInByte = Int64(Double(outputSizeInByte) * uploadProgress) + + let newSpeed = self.lastUploadSizeInByte - old + let lastAverageSpeed = self.averageUploadSpeedInByte + let newAverageSpeed = Int64(AttachmentViewModel.SpeedSmoothingFactor * Double(newSpeed) + (1 - AttachmentViewModel.SpeedSmoothingFactor) * Double(lastAverageSpeed)) + + let remainSizeInByte = Double(outputSizeInByte) * (1 - uploadProgress) + + let speed = Double(newAverageSpeed) + if speed != .zero { + // estimate by speed + let uploadRemainTimeInSecond = remainSizeInByte / speed + // estimate by progress 1s for 10% + let remainPercentage = 1 - uploadProgress + let estimateRemainTimeByProgress = remainPercentage / 0.1 + // max estimate + var remainTimeInSecond = max(estimateRemainTimeByProgress, uploadRemainTimeInSecond) + + // do not increate timer when < 5 sec + if let remainTimeInterval = self.remainTimeInterval, remainTimeInSecond < 5 { + remainTimeInSecond = min(remainTimeInterval, remainTimeInSecond) + self.remainTimeInterval = remainTimeInSecond + } else { + self.remainTimeInterval = remainTimeInSecond + } + + let string = AttachmentViewModel.remainsTimeFormatter.localizedString(fromTimeInterval: remainTimeInSecond) + remainTimeLocalizedString = string + // print("remains: \(remainSizeInByte), speed: \(newAverageSpeed), \(string)") + } else { + remainTimeLocalizedString = nil + } + + self.lastTimestamp = CACurrentMediaTime() + self.averageUploadSpeedInByte = newAverageSpeed + } +} + extension AttachmentViewModel { public enum Input: Hashable { case image(UIImage) @@ -86,13 +261,6 @@ extension AttachmentViewModel { case png case jpg } - - public var twitterMediaCategory: TwitterMediaCategory { - switch self { - case .image: return .image - case .video: return .amplifyVideo - } - } } public struct SizeLimit { @@ -111,291 +279,38 @@ extension AttachmentViewModel { } } - public enum AttachmentError: Error { + public enum AttachmentError: Error, LocalizedError { case invalidAttachmentType case attachmentTooLarge - } - - public enum TwitterMediaCategory: String { - case image = "TWEET_IMAGE" - case GIF = "TWEET_GIF" - case video = "TWEET_VIDEO" - case amplifyVideo = "AMPLIFY_VIDEO" - } -} - -extension AttachmentViewModel { - - private func load(input: Input) { - switch input { - case .image(let image): - guard let data = image.pngData() else { - error = AttachmentError.invalidAttachmentType - return - } - output = .image(data, imageKind: .png) - case .url(let url): - Task { @MainActor in - do { - let output = try await AttachmentViewModel.load(url: url) - self.output = output - } catch { - self.error = error - } - } // end Task - case .pickerResult(let pickerResult): - Task { @MainActor in - do { - let output = try await AttachmentViewModel.load(itemProvider: pickerResult.itemProvider) - self.output = output - } catch { - self.error = error - } - } // end Task - case .itemProvider(let itemProvider): - Task { @MainActor in - do { - let output = try await AttachmentViewModel.load(itemProvider: itemProvider) - self.output = output - } catch { - self.error = error - } - } // end Task - } - } - - private static func load(url: URL) async throws -> Output { - guard let uti = UTType(filenameExtension: url.pathExtension) else { - throw AttachmentError.invalidAttachmentType - } - if uti.conforms(to: .image) { - guard url.startAccessingSecurityScopedResource() else { - throw AttachmentError.invalidAttachmentType + public var errorDescription: String? { + switch self { + case .invalidAttachmentType: + return "Can not regonize this media attachment" // TODO: i18n + case .attachmentTooLarge: + return "Attachment too large" } - defer { url.stopAccessingSecurityScopedResource() } - let imageData = try Data(contentsOf: url) - return .image(imageData, imageKind: imageData.kf.imageFormat == .PNG ? .png : .jpg) - } else if uti.conforms(to: .movie) { - guard url.startAccessingSecurityScopedResource() else { - throw AttachmentError.invalidAttachmentType - } - defer { url.stopAccessingSecurityScopedResource() } - - let fileName = UUID().uuidString - let tempDirectoryURL = FileManager.default.temporaryDirectory - let fileURL = tempDirectoryURL.appendingPathComponent(fileName).appendingPathExtension(url.pathExtension) - try FileManager.default.createDirectory(at: tempDirectoryURL, withIntermediateDirectories: true, attributes: nil) - try FileManager.default.copyItem(at: url, to: fileURL) - return .video(fileURL, mimeType: UTType.movie.preferredMIMEType ?? "video/mp4") - } else { - throw AttachmentError.invalidAttachmentType - } - } - - private static func load(itemProvider: NSItemProvider) async throws -> Output { - if itemProvider.isImage() { - guard let result = try await itemProvider.loadImageData() else { - throw AttachmentError.invalidAttachmentType - } - let imageKind: Output.ImageKind = { - if let type = result.type { - if type == UTType.png { - return .png - } - if type == UTType.jpeg { - return .jpg - } - } - - let imageData = result.data - - if imageData.kf.imageFormat == .PNG { - return .png - } - if imageData.kf.imageFormat == .JPEG { - return .jpg - } - - assertionFailure("unknown image kind") - return .jpg - }() - return .image(result.data, imageKind: imageKind) - } else if itemProvider.isMovie() { - guard let result = try await itemProvider.loadVideoData() else { - throw AttachmentError.invalidAttachmentType - } - return .video(result.url, mimeType: "video/mp4") - } else { - assertionFailure() - throw AttachmentError.invalidAttachmentType } } } extension AttachmentViewModel { - static func createThumbnailForVideo(url: URL) -> UIImage? { - guard FileManager.default.fileExists(atPath: url.path) else { return nil } - let asset = AVURLAsset(url: url) - let assetImageGenerator = AVAssetImageGenerator(asset: asset) - assetImageGenerator.appliesPreferredTrackTransform = true // fix orientation - do { - let cgImage = try assetImageGenerator.copyCGImage(at: CMTimeMake(value: 0, timescale: 1), actualTime: nil) - let image = UIImage(cgImage: cgImage) - return image - } catch { - AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): thumbnail generate fail: \(error.localizedDescription)") - return nil - } + public enum Action: Hashable { + case remove + case retry } } -// MARK: - TypeIdentifiedItemProvider -extension AttachmentViewModel: TypeIdentifiedItemProvider { - public static var typeIdentifier: String { - // must in UTI format - // https://developer.apple.com/library/archive/qa/qa1796/_index.html - return "com.twidere.AttachmentViewModel" - } -} - -// MARK: - NSItemProviderWriting -extension AttachmentViewModel: NSItemProviderWriting { - - - /// Attachment uniform type idendifiers - /// - /// The latest one for in-app drag and drop. - /// And use generic `image` and `movie` type to - /// allows transformable media in different formats - public static var writableTypeIdentifiersForItemProvider: [String] { - return [ - UTType.image.identifier, - UTType.movie.identifier, - AttachmentViewModel.typeIdentifier, - ] - } - - public var writableTypeIdentifiersForItemProvider: [String] { - // should append elements in priority order from high to low - var typeIdentifiers: [String] = [] - - // FIXME: check jpg or png - switch input { - case .image: - typeIdentifiers.append(UTType.png.identifier) - case .url(let url): - let _uti = UTType(filenameExtension: url.pathExtension) - if let uti = _uti { - if uti.conforms(to: .image) { - typeIdentifiers.append(UTType.png.identifier) - } else if uti.conforms(to: .movie) { - typeIdentifiers.append(UTType.mpeg4Movie.identifier) - } - } - case .pickerResult(let item): - if item.itemProvider.isImage() { - typeIdentifiers.append(UTType.png.identifier) - } else if item.itemProvider.isMovie() { - typeIdentifiers.append(UTType.mpeg4Movie.identifier) - } - case .itemProvider(let itemProvider): - if itemProvider.isImage() { - typeIdentifiers.append(UTType.png.identifier) - } else if itemProvider.isMovie() { - typeIdentifiers.append(UTType.mpeg4Movie.identifier) - } - } - - typeIdentifiers.append(AttachmentViewModel.typeIdentifier) - - return typeIdentifiers - } - - public func loadData( - withTypeIdentifier typeIdentifier: String, - forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void - ) -> Progress? { - switch typeIdentifier { - case AttachmentViewModel.typeIdentifier: - do { - let archiver = NSKeyedArchiver(requiringSecureCoding: false) - try archiver.encodeEncodable(id, forKey: NSKeyedArchiveRootObjectKey) - archiver.finishEncoding() - let data = archiver.encodedData - completionHandler(data, nil) - } catch { - assertionFailure() - completionHandler(nil, nil) - } - default: - break - } - - let loadingProgress = Progress(totalUnitCount: 100) - - Publishers.CombineLatest( - $output, - $error - ) - .sink { [weak self] output, error in - guard let self = self else { return } - - // continue when load completed - guard output != nil || error != nil else { return } - - switch output { - case .image(let data, _): - switch typeIdentifier { - case UTType.png.identifier: - loadingProgress.completedUnitCount = 100 - completionHandler(data, nil) - default: - completionHandler(nil, nil) - } - case .video(let url, _): - switch typeIdentifier { - case UTType.png.identifier: - let _image = AttachmentViewModel.createThumbnailForVideo(url: url) - let _data = _image?.pngData() - loadingProgress.completedUnitCount = 100 - completionHandler(_data, nil) - case UTType.mpeg4Movie.identifier: - let task = URLSession.shared.dataTask(with: url) { data, response, error in - completionHandler(data, error) - } - task.progress.observe(\.fractionCompleted) { progress, change in - loadingProgress.completedUnitCount = Int64(100 * progress.fractionCompleted) - } - .store(in: &self.observations) - task.resume() - default: - completionHandler(nil, nil) - } - case nil: - completionHandler(nil, error) - } - } - .store(in: &disposeBag) - - return loadingProgress - } - -} - -extension NSItemProvider { - fileprivate func isImage() -> Bool { - return hasRepresentationConforming( - toTypeIdentifier: UTType.image.identifier, - fileOptions: [] - ) - } - - fileprivate func isMovie() -> Bool { - return hasRepresentationConforming( - toTypeIdentifier: UTType.movie.identifier, - fileOptions: [] - ) +extension AttachmentViewModel { + @MainActor + func update(uploadState: UploadState) { + self.uploadState = uploadState + self.delegate?.attachmentViewModel(self, uploadStateValueDidChange: self.uploadState) + } + + @MainActor + func update(uploadResult: UploadResult) { + self.uploadResult = uploadResult } } diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/AutoComplete/AutoCompleteViewController.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/AutoComplete/AutoCompleteViewController.swift index aa21057d1..9af1ce9bf 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/AutoComplete/AutoCompleteViewController.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/AutoComplete/AutoCompleteViewController.swift @@ -88,7 +88,7 @@ extension AutoCompleteViewController { ]) tableView.delegate = self -// viewModel.setupDiffableDataSource(tableView: tableView) + viewModel.setupDiffableDataSource(tableView: tableView) // bind to layout chevron viewModel.symbolBoundingRect diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/AutoComplete/AutoCompleteViewModel+Diffable.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/AutoComplete/AutoCompleteViewModel+Diffable.swift index adbf6ac09..2dd815d0a 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/AutoComplete/AutoCompleteViewModel+Diffable.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/AutoComplete/AutoCompleteViewModel+Diffable.swift @@ -6,17 +6,18 @@ // import UIKit +import MastodonCore extension AutoCompleteViewModel { -// func setupDiffableDataSource( -// tableView: UITableView -// ) { -// diffableDataSource = AutoCompleteSection.tableViewDiffableDataSource(for: tableView) -// -// var snapshot = NSDiffableDataSourceSnapshot() -// snapshot.appendSections([.main]) -// diffableDataSource?.apply(snapshot) -// } + func setupDiffableDataSource( + tableView: UITableView + ) { + diffableDataSource = AutoCompleteSection.tableViewDiffableDataSource(tableView: tableView) + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + diffableDataSource?.apply(snapshot) + } } diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/AutoComplete/AutoCompleteViewModel+State.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/AutoComplete/AutoCompleteViewModel+State.swift index b1f5f3187..7f93c4ba7 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/AutoComplete/AutoCompleteViewModel+State.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/AutoComplete/AutoCompleteViewModel+State.swift @@ -102,7 +102,7 @@ extension AutoCompleteViewModel.State { return } - guard let customEmojiViewModel = viewModel.customEmojiViewModel.value else { + guard let customEmojiViewModel = viewModel.customEmojiViewModel else { await enter(state: Fail.self) return } diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/AutoComplete/AutoCompleteViewModel.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/AutoComplete/AutoCompleteViewModel.swift index 61715cd63..7459f68d1 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/AutoComplete/AutoCompleteViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/AutoComplete/AutoCompleteViewModel.swift @@ -20,7 +20,7 @@ final class AutoCompleteViewModel { let authContext: AuthContext public let inputText = CurrentValueSubject("") // contains "@" or "#" prefix public let symbolBoundingRect = CurrentValueSubject(.zero) - public let customEmojiViewModel = CurrentValueSubject(nil) + public let customEmojiViewModel: EmojiService.CustomEmojiViewModel? // output public var autoCompleteItems = CurrentValueSubject<[AutoCompleteItem], Never>([]) @@ -40,6 +40,8 @@ final class AutoCompleteViewModel { init(context: AppContext, authContext: AuthContext) { self.context = context self.authContext = authContext + self.customEmojiViewModel = context.emojiService.dequeueCustomEmojiViewModel(for: authContext.mastodonAuthenticationBox.domain) + // end init autoCompleteItems .receive(on: DispatchQueue.main) diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/AutoComplete/View/AutoCompleteTopChevronView.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/AutoComplete/View/AutoCompleteTopChevronView.swift index ccc36b1df..6b842c9f1 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/AutoComplete/View/AutoCompleteTopChevronView.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/AutoComplete/View/AutoCompleteTopChevronView.swift @@ -8,7 +8,6 @@ import UIKit import Combine import MastodonCore -import MastodonUI final class AutoCompleteTopChevronView: UIView { diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift index 3417ed935..ea6a0136a 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift @@ -14,12 +14,15 @@ import MastodonCore public final class ComposeContentViewController: UIViewController { + static let minAutoCompleteVisibleHeight: CGFloat = 100 + let logger = Logger(subsystem: "ComposeContentViewController", category: "ViewController") var disposeBag = Set() public var viewModel: ComposeContentViewModel! private(set) lazy var composeContentToolbarViewModel = ComposeContentToolbarView.ViewModel(delegate: self) + // tableView container let tableView: ComposeTableView = { let tableView = ComposeTableView() tableView.estimatedRowHeight = UITableView.automaticDimension @@ -29,6 +32,16 @@ public final class ComposeContentViewController: UIViewController { return tableView }() + // auto complete + private(set) lazy var autoCompleteViewController: AutoCompleteViewController = { + let viewController = AutoCompleteViewController() + viewController.viewModel = AutoCompleteViewModel(context: viewModel.context, authContext: viewModel.authContext) + viewController.delegate = self + // viewController.viewModel.customEmojiViewModel.value = viewModel.customEmojiViewModel + return viewController + }() + + // toolbar lazy var composeContentToolbarView = ComposeContentToolbarView(viewModel: composeContentToolbarViewModel) var composeContentToolbarViewBottomLayoutConstraint: NSLayoutConstraint! let composeContentToolbarBackgroundView = UIView() @@ -60,6 +73,15 @@ public final class ComposeContentViewController: UIViewController { documentPickerController.delegate = self return documentPickerController }() + + // emoji picker inputView + let customEmojiPickerInputView: CustomEmojiPickerInputView = { + let view = CustomEmojiPickerInputView( + frame: CGRect(x: 0, y: 0, width: 0, height: 300), + inputViewStyle: .keyboard + ) + return view + }() deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) @@ -71,6 +93,8 @@ extension ComposeContentViewController { public override func viewDidLoad() { super.viewDidLoad() + viewModel.delegate = self + // setup view self.setupBackgroundColor(theme: ThemeService.shared.currentTheme.value) ThemeService.shared.currentTheme @@ -94,6 +118,12 @@ extension ComposeContentViewController { tableView.delegate = self viewModel.setupDataSource(tableView: tableView) + // setup emoji picker + customEmojiPickerInputView.collectionView.delegate = self + viewModel.customEmojiPickerInputViewModel.customEmojiPickerInputView = customEmojiPickerInputView + viewModel.setupCustomEmojiPickerDiffableDataSource(collectionView: customEmojiPickerInputView.collectionView) + + // setup toolbar let toolbarHostingView = UIHostingController(rootView: composeContentToolbarView) toolbarHostingView.view.translatesAutoresizingMaskIntoConstraints = false view.addSubview(toolbarHostingView.view) @@ -116,49 +146,43 @@ extension ComposeContentViewController { view.bottomAnchor.constraint(equalTo: composeContentToolbarBackgroundView.bottomAnchor), ]) - let keyboardHasShortcutBar = CurrentValueSubject(traitCollection.userInterfaceIdiom == .pad) // update default value later + // bind keyboard let keyboardEventPublishers = Publishers.CombineLatest3( KeyboardResponderService.shared.isShow, KeyboardResponderService.shared.state, KeyboardResponderService.shared.endFrame ) -// Publishers.CombineLatest3( -// viewModel.$isCustomEmojiComposing, -// ) - keyboardEventPublishers - .sink(receiveValue: { [weak self] keyboardEvents in + Publishers.CombineLatest3( + keyboardEventPublishers, + viewModel.$isEmojiActive, + viewModel.$autoCompleteInfo + ) + .sink(receiveValue: { [weak self] keyboardEvents, isEmojiActive, autoCompleteInfo in guard let self = self else { return } let (isShow, state, endFrame) = keyboardEvents - -// switch self.traitCollection.userInterfaceIdiom { -// case .pad: -// keyboardHasShortcutBar.value = state != .floating -// default: -// keyboardHasShortcutBar.value = false -// } -// + let extraMargin: CGFloat = { var margin = ComposeContentToolbarView.toolbarHeight -// if autoCompleteInfo != nil { -//// margin += ComposeViewController.minAutoCompleteVisibleHeight -// } + if autoCompleteInfo != nil { + margin += ComposeContentViewController.minAutoCompleteVisibleHeight + } return margin }() -// + guard isShow, state == .dock else { self.tableView.contentInset.bottom = extraMargin self.tableView.verticalScrollIndicatorInsets.bottom = extraMargin -// if let superView = self.autoCompleteViewController.tableView.superview { -// let autoCompleteTableViewBottomInset: CGFloat = { -// let tableViewFrameInWindow = superView.convert(self.autoCompleteViewController.tableView.frame, to: nil) -// let padding = tableViewFrameInWindow.maxY + self.composeToolbarView.frame.height + AutoCompleteViewController.chevronViewHeight - self.view.frame.maxY -// return max(0, padding) -// }() -// self.autoCompleteViewController.tableView.contentInset.bottom = autoCompleteTableViewBottomInset -// self.autoCompleteViewController.tableView.verticalScrollIndicatorInsets.bottom = autoCompleteTableViewBottomInset -// } + if let superView = self.autoCompleteViewController.tableView.superview { + let autoCompleteTableViewBottomInset: CGFloat = { + let tableViewFrameInWindow = superView.convert(self.autoCompleteViewController.tableView.frame, to: nil) + let padding = tableViewFrameInWindow.maxY + ComposeContentToolbarView.toolbarHeight + AutoCompleteViewController.chevronViewHeight - self.view.frame.maxY + return max(0, padding) + }() + self.autoCompleteViewController.tableView.contentInset.bottom = autoCompleteTableViewBottomInset + self.autoCompleteViewController.tableView.verticalScrollIndicatorInsets.bottom = autoCompleteTableViewBottomInset + } UIView.animate(withDuration: 0.3) { self.composeContentToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom @@ -169,17 +193,16 @@ extension ComposeContentViewController { return } // isShow AND dock state -// self.systemKeyboardHeight = endFrame.height // adjust inset for auto-complete -// let autoCompleteTableViewBottomInset: CGFloat = { -// guard let superview = self.autoCompleteViewController.tableView.superview else { return .zero } -// let tableViewFrameInWindow = superview.convert(self.autoCompleteViewController.tableView.frame, to: nil) -// let padding = tableViewFrameInWindow.maxY + self.composeToolbarView.frame.height + AutoCompleteViewController.chevronViewHeight - endFrame.minY -// return max(0, padding) -// }() -// self.autoCompleteViewController.tableView.contentInset.bottom = autoCompleteTableViewBottomInset -// self.autoCompleteViewController.tableView.verticalScrollIndicatorInsets.bottom = autoCompleteTableViewBottomInset + let autoCompleteTableViewBottomInset: CGFloat = { + guard let superview = self.autoCompleteViewController.tableView.superview else { return .zero } + let tableViewFrameInWindow = superview.convert(self.autoCompleteViewController.tableView.frame, to: nil) + let padding = tableViewFrameInWindow.maxY + ComposeContentToolbarView.toolbarHeight + AutoCompleteViewController.chevronViewHeight - endFrame.minY + return max(0, padding) + }() + self.autoCompleteViewController.tableView.contentInset.bottom = autoCompleteTableViewBottomInset + self.autoCompleteViewController.tableView.verticalScrollIndicatorInsets.bottom = autoCompleteTableViewBottomInset // adjust inset for tableView let contentFrame = self.view.convert(self.tableView.frame, to: nil) @@ -218,14 +241,63 @@ extension ComposeContentViewController { } .store(in: &disposeBag) + // bind auto-complete + viewModel.$autoCompleteInfo + .receive(on: DispatchQueue.main) + .sink { [weak self] info in + guard let self = self else { return } + guard let textView = self.viewModel.contentMetaText?.textView else { return } + if self.autoCompleteViewController.view.superview == nil { + self.autoCompleteViewController.view.frame = self.view.bounds + // add to container view. seealso: `viewDidLayoutSubviews()` + self.viewModel.composeContentTableViewCell.contentView.addSubview(self.autoCompleteViewController.view) + self.addChild(self.autoCompleteViewController) + self.autoCompleteViewController.didMove(toParent: self) + self.autoCompleteViewController.view.isHidden = true + self.tableView.autoCompleteViewController = self.autoCompleteViewController + } + self.updateAutoCompleteViewControllerLayout() + self.autoCompleteViewController.view.isHidden = info == nil + guard let info = info else { return } + let symbolBoundingRectInContainer = textView.convert(info.symbolBoundingRect, to: self.autoCompleteViewController.chevronView) + print(info.symbolBoundingRect) + self.autoCompleteViewController.view.frame.origin.y = info.textBoundingRect.maxY + self.viewModel.contentTextViewFrame.minY + self.autoCompleteViewController.viewModel.symbolBoundingRect.value = symbolBoundingRectInContainer + self.autoCompleteViewController.viewModel.inputText.value = String(info.inputText) + } + .store(in: &disposeBag) + + // bind emoji picker + viewModel.customEmojiViewModel?.emojis + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] emojis in + guard let self = self else { return } + if emojis.isEmpty { + self.customEmojiPickerInputView.activityIndicatorView.startAnimating() + } else { + self.customEmojiPickerInputView.activityIndicatorView.stopAnimating() + } + }) + .store(in: &disposeBag) + // bind toolbar bindToolbarViewModel() + + // bind attachment picker + viewModel.$attachmentViewModels + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.resetImagePicker() + } + .store(in: &disposeBag) } public override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() viewModel.viewLayoutFrame.update(view: view) + updateAutoCompleteViewControllerLayout() } public override func viewSafeAreaInsetsDidChange() { @@ -257,6 +329,8 @@ extension ComposeContentViewController { } private func bindToolbarViewModel() { + viewModel.$isAttachmentButtonEnabled.assign(to: &composeContentToolbarViewModel.$isAttachmentButtonEnabled) + viewModel.$isPollButtonEnabled.assign(to: &composeContentToolbarViewModel.$isPollButtonEnabled) viewModel.$isPollActive.assign(to: &composeContentToolbarViewModel.$isPollActive) viewModel.$isEmojiActive.assign(to: &composeContentToolbarViewModel.$isEmojiActive) viewModel.$isContentWarningActive.assign(to: &composeContentToolbarViewModel.$isContentWarningActive) @@ -264,6 +338,29 @@ extension ComposeContentViewController { viewModel.$contentWeightedLength.assign(to: &composeContentToolbarViewModel.$contentWeightedLength) viewModel.$contentWarningWeightedLength.assign(to: &composeContentToolbarViewModel.$contentWarningWeightedLength) } + + private func updateAutoCompleteViewControllerLayout() { + // pin autoCompleteViewController frame to current view + if let containerView = autoCompleteViewController.view.superview { + let viewFrameInWindow = containerView.convert(autoCompleteViewController.view.frame, to: view) + if viewFrameInWindow.origin.x != 0 { + autoCompleteViewController.view.frame.origin.x = -viewFrameInWindow.origin.x + } + autoCompleteViewController.view.frame.size.width = view.frame.width + } + } + + private func resetImagePicker() { + let selectionLimit = max(1, viewModel.maxMediaAttachmentLimit - viewModel.attachmentViewModels.count) + let configuration = ComposeContentViewController.createPhotoLibraryPickerConfiguration(selectionLimit: selectionLimit) + photoLibraryPicker = createImagePicker(configuration: configuration) + } + + private func createImagePicker(configuration: PHPickerConfiguration) -> PHPickerViewController { + let imagePicker = PHPickerViewController(configuration: configuration) + imagePicker.delegate = self + return imagePicker + } } // MARK: - UIScrollViewDelegate @@ -325,16 +422,15 @@ extension ComposeContentViewController: PHPickerViewControllerDelegate { public func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true, completion: nil) - // TODO: -// let attachmentServices: [MastodonAttachmentService] = results.map { result in -// let service = MastodonAttachmentService( -// context: context, -// pickerResult: result, -// initialAuthenticationBox: viewModel.authenticationBox -// ) -// return service -// } -// viewModel.attachmentServices = viewModel.attachmentServices + attachmentServices + let attachmentViewModels: [AttachmentViewModel] = results.map { result in + AttachmentViewModel( + api: viewModel.context.apiService, + authContext: viewModel.authContext, + input: .pickerResult(result), + delegate: viewModel + ) + } + viewModel.attachmentViewModels += attachmentViewModels } } @@ -345,12 +441,13 @@ extension ComposeContentViewController: UIImagePickerControllerDelegate & UINavi guard let image = info[.originalImage] as? UIImage else { return } -// let attachmentService = MastodonAttachmentService( -// context: context, -// image: image, -// initialAuthenticationBox: viewModel.authenticationBox -// ) -// viewModel.attachmentServices = viewModel.attachmentServices + [attachmentService] + let attachmentViewModel = AttachmentViewModel( + api: viewModel.context.apiService, + authContext: viewModel.authContext, + input: .image(image), + delegate: viewModel + ) + viewModel.attachmentViewModels += [attachmentViewModel] } public func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { @@ -364,12 +461,13 @@ extension ComposeContentViewController: UIDocumentPickerDelegate { public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { guard let url = urls.first else { return } -// let attachmentService = MastodonAttachmentService( -// context: context, -// documentURL: url, -// initialAuthenticationBox: viewModel.authenticationBox -// ) -// viewModel.attachmentServices = viewModel.attachmentServices + [attachmentService] + let attachmentViewModel = AttachmentViewModel( + api: viewModel.context.apiService, + authContext: viewModel.authContext, + input: .url(url), + delegate: viewModel + ) + viewModel.attachmentViewModels += [attachmentViewModel] } } @@ -428,3 +526,123 @@ extension ComposeContentViewController: ComposeContentToolbarViewDelegate { } } } + +// MARK: - AutoCompleteViewControllerDelegate +extension ComposeContentViewController: AutoCompleteViewControllerDelegate { + func autoCompleteViewController( + _ viewController: AutoCompleteViewController, + didSelectItem item: AutoCompleteItem + ) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): did select item: \(String(describing: item))") + + guard let info = viewModel.autoCompleteInfo else { return } + guard let metaText = viewModel.contentMetaText else { return } + + let _replacedText: String? = { + var text: String + switch item { + case .hashtag(let hashtag): + text = "#" + hashtag.name + case .hashtagV1(let hashtagName): + text = "#" + hashtagName + case .account(let account): + text = "@" + account.acct + case .emoji(let emoji): + text = ":" + emoji.shortcode + ":" + case .bottomLoader: + return nil + } + return text + }() + guard let replacedText = _replacedText else { return } + guard let text = metaText.textView.text else { return } + + let range = NSRange(info.toHighlightEndRange, in: text) + metaText.textStorage.replaceCharacters(in: range, with: replacedText) + viewModel.autoCompleteInfo = nil + + // set selected range + let newRange = NSRange(location: range.location + (replacedText as NSString).length, length: 0) + guard metaText.textStorage.length <= newRange.location else { return } + metaText.textView.selectedRange = newRange + + // append a space and trigger textView delegate update + DispatchQueue.main.async { + metaText.textView.insertText(" ") + } + } +} + +// MARK: - UICollectionViewDelegate +extension ComposeContentViewController: UICollectionViewDelegate { + + public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: select %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription) + + switch collectionView { + case customEmojiPickerInputView.collectionView: + guard let diffableDataSource = viewModel.customEmojiPickerDiffableDataSource else { return } + let item = diffableDataSource.itemIdentifier(for: indexPath) + guard case let .emoji(attribute) = item else { return } + let emoji = attribute.emoji + + // make click sound + UIDevice.current.playInputClick() + + // retrieve active text input and insert emoji + // the trailing space is REQUIRED to make regex happy + _ = viewModel.customEmojiPickerInputViewModel.insertText(":\(emoji.shortcode): ") + default: + assertionFailure() + } + } // end func + +} + +// MARK: - ComposeContentViewModelDelegate +extension ComposeContentViewController: ComposeContentViewModelDelegate { + public func composeContentViewModel( + _ viewModel: ComposeContentViewModel, + handleAutoComplete info: ComposeContentViewModel.AutoCompleteInfo + ) -> Bool { + let snapshot = autoCompleteViewController.viewModel.diffableDataSource.snapshot() + guard let item = snapshot.itemIdentifiers.first else { return false } + + // FIXME: redundant code + guard let metaText = viewModel.contentMetaText else { return false } + guard let text = metaText.textView.text else { return false } + let _replacedText: String? = { + var text: String + switch item { + case .hashtag(let hashtag): + text = "#" + hashtag.name + case .hashtagV1(let hashtagName): + text = "#" + hashtagName + case .account(let account): + text = "@" + account.acct + case .emoji(let emoji): + text = ":" + emoji.shortcode + ":" + case .bottomLoader: + return nil + } + return text + }() + guard let replacedText = _replacedText else { return false } + + let range = NSRange(info.toHighlightEndRange, in: text) + metaText.textStorage.replaceCharacters(in: range, with: replacedText) + viewModel.autoCompleteInfo = nil + + // set selected range + let newRange = NSRange(location: range.location + (replacedText as NSString).length, length: 0) + guard metaText.textStorage.length <= newRange.location else { return true } + metaText.textView.selectedRange = newRange + + // append a space and trigger textView delegate update + DispatchQueue.main.async { + metaText.textView.insertText(" ") + } + + return true + } +} diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+DataSource.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+DataSource.swift index 3f6028b56..abbfe0e61 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+DataSource.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+DataSource.swift @@ -66,14 +66,15 @@ extension ComposeContentViewModel { guard let replyTo = status.object(in: context.managedObjectContext) else { return } cell.statusView.configure(status: replyTo) } - case .hashtag(let hashtag): + case .hashtag: break - case .mention(let user): + case .mention: break } } } +// MARK: - UITableViewDataSource extension ComposeContentViewModel: UITableViewDataSource { public func numberOfSections(in tableView: UITableView) -> Int { return Section.allCases.count @@ -99,3 +100,42 @@ extension ComposeContentViewModel: UITableViewDataSource { } } } + +extension ComposeContentViewModel { + + func setupCustomEmojiPickerDiffableDataSource( + collectionView: UICollectionView + ) { + let diffableDataSource = CustomEmojiPickerSection.collectionViewDiffableDataSource( + collectionView: collectionView, + context: context + ) + self.customEmojiPickerDiffableDataSource = diffableDataSource + + let domain = authContext.mastodonAuthenticationBox.domain.uppercased() + customEmojiViewModel?.emojis + .receive(on: DispatchQueue.main) + .sink { [weak self, weak diffableDataSource] emojis in + guard let _ = self else { return } + guard let diffableDataSource = diffableDataSource else { return } + + var snapshot = NSDiffableDataSourceSnapshot() + let customEmojiSection = CustomEmojiPickerSection.emoji(name: domain) + snapshot.appendSections([customEmojiSection]) + let items: [CustomEmojiPickerItem] = { + var items = [CustomEmojiPickerItem]() + for emoji in emojis where emoji.visibleInPicker { + let attribute = CustomEmojiPickerItem.CustomEmojiAttribute(emoji: emoji) + let item = CustomEmojiPickerItem.emoji(attribute: attribute) + items.append(item) + } + return items + }() + snapshot.appendItems(items, toSection: customEmojiSection) + + diffableDataSource.apply(snapshot) + } + .store(in: &disposeBag) + } + +} diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+MetaTextDelegate.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+MetaTextDelegate.swift index 80cc033e8..8a189739d 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+MetaTextDelegate.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+MetaTextDelegate.swift @@ -37,7 +37,7 @@ extension ComposeContentViewModel: MetaTextDelegate { let content = MastodonContent( content: textInput, - emojis: [:] // TODO: emojiViewModel?.emojis.asDictionary ?? [:] + emojis: [:] // customEmojiViewModel?.emojis.value.asDictionary ?? [:] ) let metaContent = MastodonMetaContent.convert(text: content) return metaContent @@ -48,7 +48,7 @@ extension ComposeContentViewModel: MetaTextDelegate { let content = MastodonContent( content: textInput, - emojis: [:] // emojiViewModel?.emojis.asDictionary ?? [:] + emojis: [:] // customEmojiViewModel?.emojis.value.asDictionary ?? [:] ) let metaContent = MastodonMetaContent.convert(text: content) return metaContent diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+UITextViewDelegate.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+UITextViewDelegate.swift new file mode 100644 index 000000000..cdf322a38 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+UITextViewDelegate.swift @@ -0,0 +1,209 @@ +// +// ComposeContentViewModel+UITextViewDelegate.swift +// +// +// Created by MainasuK on 2022/11/13. +// + +import os.log +import UIKit + +// MARK: - UITextViewDelegate +extension ComposeContentViewModel: UITextViewDelegate { + + public func textViewDidBeginEditing(_ textView: UITextView) { + // Note: + // Xcode warning: + // Publishing changes from within view updates is not allowed, this will cause undefined behavior. + // + // Just ignore the warning and see what will happen… + switch textView { + case contentMetaText?.textView: + isContentEditing = true + case contentWarningMetaText?.textView: + isContentWarningEditing = true + default: + assertionFailure() + break + } + } + + public func textViewDidChange(_ textView: UITextView) { + switch textView { + case contentMetaText?.textView: + // update model + guard let metaText = self.contentMetaText else { + assertionFailure() + return + } + let backedString = metaText.backedString + logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(backedString)") + + // configure auto completion + setupAutoComplete(for: textView) + + case contentWarningMetaText?.textView: + break + default: + assertionFailure() + } + } + + public func textViewDidEndEditing(_ textView: UITextView) { + switch textView { + case contentMetaText?.textView: + isContentEditing = false + case contentWarningMetaText?.textView: + isContentWarningEditing = false + default: + assertionFailure() + break + } + } + + public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + switch textView { + case contentMetaText?.textView: + if text == " ", let autoCompleteInfo = self.autoCompleteInfo { + assert(delegate != nil) + let isHandled = delegate?.composeContentViewModel(self, handleAutoComplete: autoCompleteInfo) ?? false + return !isHandled + } + + return true + case contentWarningMetaText?.textView: + let isReturn = text == "\n" + if isReturn { + setContentTextViewFirstResponderIfNeeds() + } + return !isReturn + default: + assertionFailure() + return true + } + } + +} + +extension ComposeContentViewModel { + + func insertContentText(text: String) { + guard let contentMetaText = self.contentMetaText else { return } + // FIXME: smart prefix and suffix + let string = contentMetaText.textStorage.string + let isEmpty = string.isEmpty + let hasPrefix = string.hasPrefix(" ") + if hasPrefix || isEmpty { + contentMetaText.textView.insertText(text) + } else { + contentMetaText.textView.insertText(" " + text) + } + } + + func setContentTextViewFirstResponderIfNeeds() { + guard let contentMetaText = self.contentMetaText else { return } + guard !contentMetaText.textView.isFirstResponder else { return } + contentMetaText.textView.becomeFirstResponder() + } + + func setContentWarningTextViewFirstResponderIfNeeds() { + guard let contentWarningMetaText = self.contentWarningMetaText else { return } + guard !contentWarningMetaText.textView.isFirstResponder else { return } + contentWarningMetaText.textView.becomeFirstResponder() + } + +} + +extension ComposeContentViewModel { + + private func setupAutoComplete(for textView: UITextView) { + guard var autoCompletion = ComposeContentViewModel.scanAutoCompleteInfo(textView: textView) else { + self.autoCompleteInfo = nil + return + } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: auto complete %s (%s)", ((#file as NSString).lastPathComponent), #line, #function, String(autoCompletion.toHighlightEndString), String(autoCompletion.toCursorString)) + + // get layout text bounding rect + var glyphRange = NSRange() + textView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompletion.toCursorRange, in: textView.text), actualGlyphRange: &glyphRange) + let textContainer = textView.layoutManager.textContainers[0] + let textBoundingRect = textView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + + let retryLayoutTimes = autoCompleteRetryLayoutTimes + guard textBoundingRect.size != .zero else { + autoCompleteRetryLayoutTimes += 1 + // avoid infinite loop + guard retryLayoutTimes < 3 else { return } + // needs retry calculate layout when the rect position changing + DispatchQueue.main.async { + self.setupAutoComplete(for: textView) + } + return + } + autoCompleteRetryLayoutTimes = 0 + + // get symbol bounding rect + textView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompletion.symbolRange, in: textView.text), actualGlyphRange: &glyphRange) + let symbolBoundingRect = textView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + + // set bounding rect and trigger layout + autoCompletion.textBoundingRect = textBoundingRect + autoCompletion.symbolBoundingRect = symbolBoundingRect + autoCompleteInfo = autoCompletion + } + + private static func scanAutoCompleteInfo(textView: UITextView) -> AutoCompleteInfo? { + guard let text = textView.text, + textView.selectedRange.location > 0, !text.isEmpty, + let selectedRange = Range(textView.selectedRange, in: text) else { + return nil + } + let cursorIndex = selectedRange.upperBound + let _highlightStartIndex: String.Index? = { + var index = text.index(before: cursorIndex) + while index > text.startIndex { + let char = text[index] + if char == "@" || char == "#" || char == ":" { + return index + } + index = text.index(before: index) + } + assert(index == text.startIndex) + let char = text[index] + if char == "@" || char == "#" || char == ":" { + return index + } else { + return nil + } + }() + + guard let highlightStartIndex = _highlightStartIndex else { return nil } + let scanRange = NSRange(highlightStartIndex..= cursorIndex else { return nil } + let symbolRange = highlightStartIndex.. Bool +} public final class ComposeContentViewModel: NSObject, ObservableObject { @@ -28,12 +33,20 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { // input let context: AppContext let kind: Kind + weak var delegate: ComposeContentViewModelDelegate? @Published var viewLayoutFrame = ViewLayoutFrame() // author (me) @Published var authContext: AuthContext + // auto-complete info + @Published var autoCompleteRetryLayoutTimes = 0 + @Published var autoCompleteInfo: AutoCompleteInfo? = nil + + // emoji + var customEmojiPickerDiffableDataSource: UICollectionViewDiffableDataSource? + // output // limit @@ -42,10 +55,12 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { // content public weak var contentMetaText: MetaText? { didSet { -// guard let textView = contentMetaText?.textView else { return } -// customEmojiPickerInputViewModel.configure(textInput: textView) + guard let textView = contentMetaText?.textView else { return } + customEmojiPickerInputViewModel.configure(textInput: textView) } } + // for hashtag: "# " + // for mention: "@ " @Published public var initialContent = "" @Published public var content = "" @Published public var contentWeightedLength = 0 @@ -56,8 +71,8 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { // content warning weak var contentWarningMetaText: MetaText? { didSet { - //guard let textView = contentWarningMetaText?.textView else { return } - //customEmojiPickerInputViewModel.configure(textInput: textView) + guard let textView = contentWarningMetaText?.textView else { return } + customEmojiPickerInputViewModel.configure(textInput: textView) } } @Published public var isContentWarningActive = false @@ -91,6 +106,9 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { // emoji @Published var isEmojiActive = false + let customEmojiViewModel: EmojiService.CustomEmojiViewModel? + let customEmojiPickerInputViewModel = CustomEmojiPickerInputViewModel() + @Published var isLoadingCustomEmoji = false // visibility @Published var visibility: Mastodon.Entity.Status.Visibility @@ -98,8 +116,16 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { // UI & UX @Published var replyToCellFrame: CGRect = .zero @Published var contentCellFrame: CGRect = .zero + @Published var contentTextViewFrame: CGRect = .zero @Published var scrollViewState: ScrollViewState = .fold - + + @Published var characterCount: Int = 0 + + @Published public private(set) var isPublishBarButtonItemEnabled = true + @Published var isAttachmentButtonEnabled = false + @Published var isPollButtonEnabled = false + + @Published public private(set) var shouldDismiss = true public init( context: AppContext, @@ -144,9 +170,76 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { } return visibility }() + self.customEmojiViewModel = context.emojiService.dequeueCustomEmojiViewModel( + for: authContext.mastodonAuthenticationBox.domain + ) super.init() // end init + // setup initial value + switch kind { + case .reply(let record): + context.managedObjectContext.performAndWait { + guard let status = record.object(in: context.managedObjectContext) else { + assertionFailure() + return + } + let author = authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user + + var mentionAccts: [String] = [] + if author?.id != status.author.id { + mentionAccts.append("@" + status.author.acct) + } + let mentions = status.mentions + .filter { author?.id != $0.id } + for mention in mentions { + let acct = "@" + mention.acct + guard !mentionAccts.contains(acct) else { continue } + mentionAccts.append(acct) + } + for acct in mentionAccts { + UITextChecker.learnWord(acct) + } + if let spoilerText = status.spoilerText, !spoilerText.isEmpty { + self.isContentWarningActive = true + self.contentWarning = spoilerText + } + + let initialComposeContent = mentionAccts.joined(separator: " ") + let preInsertedContent: String? = initialComposeContent.isEmpty ? nil : initialComposeContent + " " + self.initialContent = preInsertedContent ?? "" + self.content = preInsertedContent ?? "" + } + case .hashtag(let hashtag): + let initialComposeContent = "#" + hashtag + UITextChecker.learnWord(initialComposeContent) + let preInsertedContent = initialComposeContent + " " + self.initialContent = preInsertedContent + self.content = preInsertedContent + case .mention(let record): + context.managedObjectContext.performAndWait { + guard let user = record.object(in: context.managedObjectContext) else { return } + let initialComposeContent = "@" + user.acct + UITextChecker.learnWord(initialComposeContent) + let preInsertedContent = initialComposeContent + " " + self.initialContent = preInsertedContent + self.content = preInsertedContent + } + case .post: + break + } + + bind() + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension ComposeContentViewModel { + private func bind() { // bind author $authContext .sink { [weak self] authContext in @@ -177,12 +270,138 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { ) .map { $0 + $1 <= $2 } .assign(to: &$isContentValid) - } - - deinit { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - } + + // bind attachment + $attachmentViewModels + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + Task { + try await self.uploadMediaInQueue() + } + } + .store(in: &disposeBag) + + // bind emoji inputView + $isEmojiActive.assign(to: &customEmojiPickerInputViewModel.$isCustomEmojiComposing) + + // bind toolbar + Publishers.CombineLatest3( + $isPollActive, + $attachmentViewModels, + $maxMediaAttachmentLimit + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] isPollActive, attachmentViewModels, maxMediaAttachmentLimit in + guard let self = self else { return } + let shouldMediaDisable = isPollActive || attachmentViewModels.count >= maxMediaAttachmentLimit + let shouldPollDisable = attachmentViewModels.count > 0 + + self.isAttachmentButtonEnabled = !shouldMediaDisable + self.isPollButtonEnabled = !shouldPollDisable + } + .store(in: &disposeBag) + + // bind status content character count + Publishers.CombineLatest3( + $contentWeightedLength, + $contentWarningWeightedLength, + $isContentWarningActive + ) + .map { contentWeightedLength, contentWarningWeightedLength, isContentWarningActive -> Int in + var count = contentWeightedLength + if isContentWarningActive { + count += contentWarningWeightedLength + } + return count + } + .assign(to: &$characterCount) + + // bind compose bar button item UI state + let isComposeContentEmpty = $content + .map { $0.isEmpty } + let isComposeContentValid = Publishers.CombineLatest( + $characterCount, + $maxTextInputLimit + ) + .map { characterCount, maxTextInputLimit in + characterCount <= maxTextInputLimit + } + let isMediaEmpty = $attachmentViewModels + .map { $0.isEmpty } + let isMediaUploadAllSuccess = $attachmentViewModels + .map { attachmentViewModels in + return Publishers.MergeMany(attachmentViewModels.map { $0.$uploadState }) + .delay(for: 0.5, scheduler: DispatchQueue.main) // convert to outputs with delay. Due to @Published emit before changes + .map { _ in attachmentViewModels.map { $0.uploadState } } + } + .switchToLatest() + .map { outputs in + guard outputs.allSatisfy({ $0 == .finish }) else { return false } + return true + } + + let isPollOptionsAllValid = $pollOptions + .map { options in + return Publishers.MergeMany(options.map { $0.$text }) + .delay(for: 0.5, scheduler: DispatchQueue.main) // convert to outputs with delay. Due to @Published emit before changes + .map { _ in options.map { $0.text } } + } + .switchToLatest() + .map { outputs in + return outputs.allSatisfy { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + } + + let isPublishBarButtonItemEnabledPrecondition1 = Publishers.CombineLatest4( + isComposeContentEmpty, + isComposeContentValid, + isMediaEmpty, + isMediaUploadAllSuccess + ) + .map { isComposeContentEmpty, isComposeContentValid, isMediaEmpty, isMediaUploadAllSuccess -> Bool in + if isMediaEmpty { + return isComposeContentValid && !isComposeContentEmpty + } else { + return isComposeContentValid && isMediaUploadAllSuccess + } + } + .eraseToAnyPublisher() + + let isPublishBarButtonItemEnabledPrecondition2 = Publishers.CombineLatest4( + isComposeContentEmpty, + isComposeContentValid, + $isPollActive, + isPollOptionsAllValid + ) + .map { isComposeContentEmpty, isComposeContentValid, isPollComposing, isPollOptionsAllValid -> Bool in + if isPollComposing { + return isComposeContentValid && !isComposeContentEmpty && isPollOptionsAllValid + } else { + return isComposeContentValid && !isComposeContentEmpty + } + } + .eraseToAnyPublisher() + + Publishers.CombineLatest( + isPublishBarButtonItemEnabledPrecondition1, + isPublishBarButtonItemEnabledPrecondition2 + ) + .map { $0 && $1 } + .assign(to: &$isPublishBarButtonItemEnabled) + + // bind modal dismiss state + $content + .receive(on: DispatchQueue.main) + .map { content in + if content.isEmpty { + return true + } + // if the trimmed content equal to initial content + return content.trimmingCharacters(in: .whitespacesAndNewlines) == self.initialContent + } + .assign(to: &$shouldDismiss) + } } extension ComposeContentViewModel { @@ -192,13 +411,30 @@ extension ComposeContentViewModel { case mention(user: ManagedObjectRecord) case reply(status: ManagedObjectRecord) } - + public enum ScrollViewState { case fold // snap to input case expand // snap to reply } } +extension ComposeContentViewModel { + public struct AutoCompleteInfo { + // model + let inputText: Substring + // range + let symbolRange: Range + let symbolString: Substring + let toCursorRange: Range + let toCursorString: Substring + let toHighlightEndRange: Range + let toHighlightEndString: Substring + // geometry + var textBoundingRect: CGRect = .zero + var symbolBoundingRect: CGRect = .zero + } +} + extension ComposeContentViewModel { func createNewPollOptionIfCould() { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") @@ -275,70 +511,58 @@ extension ComposeContentViewModel { } // end func publisher() } -// MARK: - UITextViewDelegate -extension ComposeContentViewModel: UITextViewDelegate { - public func textViewDidBeginEditing(_ textView: UITextView) { - switch textView { - case contentMetaText?.textView: - isContentEditing = true - case contentWarningMetaText?.textView: - isContentWarningEditing = true - default: - break - } - } +extension ComposeContentViewModel { - public func textViewDidEndEditing(_ textView: UITextView) { - switch textView { - case contentMetaText?.textView: - isContentEditing = false - case contentWarningMetaText?.textView: - isContentWarningEditing = false - default: - break + public enum AttachmentPrecondition: Error, LocalizedError { + case videoAttachWithPhoto + case moreThanOneVideo + + public var errorDescription: String? { + return L10n.Common.Alerts.PublishPostFailure.title } - } - - public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { - switch textView { - case contentMetaText?.textView: - return true - case contentWarningMetaText?.textView: - let isReturn = text == "\n" - if isReturn { - setContentTextViewFirstResponderIfNeeds() + + public var failureReason: String? { + switch self { + case .videoAttachWithPhoto: + return L10n.Common.Alerts.PublishPostFailure.AttachmentsMessage.videoAttachWithPhoto + case .moreThanOneVideo: + return L10n.Common.Alerts.PublishPostFailure.AttachmentsMessage.moreThanOneVideo + } + } + } + + // check exclusive limit: + // - up to 1 video + // - up to N photos + public func checkAttachmentPrecondition() throws { + let attachmentViewModels = self.attachmentViewModels + guard !attachmentViewModels.isEmpty else { return } + + var photoAttachmentViewModels: [AttachmentViewModel] = [] + var videoAttachmentViewModels: [AttachmentViewModel] = [] + attachmentViewModels.forEach { attachmentViewModel in + guard let output = attachmentViewModel.output else { + assertionFailure() + return + } + switch output { + case .image: + photoAttachmentViewModels.append(attachmentViewModel) + case .video: + videoAttachmentViewModels.append(attachmentViewModel) + } + } + + if !videoAttachmentViewModels.isEmpty { + guard videoAttachmentViewModels.count == 1 else { + throw AttachmentPrecondition.moreThanOneVideo + } + guard photoAttachmentViewModels.isEmpty else { + throw AttachmentPrecondition.videoAttachWithPhoto } - return !isReturn - default: - assertionFailure() - return true } } - func insertContentText(text: String) { - guard let contentMetaText = self.contentMetaText else { return } - // FIXME: smart prefix and suffix - let string = contentMetaText.textStorage.string - let isEmpty = string.isEmpty - let hasPrefix = string.hasPrefix(" ") - if hasPrefix || isEmpty { - contentMetaText.textView.insertText(text) - } else { - contentMetaText.textView.insertText(" " + text) - } - } - - func setContentTextViewFirstResponderIfNeeds() { - guard let contentMetaText = self.contentMetaText else { return } - guard !contentMetaText.textView.isFirstResponder else { return } - contentMetaText.textView.becomeFirstResponder() - } - - func setContentWarningTextViewFirstResponderIfNeeds() { - guard let contentWarningMetaText = self.contentWarningMetaText else { return } - guard !contentWarningMetaText.textView.isFirstResponder else { return } - contentWarningMetaText.textView.becomeFirstResponder() - } } // MARK: - DeleteBackwardResponseTextFieldRelayDelegate @@ -392,3 +616,56 @@ extension ComposeContentViewModel: DeleteBackwardResponseTextFieldRelayDelegate } } + +// MARK: - AttachmentViewModelDelegate +extension ComposeContentViewModel: AttachmentViewModelDelegate { + + public func attachmentViewModel( + _ viewModel: AttachmentViewModel, + uploadStateValueDidChange state: AttachmentViewModel.UploadState + ) { + Task { + try await uploadMediaInQueue() + } + } + + @MainActor + func uploadMediaInQueue() async throws { + for (i, attachmentViewModel) in attachmentViewModels.enumerated() { + switch attachmentViewModel.uploadState { + case .none: + return + case .compressing: + return + case .ready: + let count = self.attachmentViewModels.count + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): upload \(i)/\(count) attachment") + try await attachmentViewModel.upload() + return + case .uploading: + return + case .fail: + return + case .finish: + continue + } + } + } + + public func attachmentViewModel( + _ viewModel: AttachmentViewModel, + actionButtonDidPressed action: AttachmentViewModel.Action + ) { + switch action { + case .retry: + Task { + try await viewModel.upload(isRetry: true) + } + case .remove: + attachmentViewModels.removeAll(where: { $0 === viewModel }) + Task { + try await uploadMediaInQueue() + } + } + } +} diff --git a/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerHeaderCollectionReusableView.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/EmojiPicker/CustomEmojiPickerHeaderCollectionReusableView.swift similarity index 100% rename from Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerHeaderCollectionReusableView.swift rename to MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/EmojiPicker/CustomEmojiPickerHeaderCollectionReusableView.swift diff --git a/Mastodon/Scene/Compose/View/CustomEmojiPickerInputView.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/EmojiPicker/CustomEmojiPickerInputView.swift similarity index 100% rename from Mastodon/Scene/Compose/View/CustomEmojiPickerInputView.swift rename to MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/EmojiPicker/CustomEmojiPickerInputView.swift diff --git a/Mastodon/Scene/Compose/View/CustomEmojiPickerInputViewModel.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/EmojiPicker/CustomEmojiPickerInputViewModel.swift similarity index 52% rename from Mastodon/Scene/Compose/View/CustomEmojiPickerInputViewModel.swift rename to MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/EmojiPicker/CustomEmojiPickerInputViewModel.swift index 496c8191b..729524ce5 100644 --- a/Mastodon/Scene/Compose/View/CustomEmojiPickerInputViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/EmojiPicker/CustomEmojiPickerInputViewModel.swift @@ -9,7 +9,6 @@ import UIKit import Combine import MetaTextKit import MastodonCore -import MastodonUI final class CustomEmojiPickerInputViewModel { @@ -20,8 +19,7 @@ final class CustomEmojiPickerInputViewModel { // input weak var customEmojiPickerInputView: CustomEmojiPickerInputView? - // output - let isCustomEmojiComposing = CurrentValueSubject(false) + @Published var isCustomEmojiComposing = false } @@ -51,27 +49,28 @@ extension CustomEmojiPickerInputViewModel { for reference in customEmojiReplaceableTextInputReferences { guard let textInput = reference.value else { continue } guard textInput.isFirstResponder == true else { continue } - guard let selectedTextRange = textInput.selectedTextRange else { continue } + // guard let selectedTextRange = textInput.selectedTextRange else { continue } textInput.insertText(text) + // FIXME: inline emoji // due to insert text render as attachment // the cursor reset logic not works // hack with hard code +2 offset - assert(text.hasSuffix(": ")) - guard text.hasPrefix(":") && text.hasSuffix(": ") else { continue } - - if let _ = textInput as? MetaTextView { - if let newPosition = textInput.position(from: selectedTextRange.start, offset: 2) { - let newSelectedTextRange = textInput.textRange(from: newPosition, to: newPosition) - textInput.selectedTextRange = newSelectedTextRange - } - } else { - if let newPosition = textInput.position(from: selectedTextRange.start, offset: text.length) { - let newSelectedTextRange = textInput.textRange(from: newPosition, to: newPosition) - textInput.selectedTextRange = newSelectedTextRange - } - } + // assert(text.hasSuffix(": ")) + // guard text.hasPrefix(":") && text.hasSuffix(": ") else { continue } + // + // if let _ = textInput as? MetaTextView { + // if let newPosition = textInput.position(from: selectedTextRange.start, offset: 2) { + // let newSelectedTextRange = textInput.textRange(from: newPosition, to: newPosition) + // textInput.selectedTextRange = newSelectedTextRange + // } + // } else { + // if let newPosition = textInput.position(from: selectedTextRange.start, offset: text.length) { + // let newSelectedTextRange = textInput.textRange(from: newPosition, to: newPosition) + // textInput.selectedTextRange = newSelectedTextRange + // } + // } return reference } @@ -81,3 +80,16 @@ extension CustomEmojiPickerInputViewModel { } +extension CustomEmojiPickerInputViewModel { + public func configure(textInput: CustomEmojiReplaceableTextInput) { + $isCustomEmojiComposing + .receive(on: DispatchQueue.main) + .sink { [weak self] isCustomEmojiComposing in + guard let self = self else { return } + textInput.inputView = isCustomEmojiComposing ? self.customEmojiPickerInputView : nil + textInput.reloadInputViews() + self.append(customEmojiReplaceableTextInput: textInput) + } + .store(in: &disposeBag) + } +} diff --git a/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerItemCollectionViewCell.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/EmojiPicker/CustomEmojiPickerItemCollectionViewCell.swift similarity index 100% rename from Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerItemCollectionViewCell.swift rename to MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/EmojiPicker/CustomEmojiPickerItemCollectionViewCell.swift diff --git a/Mastodon/Helper/MastodonRegex.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Helper/MastodonRegex.swift similarity index 100% rename from Mastodon/Helper/MastodonRegex.swift rename to MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Helper/MastodonRegex.swift diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Poll/PollOptionTextField.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Poll/PollOptionTextField.swift index 5143bea35..fa409c114 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Poll/PollOptionTextField.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Poll/PollOptionTextField.swift @@ -39,7 +39,7 @@ public struct PollOptionTextField: UIViewRepresentable { textField.text = text textField.placeholder = { if index >= 0 { - return L10n.Scene.Compose.Poll.optionNumber(index) + return L10n.Scene.Compose.Poll.optionNumber(index + 1) } else { assertionFailure() return "" diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift index ea3be18a8..93f3dd3a1 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift @@ -119,13 +119,31 @@ extension MastodonStatusPublisher: StatusPublisher { progress.addChild(attachmentViewModel.progress, withPendingUnitCount: publishAttachmentTaskWeight) // upload media do { - let result = try await attachmentViewModel.upload(context: uploadContext) - guard case let .mastodon(response) = result else { - assertionFailure() - continue + guard let attachment = attachmentViewModel.uploadResult else { + // precondition: all media uploaded + throw AppError.badRequest } - let attachmentID = response.value.id - attachmentIDs.append(attachmentID) + attachmentIDs.append(attachment.id) + + let caption = attachmentViewModel.caption + guard !caption.isEmpty else { continue } + + _ = try await api.updateMedia( + domain: authContext.mastodonAuthenticationBox.domain, + attachmentID: attachment.id, + query: .init( + file: nil, + thumbnail: nil, + description: caption, + focus: nil + ), + mastodonAuthenticationBox: authContext.mastodonAuthenticationBox + ).singleOutput() + + // TODO: allow background upload + // let attachment = try await attachmentViewModel.upload(context: uploadContext) + // let attachmentID = attachment.id + // attachmentIDs.append(attachmentID) } catch { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): upload attachment fail: \(error.localizedDescription)") _state = .failure(error) diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/TableViewCell/ComposeContentTableViewCell.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/TableViewCell/ComposeContentTableViewCell.swift index 3a646f1fc..90d432825 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/TableViewCell/ComposeContentTableViewCell.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/TableViewCell/ComposeContentTableViewCell.swift @@ -7,74 +7,12 @@ import os.log import UIKit -import Combine -import MetaTextKit -import UITextView_Placeholder -import MastodonAsset -import MastodonLocalization import UIHostingConfigurationBackport -//protocol ComposeStatusContentTableViewCellDelegate: AnyObject { -// func composeStatusContentTableViewCell(_ cell: ComposeStatusContentTableViewCell, textViewShouldBeginEditing textView: UITextView) -> Bool -//} - final class ComposeContentTableViewCell: UITableViewCell { let logger = Logger(subsystem: "ComposeContentTableViewCell", category: "View") -// var disposeBag = Set() -// weak var delegate: ComposeStatusContentTableViewCellDelegate? -// -// let statusView = StatusView() -// -// let statusContentWarningEditorView = StatusContentWarningEditorView() -// -// let textEditorViewContainerView = UIView() -// -// static let metaTextViewTag: Int = 333 -// let metaText: MetaText = { -// let metaText = MetaText() -// metaText.textView.backgroundColor = .clear -// metaText.textView.isScrollEnabled = false -// metaText.textView.keyboardType = .twitter -// metaText.textView.textDragInteraction?.isEnabled = false // disable drag for link and attachment -// metaText.textView.textContainer.lineFragmentPadding = 10 // leading inset -// metaText.textView.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)) -// metaText.textView.attributedPlaceholder = { -// var attributes = metaText.textAttributes -// attributes[.foregroundColor] = Asset.Colors.Label.secondary.color -// return NSAttributedString( -// string: L10n.Scene.Compose.contentInputPlaceholder, -// attributes: attributes -// ) -// }() -// metaText.paragraphStyle = { -// let style = NSMutableParagraphStyle() -// style.lineSpacing = 5 -// style.paragraphSpacing = 0 -// return style -// }() -// metaText.textAttributes = [ -// .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)), -// .foregroundColor: Asset.Colors.Label.primary.color, -// ] -// metaText.linkAttributes = [ -// .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)), -// .foregroundColor: Asset.Colors.brand.color, -// ] -// return metaText -// }() -// -// // output -// let contentWarningContent = PassthroughSubject() -// -// override func prepareForReuse() { -// super.prepareForReuse() -// -// metaText.delegate = nil -// metaText.textView.delegate = nil -// } - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) _init() @@ -93,79 +31,6 @@ extension ComposeContentTableViewCell { selectionStyle = .none layer.zPosition = 999 backgroundColor = .clear - -// let containerStackView = UIStackView() -// containerStackView.axis = .vertical -// containerStackView.translatesAutoresizingMaskIntoConstraints = false -// contentView.addSubview(containerStackView) -// NSLayoutConstraint.activate([ -// containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor), -// containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), -// containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), -// containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), -// ]) -// containerStackView.preservesSuperviewLayoutMargins = true -// -// containerStackView.addArrangedSubview(statusContentWarningEditorView) -// statusContentWarningEditorView.setContentHuggingPriority(.required - 1, for: .vertical) -// -// let statusContainerView = UIView() -// statusContainerView.preservesSuperviewLayoutMargins = true -// containerStackView.addArrangedSubview(statusContainerView) -// statusView.translatesAutoresizingMaskIntoConstraints = false -// statusContainerView.addSubview(statusView) -// NSLayoutConstraint.activate([ -// statusView.topAnchor.constraint(equalTo: statusContainerView.topAnchor, constant: 20), -// statusView.leadingAnchor.constraint(equalTo: statusContainerView.leadingAnchor), -// statusView.trailingAnchor.constraint(equalTo: statusContainerView.trailingAnchor), -// statusView.bottomAnchor.constraint(equalTo: statusContainerView.bottomAnchor), -// ]) -// statusView.setup(style: .composeStatusAuthor) -// -// containerStackView.addArrangedSubview(textEditorViewContainerView) -// metaText.textView.translatesAutoresizingMaskIntoConstraints = false -// textEditorViewContainerView.addSubview(metaText.textView) -// NSLayoutConstraint.activate([ -// metaText.textView.topAnchor.constraint(equalTo: textEditorViewContainerView.topAnchor), -// metaText.textView.leadingAnchor.constraint(equalTo: textEditorViewContainerView.layoutMarginsGuide.leadingAnchor), -// metaText.textView.trailingAnchor.constraint(equalTo: textEditorViewContainerView.layoutMarginsGuide.trailingAnchor), -// metaText.textView.bottomAnchor.constraint(equalTo: textEditorViewContainerView.bottomAnchor), -// metaText.textView.heightAnchor.constraint(greaterThanOrEqualToConstant: 64).priority(.defaultHigh), -// ]) -// statusContentWarningEditorView.textView.delegate = self } } - -// MARK: - UITextViewDelegate -//extension ComposeStatusContentTableViewCell: UITextViewDelegate { -// -// func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { -// return delegate?.composeStatusContentTableViewCell(self, textViewShouldBeginEditing: textView) ?? true -// } -// -// func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { -// switch textView { -// case statusContentWarningEditorView.textView: -// // disable input line break -// guard text != "\n" else { return false } -// return true -// default: -// assertionFailure() -// return true -// } -// } -// -// func textViewDidChange(_ textView: UITextView) { -// logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): text: \(textView.text ?? "")") -// guard textView === statusContentWarningEditorView.textView else { return } -// // replace line break with space -// // needs check input state to prevent break the IME -// if textView.markedTextRange == nil { -// textView.text = textView.text.replacingOccurrences(of: "\n", with: " ") -// } -// contentWarningContent.send(textView.text) -// } -// -//} - diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/TableViewCell/ComposeStatusAttachmentTableViewCell.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/TableViewCell/ComposeStatusAttachmentTableViewCell.swift deleted file mode 100644 index 42a851bf1..000000000 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/TableViewCell/ComposeStatusAttachmentTableViewCell.swift +++ /dev/null @@ -1,172 +0,0 @@ -// -// ComposeStatusAttachmentTableViewCell.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-6-29. -// - -import UIKit -import SwiftUI -import Combine -import AlamofireImage -import MastodonAsset -import MastodonCore -import MastodonLocalization -import UIHostingConfigurationBackport - -//final class ComposeStatusAttachmentTableViewCell: UITableViewCell { -// -// private(set) var dataSource: UICollectionViewDiffableDataSource! -// weak var composeStatusAttachmentCollectionViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate? -// var observations = Set() -// -// private static func createLayout() -> UICollectionViewLayout { -// let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)) -// let item = NSCollectionLayoutItem(layoutSize: itemSize) -// let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)) -// let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item]) -// let section = NSCollectionLayoutSection(group: group) -// section.contentInsetsReference = .readableContent -// return UICollectionViewCompositionalLayout(section: section) -// } -// -// private(set) var collectionViewHeightLayoutConstraint: NSLayoutConstraint! -// let collectionView: UICollectionView = { -// let collectionViewLayout = ComposeStatusAttachmentTableViewCell.createLayout() -// let collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout) -// collectionView.register(ComposeStatusAttachmentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self)) -// collectionView.backgroundColor = .clear -// collectionView.alwaysBounceVertical = true -// collectionView.isScrollEnabled = false -// return collectionView -// }() -// let collectionViewHeightDidUpdate = PassthroughSubject() -// -// override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { -// super.init(style: style, reuseIdentifier: reuseIdentifier) -// _init() -// } -// -// required init?(coder: NSCoder) { -// super.init(coder: coder) -// _init() -// } -// -//} -// -//extension ComposeStatusAttachmentTableViewCell { -// -// private func _init() { -// backgroundColor = .clear -// contentView.backgroundColor = .clear -// -// collectionView.translatesAutoresizingMaskIntoConstraints = false -// contentView.addSubview(collectionView) -// collectionViewHeightLayoutConstraint = collectionView.heightAnchor.constraint(equalToConstant: 200).priority(.defaultHigh) -// NSLayoutConstraint.activate([ -// collectionView.topAnchor.constraint(equalTo: contentView.topAnchor), -// collectionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), -// collectionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), -// collectionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), -// collectionViewHeightLayoutConstraint, -// ]) -// -// collectionView.observe(\.contentSize, options: [.initial, .new]) { [weak self] collectionView, _ in -// guard let self = self else { return } -// self.collectionViewHeightLayoutConstraint.constant = collectionView.contentSize.height -// self.collectionViewHeightDidUpdate.send() -// } -// .store(in: &observations) -// -// self.dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { -// [weak self] collectionView, indexPath, item -> UICollectionViewCell? in -// guard let _ = self else { return UICollectionViewCell() } -// switch item { -// case .attachment: -// let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self), for: indexPath) as! ComposeStatusAttachmentCollectionViewCell -// cell.contentConfiguration = UIHostingConfigurationBackport { -// HStack { -// Image(systemName: "star") -// Text("Favorites") -// Spacer() -// } -// } -//// cell.attachmentContainerView.descriptionTextView.text = attachmentService.description.value -//// cell.delegate = self.composeStatusAttachmentCollectionViewCellDelegate -//// attachmentService.thumbnailImage -//// .receive(on: DispatchQueue.main) -//// .sink { [weak cell] thumbnailImage in -//// guard let cell = cell else { return } -//// let size = cell.attachmentContainerView.previewImageView.frame.size != .zero ? cell.attachmentContainerView.previewImageView.frame.size : CGSize(width: 1, height: 1) -//// guard let image = thumbnailImage else { -//// let placeholder = UIImage.placeholder( -//// size: size, -//// color: ThemeService.shared.currentTheme.value.systemGroupedBackgroundColor -//// ) -//// .af.imageRounded( -//// withCornerRadius: AttachmentContainerView.containerViewCornerRadius -//// ) -//// cell.attachmentContainerView.previewImageView.image = placeholder -//// return -//// } -//// // cannot get correct size. set corner radius on layer -//// cell.attachmentContainerView.previewImageView.image = image -//// } -//// .store(in: &cell.disposeBag) -//// Publishers.CombineLatest( -//// attachmentService.uploadStateMachineSubject.eraseToAnyPublisher(), -//// attachmentService.error.eraseToAnyPublisher() -//// ) -//// .receive(on: DispatchQueue.main) -//// .sink { [weak cell, weak attachmentService] uploadState, error in -//// guard let cell = cell else { return } -//// guard let attachmentService = attachmentService else { return } -//// cell.attachmentContainerView.emptyStateView.isHidden = error == nil -//// cell.attachmentContainerView.descriptionBackgroundView.isHidden = error != nil -//// if let error = error { -//// cell.attachmentContainerView.activityIndicatorView.stopAnimating() -//// cell.attachmentContainerView.emptyStateView.label.text = error.localizedDescription -//// } else { -//// guard let uploadState = uploadState else { return } -//// switch uploadState { -//// case is MastodonAttachmentService.UploadState.Finish: -//// cell.attachmentContainerView.activityIndicatorView.stopAnimating() -//// case is MastodonAttachmentService.UploadState.Fail: -//// cell.attachmentContainerView.activityIndicatorView.stopAnimating() -//// // FIXME: not display -//// cell.attachmentContainerView.emptyStateView.label.text = { -//// if let file = attachmentService.file.value { -//// switch file { -//// case .jpeg, .png, .gif: -//// return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo) -//// case .other: -//// return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.video) -//// } -//// } else { -//// return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo) -//// } -//// }() -//// default: -//// break -//// } -//// } -//// } -//// .store(in: &cell.disposeBag) -//// NotificationCenter.default.publisher( -//// for: UITextView.textDidChangeNotification, -//// object: cell.attachmentContainerView.descriptionTextView -//// ) -//// .receive(on: DispatchQueue.main) -//// .sink { notification in -//// guard let textField = notification.object as? UITextView else { return } -//// let text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) -//// attachmentService.description.value = text -//// } -//// .store(in: &cell.disposeBag) -// return cell -// } -// } -// } -// -//} -// diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/TableViewCell/ComposeStatusPollTableViewCell.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/TableViewCell/ComposeStatusPollTableViewCell.swift deleted file mode 100644 index 27b835a5a..000000000 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/TableViewCell/ComposeStatusPollTableViewCell.swift +++ /dev/null @@ -1,209 +0,0 @@ -// -// ComposeStatusPollTableViewCell.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-6-29. -// - -import os.log -import UIKit -import Combine -import MastodonAsset -import MastodonLocalization - -//protocol ComposeStatusPollTableViewCellDelegate: AnyObject { -// func composeStatusPollTableViewCell(_ cell: ComposeStatusPollTableViewCell, pollOptionAttributesDidReorder options: [ComposeStatusPollItem.PollOptionAttribute]) -//} -// -//final class ComposeStatusPollTableViewCell: UITableViewCell { -// -// let logger = Logger(subsystem: "ComposeStatusPollTableViewCell", category: "UI") -// -// private(set) var dataSource: UICollectionViewDiffableDataSource! -// var observations = Set() -// -// weak var customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel? -// weak var delegate: ComposeStatusPollTableViewCellDelegate? -// weak var composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate? -// weak var composeStatusPollOptionAppendEntryCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate? -// weak var composeStatusPollExpiresOptionCollectionViewCellDelegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate? -// -// private static func createLayout() -> UICollectionViewLayout { -// let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)) -// let item = NSCollectionLayoutItem(layoutSize: itemSize) -// let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)) -// let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item]) -// let section = NSCollectionLayoutSection(group: group) -// section.contentInsetsReference = .readableContent -// return UICollectionViewCompositionalLayout(section: section) -// } -// -// private(set) var collectionViewHeightLayoutConstraint: NSLayoutConstraint! -// let collectionView: UICollectionView = { -// let collectionViewLayout = ComposeStatusPollTableViewCell.createLayout() -// let collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout) -// collectionView.register(ComposeStatusPollOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self)) -// collectionView.register(ComposeStatusPollOptionAppendEntryCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self)) -// collectionView.register(ComposeStatusPollExpiresOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollExpiresOptionCollectionViewCell.self)) -// collectionView.backgroundColor = .clear -// collectionView.alwaysBounceVertical = true -// collectionView.isScrollEnabled = false -// collectionView.dragInteractionEnabled = true -// return collectionView -// }() -// let collectionViewHeightDidUpdate = PassthroughSubject() -// -// override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { -// super.init(style: style, reuseIdentifier: reuseIdentifier) -// _init() -// } -// -// required init?(coder: NSCoder) { -// super.init(coder: coder) -// _init() -// } -// -//} -// -//extension ComposeStatusPollTableViewCell { -// -// private func _init() { -// backgroundColor = .clear -// contentView.backgroundColor = .clear -// -// collectionView.translatesAutoresizingMaskIntoConstraints = false -// contentView.addSubview(collectionView) -// collectionViewHeightLayoutConstraint = collectionView.heightAnchor.constraint(equalToConstant: 300).priority(.defaultHigh) -// NSLayoutConstraint.activate([ -// collectionView.topAnchor.constraint(equalTo: contentView.topAnchor), -// collectionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), -// collectionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), -// collectionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), -// collectionViewHeightLayoutConstraint, -// ]) -// -// collectionView.observe(\.contentSize, options: [.initial, .new]) { [weak self] collectionView, _ in -// guard let self = self else { return } -// self.collectionViewHeightLayoutConstraint.constant = collectionView.contentSize.height -// self.collectionViewHeightDidUpdate.send() -// } -// .store(in: &observations) -// -// self.dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { [ -// weak self -// ] collectionView, indexPath, item -> UICollectionViewCell? in -// guard let self = self else { return UICollectionViewCell() } -// -// switch item { -// case .pollOption(let attribute): -// let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self), for: indexPath) as! ComposeStatusPollOptionCollectionViewCell -// cell.pollOptionView.optionTextField.text = attribute.option.value -// cell.pollOptionView.optionTextField.placeholder = L10n.Scene.Compose.Poll.optionNumber(indexPath.item + 1) -// cell.pollOption -// .receive(on: DispatchQueue.main) -// .assign(to: \.value, on: attribute.option) -// .store(in: &cell.disposeBag) -// cell.delegate = self.composeStatusPollOptionCollectionViewCellDelegate -// if let customEmojiPickerInputViewModel = self.customEmojiPickerInputViewModel { -// ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.pollOptionView.optionTextField, disposeBag: &cell.disposeBag) -// } -// return cell -// case .pollOptionAppendEntry: -// let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self), for: indexPath) as! ComposeStatusPollOptionAppendEntryCollectionViewCell -// cell.delegate = self.composeStatusPollOptionAppendEntryCollectionViewCellDelegate -// return cell -// case .pollExpiresOption(let attribute): -// let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollExpiresOptionCollectionViewCell.self), for: indexPath) as! ComposeStatusPollExpiresOptionCollectionViewCell -// cell.durationButton.setTitle(L10n.Scene.Compose.Poll.durationTime(attribute.expiresOption.value.title), for: .normal) -// attribute.expiresOption -// .receive(on: DispatchQueue.main) -// .sink { [weak cell] expiresOption in -// guard let cell = cell else { return } -// cell.durationButton.setTitle(L10n.Scene.Compose.Poll.durationTime(expiresOption.title), for: .normal) -// } -// .store(in: &cell.disposeBag) -// cell.delegate = self.composeStatusPollExpiresOptionCollectionViewCellDelegate -// return cell -// } -// } -// -// collectionView.dragDelegate = self -// collectionView.dropDelegate = self -// } -// -//} -// -//// MARK: - UICollectionViewDragDelegate -//extension ComposeStatusPollTableViewCell: UICollectionViewDragDelegate { -// -// func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { -// guard let item = dataSource.itemIdentifier(for: indexPath) else { return [] } -// switch item { -// case .pollOption: -// let itemProvider = NSItemProvider(object: String(item.hashValue) as NSString) -// let dragItem = UIDragItem(itemProvider: itemProvider) -// dragItem.localObject = item -// return [dragItem] -// default: -// return [] -// } -// } -// -// func collectionView(_ collectionView: UICollectionView, dragSessionIsRestrictedToDraggingApplication session: UIDragSession) -> Bool { -// // drag to app should be the same app -// return true -// } -//} -// -//// MARK: - UICollectionViewDropDelegate -//extension ComposeStatusPollTableViewCell: UICollectionViewDropDelegate { -// // didUpdate -// func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal { -// guard collectionView.hasActiveDrag, -// let destinationIndexPath = destinationIndexPath, -// let item = dataSource.itemIdentifier(for: destinationIndexPath) -// else { -// return UICollectionViewDropProposal(operation: .forbidden) -// } -// -// switch item { -// case .pollOption: -// return UICollectionViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath) -// default: -// return UICollectionViewDropProposal(operation: .cancel) -// } -// } -// -// // performDrop -// func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) { -// guard let dropItem = coordinator.items.first, -// let item = dropItem.dragItem.localObject as? ComposeStatusPollItem, -// case .pollOption = item -// else { return } -// -// guard coordinator.proposal.operation == .move else { return } -// guard let destinationIndexPath = coordinator.destinationIndexPath, -// let _ = collectionView.cellForItem(at: destinationIndexPath) as? ComposeStatusPollOptionCollectionViewCell -// else { return } -// -// var snapshot = dataSource.snapshot() -// guard destinationIndexPath.row < snapshot.itemIdentifiers.count else { return } -// let anchorItem = snapshot.itemIdentifiers[destinationIndexPath.row] -// snapshot.moveItem(item, afterItem: anchorItem) -// dataSource.apply(snapshot) -// -// coordinator.drop(dropItem.dragItem, toItemAt: destinationIndexPath) -// } -//} -// -//extension ComposeStatusPollTableViewCell: UICollectionViewDelegate { -// func collectionView(_ collectionView: UICollectionView, targetIndexPathForMoveFromItemAt originalIndexPath: IndexPath, toProposedIndexPath proposedIndexPath: IndexPath) -> IndexPath { -// logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(originalIndexPath.debugDescription) -> \(proposedIndexPath.debugDescription)") -// -// guard let _ = collectionView.cellForItem(at: proposedIndexPath) as? ComposeStatusPollOptionCollectionViewCell else { -// return originalIndexPath -// } -// -// return proposedIndexPath -// } -//} diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentToolbarView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView+ViewModel.swift similarity index 83% rename from MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentToolbarView+ViewModel.swift rename to MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView+ViewModel.swift index 4a34c77d4..6374203fa 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentToolbarView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView+ViewModel.swift @@ -27,6 +27,9 @@ extension ComposeContentToolbarView { @Published var isEmojiActive = false @Published var isContentWarningActive = false + @Published var isAttachmentButtonEnabled = false + @Published var isPollButtonEnabled = false + @Published public var maxTextInputLimit = 500 @Published public var contentWeightedLength = 0 @Published public var contentWarningWeightedLength = 0 @@ -120,4 +123,19 @@ extension ComposeContentToolbarView.ViewModel { return action.inactiveImage } } + + func label(for action: Action) -> String { + switch action { + case .attachment: + return L10n.Scene.Compose.Accessibility.appendAttachment + case .poll: + return isPollActive ? L10n.Scene.Compose.Accessibility.removePoll : L10n.Scene.Compose.Accessibility.appendPoll + case .emoji: + return L10n.Scene.Compose.Accessibility.customEmojiPicker + case .contentWarning: + return isContentWarningActive ? L10n.Scene.Compose.Accessibility.disableContentWarning : L10n.Scene.Compose.Accessibility.enableContentWarning + case .visibility: + return L10n.Scene.Compose.Accessibility.postVisibilityMenu + } + } } diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentToolbarView.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView.swift similarity index 82% rename from MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentToolbarView.swift rename to MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView.swift index 52026c636..683164bdd 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentToolbarView.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView.swift @@ -44,7 +44,9 @@ struct ComposeContentToolbarView: View { } } label: { label(for: action) + .opacity(viewModel.isAttachmentButtonEnabled ? 1.0 : 0.5) } + .disabled(!viewModel.isAttachmentButtonEnabled) .frame(width: 48, height: 48) case .visibility: Menu { @@ -61,8 +63,19 @@ struct ComposeContentToolbarView: View { } } label: { label(for: viewModel.visibility.image) + .accessibilityLabel(L10n.Scene.Compose.Keyboard.selectVisibilityEntry(viewModel.visibility.title)) } .frame(width: 48, height: 48) + case .poll: + Button { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(String(describing: action))") + viewModel.delegate?.composeContentToolbarView(viewModel, toolbarItemDidPressed: action) + } label: { + label(for: action) + .opacity(viewModel.isPollButtonEnabled ? 1.0 : 0.5) + } + .disabled(!viewModel.isPollButtonEnabled) + .frame(width: 48, height: 48) default: Button { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(String(describing: action))") @@ -86,11 +99,14 @@ struct ComposeContentToolbarView: View { Text("\(remains)") .foregroundColor(Color(isOverflow ? UIColor.systemRed : UIColor.secondaryLabel)) .font(.system(size: isOverflow ? 18 : 16, weight: isOverflow ? .medium : .regular)) + .accessibilityLabel(L10n.A11y.Plural.Count.charactersLeft(remains)) } .padding(.leading, 4) // 4 + 12 = 16 .padding(.trailing, 16) .frame(height: ComposeContentToolbarView.toolbarHeight) .background(Color(viewModel.backgroundColor)) + .accessibilityElement(children: .contain) + .accessibilityLabel(L10n.Scene.Compose.Accessibility.postOptions) } } @@ -100,6 +116,7 @@ extension ComposeContentToolbarView { Image(uiImage: viewModel.image(for: action)) .foregroundColor(Color(Asset.Scene.Compose.buttonTint.color)) .frame(width: 24, height: 24, alignment: .center) + .accessibilityLabel(viewModel.label(for: action)) } func label(for image: UIImage) -> some View { diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentView.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentView.swift index 25584848a..10ca4e71f 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentView.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentView.swift @@ -11,15 +11,18 @@ import MastodonAsset import MastodonCore import MastodonLocalization import Stripes +import Kingfisher public struct ComposeContentView: View { static let logger = Logger(subsystem: "ComposeContentView", category: "View") var logger: Logger { ComposeContentView.logger } + static let contentViewCoordinateSpace = "ComposeContentView.Content" static var margin: CGFloat = 16 @ObservedObject var viewModel: ComposeContentViewModel + public var body: some View { VStack(spacing: .zero) { @@ -105,9 +108,25 @@ public struct ComposeContentView: View { .frame(minHeight: 100) .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, ComposeContentView.margin) + .background( + GeometryReader { proxy in + Color.clear.preference(key: ViewFramePreferenceKey.self, value: proxy.frame(in: .named(ComposeContentView.contentViewCoordinateSpace))) + } + .onPreferenceChange(ViewFramePreferenceKey.self) { frame in + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): content textView frame: \(frame.debugDescription)") + let rect = frame.standardized + viewModel.contentTextViewFrame = CGRect( + origin: frame.origin, + size: CGSize(width: floor(rect.width), height: floor(rect.height)) + ) + } + ) // poll pollView .padding(.horizontal, ComposeContentView.margin) + // media + mediaView + .padding(.horizontal, ComposeContentView.margin) } .background( GeometryReader { proxy in @@ -124,6 +143,7 @@ public struct ComposeContentView: View { ) Spacer() } // end VStack + .coordinateSpace(name: ComposeContentView.contentViewCoordinateSpace) } // end body } @@ -147,6 +167,8 @@ extension ComposeContentView { } Spacer() } + .accessibilityElement(children: .ignore) + .accessibilityLabel(L10n.Scene.Compose.Accessibility.postingAs([viewModel.name.string, viewModel.username].joined(separator: ", "))) } } @@ -165,7 +187,7 @@ extension ComposeContentView { index: _index, deleteBackwardResponseTextFieldRelayDelegate: viewModel ) { textField in - // viewModel.customEmojiPickerInputViewModel.configure(textInput: textField) + viewModel.customEmojiPickerInputViewModel.configure(textInput: textField) } } if viewModel.maxPollOptionLimit != viewModel.pollOptions.count { @@ -194,6 +216,24 @@ extension ComposeContentView { } } // end VStack } + + // MARK: - media + var mediaView: some View { + VStack(spacing: 16) { + ForEach(viewModel.attachmentViewModels, id: \.self) { attachmentViewModel in + AttachmentView(viewModel: attachmentViewModel) + .clipShape(Rectangle()) + .badgeView( + Button { + viewModel.attachmentViewModels.removeAll(where: { $0 === attachmentViewModel }) + } label: { + Image(systemName: "minus.circle.fill") + .foregroundColor(.red) + } + ) + } // end ForEach + } // end VStack + } } //private struct ScrollOffsetPreferenceKey: PreferenceKey { diff --git a/MastodonSDK/Sources/MastodonUI/Vendor/CircleProgressView.swift b/MastodonSDK/Sources/MastodonUI/Vendor/CircleProgressView.swift new file mode 100644 index 000000000..f9b09e740 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Vendor/CircleProgressView.swift @@ -0,0 +1,29 @@ +// +// CircleProgressView.swift +// +// +// Created by MainasuK on 2022/11/10. +// + +import Foundation +import SwiftUI + +/// https://stackoverflow.com/a/71467536/3797903 +struct CircleProgressView: View { + + let progress: Double + + var body: some View { + let lineWidth: CGFloat = 4 + let tintColor = Color.white + ZStack { + Circle() + .trim(from: 0.0, to: CGFloat(progress)) + .stroke(style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt, lineJoin: .bevel)) + .foregroundColor(tintColor) + .rotationEffect(Angle(degrees: 270.0)) + } + .padding(ceil(lineWidth / 2)) + } + +} diff --git a/MastodonSDK/Sources/MastodonUI/Vendor/MetaTextView+PasteExtensions.swift b/MastodonSDK/Sources/MastodonUI/Vendor/MetaTextView+PasteExtensions.swift new file mode 100644 index 000000000..8fe1949af --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Vendor/MetaTextView+PasteExtensions.swift @@ -0,0 +1,29 @@ +// +// MetaTextView+PasteExtensions.swift +// Mastodon +// +// Created by Rick Kerkhof on 30/10/2022. +// + +import Foundation +import MetaTextKit +import UIKit + +extension MetaTextView { + public override func paste(_ sender: Any?) { + super.paste(sender) + + var nextResponder = self.next; + + // Force the event to bubble through ALL responders + // This is a workaround as somewhere down the chain the paste event gets eaten + while (nextResponder != nil) { + if let nextResponder = nextResponder { + if (nextResponder.responds(to: #selector(UIResponderStandardEditActions.paste(_:)))) { + nextResponder.perform(#selector(UIResponderStandardEditActions.paste(_:)), with: sender) + } + } + nextResponder = nextResponder?.next; + } + } +} diff --git a/MastodonSDK/Sources/MastodonUI/Vendor/VisualEffectView.swift b/MastodonSDK/Sources/MastodonUI/Vendor/VisualEffectView.swift new file mode 100644 index 000000000..fe89b0457 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Vendor/VisualEffectView.swift @@ -0,0 +1,15 @@ +// +// VisualEffectView.swift +// +// +// Created by MainasuK on 2022/11/8. +// + +import SwiftUI + +// ref: https://stackoverflow.com/a/59111492/3797903 +public struct VisualEffectView: UIViewRepresentable { + public var effect: UIVisualEffect? + public func makeUIView(context: UIViewRepresentableContext) -> UIVisualEffectView { UIVisualEffectView() } + public func updateUIView(_ uiView: UIVisualEffectView, context: UIViewRepresentableContext) { uiView.effect = effect } +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index d0b5daa6f..416226cbb 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -315,7 +315,6 @@ extension StatusView.ViewModel { statusView.contentMetaText.configure( content: content ) - statusView.contentMetaText.textView.accessibilityLabel = content.string statusView.contentMetaText.textView.accessibilityTraits = [.staticText] statusView.contentMetaText.textView.accessibilityElementsHidden = false } else { @@ -727,8 +726,23 @@ extension StatusView.ViewModel { statusView.accessibilityLabel = accessibilityLabel } .store(in: &disposeBag) + + Publishers.CombineLatest( + $content, + $isContentReveal.removeDuplicates() + ) + .map { content, isRevealed in + guard isRevealed, let entities = content?.entities else { return [] } + return entities.compactMap { entity in + guard let name = entity.accessibilityCustomActionLabel else { return nil } + return UIAccessibilityCustomAction(name: name) { action in + statusView.delegate?.statusView(statusView, metaText: statusView.contentMetaText, didSelectMeta: entity.meta) + return true + } + } + } + .assign(to: \.accessibilityCustomActions, on: statusView.contentMetaText.textView) + .store(in: &disposeBag) } } - - diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift index 8ab34ce4f..563bc7e3d 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift @@ -547,6 +547,13 @@ extension StatusView { } +extension StatusView { + public override var accessibilityCustomActions: [UIAccessibilityCustomAction]? { + get { contentMetaText.textView.accessibilityCustomActions } + set { } + } +} + // MARK: - AdaptiveContainerView extension StatusView: AdaptiveContainerView { public func updateContainerViewComponentsLayoutMarginsRelativeArrangementBehavior(isEnabled: Bool) { diff --git a/Podfile b/Podfile index 28757d528..596aec62b 100644 --- a/Podfile +++ b/Podfile @@ -8,7 +8,6 @@ target 'Mastodon' do # Pods for Mastodon # UI - pod 'UITextField+Shake', '~> 1.2' pod 'XLPagerTabStrip', '~> 9.0.0' # misc diff --git a/Podfile.lock b/Podfile.lock index 12680db21..3b9928a0b 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -6,7 +6,6 @@ PODS: - Sourcery/CLI-Only (= 1.6.1) - Sourcery/CLI-Only (1.6.1) - SwiftGen (6.4.0) - - "UITextField+Shake (1.2.1)" - XLPagerTabStrip (9.0.0) DEPENDENCIES: @@ -15,7 +14,6 @@ DEPENDENCIES: - Kanna (~> 5.2.2) - Sourcery (~> 1.6.1) - SwiftGen (~> 6.4.0) - - "UITextField+Shake (~> 1.2)" - XLPagerTabStrip (~> 9.0.0) SPEC REPOS: @@ -25,7 +23,6 @@ SPEC REPOS: - Kanna - Sourcery - SwiftGen - - "UITextField+Shake" - XLPagerTabStrip SPEC CHECKSUMS: @@ -34,9 +31,8 @@ SPEC CHECKSUMS: Kanna: 01cfbddc127f5ff0963692f285fcbc8a9d62d234 Sourcery: f3759f803bd0739f74fc92a4341eed0473ce61ac SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108 - "UITextField+Shake": 298ac5a0f239d731bdab999b19b628c956ca0ac3 XLPagerTabStrip: 61c57fd61f611ee5f01ff1495ad6fbee8bf496c5 -PODFILE CHECKSUM: 8b15fb6d4e801b7a7e7761a2e2fe40a89b1da4ff +PODFILE CHECKSUM: 8fddf46611e09d2eb1a5d67c464c236884a08e80 COCOAPODS: 1.11.3 diff --git a/README.md b/README.md index bf35b4599..d2a19e715 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Mastodon + [![CI](https://github.com/mastodon/mastodon-ios/actions/workflows/main.yml/badge.svg)](https://github.com/mastodon/mastodon-ios/actions/workflows/main.yml) [![Crowdin](https://badges.crowdin.net/mastodon-for-ios/localized.svg)](https://crowdin.com/project/mastodon-for-ios) @@ -11,12 +12,14 @@ This is the repository for the official iOS App for Mastodon. You can install it Read this blog post for this app to learn more. > [Developing an official iOS app for Mastodon](https://blog.joinmastodon.org/2021/02/developing-an-official-ios-app-for-mastodon/) -## Getting Start +## Getting Started + - Read the setup guide [here](./Documentation/Setup.md) - About [contributing](./Documentation/CONTRIBUTING.md) - [Documentation folder](./Documentation/) ## Acknowledgments + Thanks to these open-sources projects listed [here](./Documentation/Acknowledgments.md). ## License diff --git a/ShareActionExtension/Scene/ComposeViewController.swift b/ShareActionExtension/Scene/ComposeViewController.swift deleted file mode 100644 index c93c05147..000000000 --- a/ShareActionExtension/Scene/ComposeViewController.swift +++ /dev/null @@ -1,327 +0,0 @@ -// -// ComposeViewController.swift -// MastodonShareAction -// -// Created by MainasuK Cirno on 2021-7-16. -// - -import os.log -import UIKit -import Combine -import MastodonUI -import SwiftUI -import MastodonAsset -import MastodonLocalization -import MastodonCore -import MastodonUI - -class ComposeViewController: UIViewController { - - let logger = Logger(subsystem: "ComposeViewController", category: "ViewController") - - let context = AppContext() - - var disposeBag = Set() - private(set) lazy var viewModel = ComposeViewModel(context: context) - - let publishButton: UIButton = { - let button = RoundedEdgesButton(type: .custom) - button.setTitle(L10n.Scene.Compose.composeAction, for: .normal) - button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold) - button.setBackgroundImage(.placeholder(color: Asset.Colors.brand.color), for: .normal) - button.setBackgroundImage(.placeholder(color: Asset.Colors.brand.color.withAlphaComponent(0.5)), for: .highlighted) - button.setBackgroundImage(.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled) - button.setTitleColor(.white, for: .normal) - button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 16, bottom: 5, right: 16) // set 28pt height - button.adjustsImageWhenHighlighted = false - return button - }() - - private(set) lazy var cancelBarButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ComposeViewController.cancelBarButtonItemPressed(_:))) - private(set) lazy var publishBarButtonItem: UIBarButtonItem = { - let barButtonItem = UIBarButtonItem(customView: publishButton) - publishButton.addTarget(self, action: #selector(ComposeViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside) - return barButtonItem - }() - - let activityIndicatorBarButtonItem: UIBarButtonItem = { - let indicatorView = UIActivityIndicatorView(style: .medium) - let barButtonItem = UIBarButtonItem(customView: indicatorView) - indicatorView.startAnimating() - return barButtonItem - }() - - -// let viewSafeAreaDidChange = PassthroughSubject() -// let composeToolbarView = ComposeToolbarView() -// var composeToolbarViewBottomLayoutConstraint: NSLayoutConstraint! -// let composeToolbarBackgroundView = UIView() -} - -extension ComposeViewController { - - override func viewDidLoad() { - super.viewDidLoad() - -// navigationController?.presentationController?.delegate = self -// -// setupBackgroundColor(theme: ThemeService.shared.currentTheme.value) -// ThemeService.shared.currentTheme -// .receive(on: DispatchQueue.main) -// .sink { [weak self] theme in -// guard let self = self else { return } -// self.setupBackgroundColor(theme: theme) -// } -// .store(in: &disposeBag) -// -// navigationItem.leftBarButtonItem = cancelBarButtonItem -// viewModel.isBusy -// .receive(on: DispatchQueue.main) -// .sink { [weak self] isBusy in -// guard let self = self else { return } -// self.navigationItem.rightBarButtonItem = isBusy ? self.activityIndicatorBarButtonItem : self.publishBarButtonItem -// } -// .store(in: &disposeBag) -// -// let hostingViewController = UIHostingController( -// rootView: ComposeView().environmentObject(viewModel.composeViewModel) -// ) -// addChild(hostingViewController) -// view.addSubview(hostingViewController.view) -// hostingViewController.view.translatesAutoresizingMaskIntoConstraints = false -// view.addSubview(hostingViewController.view) -// NSLayoutConstraint.activate([ -// hostingViewController.view.topAnchor.constraint(equalTo: view.topAnchor), -// hostingViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), -// hostingViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), -// hostingViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), -// ]) -// hostingViewController.didMove(toParent: self) -// -// composeToolbarView.translatesAutoresizingMaskIntoConstraints = false -// view.addSubview(composeToolbarView) -// composeToolbarViewBottomLayoutConstraint = view.bottomAnchor.constraint(equalTo: composeToolbarView.bottomAnchor) -// NSLayoutConstraint.activate([ -// composeToolbarView.leadingAnchor.constraint(equalTo: view.leadingAnchor), -// composeToolbarView.trailingAnchor.constraint(equalTo: view.trailingAnchor), -// composeToolbarViewBottomLayoutConstraint, -// composeToolbarView.heightAnchor.constraint(equalToConstant: ComposeToolbarView.toolbarHeight), -// ]) -// composeToolbarView.preservesSuperviewLayoutMargins = true -// composeToolbarView.delegate = self -// -// composeToolbarBackgroundView.translatesAutoresizingMaskIntoConstraints = false -// view.insertSubview(composeToolbarBackgroundView, belowSubview: composeToolbarView) -// NSLayoutConstraint.activate([ -// composeToolbarBackgroundView.topAnchor.constraint(equalTo: composeToolbarView.topAnchor), -// composeToolbarBackgroundView.leadingAnchor.constraint(equalTo: composeToolbarView.leadingAnchor), -// composeToolbarBackgroundView.trailingAnchor.constraint(equalTo: composeToolbarView.trailingAnchor), -// view.bottomAnchor.constraint(equalTo: composeToolbarBackgroundView.bottomAnchor), -// ]) -// -// // FIXME: using iOS 15 toolbar for .keyboard placement -// let keyboardEventPublishers = Publishers.CombineLatest3( -// KeyboardResponderService.shared.isShow, -// KeyboardResponderService.shared.state, -// KeyboardResponderService.shared.endFrame -// ) -// -// Publishers.CombineLatest( -// keyboardEventPublishers, -// viewSafeAreaDidChange -// ) -// .sink(receiveValue: { [weak self] keyboardEvents, _ in -// guard let self = self else { return } -// -// let (isShow, state, endFrame) = keyboardEvents -// guard isShow, state == .dock else { -// UIView.animate(withDuration: 0.3) { -// self.composeToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom -// self.view.layoutIfNeeded() -// } -// return -// } -// // isShow AND dock state -// -// UIView.animate(withDuration: 0.3) { -// self.composeToolbarViewBottomLayoutConstraint.constant = endFrame.height -// self.view.layoutIfNeeded() -// } -// }) -// .store(in: &disposeBag) -// -// // bind visibility toolbar UI -// Publishers.CombineLatest( -// viewModel.selectedStatusVisibility, -// viewModel.traitCollectionDidChangePublisher -// ) -// .receive(on: DispatchQueue.main) -// .sink { [weak self] type, _ in -// guard let self = self else { return } -// let image = type.image(interfaceStyle: self.traitCollection.userInterfaceStyle) -// self.composeToolbarView.visibilityButton.setImage(image, for: .normal) -// self.composeToolbarView.activeVisibilityType.value = type -// } -// .store(in: &disposeBag) -// -// // bind counter -// viewModel.characterCount -// .receive(on: DispatchQueue.main) -// .sink { [weak self] characterCount in -// guard let self = self else { return } -// let count = ShareViewModel.composeContentLimit - characterCount -// self.composeToolbarView.characterCountLabel.text = "\(count)" -// switch count { -// case _ where count < 0: -// self.composeToolbarView.characterCountLabel.font = .monospacedDigitSystemFont(ofSize: 24, weight: .bold) -// self.composeToolbarView.characterCountLabel.textColor = Asset.Colors.danger.color -// self.composeToolbarView.characterCountLabel.accessibilityLabel = L10n.A11y.Plural.Count.inputLimitExceeds(abs(count)) -// default: -// self.composeToolbarView.characterCountLabel.font = .monospacedDigitSystemFont(ofSize: 15, weight: .regular) -// self.composeToolbarView.characterCountLabel.textColor = Asset.Colors.Label.secondary.color -// self.composeToolbarView.characterCountLabel.accessibilityLabel = L10n.A11y.Plural.Count.inputLimitRemains(count) -// } -// } -// .store(in: &disposeBag) -// -// // bind valid -// viewModel.isValid -// .receive(on: DispatchQueue.main) -// .assign(to: \.isEnabled, on: publishButton) -// .store(in: &disposeBag) - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - -// viewModel.viewDidAppear.value = true -// viewModel.inputItems.value = extensionContext?.inputItems.compactMap { $0 as? NSExtensionItem } ?? [] -// -// viewModel.composeViewModel.viewDidAppear = true - } - - override func viewSafeAreaInsetsDidChange() { - super.viewSafeAreaInsetsDidChange() - -// viewSafeAreaDidChange.send() - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - -// viewModel.traitCollectionDidChangePublisher.send() - } - -} - -//extension ComposeViewController { -// private func setupBackgroundColor(theme: Theme) { -// view.backgroundColor = theme.systemElevatedBackgroundColor -// viewModel.composeViewModel.backgroundColor = theme.systemElevatedBackgroundColor -// composeToolbarBackgroundView.backgroundColor = theme.composeToolbarBackgroundColor -// -// let barAppearance = UINavigationBarAppearance() -// barAppearance.configureWithDefaultBackground() -// barAppearance.backgroundColor = theme.navigationBarBackgroundColor -// navigationItem.standardAppearance = barAppearance -// navigationItem.compactAppearance = barAppearance -// navigationItem.scrollEdgeAppearance = barAppearance -// } -// -// private func showDismissConfirmAlertController() { -// let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) // can not use alert in extension -// let discardAction = UIAlertAction(title: L10n.Common.Controls.Actions.discard, style: .destructive) { _ in -// self.extensionContext?.cancelRequest(withError: ShareViewModel.ShareError.userCancelShare) -// } -// alertController.addAction(discardAction) -// let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .cancel, handler: nil) -// alertController.addAction(okAction) -// self.present(alertController, animated: true, completion: nil) -// } -//} -// -extension ComposeViewController { - @objc private func cancelBarButtonItemPressed(_ sender: UIBarButtonItem) { - logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") - -// showDismissConfirmAlertController() - } - - @objc private func publishBarButtonItemPressed(_ sender: UIBarButtonItem) { - logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") - -// viewModel.isPublishing.value = true -// -// viewModel.publish() -// .delay(for: 2, scheduler: DispatchQueue.main) -// .receive(on: DispatchQueue.main) -// .sink { [weak self] completion in -// guard let self = self else { return } -// self.viewModel.isPublishing.value = false -// -// switch completion { -// case .failure: -// let alertController = UIAlertController( -// title: L10n.Common.Alerts.PublishPostFailure.title, -// message: L10n.Common.Alerts.PublishPostFailure.message, -// preferredStyle: .actionSheet // can not use alert in extension -// ) -// let okAction = UIAlertAction( -// title: L10n.Common.Controls.Actions.ok, -// style: .cancel, -// handler: nil -// ) -// alertController.addAction(okAction) -// self.present(alertController, animated: true, completion: nil) -// case .finished: -// self.publishButton.setTitle(L10n.Common.Controls.Actions.done, for: .normal) -// self.publishButton.isUserInteractionEnabled = false -// DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in -// guard let self = self else { return } -// self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) -// } -// } -// } receiveValue: { response in -// // do nothing -// } -// .store(in: &disposeBag) - } -} - -//// MARK - ComposeToolbarViewDelegate -//extension ComposeViewController: ComposeToolbarViewDelegate { -// -// func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: UIButton) { -// logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") -// -// withAnimation { -// viewModel.composeViewModel.isContentWarningComposing.toggle() -// } -// } -// -// func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: UIButton, visibilitySelectionType type: ComposeToolbarView.VisibilitySelectionType) { -// logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") -// -// viewModel.selectedStatusVisibility.value = type -// } -// -//} -// -//// MARK: - UIAdaptivePresentationControllerDelegate -//extension ComposeViewController: UIAdaptivePresentationControllerDelegate { -// -// func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { -// return viewModel.shouldDismiss.value -// } -// -// func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { -// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) -// showDismissConfirmAlertController() -// -// } -// -// func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { -// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) -// } -// -//} diff --git a/ShareActionExtension/Scene/ComposeViewModel.swift b/ShareActionExtension/Scene/ComposeViewModel.swift deleted file mode 100644 index 4470cfbe8..000000000 --- a/ShareActionExtension/Scene/ComposeViewModel.swift +++ /dev/null @@ -1,417 +0,0 @@ -// -// ComposeViewModel.swift -// MastodonShareAction -// -// Created by MainasuK Cirno on 2021-7-16. -// - -import os.log -import Foundation -import Combine -import CoreData -import CoreDataStack -import MastodonSDK -import MastodonUI -import SwiftUI -import UniformTypeIdentifiers -import MastodonAsset -import MastodonLocalization -import MastodonUI -import MastodonCore - -final class ComposeViewModel { - - let logger = Logger(subsystem: "ComposeViewModel", category: "ViewModel") - - var disposeBag = Set() - - static let composeContentLimit: Int = 500 - - // input - let context: AppContext - -// private var coreDataStack: CoreDataStack? -// var managedObjectContext: NSManagedObjectContext? -// var api: APIService? -// -// var inputItems = CurrentValueSubject<[NSExtensionItem], Never>([]) -// let viewDidAppear = CurrentValueSubject(false) -// let traitCollectionDidChangePublisher = CurrentValueSubject(Void()) // use CurrentValueSubject to make initial event emit -// let selectedStatusVisibility = CurrentValueSubject(.public) -// -// // output -// let authentication = CurrentValueSubject?, Never>(nil) -// let isFetchAuthentication = CurrentValueSubject(true) -// let isPublishing = CurrentValueSubject(false) -// let isBusy = CurrentValueSubject(true) -// let isValid = CurrentValueSubject(false) -// let shouldDismiss = CurrentValueSubject(true) -// let composeViewModel = ComposeViewModel() -// let characterCount = CurrentValueSubject(0) - - init(context: AppContext) { - self.context = context - // end init - -// viewDidAppear.receive(on: DispatchQueue.main) -// .removeDuplicates() -// .sink { [weak self] viewDidAppear in -// guard let self = self else { return } -// guard viewDidAppear else { return } -// self.setupCoreData() -// } -// .store(in: &disposeBag) -// -// Publishers.CombineLatest( -// inputItems.removeDuplicates(), -// viewDidAppear.removeDuplicates() -// ) -// .receive(on: DispatchQueue.main) -// .sink { [weak self] inputItems, _ in -// guard let self = self else { return } -// self.parse(inputItems: inputItems) -// } -// .store(in: &disposeBag) -// -// // bind authentication loading state -// authentication -// .map { result in result == nil } -// .assign(to: \.value, on: isFetchAuthentication) -// .store(in: &disposeBag) -// -// // bind user locked state -// authentication -// .compactMap { result -> Bool? in -// guard let result = result else { return nil } -// switch result { -// case .success(let authentication): -// return authentication.user.locked -// case .failure: -// return nil -// } -// } -// .map { locked -> ComposeToolbarView.VisibilitySelectionType in -// locked ? .private : .public -// } -// .assign(to: \.value, on: selectedStatusVisibility) -// .store(in: &disposeBag) -// -// // bind author -// authentication -// .receive(on: DispatchQueue.main) -// .sink { [weak self] result in -// guard let self = self else { return } -// guard let result = result else { return } -// switch result { -// case .success(let authentication): -// self.composeViewModel.avatarImageURL = authentication.user.avatarImageURL() -// self.composeViewModel.authorName = authentication.user.displayNameWithFallback -// self.composeViewModel.authorUsername = "@" + authentication.user.username -// case .failure: -// self.composeViewModel.avatarImageURL = nil -// self.composeViewModel.authorName = " " -// self.composeViewModel.authorUsername = " " -// } -// } -// .store(in: &disposeBag) -// -// // bind authentication to compose view model -// authentication -// .map { result -> MastodonAuthentication? in -// guard let result = result else { return nil } -// switch result { -// case .success(let authentication): -// return authentication -// case .failure: -// return nil -// } -// } -// .assign(to: &composeViewModel.$authentication) -// -// // bind isBusy -// Publishers.CombineLatest( -// isFetchAuthentication, -// isPublishing -// ) -// .receive(on: DispatchQueue.main) -// .map { $0 || $1 } -// .assign(to: \.value, on: isBusy) -// .store(in: &disposeBag) -// -// // pass initial i18n string -// composeViewModel.statusPlaceholder = L10n.Scene.Compose.contentInputPlaceholder -// composeViewModel.contentWarningPlaceholder = L10n.Scene.Compose.ContentWarning.placeholder -// composeViewModel.toolbarHeight = ComposeToolbarView.toolbarHeight -// -// // bind compose bar button item UI state -// let isComposeContentEmpty = composeViewModel.$statusContent -// .map { $0.isEmpty } -// -// isComposeContentEmpty -// .assign(to: \.value, on: shouldDismiss) -// .store(in: &disposeBag) -// -// let isComposeContentValid = composeViewModel.$characterCount -// .map { characterCount -> Bool in -// return characterCount <= ShareViewModel.composeContentLimit -// } -// let isMediaEmpty = composeViewModel.$attachmentViewModels -// .map { $0.isEmpty } -// let isMediaUploadAllSuccess = composeViewModel.$attachmentViewModels -// .map { viewModels in -// viewModels.allSatisfy { $0.uploadStateMachineSubject.value is StatusAttachmentViewModel.UploadState.Finish } -// } -// -// let isPublishBarButtonItemEnabledPrecondition1 = Publishers.CombineLatest4( -// isComposeContentEmpty, -// isComposeContentValid, -// isMediaEmpty, -// isMediaUploadAllSuccess -// ) -// .map { isComposeContentEmpty, isComposeContentValid, isMediaEmpty, isMediaUploadAllSuccess -> Bool in -// if isMediaEmpty { -// return isComposeContentValid && !isComposeContentEmpty -// } else { -// return isComposeContentValid && isMediaUploadAllSuccess -// } -// } -// .eraseToAnyPublisher() -// -// let isPublishBarButtonItemEnabledPrecondition2 = Publishers.CombineLatest( -// isComposeContentEmpty, -// isComposeContentValid -// ) -// .map { isComposeContentEmpty, isComposeContentValid -> Bool in -// return isComposeContentValid && !isComposeContentEmpty -// } -// .eraseToAnyPublisher() -// -// Publishers.CombineLatest( -// isPublishBarButtonItemEnabledPrecondition1, -// isPublishBarButtonItemEnabledPrecondition2 -// ) -// .map { $0 && $1 } -// .assign(to: \.value, on: isValid) -// .store(in: &disposeBag) -// -// // bind counter -// composeViewModel.$characterCount -// .assign(to: \.value, on: characterCount) -// .store(in: &disposeBag) -// -// // setup theme -// setupBackgroundColor(theme: ThemeService.shared.currentTheme.value) -// ThemeService.shared.currentTheme -// .receive(on: DispatchQueue.main) -// .sink { [weak self] theme in -// guard let self = self else { return } -// self.setupBackgroundColor(theme: theme) -// } -// .store(in: &disposeBag) - } - - private func setupBackgroundColor(theme: Theme) { -// composeViewModel.contentWarningBackgroundColor = Color(theme.contentWarningOverlayBackgroundColor) - } - -} - -//extension ShareViewModel { -// enum ShareError: Error { -// case `internal`(error: Error) -// case userCancelShare -// case missingAuthentication -// } -//} - -extension ComposeViewModel { -// private func setupCoreData() { -// logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") -// DispatchQueue.global().async { -// let _coreDataStack = CoreDataStack() -// self.coreDataStack = _coreDataStack -// self.managedObjectContext = _coreDataStack.persistentContainer.viewContext -// -// _coreDataStack.didFinishLoad -// .receive(on: RunLoop.main) -// .sink { [weak self] didFinishLoad in -// guard let self = self else { return } -// guard didFinishLoad else { return } -// guard let managedObjectContext = self.managedObjectContext else { return } -// -// -// self.api = APIService(backgroundManagedObjectContext: _coreDataStack.newTaskContext()) -// -// self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch authentication…") -// managedObjectContext.perform { -// do { -// let request = MastodonAuthentication.sortedFetchRequest -// let authentications = try managedObjectContext.fetch(request) -// let authentication = authentications.sorted(by: { $0.activedAt > $1.activedAt }).first -// guard let activeAuthentication = authentication else { -// self.authentication.value = .failure(ShareError.missingAuthentication) -// return -// } -// self.authentication.value = .success(activeAuthentication) -// self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch authentication success \(activeAuthentication.userID)") -// } catch { -// self.authentication.value = .failure(ShareError.internal(error: error)) -// self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch authentication fail \(error.localizedDescription)") -// assertionFailure(error.localizedDescription) -// } -// } -// } -// .store(in: &self.disposeBag) -// } -// } -} - -//extension ShareViewModel { -// func parse(inputItems: [NSExtensionItem]) { -// var itemProviders: [NSItemProvider] = [] -// -// for item in inputItems { -// itemProviders.append(contentsOf: item.attachments ?? []) -// } -// -// let _textProvider = itemProviders.first { provider in -// return provider.hasRepresentationConforming(toTypeIdentifier: UTType.plainText.identifier, fileOptions: []) -// } -// -// let _urlProvider = itemProviders.first { provider in -// return provider.hasRepresentationConforming(toTypeIdentifier: UTType.url.identifier, fileOptions: []) -// } -// -// let _movieProvider = itemProviders.first { provider in -// return provider.hasRepresentationConforming(toTypeIdentifier: UTType.movie.identifier, fileOptions: []) -// } -// -// let imageProviders = itemProviders.filter { provider in -// return provider.hasRepresentationConforming(toTypeIdentifier: UTType.image.identifier, fileOptions: []) -// } -// -// Task { @MainActor in -// async let text = ShareViewModel.loadText(textProvider: _textProvider) -// async let url = ShareViewModel.loadURL(textProvider: _urlProvider) -// -// let content = await [text, url] -// .compactMap { $0 } -// .joined(separator: " ") -// self.composeViewModel.statusContent = content -// } -// -// guard let api = self.api else { return } -// -// if let movieProvider = _movieProvider { -// composeViewModel.setupAttachmentViewModels([ -// StatusAttachmentViewModel(api: api, itemProvider: movieProvider) -// ]) -// } else if !imageProviders.isEmpty { -// let viewModels = imageProviders.map { provider in -// StatusAttachmentViewModel(api: api, itemProvider: provider) -// } -// composeViewModel.setupAttachmentViewModels(viewModels) -// } -// -// } -// -// private static func loadText(textProvider: NSItemProvider?) async -> String? { -// guard let textProvider = textProvider else { return nil } -// do { -// let item = try await textProvider.loadItem(forTypeIdentifier: UTType.plainText.identifier) -// guard let text = item as? String else { return nil } -// return text -// } catch { -// return nil -// } -// } -// -// private static func loadURL(textProvider: NSItemProvider?) async -> String? { -// guard let textProvider = textProvider else { return nil } -// do { -// let item = try await textProvider.loadItem(forTypeIdentifier: UTType.url.identifier) -// guard let url = item as? URL else { return nil } -// return url.absoluteString -// } catch { -// return nil -// } -// } -// -//} -// -//extension ShareViewModel { -// func publish() -> AnyPublisher, Error> { -// guard let authentication = composeViewModel.authentication, -// let api = self.api -// else { -// return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() -// } -// let authenticationBox = MastodonAuthenticationBox( -// authenticationRecord: .init(objectID: authentication.objectID), -// domain: authentication.domain, -// userID: authentication.userID, -// appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken), -// userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken) -// ) -// -// let domain = authentication.domain -// let attachmentViewModels = composeViewModel.attachmentViewModels -// let mediaIDs = attachmentViewModels.compactMap { viewModel in -// viewModel.attachment.value?.id -// } -// let sensitive: Bool = composeViewModel.isContentWarningComposing -// let spoilerText: String? = { -// let text = composeViewModel.contentWarningContent -// guard !text.isEmpty else { return nil } -// return text -// }() -// let visibility = selectedStatusVisibility.value.visibility -// -// let updateMediaQuerySubscriptions: [AnyPublisher, Error>] = { -// var subscriptions: [AnyPublisher, Error>] = [] -// for attachmentViewModel in attachmentViewModels { -// guard let attachmentID = attachmentViewModel.attachment.value?.id else { continue } -// let description = attachmentViewModel.descriptionContent.trimmingCharacters(in: .whitespacesAndNewlines) -// guard !description.isEmpty else { continue } -// let query = Mastodon.API.Media.UpdateMediaQuery( -// file: nil, -// thumbnail: nil, -// description: description, -// focus: nil -// ) -// let subscription = api.updateMedia( -// domain: domain, -// attachmentID: attachmentID, -// query: query, -// mastodonAuthenticationBox: authenticationBox -// ) -// subscriptions.append(subscription) -// } -// return subscriptions -// }() -// -// let status = composeViewModel.statusContent -// -// return Publishers.MergeMany(updateMediaQuerySubscriptions) -// .collect() -// .asyncMap { attachments in -// let query = Mastodon.API.Statuses.PublishStatusQuery( -// status: status, -// mediaIDs: mediaIDs.isEmpty ? nil : mediaIDs, -// pollOptions: nil, -// pollExpiresIn: nil, -// inReplyToID: nil, -// sensitive: sensitive, -// spoilerText: spoilerText, -// visibility: visibility -// ) -// return try await api.publishStatus( -// domain: domain, -// idempotencyKey: nil, // FIXME: -// query: query, -// authenticationBox: authenticationBox -// ) -// } -// .eraseToAnyPublisher() -// } -//} diff --git a/ShareActionExtension/Scene/ShareViewController.swift b/ShareActionExtension/Scene/ShareViewController.swift new file mode 100644 index 000000000..7757452cd --- /dev/null +++ b/ShareActionExtension/Scene/ShareViewController.swift @@ -0,0 +1,330 @@ +// +// ShareViewController.swift +// ShareActionExtension +// +// Created by MainasuK on 2022/11/13. +// + +import os.log +import UIKit +import Combine +import CoreDataStack +import MastodonCore +import MastodonUI +import MastodonAsset +import MastodonLocalization +import UniformTypeIdentifiers + +final class ShareViewController: UIViewController { + + let logger = Logger(subsystem: "ShareViewController", category: "ViewController") + + var disposeBag = Set() + + let context = AppContext() + private(set) lazy var viewModel = ShareViewModel(context: context) + + let publishButton: UIButton = { + let button = RoundedEdgesButton(type: .custom) + button.cornerRadius = 10 + button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 16, bottom: 5, right: 16) // set 28pt height + button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold) + button.setTitle(L10n.Scene.Compose.composeAction, for: .normal) + return button + }() + private func configurePublishButtonApperance() { + publishButton.adjustsImageWhenHighlighted = false + publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Label.primary.color), for: .normal) + publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Label.primary.color.withAlphaComponent(0.5)), for: .highlighted) + publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled) + publishButton.setTitleColor(Asset.Colors.Label.primaryReverse.color, for: .normal) + } + + private(set) lazy var cancelBarButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ShareViewController.cancelBarButtonItemPressed(_:))) + private(set) lazy var publishBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem(customView: publishButton) + publishButton.addTarget(self, action: #selector(ShareViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside) + return barButtonItem + }() + let activityIndicatorBarButtonItem: UIBarButtonItem = { + let indicatorView = UIActivityIndicatorView(style: .medium) + let barButtonItem = UIBarButtonItem(customView: indicatorView) + indicatorView.startAnimating() + return barButtonItem + }() + + private var composeContentViewModel: ComposeContentViewModel? + private var composeContentViewController: ComposeContentViewController? + + let notSignInLabel: UILabel = { + let label = UILabel() + label.font = .preferredFont(forTextStyle: .subheadline) + label.textColor = .secondaryLabel + label.text = "No Available Account" // TODO: i18n + return label + }() + +} + +extension ShareViewController { + override func viewDidLoad() { + super.viewDidLoad() + + setupTheme(theme: ThemeService.shared.currentTheme.value) + ThemeService.shared.apply(theme: ThemeService.shared.currentTheme.value) + ThemeService.shared.currentTheme + .receive(on: DispatchQueue.main) + .sink { [weak self] theme in + guard let self = self else { return } + self.setupTheme(theme: theme) + } + .store(in: &disposeBag) + + view.backgroundColor = .systemBackground + title = L10n.Scene.Compose.Title.newPost + + navigationItem.leftBarButtonItem = cancelBarButtonItem + navigationItem.rightBarButtonItem = publishBarButtonItem + + do { + guard let authContext = try setupAuthContext() else { + setupHintLabel() + return + } + viewModel.authContext = authContext + let composeContentViewModel = ComposeContentViewModel( + context: context, + authContext: authContext, + kind: .post + ) + let composeContentViewController = ComposeContentViewController() + composeContentViewController.viewModel = composeContentViewModel + addChild(composeContentViewController) + composeContentViewController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(composeContentViewController.view) + NSLayoutConstraint.activate([ + composeContentViewController.view.topAnchor.constraint(equalTo: view.topAnchor), + composeContentViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + composeContentViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + composeContentViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + composeContentViewController.didMove(toParent: self) + + self.composeContentViewModel = composeContentViewModel + self.composeContentViewController = composeContentViewController + + Task { @MainActor in + let inputItems = self.extensionContext?.inputItems.compactMap { $0 as? NSExtensionItem } ?? [] + await load(inputItems: inputItems) + } // end Task + } catch { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): error: \(error.localizedDescription)") + } + + viewModel.$isPublishing + .receive(on: DispatchQueue.main) + .sink { [weak self] isBusy in + guard let self = self else { return } + self.navigationItem.rightBarButtonItem = isBusy ? self.activityIndicatorBarButtonItem : self.publishBarButtonItem + } + .store(in: &disposeBag) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + configurePublishButtonApperance() + } +} + +extension ShareViewController { + @objc private func cancelBarButtonItemPressed(_ sender: UIBarButtonItem) { + logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + + extensionContext?.cancelRequest(withError: NSError(domain: "org.joinmastodon.app.ShareActionExtension", code: -1)) + } + + @objc private func publishBarButtonItemPressed(_ sender: UIBarButtonItem) { + logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + + + Task { @MainActor in + viewModel.isPublishing = true + do { + guard let statusPublisher = try composeContentViewModel?.statusPublisher(), + let authContext = viewModel.authContext + else { + throw AppError.badRequest + } + + _ = try await statusPublisher.publish(api: context.apiService, authContext: authContext) + + self.publishButton.setTitle(L10n.Common.Controls.Actions.done, for: .normal) + try await Task.sleep(nanoseconds: 1 * .second) + + self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) + + } catch { + let alertController = UIAlertController.standardAlert(of: error) + present(alertController, animated: true) + return + } + viewModel.isPublishing = false + + } + } +} + +extension ShareViewController { + private func setupAuthContext() throws -> AuthContext? { + let request = MastodonAuthentication.activeSortedFetchRequest // use active order + let _authentication = try context.managedObjectContext.fetch(request).first + let _authContext = _authentication.flatMap { AuthContext(authentication: $0) } + return _authContext + } + + private func setupHintLabel() { + notSignInLabel.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(notSignInLabel) + NSLayoutConstraint.activate([ + notSignInLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + notSignInLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + } + + private func setupTheme(theme: Theme) { + view.backgroundColor = theme.systemElevatedBackgroundColor + + let barAppearance = UINavigationBarAppearance() + barAppearance.configureWithDefaultBackground() + barAppearance.backgroundColor = theme.navigationBarBackgroundColor + navigationItem.standardAppearance = barAppearance + navigationItem.compactAppearance = barAppearance + navigationItem.scrollEdgeAppearance = barAppearance + } + + private func showDismissConfirmAlertController() { + let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) // can not use alert in extension + let discardAction = UIAlertAction(title: L10n.Common.Controls.Actions.discard, style: .destructive) { _ in + self.extensionContext?.cancelRequest(withError: ShareError.userCancelShare) + } + alertController.addAction(discardAction) + let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .cancel, handler: nil) + alertController.addAction(okAction) + self.present(alertController, animated: true, completion: nil) + } +} + +// MARK: - UIAdaptivePresentationControllerDelegate +extension ShareViewController: UIAdaptivePresentationControllerDelegate { + + func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { + return composeContentViewModel?.shouldDismiss ?? true + } + + func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + showDismissConfirmAlertController() + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension ShareViewController { + + private func load(inputItems: [NSExtensionItem]) async { + guard let composeContentViewModel = self.composeContentViewModel, + let authContext = viewModel.authContext + else { + assertionFailure() + return + } + var itemProviders: [NSItemProvider] = [] + + for item in inputItems { + itemProviders.append(contentsOf: item.attachments ?? []) + } + + let _textProvider = itemProviders.first { provider in + return provider.hasRepresentationConforming(toTypeIdentifier: UTType.plainText.identifier, fileOptions: []) + } + + let _urlProvider = itemProviders.first { provider in + return provider.hasRepresentationConforming(toTypeIdentifier: UTType.url.identifier, fileOptions: []) + } + + let _movieProvider = itemProviders.first { provider in + return provider.hasRepresentationConforming(toTypeIdentifier: UTType.movie.identifier, fileOptions: []) + } + + let imageProviders = itemProviders.filter { provider in + return provider.hasRepresentationConforming(toTypeIdentifier: UTType.image.identifier, fileOptions: []) + } + + async let text = ShareViewController.loadText(textProvider: _textProvider) + async let url = ShareViewController.loadURL(textProvider: _urlProvider) + + let content = await [text, url] + .compactMap { $0 } + .joined(separator: " ") + // passby the viewModel `content` value + if !content.isEmpty { + composeContentViewModel.content = content + " " + composeContentViewModel.contentMetaText?.textView.insertText(content + " ") + } + + if let movieProvider = _movieProvider { + let attachmentViewModel = AttachmentViewModel( + api: context.apiService, + authContext: authContext, + input: .itemProvider(movieProvider), + delegate: composeContentViewModel + ) + composeContentViewModel.attachmentViewModels.append(attachmentViewModel) + } else if !imageProviders.isEmpty { + let attachmentViewModels = imageProviders.map { provider in + AttachmentViewModel( + api: context.apiService, + authContext: authContext, + input: .itemProvider(provider), + delegate: composeContentViewModel + ) + } + composeContentViewModel.attachmentViewModels.append(contentsOf: attachmentViewModels) + } + } + + private static func loadText(textProvider: NSItemProvider?) async -> String? { + guard let textProvider = textProvider else { return nil } + do { + let item = try await textProvider.loadItem(forTypeIdentifier: UTType.plainText.identifier) + guard let text = item as? String else { return nil } + return text + } catch { + return nil + } + } + + private static func loadURL(textProvider: NSItemProvider?) async -> String? { + guard let textProvider = textProvider else { return nil } + do { + let item = try await textProvider.loadItem(forTypeIdentifier: UTType.url.identifier) + guard let url = item as? URL else { return nil } + return url.absoluteString + } catch { + return nil + } + } + +} + +extension ShareViewController { + enum ShareError: Error { + case `internal`(error: Error) + case userCancelShare + case missingAuthentication + } +} diff --git a/ShareActionExtension/Scene/ShareViewModel.swift b/ShareActionExtension/Scene/ShareViewModel.swift new file mode 100644 index 000000000..ef8e200a6 --- /dev/null +++ b/ShareActionExtension/Scene/ShareViewModel.swift @@ -0,0 +1,43 @@ +// +// ShareViewModel.swift +// MastodonShareAction +// +// Created by MainasuK Cirno on 2021-7-16. +// + +import os.log +import Foundation +import Combine +import CoreData +import CoreDataStack +import MastodonSDK +import SwiftUI +import UniformTypeIdentifiers +import MastodonAsset +import MastodonLocalization +import MastodonUI +import MastodonCore + +final class ShareViewModel { + + let logger = Logger(subsystem: "ComposeViewModel", category: "ViewModel") + + var disposeBag = Set() + + // input + let context: AppContext + @Published var authContext: AuthContext? + + @Published var isPublishing = false + + // output + + init( + context: AppContext + ) { + self.context = context + // end init + + } + +} diff --git a/ShareActionExtension/Scene/View/ComposeToolbarView.swift b/ShareActionExtension/Scene/View/ComposeToolbarView.swift deleted file mode 100644 index 557706fd8..000000000 --- a/ShareActionExtension/Scene/View/ComposeToolbarView.swift +++ /dev/null @@ -1,263 +0,0 @@ -// -// ComposeToolbarView.swift -// ShareActionExtension -// -// Created by MainasuK Cirno on 2021-7-19. -// - -import os.log -import UIKit -import Combine -import MastodonSDK -import MastodonUI -import MastodonAsset -import MastodonLocalization -import MastodonCore -import MastodonUI - -protocol ComposeToolbarViewDelegate: AnyObject { - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: UIButton) - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: UIButton, visibilitySelectionType type: ComposeToolbarView.VisibilitySelectionType) -} - -final class ComposeToolbarView: UIView { - - var disposeBag = Set() - - static let toolbarButtonSize: CGSize = CGSize(width: 44, height: 44) - static let toolbarHeight: CGFloat = 44 - - weak var delegate: ComposeToolbarViewDelegate? - - let contentWarningButton: UIButton = { - let button = HighlightDimmableButton() - ComposeToolbarView.configureToolbarButtonAppearance(button: button) - button.setImage(UIImage(systemName: "exclamationmark.shield", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)), for: .normal) - button.accessibilityLabel = L10n.Scene.Compose.Accessibility.enableContentWarning - return button - }() - - let visibilityButton: UIButton = { - let button = HighlightDimmableButton() - ComposeToolbarView.configureToolbarButtonAppearance(button: button) - button.setImage(UIImage(systemName: "person.3", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)), for: .normal) - button.accessibilityLabel = L10n.Scene.Compose.Accessibility.postVisibilityMenu - return button - }() - - let characterCountLabel: UILabel = { - let label = UILabel() - label.font = .systemFont(ofSize: 15, weight: .regular) - label.text = "500" - label.textColor = Asset.Colors.Label.secondary.color - label.accessibilityLabel = L10n.A11y.Plural.Count.inputLimitRemains(500) - return label - }() - - let activeVisibilityType = CurrentValueSubject(.public) - - override init(frame: CGRect) { - super.init(frame: frame) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - -} - -extension ComposeToolbarView { - - private func _init() { - setupBackgroundColor(theme: ThemeService.shared.currentTheme.value) - ThemeService.shared.currentTheme - .receive(on: DispatchQueue.main) - .sink { [weak self] theme in - guard let self = self else { return } - self.setupBackgroundColor(theme: theme) - } - .store(in: &disposeBag) - - let stackView = UIStackView() - stackView.axis = .horizontal - stackView.spacing = 0 - stackView.distribution = .fillEqually - stackView.translatesAutoresizingMaskIntoConstraints = false - addSubview(stackView) - NSLayoutConstraint.activate([ - stackView.centerYAnchor.constraint(equalTo: centerYAnchor), - layoutMarginsGuide.leadingAnchor.constraint(equalTo: stackView.leadingAnchor, constant: 8), // tweak button margin offset - ]) - - let buttons = [ - contentWarningButton, - visibilityButton, - ] - buttons.forEach { button in - button.translatesAutoresizingMaskIntoConstraints = false - stackView.addArrangedSubview(button) - NSLayoutConstraint.activate([ - button.widthAnchor.constraint(equalToConstant: 44), - button.heightAnchor.constraint(equalToConstant: 44), - ]) - } - - characterCountLabel.translatesAutoresizingMaskIntoConstraints = false - addSubview(characterCountLabel) - NSLayoutConstraint.activate([ - characterCountLabel.topAnchor.constraint(equalTo: topAnchor), - characterCountLabel.leadingAnchor.constraint(greaterThanOrEqualTo: stackView.trailingAnchor, constant: 8), - characterCountLabel.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), - characterCountLabel.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - characterCountLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) - - contentWarningButton.addTarget(self, action: #selector(ComposeToolbarView.contentWarningButtonDidPressed(_:)), for: .touchUpInside) - visibilityButton.menu = createVisibilityContextMenu(interfaceStyle: traitCollection.userInterfaceStyle) - visibilityButton.showsMenuAsPrimaryAction = true - - updateToolbarButtonUserInterfaceStyle() - - // update menu when selected visibility type changed - activeVisibilityType - .receive(on: RunLoop.main) - .sink { [weak self] type in - guard let self = self else { return } - self.visibilityButton.menu = self.createVisibilityContextMenu(interfaceStyle: self.traitCollection.userInterfaceStyle) - } - .store(in: &disposeBag) - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - updateToolbarButtonUserInterfaceStyle() - } - -} - -extension ComposeToolbarView { - private func setupBackgroundColor(theme: Theme) { - backgroundColor = theme.composeToolbarBackgroundColor - } -} - -extension ComposeToolbarView { - enum MediaSelectionType: String { - case camera - case photoLibrary - case browse - } - - enum VisibilitySelectionType: String, CaseIterable { - case `public` - // TODO: remove unlisted option from codebase - // case unlisted - case `private` - case direct - - var title: String { - switch self { - case .public: return L10n.Scene.Compose.Visibility.public - // case .unlisted: return L10n.Scene.Compose.Visibility.unlisted - case .private: return L10n.Scene.Compose.Visibility.private - case .direct: return L10n.Scene.Compose.Visibility.direct - } - } - - func image(interfaceStyle: UIUserInterfaceStyle) -> UIImage { - switch self { - case .public: return UIImage(systemName: "globe", withConfiguration: UIImage.SymbolConfiguration(pointSize: 19, weight: .medium))! - // case .unlisted: return UIImage(systemName: "eye.slash", withConfiguration: UIImage.SymbolConfiguration(pointSize: 18, weight: .regular))! - case .private: - switch interfaceStyle { - case .light: return UIImage(systemName: "person.3", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium))! - default: return UIImage(systemName: "person.3.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium))! - } - case .direct: return UIImage(systemName: "at", withConfiguration: UIImage.SymbolConfiguration(pointSize: 19, weight: .regular))! - } - } - - var visibility: Mastodon.Entity.Status.Visibility { - switch self { - case .public: return .public - // case .unlisted: return .unlisted - case .private: return .private - case .direct: return .direct - } - } - } -} - -extension ComposeToolbarView { - - private static func configureToolbarButtonAppearance(button: UIButton) { - button.tintColor = ThemeService.tintColor - button.setBackgroundImage(.placeholder(size: ComposeToolbarView.toolbarButtonSize, color: .systemFill), for: .highlighted) - button.layer.masksToBounds = true - button.layer.cornerRadius = 5 - button.layer.cornerCurve = .continuous - } - - private func updateToolbarButtonUserInterfaceStyle() { - switch traitCollection.userInterfaceStyle { - case .light: - contentWarningButton.setImage(UIImage(systemName: "exclamationmark.shield", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal) - - case .dark: - contentWarningButton.setImage(UIImage(systemName: "exclamationmark.shield.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal) - - default: - assertionFailure() - } - - visibilityButton.menu = createVisibilityContextMenu(interfaceStyle: traitCollection.userInterfaceStyle) - } - - private func createVisibilityContextMenu(interfaceStyle: UIUserInterfaceStyle) -> UIMenu { - let children: [UIMenuElement] = VisibilitySelectionType.allCases.map { type in - let state: UIMenuElement.State = activeVisibilityType.value == type ? .on : .off - return UIAction(title: type.title, image: type.image(interfaceStyle: interfaceStyle), identifier: nil, discoverabilityTitle: nil, attributes: [], state: state) { [weak self] action in - guard let self = self else { return } - os_log(.info, "%{public}s[%{public}ld], %{public}s: visibilitySelectionType: %s", ((#file as NSString).lastPathComponent), #line, #function, type.rawValue) - self.delegate?.composeToolbarView(self, visibilityButtonDidPressed: self.visibilityButton, visibilitySelectionType: type) - } - } - return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children) - } - -} - -extension ComposeToolbarView { - - @objc private func contentWarningButtonDidPressed(_ sender: UIButton) { - os_log(.info, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.composeToolbarView(self, contentWarningButtonDidPressed: sender) - } - -} - -#if canImport(SwiftUI) && DEBUG -import SwiftUI - -struct ComposeToolbarView_Previews: PreviewProvider { - - static var previews: some View { - UIViewPreview(width: 375) { - let toolbarView = ComposeToolbarView() - toolbarView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - toolbarView.widthAnchor.constraint(equalToConstant: 375).priority(.defaultHigh), - toolbarView.heightAnchor.constraint(equalToConstant: 64).priority(.defaultHigh), - ]) - return toolbarView - } - .previewLayout(.fixed(width: 375, height: 100)) - } - -} - -#endif - diff --git a/ShareActionExtension/Scene/View/ComposeView.swift b/ShareActionExtension/Scene/View/ComposeView.swift deleted file mode 100644 index a688d6492..000000000 --- a/ShareActionExtension/Scene/View/ComposeView.swift +++ /dev/null @@ -1,151 +0,0 @@ -// -// ComposeView.swift -// -// -// Created by MainasuK Cirno on 2021-7-16. -// - -import UIKit -import SwiftUI - -public struct ComposeView: View { - - @EnvironmentObject var viewModel: ComposeViewModel - @State var statusEditorViewWidth: CGFloat = .zero - - let horizontalMargin: CGFloat = 20 - - public init() { } - - public var body: some View { - GeometryReader { proxy in - List { - // Content Warning - if viewModel.isContentWarningComposing { - ContentWarningEditorView( - contentWarningContent: $viewModel.contentWarningContent, - placeholder: viewModel.contentWarningPlaceholder - ) - .padding(EdgeInsets(top: 6, leading: horizontalMargin, bottom: 6, trailing: horizontalMargin)) - .background(viewModel.contentWarningBackgroundColor) - .transition(.opacity) - .listRow(backgroundColor: Color(viewModel.backgroundColor)) - } - - // Author - StatusAuthorView( - avatarImageURL: viewModel.avatarImageURL, - name: viewModel.authorName, - username: viewModel.authorUsername - ) - .padding(EdgeInsets(top: 20, leading: horizontalMargin, bottom: 16, trailing: horizontalMargin)) - .listRow(backgroundColor: Color(viewModel.backgroundColor)) - - // Editor - StatusEditorView( - string: $viewModel.statusContent, - placeholder: viewModel.statusPlaceholder, - width: statusEditorViewWidth, - attributedString: viewModel.statusContentAttributedString, - keyboardType: .twitter, - viewDidAppear: $viewModel.viewDidAppear - ) - .frame(width: statusEditorViewWidth) - .frame(minHeight: 100) - .padding(EdgeInsets(top: 0, leading: horizontalMargin, bottom: 0, trailing: horizontalMargin)) - .listRow(backgroundColor: Color(viewModel.backgroundColor)) - - // Attachments - ForEach(viewModel.attachmentViewModels) { attachmentViewModel in - let descriptionBinding = Binding { - return attachmentViewModel.descriptionContent - } set: { newValue in - attachmentViewModel.descriptionContent = newValue - } - - StatusAttachmentView( - image: attachmentViewModel.thumbnailImage, - descriptionPlaceholder: attachmentViewModel.descriptionPlaceholder, - description: descriptionBinding, - errorPrompt: attachmentViewModel.errorPrompt, - errorPromptImage: attachmentViewModel.errorPromptImage, - isUploading: attachmentViewModel.isUploading, - progressViewTintColor: attachmentViewModel.progressViewTintColor, - removeButtonAction: { - self.viewModel.removeAttachmentViewModel(attachmentViewModel) - } - ) - } - .padding(EdgeInsets(top: 16, leading: horizontalMargin, bottom: 0, trailing: horizontalMargin)) - .fixedSize(horizontal: false, vertical: true) - .listRow(backgroundColor: Color(viewModel.backgroundColor)) - - // bottom padding - Color.clear - .frame(height: viewModel.toolbarHeight + 20) - .listRow(backgroundColor: Color(viewModel.backgroundColor)) - } // end List - .listStyle(.plain) - .introspectTableView(customize: { tableView in - // tableView.keyboardDismissMode = .onDrag - tableView.verticalScrollIndicatorInsets.bottom = viewModel.toolbarHeight - }) - .preference( - key: ComposeListViewFramePreferenceKey.self, - value: proxy.frame(in: .local) - ) - .onPreferenceChange(ComposeListViewFramePreferenceKey.self) { frame in - var frame = frame - frame.size.width = frame.width - 2 * horizontalMargin - statusEditorViewWidth = frame.width - } // end List - .introspectTableView(customize: { tableView in - tableView.backgroundColor = .clear - }) - .overrideBackground(color: Color(viewModel.backgroundColor)) - } // end GeometryReader - } // end body -} - -struct ComposeListViewFramePreferenceKey: PreferenceKey { - static var defaultValue: CGRect = .zero - static func reduce(value: inout CGRect, nextValue: () -> CGRect) { } -} - -extension View { - // hack for separator line - @ViewBuilder - func listRow(backgroundColor: Color) -> some View { - // expand list row to edge (set inset) - // then hide the separator - if #available(iOS 15, *) { - frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - .listRowInsets(EdgeInsets(top: -1, leading: -1, bottom: -1, trailing: -1)) - .background(backgroundColor) - .listRowSeparator(.hidden) // new API - } else { - frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - .listRowInsets(EdgeInsets(top: -1, leading: -1, bottom: -1, trailing: -1)) // separator line hidden magic - .background(backgroundColor) - } - } - - @ViewBuilder - func overrideBackground(color: Color) -> some View { - background(color.ignoresSafeArea()) - } -} - - -struct ComposeView_Previews: PreviewProvider { - - static let viewModel: ComposeViewModel = { - let viewModel = ComposeViewModel() - return viewModel - }() - - static var previews: some View { - ComposeView().environmentObject(viewModel) - } - -} diff --git a/ShareActionExtension/Scene/View/ComposeViewModel.swift b/ShareActionExtension/Scene/View/ComposeViewModel.swift deleted file mode 100644 index 88c2b896f..000000000 --- a/ShareActionExtension/Scene/View/ComposeViewModel.swift +++ /dev/null @@ -1,130 +0,0 @@ -// -// ComposeViewModel.swift -// ShareActionExtension -// -// Created by MainasuK Cirno on 2021-7-16. -// - -import Foundation -import SwiftUI -import Combine -import CoreDataStack - -class ComposeViewModel: ObservableObject { - - var disposeBag = Set() - - @Published var authentication: MastodonAuthentication? - - @Published var backgroundColor: UIColor = .clear - @Published var toolbarHeight: CGFloat = 0 - @Published var viewDidAppear = false - - @Published var avatarImageURL: URL? - @Published var authorName: String = "" - @Published var authorUsername: String = "" - - @Published var statusContent = "" - @Published var statusPlaceholder = "" - @Published var statusContentAttributedString = NSAttributedString() - - @Published var isContentWarningComposing = false - @Published var contentWarningBackgroundColor = Color.secondary - @Published var contentWarningPlaceholder = "" - @Published var contentWarningContent = "" - - @Published private(set) var attachmentViewModels: [StatusAttachmentViewModel] = [] - - @Published var characterCount = 0 - - public init() { - $statusContent - .map { NSAttributedString(string: $0) } - .assign(to: &$statusContentAttributedString) - - Publishers.CombineLatest3( - $statusContent, - $isContentWarningComposing, - $contentWarningContent - ) - .map { statusContent, isContentWarningComposing, contentWarningContent in - var count = statusContent.count - if isContentWarningComposing { - count += contentWarningContent.count - } - return count - } - .assign(to: &$characterCount) - - // setup attribute updater - $attachmentViewModels - .receive(on: DispatchQueue.main) - .debounce(for: 0.3, scheduler: DispatchQueue.main) - .sink { attachmentViewModels in - // drive upload state - // make image upload in the queue - for attachmentViewModel in attachmentViewModels { - // skip when prefix N task when task finish OR fail OR uploading - guard let currentState = attachmentViewModel.uploadStateMachine.currentState else { break } - if currentState is StatusAttachmentViewModel.UploadState.Fail { - continue - } - if currentState is StatusAttachmentViewModel.UploadState.Finish { - continue - } - if currentState is StatusAttachmentViewModel.UploadState.Uploading { - break - } - // trigger uploading one by one - if currentState is StatusAttachmentViewModel.UploadState.Initial { - attachmentViewModel.uploadStateMachine.enter(StatusAttachmentViewModel.UploadState.Uploading.self) - break - } - } - } - .store(in: &disposeBag) - - #if DEBUG - // avatarImageURL = URL(string: "https://upload.wikimedia.org/wikipedia/commons/2/2c/Rotating_earth_%28large%29.gif") - // authorName = "Alice" - // authorUsername = "alice" - #endif - } - -} - -extension ComposeViewModel { - func setupAttachmentViewModels(_ viewModels: [StatusAttachmentViewModel]) { - attachmentViewModels = viewModels - for viewModel in viewModels { - // set delegate - viewModel.delegate = self - // set observed - viewModel.objectWillChange.sink { [weak self] _ in - guard let self = self else { return } - self.objectWillChange.send() - } - .store(in: &viewModel.disposeBag) - // bind authentication - $authentication - .assign(to: \.value, on: viewModel.authentication) - .store(in: &viewModel.disposeBag) - } - } - - func removeAttachmentViewModel(_ viewModel: StatusAttachmentViewModel) { - if let index = attachmentViewModels.firstIndex(where: { $0 === viewModel }) { - attachmentViewModels.remove(at: index) - } - } -} - -// MARK: - StatusAttachmentViewModelDelegate -extension ComposeViewModel: StatusAttachmentViewModelDelegate { - func statusAttachmentViewModel(_ viewModel: StatusAttachmentViewModel, uploadStateDidChange state: StatusAttachmentViewModel.UploadState?) { - // trigger event update - DispatchQueue.main.async { - self.attachmentViewModels = self.attachmentViewModels - } - } -} diff --git a/ShareActionExtension/Scene/View/ContentWarningEditorView.swift b/ShareActionExtension/Scene/View/ContentWarningEditorView.swift deleted file mode 100644 index 833c919fc..000000000 --- a/ShareActionExtension/Scene/View/ContentWarningEditorView.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// ContentWarningEditorView.swift -// -// -// Created by MainasuK Cirno on 2021-7-19. -// - -import SwiftUI -import Introspect - -struct ContentWarningEditorView: View { - - @Binding var contentWarningContent: String - let placeholder: String - let spacing: CGFloat = 11 - - var body: some View { - HStack(alignment: .center, spacing: spacing) { - Image(systemName: "exclamationmark.shield") - .font(.system(size: 30, weight: .regular)) - Text(contentWarningContent.isEmpty ? " " : contentWarningContent) - .opacity(0) - .padding(.all, 8) - .frame(maxWidth: .infinity) - .overlay( - TextEditor(text: $contentWarningContent) - .introspectTextView { textView in - textView.backgroundColor = .clear - textView.placeholder = placeholder - } - ) - } - } -} - -struct ContentWarningEditorView_Previews: PreviewProvider { - - @State static var content = "" - - static var previews: some View { - ContentWarningEditorView( - contentWarningContent: $content, - placeholder: "Write an accurate warning here..." - ) - .previewLayout(.fixed(width: 375, height: 100)) - } -} - diff --git a/ShareActionExtension/Scene/View/StatusAttachmentView.swift b/ShareActionExtension/Scene/View/StatusAttachmentView.swift deleted file mode 100644 index 90b8aceeb..000000000 --- a/ShareActionExtension/Scene/View/StatusAttachmentView.swift +++ /dev/null @@ -1,126 +0,0 @@ -// -// StatusAttachmentView.swift -// -// -// Created by MainasuK Cirno on 2021-7-19. -// - -import SwiftUI -import Introspect - -struct StatusAttachmentView: View { - - let image: UIImage? - let descriptionPlaceholder: String - @Binding var description: String - let errorPrompt: String? - let errorPromptImage: UIImage - let isUploading: Bool - let progressViewTintColor: UIColor - - let removeButtonAction: () -> Void - - var body: some View { - let image = image ?? UIImage.placeholder(color: .systemFill) - ZStack(alignment: .bottom) { - if let errorPrompt = errorPrompt { - Color.clear - .aspectRatio(CGSize(width: 16, height: 9), contentMode: .fill) - .overlay( - VStack(alignment: .center) { - Image(uiImage: errorPromptImage) - Text(errorPrompt) - .lineLimit(2) - } - ) - .background(Color.gray) - } else { - Color.clear - .aspectRatio(CGSize(width: 16, height: 9), contentMode: .fill) - .overlay( - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fill) - ) - .background(Color.gray) - LinearGradient(gradient: Gradient(colors: [Color(white: 0, opacity: 0.69), Color.clear]), startPoint: .bottom, endPoint: .top) - .frame(maxHeight: 71) - TextField("", text: $description) - .placeholder(when: description.isEmpty) { - Text(descriptionPlaceholder).foregroundColor(Color(white: 1, opacity: 0.6)) - .lineLimit(1) - } - .foregroundColor(.white) - .font(.system(size: 15, weight: .regular, design: .default)) - .padding(EdgeInsets(top: 0, leading: 8, bottom: 7, trailing: 8)) - } - } - .cornerRadius(4) - .badgeView( - Button(action: { - removeButtonAction() - }, label: { - Image(systemName: "minus.circle.fill") - .renderingMode(.original) - .font(.system(size: 22, weight: .bold, design: .default)) - }) - .buttonStyle(BorderlessButtonStyle()) - ) - .overlay( - Group { - if isUploading { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: Color(progressViewTintColor))) - } - } - ) - } -} - -extension View { - func badgeView(_ content: Content) -> some View where Content: View { - overlay( - ZStack { - content - } - .alignmentGuide(.top) { $0.height / 2 } - .alignmentGuide(.trailing) { $0.width / 2 } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) - ) - } -} - -/// ref: https://stackoverflow.com/a/57715771/3797903 -extension View { - func placeholder( - when shouldShow: Bool, - alignment: Alignment = .leading, - @ViewBuilder placeholder: () -> Content) -> some View { - - ZStack(alignment: alignment) { - placeholder().opacity(shouldShow ? 1 : 0) - self - } - } -} - - -//struct StatusAttachmentView_Previews: PreviewProvider { -// static var previews: some View { -// ScrollView { -// StatusAttachmentView( -// image: UIImage(systemName: "photo"), -// descriptionPlaceholder: "Describe photo", -// description: .constant(""), -// errorPrompt: nil, -// errorPromptImage: StatusAttachmentViewModel.photoFillSplitImage, -// isUploading: true, -// progressViewTintColor: .systemFill, -// removeButtonAction: { -// // do nothing -// } -// ) -// .padding(20) -// } -// } -//} diff --git a/ShareActionExtension/Scene/View/StatusAttachmentViewModel+UploadState.swift b/ShareActionExtension/Scene/View/StatusAttachmentViewModel+UploadState.swift deleted file mode 100644 index 56942cde0..000000000 --- a/ShareActionExtension/Scene/View/StatusAttachmentViewModel+UploadState.swift +++ /dev/null @@ -1,131 +0,0 @@ -// -// StatusAttachmentViewModel+UploadState.swift -// ShareActionExtension -// -// Created by MainasuK Cirno on 2021-7-20. -// - -import os.log -import Foundation -import Combine -import GameplayKit -import MastodonSDK -import MastodonCore - -extension StatusAttachmentViewModel { - class UploadState: GKState { - weak var viewModel: StatusAttachmentViewModel? - - init(viewModel: StatusAttachmentViewModel) { - 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) - viewModel?.uploadStateMachineSubject.send(self) - } - } -} - -extension StatusAttachmentViewModel.UploadState { - - class Initial: StatusAttachmentViewModel.UploadState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - guard viewModel?.authentication.value != nil else { return false } - if stateClass == Initial.self { - return true - } - - if viewModel?.file.value != nil { - return stateClass == Uploading.self - } else { - return stateClass == Fail.self - } - } - } - - class Uploading: StatusAttachmentViewModel.UploadState { - let logger = Logger(subsystem: "StatusAttachmentViewModel.UploadState.Uploading", category: "logic") - var needsFallback = false - - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Fail.self || stateClass == Finish.self || stateClass == Uploading.self - } - - override func didEnter(from previousState: GKState?) { - super.didEnter(from: previousState) - - guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - guard let authentication = viewModel.authentication.value else { return } - guard let file = viewModel.file.value else { return } - - let description = viewModel.descriptionContent - let query = Mastodon.API.Media.UploadMediaQuery( - file: file, - thumbnail: nil, - description: description, - focus: nil - ) - - let mastodonAuthenticationBox = MastodonAuthenticationBox( - authenticationRecord: .init(objectID: authentication.objectID), - domain: authentication.domain, - userID: authentication.userID, - appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken), - userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken) - ) - - // and needs clone the `query` if needs retry - viewModel.api.uploadMedia( - domain: mastodonAuthenticationBox.domain, - query: query, - mastodonAuthenticationBox: mastodonAuthenticationBox, - needsFallback: needsFallback - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] completion in - guard let self = self else { return } - switch completion { - case .failure(let error): - if let apiError = error as? Mastodon.API.Error, - apiError.httpResponseStatus == .notFound, - self.needsFallback == false - { - self.needsFallback = true - stateMachine.enter(Uploading.self) - self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fallback to V1") - } else { - self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fail: \(error.localizedDescription)") - viewModel.error = error - stateMachine.enter(Fail.self) - } - case .finished: - self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): upload attachment success") - break - } - } receiveValue: { [weak self] response in - guard let self = self else { return } - self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): upload attachment \(response.value.id) success, \(response.value.url ?? "")") - viewModel.attachment.value = response.value - stateMachine.enter(Finish.self) - } - .store(in: &viewModel.disposeBag) - } - - } - - class Fail: StatusAttachmentViewModel.UploadState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - // allow discard publishing - return stateClass == Uploading.self || stateClass == Finish.self - } - } - - class Finish: StatusAttachmentViewModel.UploadState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return false - } - } - -} - diff --git a/ShareActionExtension/Scene/View/StatusAttachmentViewModel.swift b/ShareActionExtension/Scene/View/StatusAttachmentViewModel.swift deleted file mode 100644 index 19251d0be..000000000 --- a/ShareActionExtension/Scene/View/StatusAttachmentViewModel.swift +++ /dev/null @@ -1,227 +0,0 @@ -// -// StatusAttachmentViewModel.swift -// ShareActionExtension -// -// Created by MainasuK Cirno on 2021-7-19. -// - -import os.log -import Foundation -import SwiftUI -import Combine -import CoreDataStack -import MastodonSDK -import MastodonUI -import AVFoundation -import GameplayKit -import MobileCoreServices -import UniformTypeIdentifiers -import MastodonAsset -import MastodonCore -import MastodonLocalization - -protocol StatusAttachmentViewModelDelegate: AnyObject { - func statusAttachmentViewModel(_ viewModel: StatusAttachmentViewModel, uploadStateDidChange state: StatusAttachmentViewModel.UploadState?) -} - -final class StatusAttachmentViewModel: ObservableObject, Identifiable { - - 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 logger = Logger(subsystem: "StatusAttachmentViewModel", category: "logic") - - weak var delegate: StatusAttachmentViewModelDelegate? - var disposeBag = Set() - - let id = UUID() - let itemProvider: NSItemProvider - - // input - let api: APIService - let file = CurrentValueSubject(nil) - let authentication = CurrentValueSubject(nil) - @Published var descriptionContent = "" - - // output - let attachment = CurrentValueSubject(nil) - @Published var thumbnailImage: UIImage? - @Published var descriptionPlaceholder = "" - @Published var isUploading = true - @Published var progressViewTintColor = UIColor.systemFill - @Published var error: Error? - @Published var errorPrompt: String? - @Published var errorPromptImage: UIImage = StatusAttachmentViewModel.photoFillSplitImage - - private(set) lazy var uploadStateMachine: GKStateMachine = { - // exclude timeline middle fetcher state - let stateMachine = GKStateMachine(states: [ - UploadState.Initial(viewModel: self), - UploadState.Uploading(viewModel: self), - UploadState.Fail(viewModel: self), - UploadState.Finish(viewModel: self), - ]) - stateMachine.enter(UploadState.Initial.self) - return stateMachine - }() - lazy var uploadStateMachineSubject = CurrentValueSubject(nil) - - init( - api: APIService, - itemProvider: NSItemProvider - ) { - self.api = api - self.itemProvider = itemProvider - - // bind attachment from item provider - Just(itemProvider) - .receive(on: DispatchQueue.main) - .flatMap { result -> AnyPublisher in - if itemProvider.hasRepresentationConforming(toTypeIdentifier: UTType.image.identifier, fileOptions: []) { - return ItemProviderLoader.loadImageData(from: result).eraseToAnyPublisher() - } - if itemProvider.hasRepresentationConforming(toTypeIdentifier: UTType.movie.identifier, fileOptions: []) { - return ItemProviderLoader.loadVideoData(from: result).eraseToAnyPublisher() - } - return Fail(error: AttachmentError.invalidAttachmentType).eraseToAnyPublisher() - } - .sink { [weak self] completion in - guard let self = self else { return } - switch completion { - case .failure(let error): - self.error = error - self.uploadStateMachine.enter(UploadState.Fail.self) - case .finished: - break - } - } receiveValue: { [weak self] file in - guard let self = self else { return } - self.file.value = file - self.uploadStateMachine.enter(UploadState.Initial.self) - } - .store(in: &disposeBag) - - // bind progress view tint color - $thumbnailImage - .receive(on: DispatchQueue.main) - .map { image -> UIColor in - guard let image = image else { return .systemFill } - switch image.domainLumaCoefficientsStyle { - case .light: - return UIColor.black.withAlphaComponent(0.8) - default: - return UIColor.white.withAlphaComponent(0.8) - } - } - .assign(to: &$progressViewTintColor) - - // bind description placeholder and error prompt image - file - .receive(on: DispatchQueue.main) - .sink { [weak self] file in - guard let self = self else { return } - guard let file = file else { return } - switch file { - case .jpeg, .png, .gif: - self.descriptionPlaceholder = L10n.Scene.Compose.Attachment.descriptionPhoto - self.errorPromptImage = StatusAttachmentViewModel.photoFillSplitImage - case .other: - self.descriptionPlaceholder = L10n.Scene.Compose.Attachment.descriptionVideo - self.errorPromptImage = StatusAttachmentViewModel.videoSplashImage - } - } - .store(in: &disposeBag) - - // bind thumbnail image - file - .receive(on: DispatchQueue.main) - .map { file -> UIImage? in - guard let file = file else { - return nil - } - - switch file { - case .jpeg(let data), .png(let data): - return data.flatMap { UIImage(data: $0) } - case .gif: - // TODO: - return nil - case .other(let url, _, _): - guard let url = url, FileManager.default.fileExists(atPath: url.path) else { return nil } - let asset = AVURLAsset(url: url) - let assetImageGenerator = AVAssetImageGenerator(asset: asset) - assetImageGenerator.appliesPreferredTrackTransform = true // fix orientation - do { - let cgImage = try assetImageGenerator.copyCGImage(at: CMTimeMake(value: 0, timescale: 1), actualTime: nil) - let image = UIImage(cgImage: cgImage) - return image - } catch { - self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): thumbnail generate fail: \(error.localizedDescription)") - return nil - } - } - } - .assign(to: &$thumbnailImage) - - // bind state and error - Publishers.CombineLatest( - uploadStateMachineSubject, - $error - ) - .sink { [weak self] state, error in - guard let self = self else { return } - // trigger delegate - self.delegate?.statusAttachmentViewModel(self, uploadStateDidChange: state) - - // set error prompt - if let error = error { - self.isUploading = false - self.errorPrompt = error.localizedDescription - } else { - guard let state = state else { return } - switch state { - case is UploadState.Finish: - self.isUploading = false - case is UploadState.Fail: - self.isUploading = false - // FIXME: not display - self.errorPrompt = { - guard let file = self.file.value else { - return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo) - } - switch file { - case .jpeg, .png, .gif: - return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo) - case .other: - return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.video) - } - }() - default: - break - } - } - } - .store(in: &disposeBag) - - // trigger delegate when authentication get new value - authentication - .receive(on: DispatchQueue.main) - .sink { [weak self] authentication in - guard let self = self else { return } - guard authentication != nil else { return } - self.delegate?.statusAttachmentViewModel(self, uploadStateDidChange: self.uploadStateMachineSubject.value) - } - .store(in: &disposeBag) - } - -} - -extension StatusAttachmentViewModel { - enum AttachmentError: Error { - case invalidAttachmentType - case attachmentTooLarge - } -} diff --git a/ShareActionExtension/Scene/View/StatusAuthorView.swift b/ShareActionExtension/Scene/View/StatusAuthorView.swift deleted file mode 100644 index 24453abe2..000000000 --- a/ShareActionExtension/Scene/View/StatusAuthorView.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// StatusAuthorView.swift -// -// -// Created by MainasuK Cirno on 2021-7-16. -// - -import SwiftUI -import MastodonUI -import Nuke -import FLAnimatedImage - -struct StatusAuthorView: View { - - let avatarImageURL: URL? - let name: String - let username: String - - var body: some View { - HStack(spacing: 5) { - AnimatedImage(imageURL: avatarImageURL) - .frame(width: 42, height: 42) - .background(Color(UIColor.systemFill)) - .cornerRadius(4) - VStack(alignment: .leading) { - Text(name) - .font(.headline) - Text(username) - .font(.subheadline) - .foregroundColor(.secondary) - } - Spacer() - } - } -} - -struct StatusAuthorView_Previews: PreviewProvider { - static var previews: some View { - StatusAuthorView( - avatarImageURL: URL(string: "https://upload.wikimedia.org/wikipedia/commons/2/2c/Rotating_earth_%28large%29.gif"), - name: "Alice", - username: "alice" - ) - } -} diff --git a/ShareActionExtension/Scene/View/StatusEditorView.swift b/ShareActionExtension/Scene/View/StatusEditorView.swift deleted file mode 100644 index f670f6601..000000000 --- a/ShareActionExtension/Scene/View/StatusEditorView.swift +++ /dev/null @@ -1,105 +0,0 @@ -// -// StatusEditorView.swift -// -// -// Created by MainasuK Cirno on 2021-7-16. -// - -import UIKit -import SwiftUI -import UITextView_Placeholder - -public struct StatusEditorView: UIViewRepresentable { - - @Binding var string: String - let placeholder: String - let width: CGFloat - let attributedString: NSAttributedString - let keyboardType: UIKeyboardType - @Binding var viewDidAppear: Bool - - public init( - string: Binding, - placeholder: String, - width: CGFloat, - attributedString: NSAttributedString, - keyboardType: UIKeyboardType, - viewDidAppear: Binding - ) { - self._string = string - self.placeholder = placeholder - self.width = width - self.attributedString = attributedString - self.keyboardType = keyboardType - self._viewDidAppear = viewDidAppear - } - - public func makeUIView(context: Context) -> UITextView { - let textView = UITextView(frame: .zero) - textView.placeholder = placeholder - - textView.isScrollEnabled = false - textView.font = .preferredFont(forTextStyle: .body) - textView.textColor = .label - textView.keyboardType = keyboardType - textView.delegate = context.coordinator - textView.backgroundColor = .clear - - textView.translatesAutoresizingMaskIntoConstraints = false - let widthLayoutConstraint = textView.widthAnchor.constraint(equalToConstant: 100) - widthLayoutConstraint.priority = .required - 1 - context.coordinator.widthLayoutConstraint = widthLayoutConstraint - - return textView - } - - public func updateUIView(_ textView: UITextView, context: Context) { - // preserve currently selected text range to prevent cursor jump - let currentlySelectedRange = textView.selectedRange - - // update content - // textView.attributedText = attributedString - textView.text = string - - // update layout - context.coordinator.updateLayout(width: width) - - // set becomeFirstResponder - if viewDidAppear { - viewDidAppear = false - textView.becomeFirstResponder() - } - - // restore selected text range - textView.selectedRange = currentlySelectedRange - } - - public func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - public class Coordinator: NSObject, UITextViewDelegate { - var parent: StatusEditorView - var widthLayoutConstraint: NSLayoutConstraint? - - init(_ parent: StatusEditorView) { - self.parent = parent - } - - public func textViewDidChange(_ textView: UITextView) { - // prevent break IME input - if textView.markedTextRange == nil { - parent.string = textView.text - } - } - - func updateLayout(width: CGFloat) { - guard let widthLayoutConstraint = widthLayoutConstraint else { return } - widthLayoutConstraint.constant = width - widthLayoutConstraint.isActive = true - } - } - -} - -