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
- }
- }
-
-}
-
-