mirror of
https://github.com/mastodon/mastodon-ios.git
synced 2025-02-02 18:36:44 +01:00
Merge remote-tracking branch 'upstream/develop' into account-switcher-a11y
This commit is contained in:
commit
97b6a3de4c
@ -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 \
|
||||
|
@ -68,6 +68,28 @@
|
||||
<string>%ld characters</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>a11y.plural.count.characters_left</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@character_count@ left</string>
|
||||
<key>character_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>ld</string>
|
||||
<key>zero</key>
|
||||
<string>no characters</string>
|
||||
<key>one</key>
|
||||
<string>1 character</string>
|
||||
<key>few</key>
|
||||
<string>%ld characters</string>
|
||||
<key>many</key>
|
||||
<string>%ld characters</string>
|
||||
<key>other</key>
|
||||
<string>%ld characters</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>plural.count.followed_by_and_mutual</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
|
@ -50,6 +50,28 @@
|
||||
<string>%ld characters</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>a11y.plural.count.characters_left</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@character_count@ left</string>
|
||||
<key>character_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>ld</string>
|
||||
<key>zero</key>
|
||||
<string>no characters</string>
|
||||
<key>one</key>
|
||||
<string>1 character</string>
|
||||
<key>few</key>
|
||||
<string>%ld characters</string>
|
||||
<key>many</key>
|
||||
<string>%ld characters</string>
|
||||
<key>other</key>
|
||||
<string>%ld characters</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>plural.count.followed_by_and_mutual</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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 = "<group>"; };
|
||||
DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerItem.swift; sourceTree = "<group>"; };
|
||||
DB1FD45F25F278AF004CFCFC /* CategoryPickerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerSection.swift; sourceTree = "<group>"; };
|
||||
DB221B15260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerInputViewModel.swift; sourceTree = "<group>"; };
|
||||
DB2B3ABD25E37E15007045F9 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = "<group>"; };
|
||||
DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollExpiresOptionCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
@ -718,9 +710,6 @@
|
||||
DB427DF725BAA00100D1B89D /* MastodonUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonUITests.swift; sourceTree = "<group>"; };
|
||||
DB427DF925BAA00100D1B89D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
DB443CD32694627B00159B29 /* AppearanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceView.swift; sourceTree = "<group>"; };
|
||||
DB44767A260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerInputView.swift; sourceTree = "<group>"; };
|
||||
DB447690260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerItemCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
DB447696260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerHeaderCollectionReusableView.swift; sourceTree = "<group>"; };
|
||||
DB4481B825EE289600BEFB67 /* UITableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UITableView.swift; sourceTree = "<group>"; };
|
||||
DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIAlertController.swift; sourceTree = "<group>"; };
|
||||
DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIBarButtonItem.swift; sourceTree = "<group>"; };
|
||||
@ -820,7 +809,6 @@
|
||||
DB64BA442851F23000ADF1B7 /* MastodonAuthentication+Fetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonAuthentication+Fetch.swift"; sourceTree = "<group>"; };
|
||||
DB64BA472851F29300ADF1B7 /* Account+Fetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Account+Fetch.swift"; sourceTree = "<group>"; };
|
||||
DB65C63627A2AF6C008BAC2E /* ReportItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportItem.swift; sourceTree = "<group>"; };
|
||||
DB66728B25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+DataSource.swift"; sourceTree = "<group>"; };
|
||||
DB6746EA278ED8B0008A6B94 /* PollOptionView+Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PollOptionView+Configuration.swift"; sourceTree = "<group>"; };
|
||||
DB6746EC278F45F0008A6B94 /* AutoGenerateProtocolRelayDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoGenerateProtocolRelayDelegate.swift; sourceTree = "<group>"; };
|
||||
DB6746EF278F463B008A6B94 /* AutoGenerateProtocolDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoGenerateProtocolDelegate.swift; sourceTree = "<group>"; };
|
||||
@ -912,7 +900,6 @@
|
||||
DB98EB6827B21A7C0082E365 /* ReportResultActionTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportResultActionTableViewCell.swift; sourceTree = "<group>"; };
|
||||
DB98EB6A27B243470082E365 /* SettingsAppearanceTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsAppearanceTableViewCell+ViewModel.swift"; sourceTree = "<group>"; };
|
||||
DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttachmentContainerView+EmptyStateView.swift"; sourceTree = "<group>"; };
|
||||
DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+PublishState.swift"; sourceTree = "<group>"; };
|
||||
DB9D6BE825E4F5340051B173 /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = "<group>"; };
|
||||
DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = "<group>"; };
|
||||
DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = "<group>"; };
|
||||
@ -961,14 +948,12 @@
|
||||
DBB525632612C988002F1F29 /* MeProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeProfileViewModel.swift; sourceTree = "<group>"; };
|
||||
DBB8AB4526AECDE200F6D281 /* SendPostIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendPostIntentHandler.swift; sourceTree = "<group>"; };
|
||||
DBB9759B262462E1004620BD /* ThreadMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadMetaView.swift; sourceTree = "<group>"; };
|
||||
DBBC24A726A52F9000398BB9 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = "<group>"; };
|
||||
DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonRegex.swift; sourceTree = "<group>"; };
|
||||
DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = "<group>"; };
|
||||
DBC3872329214121001EC0FD /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
DBC6461726A170AB00B0E31B /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = "<group>"; };
|
||||
DBC6461926A170AB00B0E31B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
DBC6462226A1712000B0E31B /* ComposeViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = "<group>"; };
|
||||
DBC6462226A1712000B0E31B /* ShareViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShareViewModel.swift; sourceTree = "<group>"; };
|
||||
DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentWarningEditorView.swift; sourceTree = "<group>"; };
|
||||
DBC9E3A3282E13BB0063A4D9 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = eu.lproj/Intents.strings; sourceTree = "<group>"; };
|
||||
DBC9E3A4282E13BB0063A4D9 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = eu.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
@ -1042,15 +1027,7 @@
|
||||
DBFEEC98279BDCDE004F81DD /* ProfileAboutViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileAboutViewModel.swift; sourceTree = "<group>"; };
|
||||
DBFEEC9A279BDDD9004F81DD /* ProfileAboutViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileAboutViewModel+Diffable.swift"; sourceTree = "<group>"; };
|
||||
DBFEEC9C279C12C1004F81DD /* ProfileFieldEditCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldEditCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
DBFEF05526A576EE006D7ED1 /* StatusEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditorView.swift; sourceTree = "<group>"; };
|
||||
DBFEF05626A576EE006D7ED1 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
|
||||
DBFEF05726A576EE006D7ED1 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = "<group>"; };
|
||||
DBFEF05826A576EE006D7ED1 /* ContentWarningEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningEditorView.swift; sourceTree = "<group>"; };
|
||||
DBFEF05926A576EE006D7ED1 /* StatusAuthorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusAuthorView.swift; sourceTree = "<group>"; };
|
||||
DBFEF05A26A576EE006D7ED1 /* StatusAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusAttachmentView.swift; sourceTree = "<group>"; };
|
||||
DBFEF06226A577F2006D7ED1 /* StatusAttachmentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusAttachmentViewModel.swift; sourceTree = "<group>"; };
|
||||
DBFEF06726A58D07006D7ED1 /* ShareActionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareActionExtension.entitlements; sourceTree = "<group>"; };
|
||||
DBFEF06C26A67FB7006D7ED1 /* StatusAttachmentViewModel+UploadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusAttachmentViewModel+UploadState.swift"; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
@ -1342,7 +1319,7 @@
|
||||
path = Protocol;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2D76319C25C151DE00929FB9 /* Diffiable */ = {
|
||||
2D76319C25C151DE00929FB9 /* Diffable */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB4F097826A039B400D62E92 /* Onboarding */,
|
||||
@ -1357,7 +1334,7 @@
|
||||
DB3E6FE52806A5BA00B035AE /* Discovery */,
|
||||
DB0617FA27855B660030EE79 /* Settings */,
|
||||
);
|
||||
path = Diffiable;
|
||||
path = Diffable;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
@ -2159,8 +2132,6 @@
|
||||
DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */,
|
||||
DB87D4502609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift */,
|
||||
DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */,
|
||||
DB447690260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift */,
|
||||
DB447696260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift */,
|
||||
);
|
||||
path = CollectionViewCell;
|
||||
sourceTree = "<group>";
|
||||
@ -2520,7 +2491,6 @@
|
||||
DBBC24D526A54BCB00398BB9 /* Helper */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */,
|
||||
DBF3B7402733EB9400E21627 /* MastodonLocalCode.swift */,
|
||||
);
|
||||
path = Helper;
|
||||
@ -2687,28 +2657,11 @@
|
||||
path = Cell;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
DBFEF06126A57721006D7ED1 /* Scene */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DBFEF05426A576EE006D7ED1 /* View */,
|
||||
DBC6462226A1712000B0E31B /* ComposeViewModel.swift */,
|
||||
DBC6461426A170AB00B0E31B /* ComposeViewController.swift */,
|
||||
DBC6462226A1712000B0E31B /* ShareViewModel.swift */,
|
||||
DBC3872329214121001EC0FD /* ShareViewController.swift */,
|
||||
);
|
||||
path = Scene;
|
||||
sourceTree = "<group>";
|
||||
@ -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;
|
||||
};
|
||||
|
@ -117,7 +117,7 @@
|
||||
<key>NotificationService.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>18</integer>
|
||||
<integer>16</integer>
|
||||
</dict>
|
||||
<key>ShareActionExtension.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
|
@ -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",
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -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<CustomEmojiPickerSection, CustomEmojiPickerItem>()
|
||||
// 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<ComposeStatusPollSection, ComposeStatusPollItem>()
|
||||
// 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<ComposeStatusAttachmentSection, ComposeStatusAttachmentItem>()
|
||||
// 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)
|
||||
// }
|
||||
//}
|
@ -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<Mastodon.Response.Content<Mastodon.Entity.Attachment>, Error>] = {
|
||||
// var subscriptions: [AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Attachment>, 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<Mastodon.Entity.Status> 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
|
||||
// }
|
||||
// }
|
||||
//
|
||||
//}
|
@ -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, Never>(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<ComposeStatusSection, ComposeStatusItem>?
|
||||
// var customEmojiPickerDiffableDataSource: UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem>?
|
||||
// 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<PublishState?, Never>(nil)
|
||||
// private(set) var publishDate = Date() // update it when enter Publishing state
|
||||
//
|
||||
// // TODO: group post material into Hashable class
|
||||
// var idempotencyKey = CurrentValueSubject<String, Never>(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: "#<hashtag> "
|
||||
// // for mention: "@<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
|
||||
// }
|
||||
//}
|
||||
|
@ -7,7 +7,6 @@
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import GameplayKit
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -8,7 +8,6 @@
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
import MastodonSDK
|
||||
import MastodonUI
|
||||
import MastodonAsset
|
||||
import MastodonCore
|
||||
import MastodonUI
|
||||
|
@ -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 {
|
||||
|
@ -99,6 +99,10 @@ extension StatusTableViewCell {
|
||||
return true
|
||||
}
|
||||
|
||||
override var accessibilityCustomActions: [UIAccessibilityCustomAction]? {
|
||||
get { statusView.accessibilityCustomActions }
|
||||
set { }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AdaptiveContainerMarginTableViewCell
|
||||
|
@ -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 {
|
||||
|
@ -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(
|
||||
|
@ -0,0 +1,9 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"provides-namespace" : true
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
@ -0,0 +1,15 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Arrow Clockwise.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true
|
||||
}
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Dismiss.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true
|
||||
}
|
||||
}
|
@ -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
|
@ -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")
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -13,6 +13,15 @@ import MastodonCommon
|
||||
import MastodonSDK
|
||||
|
||||
extension APIService {
|
||||
public func authenticatedUserInfo(
|
||||
authenticationBox: MastodonAuthenticationBox
|
||||
) async throws -> Mastodon.Response.Content<Mastodon.Entity.Account> {
|
||||
try await accountInfo(
|
||||
domain: authenticationBox.domain,
|
||||
userID: authenticationBox.userID,
|
||||
authorization: authenticationBox.userAuthorization
|
||||
)
|
||||
}
|
||||
|
||||
public func accountInfo(
|
||||
domain: String,
|
||||
|
@ -25,6 +25,7 @@ public final class AuthenticationService: NSObject {
|
||||
// output
|
||||
@Published public var mastodonAuthentications: [ManagedObjectRecord<MastodonAuthentication>] = []
|
||||
@Published public var mastodonAuthenticationBoxes: [MastodonAuthenticationBox] = []
|
||||
public let updateActiveUserAccountPublisher = PassthroughSubject<Void, Never>()
|
||||
|
||||
init(
|
||||
managedObjectContext: NSManagedObjectContext,
|
||||
|
@ -24,7 +24,7 @@ public final class InstanceService {
|
||||
weak var authenticationService: AuthenticationService?
|
||||
|
||||
// output
|
||||
|
||||
|
||||
init(
|
||||
apiService: APIService,
|
||||
authenticationService: AuthenticationService
|
||||
|
@ -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)
|
||||
|
@ -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.";
|
||||
|
@ -50,6 +50,28 @@
|
||||
<string>%ld characters</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>a11y.plural.count.characters_left</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@character_count@ left</string>
|
||||
<key>character_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>ld</string>
|
||||
<key>zero</key>
|
||||
<string>no characters</string>
|
||||
<key>one</key>
|
||||
<string>1 character</string>
|
||||
<key>few</key>
|
||||
<string>%ld characters</string>
|
||||
<key>many</key>
|
||||
<string>%ld characters</string>
|
||||
<key>other</key>
|
||||
<string>%ld characters</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>plural.count.followed_by_and_mutual</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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<CustomEmojiPickerSection, CustomEmojiPickerItem> {
|
||||
// let dataSource = UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem>(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<CustomEmojiPickerSection, CustomEmojiPickerItem> {
|
||||
let dataSource = UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem>(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
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
21
MastodonSDK/Sources/MastodonUI/Extension/View.swift
Normal file
21
MastodonSDK/Sources/MastodonUI/Extension/View.swift
Normal file
@ -0,0 +1,21 @@
|
||||
//
|
||||
// View.swift
|
||||
//
|
||||
//
|
||||
// Created by MainasuK on 2022/11/8.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
public func badgeView<Content>(_ 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)
|
||||
)
|
||||
}
|
||||
}
|
@ -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<Content: View>(
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
||||
|
||||
}
|
@ -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: []
|
||||
)
|
||||
}
|
||||
}
|
@ -52,153 +52,65 @@ extension Data {
|
||||
}
|
||||
}
|
||||
|
||||
// Twitter Only
|
||||
//extension AttachmentViewModel {
|
||||
// class SliceResult {
|
||||
//
|
||||
// let fileURL: URL
|
||||
// let chunks: Chunked<FileHandle.AsyncBytes>
|
||||
// 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<Mastodon.Entity.Attachment>)
|
||||
}
|
||||
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 ?? "<nil>")")
|
||||
|
||||
return .mastodon(attachmentUploadResponse)
|
||||
return attachmentUploadResponse.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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<AnyCancellable>()
|
||||
var observations = Set<NSKeyValueObservation>()
|
||||
|
||||
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<URL, Error>?
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
@ -88,7 +88,7 @@ extension AutoCompleteViewController {
|
||||
])
|
||||
|
||||
tableView.delegate = self
|
||||
// viewModel.setupDiffableDataSource(tableView: tableView)
|
||||
viewModel.setupDiffableDataSource(tableView: tableView)
|
||||
|
||||
// bind to layout chevron
|
||||
viewModel.symbolBoundingRect
|
||||
|
@ -6,17 +6,18 @@
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import MastodonCore
|
||||
|
||||
extension AutoCompleteViewModel {
|
||||
|
||||
// func setupDiffableDataSource(
|
||||
// tableView: UITableView
|
||||
// ) {
|
||||
// diffableDataSource = AutoCompleteSection.tableViewDiffableDataSource(for: tableView)
|
||||
//
|
||||
// var snapshot = NSDiffableDataSourceSnapshot<AutoCompleteSection, AutoCompleteItem>()
|
||||
// snapshot.appendSections([.main])
|
||||
// diffableDataSource?.apply(snapshot)
|
||||
// }
|
||||
func setupDiffableDataSource(
|
||||
tableView: UITableView
|
||||
) {
|
||||
diffableDataSource = AutoCompleteSection.tableViewDiffableDataSource(tableView: tableView)
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<AutoCompleteSection, AutoCompleteItem>()
|
||||
snapshot.appendSections([.main])
|
||||
diffableDataSource?.apply(snapshot)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -20,7 +20,7 @@ final class AutoCompleteViewModel {
|
||||
let authContext: AuthContext
|
||||
public let inputText = CurrentValueSubject<String, Never>("") // contains "@" or "#" prefix
|
||||
public let symbolBoundingRect = CurrentValueSubject<CGRect, Never>(.zero)
|
||||
public let customEmojiViewModel = CurrentValueSubject<EmojiService.CustomEmojiViewModel?, Never>(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)
|
||||
|
@ -8,7 +8,6 @@
|
||||
import UIKit
|
||||
import Combine
|
||||
import MastodonCore
|
||||
import MastodonUI
|
||||
|
||||
final class AutoCompleteTopChevronView: UIView {
|
||||
|
||||
|
@ -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<AnyCancellable>()
|
||||
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<Bool, Never>(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
|
||||
}
|
||||
}
|
||||
|
@ -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<CustomEmojiPickerSection, CustomEmojiPickerItem>()
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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..<text.endIndex, in: text)
|
||||
|
||||
guard let match = text.firstMatch(pattern: MastodonRegex.autoCompletePattern, options: [], range: scanRange) else { return nil }
|
||||
guard let matchRange = Range(match.range(at: 0), in: text) else { return nil }
|
||||
let matchStartIndex = matchRange.lowerBound
|
||||
let matchEndIndex = matchRange.upperBound
|
||||
|
||||
guard matchStartIndex == highlightStartIndex, matchEndIndex >= cursorIndex else { return nil }
|
||||
let symbolRange = highlightStartIndex..<text.index(after: highlightStartIndex)
|
||||
let symbolString = text[symbolRange]
|
||||
let toCursorRange = highlightStartIndex..<cursorIndex
|
||||
let toCursorString = text[toCursorRange]
|
||||
let toHighlightEndRange = matchStartIndex..<matchEndIndex
|
||||
let toHighlightEndString = text[toHighlightEndRange]
|
||||
|
||||
let inputText = toHighlightEndString
|
||||
let autoCompleteInfo = AutoCompleteInfo(
|
||||
inputText: inputText,
|
||||
symbolRange: symbolRange,
|
||||
symbolString: symbolString,
|
||||
toCursorRange: toCursorRange,
|
||||
toCursorString: toCursorString,
|
||||
toHighlightEndRange: toHighlightEndRange,
|
||||
toHighlightEndString: toHighlightEndString
|
||||
)
|
||||
return autoCompleteInfo
|
||||
}
|
||||
|
||||
}
|
@ -14,6 +14,11 @@ import MetaTextKit
|
||||
import MastodonMeta
|
||||
import MastodonCore
|
||||
import MastodonSDK
|
||||
import MastodonLocalization
|
||||
|
||||
public protocol ComposeContentViewModelDelegate: AnyObject {
|
||||
func composeContentViewModel(_ viewModel: ComposeContentViewModel, handleAutoComplete info: ComposeContentViewModel.AutoCompleteInfo) -> 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<CustomEmojiPickerSection, CustomEmojiPickerItem>?
|
||||
|
||||
// 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: "#<hashtag> "
|
||||
// for mention: "@<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<MastodonUser>)
|
||||
case reply(status: ManagedObjectRecord<Status>)
|
||||
}
|
||||
|
||||
|
||||
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<String.Index>
|
||||
let symbolString: Substring
|
||||
let toCursorRange: Range<String.Index>
|
||||
let toCursorString: Substring
|
||||
let toHighlightEndRange: Range<String.Index>
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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<Bool, Never>(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)
|
||||
}
|
||||
}
|
@ -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 ""
|
||||
|
@ -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)
|
||||
|
@ -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<AnyCancellable>()
|
||||
// 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<String, Never>()
|
||||
//
|
||||
// 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 ?? "<nil>")")
|
||||
// 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)
|
||||
// }
|
||||
//
|
||||
//}
|
||||
|
||||
|
@ -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<ComposeStatusAttachmentSection, ComposeStatusAttachmentItem>!
|
||||
// weak var composeStatusAttachmentCollectionViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate?
|
||||
// var observations = Set<NSKeyValueObservation>()
|
||||
//
|
||||
// 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<Void, Never>()
|
||||
//
|
||||
// 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
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
//}
|
||||
//
|
@ -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<ComposeStatusPollSection, ComposeStatusPollItem>!
|
||||
// var observations = Set<NSKeyValueObservation>()
|
||||
//
|
||||
// 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<Void, Never>()
|
||||
//
|
||||
// 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
|
||||
// }
|
||||
//}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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 {
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user