Merge remote-tracking branch 'upstream/develop' into multiline-content-warning
This commit is contained in:
commit
b2e448d67c
@ -68,6 +68,28 @@
|
|||||||
<string>%ld characters</string>
|
<string>%ld characters</string>
|
||||||
</dict>
|
</dict>
|
||||||
</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>
|
<key>plural.count.followed_by_and_mutual</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
<key>NSStringLocalizedFormatKey</key>
|
||||||
|
@ -50,6 +50,28 @@
|
|||||||
<string>%ld characters</string>
|
<string>%ld characters</string>
|
||||||
</dict>
|
</dict>
|
||||||
</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>
|
<key>plural.count.followed_by_and_mutual</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
<key>NSStringLocalizedFormatKey</key>
|
||||||
|
@ -413,7 +413,9 @@
|
|||||||
"custom_emoji_picker": "Custom Emoji Picker",
|
"custom_emoji_picker": "Custom Emoji Picker",
|
||||||
"enable_content_warning": "Enable Content Warning",
|
"enable_content_warning": "Enable Content Warning",
|
||||||
"disable_content_warning": "Disable 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": {
|
"keyboard": {
|
||||||
"discard_post": "Discard Post",
|
"discard_post": "Discard Post",
|
||||||
|
@ -138,7 +138,7 @@
|
|||||||
},
|
},
|
||||||
"meta_entity": {
|
"meta_entity": {
|
||||||
"url": "Link: %s",
|
"url": "Link: %s",
|
||||||
"hashtag": "Hastag %s",
|
"hashtag": "Hashtag: %s",
|
||||||
"mention": "Show Profile: %s",
|
"mention": "Show Profile: %s",
|
||||||
"email": "Email address: %s"
|
"email": "Email address: %s"
|
||||||
},
|
},
|
||||||
@ -382,7 +382,11 @@
|
|||||||
"video": "video",
|
"video": "video",
|
||||||
"attachment_broken": "This %s is broken and can’t be\nuploaded to Mastodon.",
|
"attachment_broken": "This %s is broken and can’t be\nuploaded to Mastodon.",
|
||||||
"description_photo": "Describe the photo for the visually-impaired...",
|
"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": {
|
"poll": {
|
||||||
"duration_time": "Duration: %s",
|
"duration_time": "Duration: %s",
|
||||||
@ -413,7 +417,9 @@
|
|||||||
"custom_emoji_picker": "Custom Emoji Picker",
|
"custom_emoji_picker": "Custom Emoji Picker",
|
||||||
"enable_content_warning": "Enable Content Warning",
|
"enable_content_warning": "Enable Content Warning",
|
||||||
"disable_content_warning": "Disable 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": {
|
"keyboard": {
|
||||||
"discard_post": "Discard Post",
|
"discard_post": "Discard Post",
|
||||||
|
@ -151,7 +151,6 @@
|
|||||||
DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */; };
|
DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */; };
|
||||||
DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.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 */; };
|
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 */; };
|
DB22C92228E700A10082A9E9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = DB22C92128E700A10082A9E9 /* MastodonSDK */; };
|
||||||
DB22C92428E700A80082A9E9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = DB22C92328E700A80082A9E9 /* MastodonSDK */; };
|
DB22C92428E700A80082A9E9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = DB22C92328E700A80082A9E9 /* MastodonSDK */; };
|
||||||
DB22C92628E700AF0082A9E9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = DB22C92528E700AF0082A9E9 /* 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 */; };
|
DB427DED25BAA00100D1B89D /* MastodonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DEC25BAA00100D1B89D /* MastodonTests.swift */; };
|
||||||
DB427DF825BAA00100D1B89D /* MastodonUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DF725BAA00100D1B89D /* MastodonUITests.swift */; };
|
DB427DF825BAA00100D1B89D /* MastodonUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DF725BAA00100D1B89D /* MastodonUITests.swift */; };
|
||||||
DB443CD42694627B00159B29 /* AppearanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB443CD32694627B00159B29 /* AppearanceView.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 */; };
|
DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481B825EE289600BEFB67 /* UITableView.swift */; };
|
||||||
DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */; };
|
DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */; };
|
||||||
DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.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 */; };
|
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 */; };
|
DB64BA482851F29300ADF1B7 /* Account+Fetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB64BA472851F29300ADF1B7 /* Account+Fetch.swift */; };
|
||||||
DB65C63727A2AF6C008BAC2E /* ReportItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB65C63627A2AF6C008BAC2E /* ReportItem.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 */; };
|
DB6746EB278ED8B0008A6B94 /* PollOptionView+Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6746EA278ED8B0008A6B94 /* PollOptionView+Configuration.swift */; };
|
||||||
DB6746ED278F45F0008A6B94 /* AutoGenerateProtocolRelayDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6746EC278F45F0008A6B94 /* AutoGenerateProtocolRelayDelegate.swift */; };
|
DB6746ED278F45F0008A6B94 /* AutoGenerateProtocolRelayDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6746EC278F45F0008A6B94 /* AutoGenerateProtocolRelayDelegate.swift */; };
|
||||||
DB6746F0278F463B008A6B94 /* AutoGenerateProtocolDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6746EF278F463B008A6B94 /* AutoGenerateProtocolDelegate.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 */; };
|
DB98EB6927B21A7C0082E365 /* ReportResultActionTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98EB6827B21A7C0082E365 /* ReportResultActionTableViewCell.swift */; };
|
||||||
DB98EB6B27B243470082E365 /* SettingsAppearanceTableViewCell+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98EB6A27B243470082E365 /* SettingsAppearanceTableViewCell+ViewModel.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 */; };
|
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 */; };
|
DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BE825E4F5340051B173 /* SearchViewController.swift */; };
|
||||||
DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */; };
|
DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */; };
|
||||||
DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BFE25E4F5940051B173 /* ProfileViewController.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 */; };
|
DBB8AB4626AECDE200F6D281 /* SendPostIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB8AB4526AECDE200F6D281 /* SendPostIntentHandler.swift */; };
|
||||||
DBB8AB4F26AED13F00F6D281 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB427DDE25BAA00100D1B89D /* Assets.xcassets */; };
|
DBB8AB4F26AED13F00F6D281 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB427DDE25BAA00100D1B89D /* Assets.xcassets */; };
|
||||||
DBB9759C262462E1004620BD /* ThreadMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB9759B262462E1004620BD /* ThreadMetaView.swift */; };
|
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 */; };
|
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 */; };
|
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, ); }; };
|
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 */; };
|
DBC6462826A1736300B0E31B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB427DDE25BAA00100D1B89D /* Assets.xcassets */; };
|
||||||
DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */; };
|
DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */; };
|
||||||
DBCA0EBC282BB38A0029E2B0 /* PageboyNavigateable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCA0EBB282BB38A0029E2B0 /* PageboyNavigateable.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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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;
|
path = Protocol;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
2D76319C25C151DE00929FB9 /* Diffiable */ = {
|
2D76319C25C151DE00929FB9 /* Diffable */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
DB4F097826A039B400D62E92 /* Onboarding */,
|
DB4F097826A039B400D62E92 /* Onboarding */,
|
||||||
@ -1357,7 +1334,7 @@
|
|||||||
DB3E6FE52806A5BA00B035AE /* Discovery */,
|
DB3E6FE52806A5BA00B035AE /* Discovery */,
|
||||||
DB0617FA27855B660030EE79 /* Settings */,
|
DB0617FA27855B660030EE79 /* Settings */,
|
||||||
);
|
);
|
||||||
path = Diffiable;
|
path = Diffable;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
2D7631A425C1532200929FB9 /* Share */ = {
|
2D7631A425C1532200929FB9 /* Share */ = {
|
||||||
@ -1762,7 +1739,7 @@
|
|||||||
children = (
|
children = (
|
||||||
DB89BA1025C10FF5008580ED /* Mastodon.entitlements */,
|
DB89BA1025C10FF5008580ED /* Mastodon.entitlements */,
|
||||||
DB427DE325BAA00100D1B89D /* Info.plist */,
|
DB427DE325BAA00100D1B89D /* Info.plist */,
|
||||||
2D76319C25C151DE00929FB9 /* Diffiable */,
|
2D76319C25C151DE00929FB9 /* Diffable */,
|
||||||
DB8AF55525C1379F002E6C99 /* Scene */,
|
DB8AF55525C1379F002E6C99 /* Scene */,
|
||||||
DB8AF54125C13647002E6C99 /* Coordinator */,
|
DB8AF54125C13647002E6C99 /* Coordinator */,
|
||||||
DB8AF56225C138BC002E6C99 /* Extension */,
|
DB8AF56225C138BC002E6C99 /* Extension */,
|
||||||
@ -1878,8 +1855,6 @@
|
|||||||
DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */,
|
DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */,
|
||||||
DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */,
|
DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */,
|
||||||
DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */,
|
DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */,
|
||||||
DB44767A260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift */,
|
|
||||||
DB221B15260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift */,
|
|
||||||
DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */,
|
DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */,
|
||||||
);
|
);
|
||||||
path = View;
|
path = View;
|
||||||
@ -2146,8 +2121,6 @@
|
|||||||
DB789A2125F9F76D0071ACA0 /* CollectionViewCell */,
|
DB789A2125F9F76D0071ACA0 /* CollectionViewCell */,
|
||||||
DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */,
|
DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */,
|
||||||
DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */,
|
DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */,
|
||||||
DB66728B25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift */,
|
|
||||||
DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */,
|
|
||||||
);
|
);
|
||||||
path = Compose;
|
path = Compose;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -2159,8 +2132,6 @@
|
|||||||
DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */,
|
DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */,
|
||||||
DB87D4502609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift */,
|
DB87D4502609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift */,
|
||||||
DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */,
|
DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */,
|
||||||
DB447690260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift */,
|
|
||||||
DB447696260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift */,
|
|
||||||
);
|
);
|
||||||
path = CollectionViewCell;
|
path = CollectionViewCell;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -2520,7 +2491,6 @@
|
|||||||
DBBC24D526A54BCB00398BB9 /* Helper */ = {
|
DBBC24D526A54BCB00398BB9 /* Helper */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */,
|
|
||||||
DBF3B7402733EB9400E21627 /* MastodonLocalCode.swift */,
|
DBF3B7402733EB9400E21627 /* MastodonLocalCode.swift */,
|
||||||
);
|
);
|
||||||
path = Helper;
|
path = Helper;
|
||||||
@ -2687,28 +2657,11 @@
|
|||||||
path = Cell;
|
path = Cell;
|
||||||
sourceTree = "<group>";
|
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 */ = {
|
DBFEF06126A57721006D7ED1 /* Scene */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
DBFEF05426A576EE006D7ED1 /* View */,
|
DBC6462226A1712000B0E31B /* ShareViewModel.swift */,
|
||||||
DBC6462226A1712000B0E31B /* ComposeViewModel.swift */,
|
DBC3872329214121001EC0FD /* ShareViewController.swift */,
|
||||||
DBC6461426A170AB00B0E31B /* ComposeViewController.swift */,
|
|
||||||
);
|
);
|
||||||
path = Scene;
|
path = Scene;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -3198,7 +3151,6 @@
|
|||||||
0F1E2D0B2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift in Sources */,
|
0F1E2D0B2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift in Sources */,
|
||||||
DBDFF1902805543100557A48 /* DiscoveryPostsViewController.swift in Sources */,
|
DBDFF1902805543100557A48 /* DiscoveryPostsViewController.swift in Sources */,
|
||||||
DB697DD9278F4CED004EF2F7 /* HomeTimelineViewController+DataSourceProvider.swift in Sources */,
|
DB697DD9278F4CED004EF2F7 /* HomeTimelineViewController+DataSourceProvider.swift in Sources */,
|
||||||
DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */,
|
|
||||||
DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */,
|
DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */,
|
||||||
2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */,
|
2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */,
|
||||||
DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */,
|
DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */,
|
||||||
@ -3246,7 +3198,6 @@
|
|||||||
DBFEEC9D279C12C1004F81DD /* ProfileFieldEditCollectionViewCell.swift in Sources */,
|
DBFEEC9D279C12C1004F81DD /* ProfileFieldEditCollectionViewCell.swift in Sources */,
|
||||||
DB3E6FEC2806D7F100B035AE /* DiscoveryNewsViewController.swift in Sources */,
|
DB3E6FEC2806D7F100B035AE /* DiscoveryNewsViewController.swift in Sources */,
|
||||||
DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */,
|
DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */,
|
||||||
DBBC24DC26A54BCB00398BB9 /* MastodonRegex.swift in Sources */,
|
|
||||||
DBCBED1726132DB500B49291 /* UserTimelineViewModel+Diffable.swift in Sources */,
|
DBCBED1726132DB500B49291 /* UserTimelineViewModel+Diffable.swift in Sources */,
|
||||||
2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */,
|
2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */,
|
||||||
2DAC9E3E262FC2400062E1A6 /* SuggestionAccountViewModel.swift in Sources */,
|
2DAC9E3E262FC2400062E1A6 /* SuggestionAccountViewModel.swift in Sources */,
|
||||||
@ -3301,7 +3252,6 @@
|
|||||||
DB63F7492799126300455B82 /* FollowerListViewController+DataSourceProvider.swift in Sources */,
|
DB63F7492799126300455B82 /* FollowerListViewController+DataSourceProvider.swift in Sources */,
|
||||||
2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */,
|
2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */,
|
||||||
2DAC9E38262FC2320062E1A6 /* SuggestionAccountViewController.swift in Sources */,
|
2DAC9E38262FC2320062E1A6 /* SuggestionAccountViewController.swift in Sources */,
|
||||||
DB66728C25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift in Sources */,
|
|
||||||
DB6180E02639194B0018D199 /* MediaPreviewPagingViewController.swift in Sources */,
|
DB6180E02639194B0018D199 /* MediaPreviewPagingViewController.swift in Sources */,
|
||||||
DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */,
|
DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */,
|
||||||
5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */,
|
5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */,
|
||||||
@ -3339,7 +3289,6 @@
|
|||||||
DB98EB6227B215EB0082E365 /* ReportResultViewController.swift in Sources */,
|
DB98EB6227B215EB0082E365 /* ReportResultViewController.swift in Sources */,
|
||||||
DB6B74F4272FBAE700C70B6E /* FollowerListViewModel+Diffable.swift in Sources */,
|
DB6B74F4272FBAE700C70B6E /* FollowerListViewModel+Diffable.swift in Sources */,
|
||||||
DB6B74F2272FB67600C70B6E /* FollowerListViewModel.swift in Sources */,
|
DB6B74F2272FB67600C70B6E /* FollowerListViewModel.swift in Sources */,
|
||||||
DB44767B260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift in Sources */,
|
|
||||||
0F20222D261457EE000C64BF /* HashtagTimelineViewModel+State.swift in Sources */,
|
0F20222D261457EE000C64BF /* HashtagTimelineViewModel+State.swift in Sources */,
|
||||||
DB0009A626AEE5DC009B9D2D /* Intents.intentdefinition in Sources */,
|
DB0009A626AEE5DC009B9D2D /* Intents.intentdefinition in Sources */,
|
||||||
5B90C462262599800002E742 /* SettingsSectionHeader.swift in Sources */,
|
5B90C462262599800002E742 /* SettingsSectionHeader.swift in Sources */,
|
||||||
@ -3353,7 +3302,6 @@
|
|||||||
DBFEEC9B279BDDD9004F81DD /* ProfileAboutViewModel+Diffable.swift in Sources */,
|
DBFEEC9B279BDDD9004F81DD /* ProfileAboutViewModel+Diffable.swift in Sources */,
|
||||||
DBB525562611EDCA002F1F29 /* UserTimelineViewModel.swift in Sources */,
|
DBB525562611EDCA002F1F29 /* UserTimelineViewModel.swift in Sources */,
|
||||||
DB0618012785732C0030EE79 /* ServerRulesTableViewCell.swift in Sources */,
|
DB0618012785732C0030EE79 /* ServerRulesTableViewCell.swift in Sources */,
|
||||||
DB221B16260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift in Sources */,
|
|
||||||
DB98EB5C27B10A730082E365 /* ReportSupplementaryViewModel.swift in Sources */,
|
DB98EB5C27B10A730082E365 /* ReportSupplementaryViewModel.swift in Sources */,
|
||||||
DB0617EF277F12720030EE79 /* NavigationActionView.swift in Sources */,
|
DB0617EF277F12720030EE79 /* NavigationActionView.swift in Sources */,
|
||||||
DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */,
|
DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */,
|
||||||
@ -3437,7 +3385,6 @@
|
|||||||
2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */,
|
2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */,
|
||||||
2D4AD89C263165B500613EFC /* SuggestionAccountCollectionViewCell.swift in Sources */,
|
2D4AD89C263165B500613EFC /* SuggestionAccountCollectionViewCell.swift in Sources */,
|
||||||
DB98EB6927B21A7C0082E365 /* ReportResultActionTableViewCell.swift in Sources */,
|
DB98EB6927B21A7C0082E365 /* ReportResultActionTableViewCell.swift in Sources */,
|
||||||
DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */,
|
|
||||||
DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */,
|
DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */,
|
||||||
DB697DDF278F524F004EF2F7 /* DataSourceFacade+Profile.swift in Sources */,
|
DB697DDF278F524F004EF2F7 /* DataSourceFacade+Profile.swift in Sources */,
|
||||||
DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */,
|
DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */,
|
||||||
@ -3461,7 +3408,6 @@
|
|||||||
DB63F74D27993F5B00455B82 /* SearchHistoryUserCollectionViewCell.swift in Sources */,
|
DB63F74D27993F5B00455B82 /* SearchHistoryUserCollectionViewCell.swift in Sources */,
|
||||||
DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */,
|
DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */,
|
||||||
DB1D84382657B275000346B3 /* SegmentedControlNavigateable.swift in Sources */,
|
DB1D84382657B275000346B3 /* SegmentedControlNavigateable.swift in Sources */,
|
||||||
DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */,
|
|
||||||
0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */,
|
0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */,
|
||||||
DB7A9F932818F33C0016AF98 /* MastodonServerRulesViewController+Debug.swift in Sources */,
|
DB7A9F932818F33C0016AF98 /* MastodonServerRulesViewController+Debug.swift in Sources */,
|
||||||
2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */,
|
2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */,
|
||||||
@ -3567,9 +3513,9 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
DBC6462326A1712000B0E31B /* ComposeViewModel.swift in Sources */,
|
DBC6462326A1712000B0E31B /* ShareViewModel.swift in Sources */,
|
||||||
DBB3BA2B26A81D060004F2D4 /* FLAnimatedImageView.swift in Sources */,
|
DBB3BA2B26A81D060004F2D4 /* FLAnimatedImageView.swift in Sources */,
|
||||||
DBC6461526A170AB00B0E31B /* ComposeViewController.swift in Sources */,
|
DBC3872429214121001EC0FD /* ShareViewController.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
@ -117,7 +117,7 @@
|
|||||||
<key>NotificationService.xcscheme_^#shared#^_</key>
|
<key>NotificationService.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>18</integer>
|
<integer>16</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>ShareActionExtension.xcscheme_^#shared#^_</key>
|
<key>ShareActionExtension.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
@ -90,6 +90,15 @@
|
|||||||
"version" : "2.2.5"
|
"version" : "2.2.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "nextlevelsessionexporter",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/NextLevel/NextLevelSessionExporter.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "b6c0cce1aa37fe1547d694f958fac3c3524b74da",
|
||||||
|
"version" : "0.4.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "nuke",
|
"identity" : "nuke",
|
||||||
"kind" : "remoteSourceControl",
|
"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 MastodonMeta
|
||||||
import MastodonUI
|
import MastodonUI
|
||||||
|
|
||||||
final class ComposeViewModel: NSObject {
|
final class ComposeViewModel {
|
||||||
|
|
||||||
let logger = Logger(subsystem: "ComposeViewModel", category: "ViewModel")
|
let logger = Logger(subsystem: "ComposeViewModel", category: "ViewModel")
|
||||||
|
|
||||||
@ -30,91 +30,13 @@ final class ComposeViewModel: NSObject {
|
|||||||
let context: AppContext
|
let context: AppContext
|
||||||
let authContext: AuthContext
|
let authContext: AuthContext
|
||||||
let kind: ComposeContentViewModel.Kind
|
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
|
let traitCollectionDidChangePublisher = CurrentValueSubject<Void, Never>(Void()) // use CurrentValueSubject to make initial event emit
|
||||||
// var isViewAppeared = false
|
|
||||||
|
|
||||||
// output
|
// output
|
||||||
// let instanceConfiguration: Mastodon.Entity.Instance.Configuration?
|
|
||||||
// var composeContentLimit: Int {
|
// UI & UX
|
||||||
// guard let maxCharacters = instanceConfiguration?.statuses?.maxCharacters else { return 500 }
|
@Published var title: String
|
||||||
// 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()
|
|
||||||
|
|
||||||
init(
|
init(
|
||||||
context: AppContext,
|
context: AppContext,
|
||||||
@ -124,63 +46,14 @@ final class ComposeViewModel: NSObject {
|
|||||||
self.context = context
|
self.context = context
|
||||||
self.authContext = authContext
|
self.authContext = authContext
|
||||||
self.kind = kind
|
self.kind = kind
|
||||||
|
// end init
|
||||||
|
|
||||||
// self.title = {
|
self.title = {
|
||||||
// switch composeKind {
|
switch kind {
|
||||||
// case .post, .hashtag, .mention: return L10n.Scene.Compose.Title.newPost
|
case .post, .hashtag, .mention: return L10n.Scene.Compose.Title.newPost
|
||||||
// case .reply: return L10n.Scene.Compose.Title.newReply
|
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
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 Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import Combine
|
|
||||||
import CoreData
|
import CoreData
|
||||||
import CoreDataStack
|
import CoreDataStack
|
||||||
import GameplayKit
|
import GameplayKit
|
||||||
|
@ -552,6 +552,9 @@ extension ProfileViewController {
|
|||||||
userTimelineViewController.viewModel.stateMachine.enter(UserTimelineViewModel.State.Reloading.self)
|
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) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||||
sender.endRefreshing()
|
sender.endRefreshing()
|
||||||
}
|
}
|
||||||
|
@ -8,7 +8,6 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import MastodonSDK
|
import MastodonSDK
|
||||||
import MastodonUI
|
|
||||||
import MastodonAsset
|
import MastodonAsset
|
||||||
import MastodonCore
|
import MastodonCore
|
||||||
import MastodonUI
|
import MastodonUI
|
||||||
|
@ -312,7 +312,12 @@ extension MainTabBarController {
|
|||||||
guard let profileTabItem = _profileTabItem else { return }
|
guard let profileTabItem = _profileTabItem else { return }
|
||||||
let currentUserDisplayName = user.displayNameWithFallback ?? "no user"
|
let currentUserDisplayName = user.displayNameWithFallback ?? "no user"
|
||||||
profileTabItem.accessibilityHint = L10n.Scene.AccountList.tabBarHint(currentUserDisplayName)
|
profileTabItem.accessibilityHint = L10n.Scene.AccountList.tabBarHint(currentUserDisplayName)
|
||||||
|
|
||||||
|
context.authenticationService.updateActiveUserAccountPublisher
|
||||||
|
.sink { [weak self] in
|
||||||
|
self?.updateUserAccount()
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
} else {
|
} else {
|
||||||
self.avatarURLObserver = nil
|
self.avatarURLObserver = nil
|
||||||
}
|
}
|
||||||
@ -487,6 +492,26 @@ extension MainTabBarController {
|
|||||||
avatarButton.setNeedsLayout()
|
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 {
|
extension MainTabBarController {
|
||||||
|
@ -109,6 +109,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||||||
|
|
||||||
// trigger status filter update
|
// trigger status filter update
|
||||||
AppContext.shared.statusFilterService.filterUpdatePublisher.send()
|
AppContext.shared.statusFilterService.filterUpdatePublisher.send()
|
||||||
|
|
||||||
|
// trigger authenticated user account update
|
||||||
|
AppContext.shared.authenticationService.updateActiveUserAccountPublisher.send()
|
||||||
|
|
||||||
if let shortcutItem = savedShortCutItem {
|
if let shortcutItem = savedShortCutItem {
|
||||||
Task {
|
Task {
|
||||||
|
@ -25,9 +25,9 @@ let package = Package(
|
|||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(name: "ArkanaKeys", path: "../dependencies/ArkanaKeys"),
|
.package(name: "ArkanaKeys", path: "../dependencies/ArkanaKeys"),
|
||||||
.package(name: "FaviconFinder", url: "https://github.com/will-lumley/FaviconFinder.git", from: "3.2.2"),
|
.package(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(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/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/Alamofire.git", from: "5.4.0"),
|
||||||
.package(url: "https://github.com/Alamofire/AlamofireImage.git", from: "4.1.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"),
|
.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/SDWebImage/SDWebImage.git", from: "5.12.0"),
|
||||||
.package(url: "https://github.com/eneko/Stripes.git", from: "0.2.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/onevcat/Kingfisher.git", from: "7.4.1"),
|
||||||
|
.package(url: "https://github.com/NextLevel/NextLevelSessionExporter.git", from: "0.4.6"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
// 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: "FLAnimatedImage", package: "FLAnimatedImage"),
|
||||||
.product(name: "FaviconFinder", package: "FaviconFinder"),
|
.product(name: "FaviconFinder", package: "FaviconFinder"),
|
||||||
.product(name: "Nuke", package: "Nuke"),
|
.product(name: "Nuke", package: "Nuke"),
|
||||||
.product(name: "Introspect", package: "Introspect"),
|
.product(name: "Introspect", package: "SwiftUI-Introspect"),
|
||||||
.product(name: "UITextView+Placeholder", package: "UITextView+Placeholder"),
|
.product(name: "UITextView+Placeholder", package: "UITextView-Placeholder"),
|
||||||
.product(name: "UIHostingConfigurationBackport", package: "UIHostingConfigurationBackport"),
|
.product(name: "UIHostingConfigurationBackport", package: "UIHostingConfigurationBackport"),
|
||||||
.product(name: "TabBarPager", package: "TabBarPager"),
|
.product(name: "TabBarPager", package: "TabBarPager"),
|
||||||
.product(name: "ThirdPartyMailer", package: "ThirdPartyMailer"),
|
.product(name: "ThirdPartyMailer", package: "ThirdPartyMailer"),
|
||||||
@ -124,6 +125,7 @@ let package = Package(
|
|||||||
.product(name: "PanModal", package: "PanModal"),
|
.product(name: "PanModal", package: "PanModal"),
|
||||||
.product(name: "Stripes", package: "Stripes"),
|
.product(name: "Stripes", package: "Stripes"),
|
||||||
.product(name: "Kingfisher", package: "Kingfisher"),
|
.product(name: "Kingfisher", package: "Kingfisher"),
|
||||||
|
.product(name: "NextLevelSessionExporter", package: "NextLevelSessionExporter"),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
.testTarget(
|
.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 Scene {
|
||||||
public enum Compose {
|
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 earth = ImageAsset(name: "Scene/Compose/Earth")
|
||||||
public static let mention = ImageAsset(name: "Scene/Compose/Mention")
|
public static let mention = ImageAsset(name: "Scene/Compose/Mention")
|
||||||
public static let more = ImageAsset(name: "Scene/Compose/More")
|
public static let more = ImageAsset(name: "Scene/Compose/More")
|
||||||
|
@ -10,6 +10,10 @@ import CoreDataStack
|
|||||||
import MastodonSDK
|
import MastodonSDK
|
||||||
|
|
||||||
extension MastodonUser.Property {
|
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) {
|
init(entity: Mastodon.Entity.Account, domain: String, networkDate: Date) {
|
||||||
self.init(
|
self.init(
|
||||||
identifier: entity.id + "@" + domain,
|
identifier: entity.id + "@" + domain,
|
||||||
|
@ -8,28 +8,28 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import MastodonSDK
|
import MastodonSDK
|
||||||
|
|
||||||
enum CustomEmojiPickerItem {
|
public enum CustomEmojiPickerItem {
|
||||||
case emoji(attribute: CustomEmojiAttribute)
|
case emoji(attribute: CustomEmojiAttribute)
|
||||||
}
|
}
|
||||||
|
|
||||||
extension CustomEmojiPickerItem: Equatable, Hashable { }
|
extension CustomEmojiPickerItem: Equatable, Hashable { }
|
||||||
|
|
||||||
extension CustomEmojiPickerItem {
|
extension CustomEmojiPickerItem {
|
||||||
final class CustomEmojiAttribute: Equatable, Hashable {
|
public final class CustomEmojiAttribute: Equatable, Hashable {
|
||||||
let id = UUID()
|
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
|
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 &&
|
return lhs.id == rhs.id &&
|
||||||
lhs.emoji.shortcode == rhs.emoji.shortcode
|
lhs.emoji.shortcode == rhs.emoji.shortcode
|
||||||
}
|
}
|
||||||
|
|
||||||
func hash(into hasher: inout Hasher) {
|
public func hash(into hasher: inout Hasher) {
|
||||||
hasher.combine(id)
|
hasher.combine(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,6 +13,15 @@ import MastodonCommon
|
|||||||
import MastodonSDK
|
import MastodonSDK
|
||||||
|
|
||||||
extension APIService {
|
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(
|
public func accountInfo(
|
||||||
domain: String,
|
domain: String,
|
||||||
|
@ -25,6 +25,7 @@ public final class AuthenticationService: NSObject {
|
|||||||
// output
|
// output
|
||||||
@Published public var mastodonAuthentications: [ManagedObjectRecord<MastodonAuthentication>] = []
|
@Published public var mastodonAuthentications: [ManagedObjectRecord<MastodonAuthentication>] = []
|
||||||
@Published public var mastodonAuthenticationBoxes: [MastodonAuthenticationBox] = []
|
@Published public var mastodonAuthenticationBoxes: [MastodonAuthenticationBox] = []
|
||||||
|
public let updateActiveUserAccountPublisher = PassthroughSubject<Void, Never>()
|
||||||
|
|
||||||
init(
|
init(
|
||||||
managedObjectContext: NSManagedObjectContext,
|
managedObjectContext: NSManagedObjectContext,
|
||||||
|
@ -24,7 +24,7 @@ public final class InstanceService {
|
|||||||
weak var authenticationService: AuthenticationService?
|
weak var authenticationService: AuthenticationService?
|
||||||
|
|
||||||
// output
|
// output
|
||||||
|
|
||||||
init(
|
init(
|
||||||
apiService: APIService,
|
apiService: APIService,
|
||||||
authenticationService: AuthenticationService
|
authenticationService: AuthenticationService
|
||||||
|
@ -449,6 +449,12 @@ public enum L10n {
|
|||||||
public static let disableContentWarning = L10n.tr("Localizable", "Scene.Compose.Accessibility.DisableContentWarning")
|
public static let disableContentWarning = L10n.tr("Localizable", "Scene.Compose.Accessibility.DisableContentWarning")
|
||||||
/// Enable Content Warning
|
/// Enable Content Warning
|
||||||
public static let enableContentWarning = L10n.tr("Localizable", "Scene.Compose.Accessibility.EnableContentWarning")
|
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
|
/// Post Visibility Menu
|
||||||
public static let postVisibilityMenu = L10n.tr("Localizable", "Scene.Compose.Accessibility.PostVisibilityMenu")
|
public static let postVisibilityMenu = L10n.tr("Localizable", "Scene.Compose.Accessibility.PostVisibilityMenu")
|
||||||
/// Remove Poll
|
/// Remove Poll
|
||||||
@ -1276,6 +1282,10 @@ public enum L10n {
|
|||||||
public enum A11y {
|
public enum A11y {
|
||||||
public enum Plural {
|
public enum Plural {
|
||||||
public enum Count {
|
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@"
|
/// Plural format key: "Input limit exceeds %#@character_count@"
|
||||||
public static func inputLimitExceeds(_ p1: Int) -> String {
|
public static func inputLimitExceeds(_ p1: Int) -> String {
|
||||||
return L10n.tr("Localizable", "a11y.plural.count.input_limit_exceeds", p1)
|
return L10n.tr("Localizable", "a11y.plural.count.input_limit_exceeds", p1)
|
||||||
|
@ -161,7 +161,9 @@ Your profile looks like this to them.";
|
|||||||
"Scene.Compose.Accessibility.CustomEmojiPicker" = "Custom Emoji Picker";
|
"Scene.Compose.Accessibility.CustomEmojiPicker" = "Custom Emoji Picker";
|
||||||
"Scene.Compose.Accessibility.DisableContentWarning" = "Disable Content Warning";
|
"Scene.Compose.Accessibility.DisableContentWarning" = "Disable Content Warning";
|
||||||
"Scene.Compose.Accessibility.EnableContentWarning" = "Enable 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.PostVisibilityMenu" = "Post Visibility Menu";
|
||||||
|
"Scene.Compose.Accessibility.PostingAs" = "Posting as %@";
|
||||||
"Scene.Compose.Accessibility.RemovePoll" = "Remove Poll";
|
"Scene.Compose.Accessibility.RemovePoll" = "Remove Poll";
|
||||||
"Scene.Compose.Attachment.AttachmentBroken" = "This %@ is broken and can’t be
|
"Scene.Compose.Attachment.AttachmentBroken" = "This %@ is broken and can’t be
|
||||||
uploaded to Mastodon.";
|
uploaded to Mastodon.";
|
||||||
|
@ -50,6 +50,28 @@
|
|||||||
<string>%ld characters</string>
|
<string>%ld characters</string>
|
||||||
</dict>
|
</dict>
|
||||||
</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>
|
<key>plural.count.followed_by_and_mutual</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
<key>NSStringLocalizedFormatKey</key>
|
||||||
|
@ -43,6 +43,20 @@ extension Mastodon.API.V2.Media {
|
|||||||
request.timeoutInterval = 180 // should > 200 Kb/s for 40 MiB media attachment
|
request.timeoutInterval = 180 // should > 200 Kb/s for 40 MiB media attachment
|
||||||
let serialStream = query.serialStream
|
let serialStream = query.serialStream
|
||||||
request.httpBodyStream = serialStream.boundStreams.input
|
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)
|
return session.dataTaskPublisher(for: request)
|
||||||
.tryMap { data, response in
|
.tryMap { data, response in
|
||||||
let value = try Mastodon.API.decode(type: Mastodon.Entity.Attachment.self, from: data, response: response)
|
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() }
|
return data.map { "data:" + mimeType + ";base64," + $0.base64EncodedString() }
|
||||||
}
|
}
|
||||||
|
|
||||||
var sizeInByte: Int? {
|
public var sizeInByte: Int? {
|
||||||
switch self {
|
switch self {
|
||||||
case .jpeg(let data), .gif(let data), .png(let data):
|
case .jpeg(let data), .gif(let data), .png(let data):
|
||||||
return data?.count
|
return data?.count
|
||||||
|
@ -82,6 +82,10 @@ final class SerialStream: NSObject {
|
|||||||
|
|
||||||
self.progress.completedUnitCount += Int64(writeResult)
|
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)")
|
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.
|
// Created by MainasuK on 22/10/10.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import UIKit
|
||||||
import MastodonCore
|
import MastodonCore
|
||||||
|
|
||||||
extension CustomEmojiPickerSection {
|
extension CustomEmojiPickerSection {
|
||||||
// static func collectionViewDiffableDataSource(
|
static func collectionViewDiffableDataSource(
|
||||||
// collectionView: UICollectionView,
|
collectionView: UICollectionView,
|
||||||
// dependency: NeedsDependency
|
context: AppContext
|
||||||
// ) -> UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem> {
|
) -> UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem> {
|
||||||
// let dataSource = UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem>(collectionView: collectionView) { [weak dependency] collectionView, indexPath, item -> UICollectionViewCell? in
|
let dataSource = UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem>(collectionView: collectionView) { [weak context] collectionView, indexPath, item -> UICollectionViewCell? in
|
||||||
// guard let _ = dependency else { return nil }
|
guard let _ = context else { return nil }
|
||||||
// switch item {
|
switch item {
|
||||||
// case .emoji(let attribute):
|
case .emoji(let attribute):
|
||||||
// let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: CustomEmojiPickerItemCollectionViewCell.self), for: indexPath) as! CustomEmojiPickerItemCollectionViewCell
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: CustomEmojiPickerItemCollectionViewCell.self), for: indexPath) as! CustomEmojiPickerItemCollectionViewCell
|
||||||
// let placeholder = UIImage.placeholder(size: CustomEmojiPickerItemCollectionViewCell.itemSize, color: .systemFill)
|
let placeholder = UIImage.placeholder(size: CustomEmojiPickerItemCollectionViewCell.itemSize, color: .systemFill)
|
||||||
// .af.imageRounded(withCornerRadius: 4)
|
.af.imageRounded(withCornerRadius: 4)
|
||||||
//
|
|
||||||
// let isAnimated = !UserDefaults.shared.preferredStaticEmoji
|
let isAnimated = !UserDefaults.shared.preferredStaticEmoji
|
||||||
// let url = URL(string: isAnimated ? attribute.emoji.url : attribute.emoji.staticURL)
|
let url = URL(string: isAnimated ? attribute.emoji.url : attribute.emoji.staticURL)
|
||||||
// cell.emojiImageView.sd_setImage(
|
cell.emojiImageView.sd_setImage(
|
||||||
// with: url,
|
with: url,
|
||||||
// placeholderImage: placeholder,
|
placeholderImage: placeholder,
|
||||||
// options: [],
|
options: [],
|
||||||
// context: nil
|
context: nil
|
||||||
// )
|
)
|
||||||
// cell.accessibilityLabel = attribute.emoji.shortcode
|
cell.accessibilityLabel = attribute.emoji.shortcode
|
||||||
// return cell
|
return cell
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
//
|
|
||||||
// dataSource.supplementaryViewProvider = { [weak dataSource] collectionView, kind, indexPath -> UICollectionReusableView? in
|
dataSource.supplementaryViewProvider = { [weak dataSource] collectionView, kind, indexPath -> UICollectionReusableView? in
|
||||||
// guard let dataSource = dataSource else { return nil }
|
guard let dataSource = dataSource else { return nil }
|
||||||
// let sections = dataSource.snapshot().sectionIdentifiers
|
let sections = dataSource.snapshot().sectionIdentifiers
|
||||||
// guard indexPath.section < sections.count else { return nil }
|
guard indexPath.section < sections.count else { return nil }
|
||||||
// let section = sections[indexPath.section]
|
let section = sections[indexPath.section]
|
||||||
//
|
|
||||||
// switch kind {
|
switch kind {
|
||||||
// case String(describing: CustomEmojiPickerHeaderCollectionReusableView.self):
|
case String(describing: CustomEmojiPickerHeaderCollectionReusableView.self):
|
||||||
// let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: String(describing: CustomEmojiPickerHeaderCollectionReusableView.self), for: indexPath) as! CustomEmojiPickerHeaderCollectionReusableView
|
let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: String(describing: CustomEmojiPickerHeaderCollectionReusableView.self), for: indexPath) as! CustomEmojiPickerHeaderCollectionReusableView
|
||||||
// switch section {
|
switch section {
|
||||||
// case .emoji(let name):
|
case .emoji(let name):
|
||||||
// header.titleLabel.text = name
|
header.titleLabel.text = name
|
||||||
// }
|
}
|
||||||
// return header
|
return header
|
||||||
// default:
|
default:
|
||||||
// assertionFailure()
|
assertionFailure()
|
||||||
// return nil
|
return nil
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
//
|
|
||||||
// return dataSource
|
return dataSource
|
||||||
// }
|
}
|
||||||
}
|
}
|
||||||
|
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 SwiftUI
|
||||||
import Introspect
|
import Introspect
|
||||||
import AVKit
|
import AVKit
|
||||||
|
import MastodonAsset
|
||||||
|
import MastodonLocalization
|
||||||
|
import Introspect
|
||||||
|
|
||||||
public struct AttachmentView: View {
|
public struct AttachmentView: View {
|
||||||
|
|
||||||
static let size = CGSize(width: 56, height: 56)
|
|
||||||
static let cornerRadius: CGFloat = 8
|
|
||||||
|
|
||||||
@ObservedObject var viewModel: AttachmentViewModel
|
@ObservedObject var viewModel: AttachmentViewModel
|
||||||
|
|
||||||
let action: (Action) -> Void
|
var blurEffect: UIBlurEffect {
|
||||||
|
UIBlurEffect(style: .systemUltraThinMaterialDark)
|
||||||
@State var isCaptionEditorPresented = false
|
}
|
||||||
@State var caption = ""
|
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
Text("Hello")
|
Color.clear.aspectRatio(358.0/232.0, contentMode: .fill)
|
||||||
// Menu {
|
.overlay(
|
||||||
// menu
|
ZStack {
|
||||||
// } label: {
|
let image = viewModel.thumbnail ?? .placeholder(color: .secondarySystemFill)
|
||||||
// let image = viewModel.thumbnail ?? .placeholder(color: .systemGray3)
|
Image(uiImage: image)
|
||||||
// Image(uiImage: image)
|
.resizable()
|
||||||
// .resizable()
|
.aspectRatio(contentMode: .fill)
|
||||||
// .aspectRatio(contentMode: .fill)
|
}
|
||||||
// .frame(width: AttachmentView.size.width, height: AttachmentView.size.height)
|
)
|
||||||
// .overlay {
|
.overlay(
|
||||||
// ZStack {
|
ZStack {
|
||||||
// // spinner
|
Color.clear
|
||||||
// if viewModel.output == nil {
|
.overlay(
|
||||||
// Color.clear
|
VStack(alignment: .leading) {
|
||||||
// .background(.ultraThinMaterial)
|
let placeholder: String = {
|
||||||
// ProgressView()
|
switch viewModel.output {
|
||||||
// .progressViewStyle(CircularProgressViewStyle())
|
case .image: return L10n.Scene.Compose.Attachment.descriptionPhoto
|
||||||
// .foregroundStyle(.regularMaterial)
|
case .video: return L10n.Scene.Compose.Attachment.descriptionVideo
|
||||||
// }
|
case nil: return ""
|
||||||
// // border
|
}
|
||||||
// RoundedRectangle(cornerRadius: AttachmentView.cornerRadius)
|
}()
|
||||||
// .stroke(Color.black.opacity(0.05))
|
Spacer()
|
||||||
// }
|
TextField(placeholder, text: $viewModel.caption)
|
||||||
// .transition(.opacity)
|
.lineLimit(1)
|
||||||
// }
|
.textFieldStyle(.plain)
|
||||||
// .overlay(alignment: .bottom) {
|
.foregroundColor(.white)
|
||||||
// HStack(alignment: .bottom) {
|
.placeholder(placeholder, when: viewModel.caption.isEmpty)
|
||||||
// // alt
|
.padding(8)
|
||||||
// VStack(spacing: 2) {
|
}
|
||||||
// switch viewModel.output {
|
)
|
||||||
// case .video:
|
|
||||||
// Image(uiImage: Asset.Media.playerRectangle.image)
|
// loading…
|
||||||
// .resizable()
|
if viewModel.output == nil, viewModel.error == nil {
|
||||||
// .frame(width: 16, height: 12)
|
ProgressView()
|
||||||
// default:
|
.progressViewStyle(.circular)
|
||||||
// EmptyView()
|
}
|
||||||
// }
|
|
||||||
// if !viewModel.caption.isEmpty {
|
// load failed
|
||||||
// Image(uiImage: Asset.Media.altRectangle.image)
|
// cannot re-entry
|
||||||
// .resizable()
|
if viewModel.output == nil, let error = viewModel.error {
|
||||||
// .frame(width: 16, height: 12)
|
VisualEffectView(effect: blurEffect)
|
||||||
// }
|
VStack {
|
||||||
// }
|
Text("Load Failed") // TODO: i18n
|
||||||
// Spacer()
|
.font(.system(size: 13, weight: .semibold))
|
||||||
// // option
|
Text(error.localizedDescription)
|
||||||
// Image(systemName: "ellipsis")
|
.font(.system(size: 12, weight: .regular))
|
||||||
// .resizable()
|
}
|
||||||
// .frame(width: 12, height: 12)
|
}
|
||||||
// .symbolVariant(.circle)
|
|
||||||
// .symbolVariant(.fill)
|
// loaded
|
||||||
// .symbolRenderingMode(.palette)
|
// uploading… or upload failed
|
||||||
// .foregroundStyle(.white, .black)
|
// could retry upload when error emit
|
||||||
// }
|
if viewModel.output != nil, viewModel.uploadState != .finish {
|
||||||
// .padding(6)
|
VisualEffectView(effect: blurEffect)
|
||||||
// }
|
VStack {
|
||||||
// .cornerRadius(AttachmentView.cornerRadius)
|
let action: AttachmentViewModel.Action = {
|
||||||
// } // end Menu
|
if let _ = viewModel.error {
|
||||||
// .sheet(isPresented: $isCaptionEditorPresented) {
|
return .retry
|
||||||
// captionSheet
|
} else {
|
||||||
// } // end caption sheet
|
return .remove
|
||||||
// .sheet(isPresented: $viewModel.isPreviewPresented) {
|
}
|
||||||
// previewSheet
|
}()
|
||||||
// } // end preview sheet
|
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
|
} // 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 {
|
// https://stackoverflow.com/a/57715771/3797903
|
||||||
public enum Action: Hashable {
|
extension View {
|
||||||
case preview
|
fileprivate func placeholder<Content: View>(
|
||||||
case caption
|
when shouldShow: Bool,
|
||||||
case remove
|
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 {
|
extension AttachmentViewModel {
|
||||||
|
public enum UploadState {
|
||||||
|
case none
|
||||||
|
case compressing
|
||||||
|
case ready
|
||||||
|
case uploading
|
||||||
|
case fail
|
||||||
|
case finish
|
||||||
|
}
|
||||||
|
|
||||||
struct UploadContext {
|
struct UploadContext {
|
||||||
let apiService: APIService
|
let apiService: APIService
|
||||||
let authContext: AuthContext
|
let authContext: AuthContext
|
||||||
}
|
}
|
||||||
|
|
||||||
enum UploadResult {
|
public typealias UploadResult = Mastodon.Entity.Attachment
|
||||||
case mastodon(Mastodon.Response.Content<Mastodon.Entity.Attachment>)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AttachmentViewModel {
|
extension AttachmentViewModel {
|
||||||
func upload(context: UploadContext) async throws -> UploadResult {
|
@MainActor
|
||||||
return try await uploadMastodonMedia(
|
func upload(isRetry: Bool = false) async throws {
|
||||||
context: context
|
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(
|
private func uploadMastodonMedia(
|
||||||
context: UploadContext
|
context: UploadContext
|
||||||
) async throws -> UploadResult {
|
) async throws -> UploadResult {
|
||||||
@ -260,7 +172,7 @@ extension AttachmentViewModel {
|
|||||||
if attachmentUploadResponse.statusCode == 202 {
|
if attachmentUploadResponse.statusCode == 202 {
|
||||||
// note:
|
// note:
|
||||||
// the Mastodon server append the attachments in order by upload time
|
// the Mastodon server append the attachments in order by upload time
|
||||||
// can not upload concurrency
|
// can not upload parallels
|
||||||
let waitProcessRetryLimit = checkUploadTaskRetryLimit
|
let waitProcessRetryLimit = checkUploadTaskRetryLimit
|
||||||
var waitProcessRetryCount: Int64 = 0
|
var waitProcessRetryCount: Int64 = 0
|
||||||
|
|
||||||
@ -283,7 +195,7 @@ extension AttachmentViewModel {
|
|||||||
|
|
||||||
// escape here
|
// escape here
|
||||||
progress.completedUnitCount = progress.totalUnitCount
|
progress.completedUnitCount = progress.totalUnitCount
|
||||||
return .mastodon(attachmentStatusResponse)
|
return attachmentStatusResponse.value
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): attachment processing. Retry \(waitProcessRetryCount)/\(waitProcessRetryLimit)")
|
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 {
|
} 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>")")
|
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 PhotosUI
|
||||||
import Kingfisher
|
import Kingfisher
|
||||||
import MastodonCore
|
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 {
|
final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable {
|
||||||
|
|
||||||
static let logger = Logger(subsystem: "AttachmentViewModel", category: "ViewModel")
|
static let logger = Logger(subsystem: "AttachmentViewModel", category: "ViewModel")
|
||||||
|
let logger = Logger(subsystem: "AttachmentViewModel", category: "ViewModel")
|
||||||
|
|
||||||
public let id = UUID()
|
public let id = UUID()
|
||||||
|
|
||||||
var disposeBag = Set<AnyCancellable>()
|
var disposeBag = Set<AnyCancellable>()
|
||||||
var observations = Set<NSKeyValueObservation>()
|
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
|
// input
|
||||||
|
public let api: APIService
|
||||||
|
public let authContext: AuthContext
|
||||||
public let input: Input
|
public let input: Input
|
||||||
@Published var caption = ""
|
@Published var caption = ""
|
||||||
@Published var sizeLimit = SizeLimit()
|
@Published var sizeLimit = SizeLimit()
|
||||||
@Published public var isPreviewPresented = false
|
|
||||||
|
// var compressVideoTask: Task<URL, Error>?
|
||||||
|
|
||||||
// output
|
// output
|
||||||
@Published public private(set) var output: Output?
|
@Published public private(set) var output: Output?
|
||||||
@Published public private(set) var thumbnail: UIImage? // original size image thumbnail
|
@Published public private(set) var thumbnail: UIImage? // original size image thumbnail
|
||||||
@Published var error: Error?
|
@Published public private(set) var outputSizeInByte: Int64 = 0
|
||||||
let progress = Progress() // upload progress
|
|
||||||
|
|
||||||
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.input = input
|
||||||
|
self.delegate = delegate
|
||||||
super.init()
|
super.init()
|
||||||
// end init
|
// end init
|
||||||
|
|
||||||
defer {
|
Timer.publish(every: 1.0 / 60.0, on: .main, in: .common) // 60 FPS
|
||||||
load(input: input)
|
.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
|
$output
|
||||||
.map { output -> UIImage? in
|
.map { output -> UIImage? in
|
||||||
@ -53,22 +129,121 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
.assign(to: &$thumbnail)
|
.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 {
|
deinit {
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
|
||||||
|
uploadTask?.cancel()
|
||||||
|
|
||||||
switch output {
|
switch output {
|
||||||
case .image:
|
case .image:
|
||||||
// FIXME:
|
// FIXME:
|
||||||
break
|
break
|
||||||
case .video(let url, _):
|
case .video(let url, _):
|
||||||
try? FileManager.default.removeItem(at: url)
|
try? FileManager.default.removeItem(at: url)
|
||||||
case nil :
|
case nil:
|
||||||
break
|
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 {
|
extension AttachmentViewModel {
|
||||||
public enum Input: Hashable {
|
public enum Input: Hashable {
|
||||||
case image(UIImage)
|
case image(UIImage)
|
||||||
@ -86,13 +261,6 @@ extension AttachmentViewModel {
|
|||||||
case png
|
case png
|
||||||
case jpg
|
case jpg
|
||||||
}
|
}
|
||||||
|
|
||||||
public var twitterMediaCategory: TwitterMediaCategory {
|
|
||||||
switch self {
|
|
||||||
case .image: return .image
|
|
||||||
case .video: return .amplifyVideo
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct SizeLimit {
|
public struct SizeLimit {
|
||||||
@ -111,291 +279,38 @@ extension AttachmentViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum AttachmentError: Error {
|
public enum AttachmentError: Error, LocalizedError {
|
||||||
case invalidAttachmentType
|
case invalidAttachmentType
|
||||||
case attachmentTooLarge
|
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) {
|
public var errorDescription: String? {
|
||||||
guard url.startAccessingSecurityScopedResource() else {
|
switch self {
|
||||||
throw AttachmentError.invalidAttachmentType
|
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 {
|
extension AttachmentViewModel {
|
||||||
static func createThumbnailForVideo(url: URL) -> UIImage? {
|
public enum Action: Hashable {
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
|
case remove
|
||||||
let asset = AVURLAsset(url: url)
|
case retry
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - TypeIdentifiedItemProvider
|
extension AttachmentViewModel {
|
||||||
extension AttachmentViewModel: TypeIdentifiedItemProvider {
|
@MainActor
|
||||||
public static var typeIdentifier: String {
|
func update(uploadState: UploadState) {
|
||||||
// must in UTI format
|
self.uploadState = uploadState
|
||||||
// https://developer.apple.com/library/archive/qa/qa1796/_index.html
|
self.delegate?.attachmentViewModel(self, uploadStateValueDidChange: self.uploadState)
|
||||||
return "com.twidere.AttachmentViewModel"
|
}
|
||||||
}
|
|
||||||
}
|
@MainActor
|
||||||
|
func update(uploadResult: UploadResult) {
|
||||||
// MARK: - NSItemProviderWriting
|
self.uploadResult = uploadResult
|
||||||
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: []
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -88,7 +88,7 @@ extension AutoCompleteViewController {
|
|||||||
])
|
])
|
||||||
|
|
||||||
tableView.delegate = self
|
tableView.delegate = self
|
||||||
// viewModel.setupDiffableDataSource(tableView: tableView)
|
viewModel.setupDiffableDataSource(tableView: tableView)
|
||||||
|
|
||||||
// bind to layout chevron
|
// bind to layout chevron
|
||||||
viewModel.symbolBoundingRect
|
viewModel.symbolBoundingRect
|
||||||
|
@ -6,17 +6,18 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import MastodonCore
|
||||||
|
|
||||||
extension AutoCompleteViewModel {
|
extension AutoCompleteViewModel {
|
||||||
|
|
||||||
// func setupDiffableDataSource(
|
func setupDiffableDataSource(
|
||||||
// tableView: UITableView
|
tableView: UITableView
|
||||||
// ) {
|
) {
|
||||||
// diffableDataSource = AutoCompleteSection.tableViewDiffableDataSource(for: tableView)
|
diffableDataSource = AutoCompleteSection.tableViewDiffableDataSource(tableView: tableView)
|
||||||
//
|
|
||||||
// var snapshot = NSDiffableDataSourceSnapshot<AutoCompleteSection, AutoCompleteItem>()
|
var snapshot = NSDiffableDataSourceSnapshot<AutoCompleteSection, AutoCompleteItem>()
|
||||||
// snapshot.appendSections([.main])
|
snapshot.appendSections([.main])
|
||||||
// diffableDataSource?.apply(snapshot)
|
diffableDataSource?.apply(snapshot)
|
||||||
// }
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -102,7 +102,7 @@ extension AutoCompleteViewModel.State {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let customEmojiViewModel = viewModel.customEmojiViewModel.value else {
|
guard let customEmojiViewModel = viewModel.customEmojiViewModel else {
|
||||||
await enter(state: Fail.self)
|
await enter(state: Fail.self)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -20,7 +20,7 @@ final class AutoCompleteViewModel {
|
|||||||
let authContext: AuthContext
|
let authContext: AuthContext
|
||||||
public let inputText = CurrentValueSubject<String, Never>("") // contains "@" or "#" prefix
|
public let inputText = CurrentValueSubject<String, Never>("") // contains "@" or "#" prefix
|
||||||
public let symbolBoundingRect = CurrentValueSubject<CGRect, Never>(.zero)
|
public let symbolBoundingRect = CurrentValueSubject<CGRect, Never>(.zero)
|
||||||
public let customEmojiViewModel = CurrentValueSubject<EmojiService.CustomEmojiViewModel?, Never>(nil)
|
public let customEmojiViewModel: EmojiService.CustomEmojiViewModel?
|
||||||
|
|
||||||
// output
|
// output
|
||||||
public var autoCompleteItems = CurrentValueSubject<[AutoCompleteItem], Never>([])
|
public var autoCompleteItems = CurrentValueSubject<[AutoCompleteItem], Never>([])
|
||||||
@ -40,6 +40,8 @@ final class AutoCompleteViewModel {
|
|||||||
init(context: AppContext, authContext: AuthContext) {
|
init(context: AppContext, authContext: AuthContext) {
|
||||||
self.context = context
|
self.context = context
|
||||||
self.authContext = authContext
|
self.authContext = authContext
|
||||||
|
self.customEmojiViewModel = context.emojiService.dequeueCustomEmojiViewModel(for: authContext.mastodonAuthenticationBox.domain)
|
||||||
|
// end init
|
||||||
|
|
||||||
autoCompleteItems
|
autoCompleteItems
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
|
@ -8,7 +8,6 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import Combine
|
import Combine
|
||||||
import MastodonCore
|
import MastodonCore
|
||||||
import MastodonUI
|
|
||||||
|
|
||||||
final class AutoCompleteTopChevronView: UIView {
|
final class AutoCompleteTopChevronView: UIView {
|
||||||
|
|
||||||
|
@ -14,12 +14,15 @@ import MastodonCore
|
|||||||
|
|
||||||
public final class ComposeContentViewController: UIViewController {
|
public final class ComposeContentViewController: UIViewController {
|
||||||
|
|
||||||
|
static let minAutoCompleteVisibleHeight: CGFloat = 100
|
||||||
|
|
||||||
let logger = Logger(subsystem: "ComposeContentViewController", category: "ViewController")
|
let logger = Logger(subsystem: "ComposeContentViewController", category: "ViewController")
|
||||||
|
|
||||||
var disposeBag = Set<AnyCancellable>()
|
var disposeBag = Set<AnyCancellable>()
|
||||||
public var viewModel: ComposeContentViewModel!
|
public var viewModel: ComposeContentViewModel!
|
||||||
private(set) lazy var composeContentToolbarViewModel = ComposeContentToolbarView.ViewModel(delegate: self)
|
private(set) lazy var composeContentToolbarViewModel = ComposeContentToolbarView.ViewModel(delegate: self)
|
||||||
|
|
||||||
|
// tableView container
|
||||||
let tableView: ComposeTableView = {
|
let tableView: ComposeTableView = {
|
||||||
let tableView = ComposeTableView()
|
let tableView = ComposeTableView()
|
||||||
tableView.estimatedRowHeight = UITableView.automaticDimension
|
tableView.estimatedRowHeight = UITableView.automaticDimension
|
||||||
@ -29,6 +32,16 @@ public final class ComposeContentViewController: UIViewController {
|
|||||||
return tableView
|
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)
|
lazy var composeContentToolbarView = ComposeContentToolbarView(viewModel: composeContentToolbarViewModel)
|
||||||
var composeContentToolbarViewBottomLayoutConstraint: NSLayoutConstraint!
|
var composeContentToolbarViewBottomLayoutConstraint: NSLayoutConstraint!
|
||||||
let composeContentToolbarBackgroundView = UIView()
|
let composeContentToolbarBackgroundView = UIView()
|
||||||
@ -60,6 +73,15 @@ public final class ComposeContentViewController: UIViewController {
|
|||||||
documentPickerController.delegate = self
|
documentPickerController.delegate = self
|
||||||
return documentPickerController
|
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 {
|
deinit {
|
||||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
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() {
|
public override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
viewModel.delegate = self
|
||||||
|
|
||||||
// setup view
|
// setup view
|
||||||
self.setupBackgroundColor(theme: ThemeService.shared.currentTheme.value)
|
self.setupBackgroundColor(theme: ThemeService.shared.currentTheme.value)
|
||||||
ThemeService.shared.currentTheme
|
ThemeService.shared.currentTheme
|
||||||
@ -94,6 +118,12 @@ extension ComposeContentViewController {
|
|||||||
tableView.delegate = self
|
tableView.delegate = self
|
||||||
viewModel.setupDataSource(tableView: tableView)
|
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)
|
let toolbarHostingView = UIHostingController(rootView: composeContentToolbarView)
|
||||||
toolbarHostingView.view.translatesAutoresizingMaskIntoConstraints = false
|
toolbarHostingView.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
view.addSubview(toolbarHostingView.view)
|
view.addSubview(toolbarHostingView.view)
|
||||||
@ -116,49 +146,43 @@ extension ComposeContentViewController {
|
|||||||
view.bottomAnchor.constraint(equalTo: composeContentToolbarBackgroundView.bottomAnchor),
|
view.bottomAnchor.constraint(equalTo: composeContentToolbarBackgroundView.bottomAnchor),
|
||||||
])
|
])
|
||||||
|
|
||||||
let keyboardHasShortcutBar = CurrentValueSubject<Bool, Never>(traitCollection.userInterfaceIdiom == .pad) // update default value later
|
// bind keyboard
|
||||||
let keyboardEventPublishers = Publishers.CombineLatest3(
|
let keyboardEventPublishers = Publishers.CombineLatest3(
|
||||||
KeyboardResponderService.shared.isShow,
|
KeyboardResponderService.shared.isShow,
|
||||||
KeyboardResponderService.shared.state,
|
KeyboardResponderService.shared.state,
|
||||||
KeyboardResponderService.shared.endFrame
|
KeyboardResponderService.shared.endFrame
|
||||||
)
|
)
|
||||||
// Publishers.CombineLatest3(
|
Publishers.CombineLatest3(
|
||||||
// viewModel.$isCustomEmojiComposing,
|
keyboardEventPublishers,
|
||||||
// )
|
viewModel.$isEmojiActive,
|
||||||
keyboardEventPublishers
|
viewModel.$autoCompleteInfo
|
||||||
.sink(receiveValue: { [weak self] keyboardEvents in
|
)
|
||||||
|
.sink(receiveValue: { [weak self] keyboardEvents, isEmojiActive, autoCompleteInfo in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
|
||||||
let (isShow, state, endFrame) = keyboardEvents
|
let (isShow, state, endFrame) = keyboardEvents
|
||||||
|
|
||||||
// switch self.traitCollection.userInterfaceIdiom {
|
|
||||||
// case .pad:
|
|
||||||
// keyboardHasShortcutBar.value = state != .floating
|
|
||||||
// default:
|
|
||||||
// keyboardHasShortcutBar.value = false
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
let extraMargin: CGFloat = {
|
let extraMargin: CGFloat = {
|
||||||
var margin = ComposeContentToolbarView.toolbarHeight
|
var margin = ComposeContentToolbarView.toolbarHeight
|
||||||
// if autoCompleteInfo != nil {
|
if autoCompleteInfo != nil {
|
||||||
//// margin += ComposeViewController.minAutoCompleteVisibleHeight
|
margin += ComposeContentViewController.minAutoCompleteVisibleHeight
|
||||||
// }
|
}
|
||||||
return margin
|
return margin
|
||||||
}()
|
}()
|
||||||
//
|
|
||||||
guard isShow, state == .dock else {
|
guard isShow, state == .dock else {
|
||||||
self.tableView.contentInset.bottom = extraMargin
|
self.tableView.contentInset.bottom = extraMargin
|
||||||
self.tableView.verticalScrollIndicatorInsets.bottom = extraMargin
|
self.tableView.verticalScrollIndicatorInsets.bottom = extraMargin
|
||||||
|
|
||||||
// if let superView = self.autoCompleteViewController.tableView.superview {
|
if let superView = self.autoCompleteViewController.tableView.superview {
|
||||||
// let autoCompleteTableViewBottomInset: CGFloat = {
|
let autoCompleteTableViewBottomInset: CGFloat = {
|
||||||
// let tableViewFrameInWindow = superView.convert(self.autoCompleteViewController.tableView.frame, to: nil)
|
let tableViewFrameInWindow = superView.convert(self.autoCompleteViewController.tableView.frame, to: nil)
|
||||||
// let padding = tableViewFrameInWindow.maxY + self.composeToolbarView.frame.height + AutoCompleteViewController.chevronViewHeight - self.view.frame.maxY
|
let padding = tableViewFrameInWindow.maxY + ComposeContentToolbarView.toolbarHeight + AutoCompleteViewController.chevronViewHeight - self.view.frame.maxY
|
||||||
// return max(0, padding)
|
return max(0, padding)
|
||||||
// }()
|
}()
|
||||||
// self.autoCompleteViewController.tableView.contentInset.bottom = autoCompleteTableViewBottomInset
|
self.autoCompleteViewController.tableView.contentInset.bottom = autoCompleteTableViewBottomInset
|
||||||
// self.autoCompleteViewController.tableView.verticalScrollIndicatorInsets.bottom = autoCompleteTableViewBottomInset
|
self.autoCompleteViewController.tableView.verticalScrollIndicatorInsets.bottom = autoCompleteTableViewBottomInset
|
||||||
// }
|
}
|
||||||
|
|
||||||
UIView.animate(withDuration: 0.3) {
|
UIView.animate(withDuration: 0.3) {
|
||||||
self.composeContentToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom
|
self.composeContentToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom
|
||||||
@ -169,17 +193,16 @@ extension ComposeContentViewController {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// isShow AND dock state
|
// isShow AND dock state
|
||||||
// self.systemKeyboardHeight = endFrame.height
|
|
||||||
|
|
||||||
// adjust inset for auto-complete
|
// adjust inset for auto-complete
|
||||||
// let autoCompleteTableViewBottomInset: CGFloat = {
|
let autoCompleteTableViewBottomInset: CGFloat = {
|
||||||
// guard let superview = self.autoCompleteViewController.tableView.superview else { return .zero }
|
guard let superview = self.autoCompleteViewController.tableView.superview else { return .zero }
|
||||||
// let tableViewFrameInWindow = superview.convert(self.autoCompleteViewController.tableView.frame, to: nil)
|
let tableViewFrameInWindow = superview.convert(self.autoCompleteViewController.tableView.frame, to: nil)
|
||||||
// let padding = tableViewFrameInWindow.maxY + self.composeToolbarView.frame.height + AutoCompleteViewController.chevronViewHeight - endFrame.minY
|
let padding = tableViewFrameInWindow.maxY + ComposeContentToolbarView.toolbarHeight + AutoCompleteViewController.chevronViewHeight - endFrame.minY
|
||||||
// return max(0, padding)
|
return max(0, padding)
|
||||||
// }()
|
}()
|
||||||
// self.autoCompleteViewController.tableView.contentInset.bottom = autoCompleteTableViewBottomInset
|
self.autoCompleteViewController.tableView.contentInset.bottom = autoCompleteTableViewBottomInset
|
||||||
// self.autoCompleteViewController.tableView.verticalScrollIndicatorInsets.bottom = autoCompleteTableViewBottomInset
|
self.autoCompleteViewController.tableView.verticalScrollIndicatorInsets.bottom = autoCompleteTableViewBottomInset
|
||||||
|
|
||||||
// adjust inset for tableView
|
// adjust inset for tableView
|
||||||
let contentFrame = self.view.convert(self.tableView.frame, to: nil)
|
let contentFrame = self.view.convert(self.tableView.frame, to: nil)
|
||||||
@ -218,14 +241,63 @@ extension ComposeContentViewController {
|
|||||||
}
|
}
|
||||||
.store(in: &disposeBag)
|
.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
|
// bind toolbar
|
||||||
bindToolbarViewModel()
|
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() {
|
public override func viewDidLayoutSubviews() {
|
||||||
super.viewDidLayoutSubviews()
|
super.viewDidLayoutSubviews()
|
||||||
|
|
||||||
viewModel.viewLayoutFrame.update(view: view)
|
viewModel.viewLayoutFrame.update(view: view)
|
||||||
|
updateAutoCompleteViewControllerLayout()
|
||||||
}
|
}
|
||||||
|
|
||||||
public override func viewSafeAreaInsetsDidChange() {
|
public override func viewSafeAreaInsetsDidChange() {
|
||||||
@ -257,6 +329,8 @@ extension ComposeContentViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func bindToolbarViewModel() {
|
private func bindToolbarViewModel() {
|
||||||
|
viewModel.$isAttachmentButtonEnabled.assign(to: &composeContentToolbarViewModel.$isAttachmentButtonEnabled)
|
||||||
|
viewModel.$isPollButtonEnabled.assign(to: &composeContentToolbarViewModel.$isPollButtonEnabled)
|
||||||
viewModel.$isPollActive.assign(to: &composeContentToolbarViewModel.$isPollActive)
|
viewModel.$isPollActive.assign(to: &composeContentToolbarViewModel.$isPollActive)
|
||||||
viewModel.$isEmojiActive.assign(to: &composeContentToolbarViewModel.$isEmojiActive)
|
viewModel.$isEmojiActive.assign(to: &composeContentToolbarViewModel.$isEmojiActive)
|
||||||
viewModel.$isContentWarningActive.assign(to: &composeContentToolbarViewModel.$isContentWarningActive)
|
viewModel.$isContentWarningActive.assign(to: &composeContentToolbarViewModel.$isContentWarningActive)
|
||||||
@ -264,6 +338,29 @@ extension ComposeContentViewController {
|
|||||||
viewModel.$contentWeightedLength.assign(to: &composeContentToolbarViewModel.$contentWeightedLength)
|
viewModel.$contentWeightedLength.assign(to: &composeContentToolbarViewModel.$contentWeightedLength)
|
||||||
viewModel.$contentWarningWeightedLength.assign(to: &composeContentToolbarViewModel.$contentWarningWeightedLength)
|
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
|
// MARK: - UIScrollViewDelegate
|
||||||
@ -325,16 +422,15 @@ extension ComposeContentViewController: PHPickerViewControllerDelegate {
|
|||||||
public func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
public func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||||
picker.dismiss(animated: true, completion: nil)
|
picker.dismiss(animated: true, completion: nil)
|
||||||
|
|
||||||
// TODO:
|
let attachmentViewModels: [AttachmentViewModel] = results.map { result in
|
||||||
// let attachmentServices: [MastodonAttachmentService] = results.map { result in
|
AttachmentViewModel(
|
||||||
// let service = MastodonAttachmentService(
|
api: viewModel.context.apiService,
|
||||||
// context: context,
|
authContext: viewModel.authContext,
|
||||||
// pickerResult: result,
|
input: .pickerResult(result),
|
||||||
// initialAuthenticationBox: viewModel.authenticationBox
|
delegate: viewModel
|
||||||
// )
|
)
|
||||||
// return service
|
}
|
||||||
// }
|
viewModel.attachmentViewModels += attachmentViewModels
|
||||||
// viewModel.attachmentServices = viewModel.attachmentServices + attachmentServices
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -345,12 +441,13 @@ extension ComposeContentViewController: UIImagePickerControllerDelegate & UINavi
|
|||||||
|
|
||||||
guard let image = info[.originalImage] as? UIImage else { return }
|
guard let image = info[.originalImage] as? UIImage else { return }
|
||||||
|
|
||||||
// let attachmentService = MastodonAttachmentService(
|
let attachmentViewModel = AttachmentViewModel(
|
||||||
// context: context,
|
api: viewModel.context.apiService,
|
||||||
// image: image,
|
authContext: viewModel.authContext,
|
||||||
// initialAuthenticationBox: viewModel.authenticationBox
|
input: .image(image),
|
||||||
// )
|
delegate: viewModel
|
||||||
// viewModel.attachmentServices = viewModel.attachmentServices + [attachmentService]
|
)
|
||||||
|
viewModel.attachmentViewModels += [attachmentViewModel]
|
||||||
}
|
}
|
||||||
|
|
||||||
public func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
public func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||||
@ -364,12 +461,13 @@ extension ComposeContentViewController: UIDocumentPickerDelegate {
|
|||||||
public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||||
guard let url = urls.first else { return }
|
guard let url = urls.first else { return }
|
||||||
|
|
||||||
// let attachmentService = MastodonAttachmentService(
|
let attachmentViewModel = AttachmentViewModel(
|
||||||
// context: context,
|
api: viewModel.context.apiService,
|
||||||
// documentURL: url,
|
authContext: viewModel.authContext,
|
||||||
// initialAuthenticationBox: viewModel.authenticationBox
|
input: .url(url),
|
||||||
// )
|
delegate: viewModel
|
||||||
// viewModel.attachmentServices = viewModel.attachmentServices + [attachmentService]
|
)
|
||||||
|
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 }
|
guard let replyTo = status.object(in: context.managedObjectContext) else { return }
|
||||||
cell.statusView.configure(status: replyTo)
|
cell.statusView.configure(status: replyTo)
|
||||||
}
|
}
|
||||||
case .hashtag(let hashtag):
|
case .hashtag:
|
||||||
break
|
break
|
||||||
case .mention(let user):
|
case .mention:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - UITableViewDataSource
|
||||||
extension ComposeContentViewModel: UITableViewDataSource {
|
extension ComposeContentViewModel: UITableViewDataSource {
|
||||||
public func numberOfSections(in tableView: UITableView) -> Int {
|
public func numberOfSections(in tableView: UITableView) -> Int {
|
||||||
return Section.allCases.count
|
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(
|
let content = MastodonContent(
|
||||||
content: textInput,
|
content: textInput,
|
||||||
emojis: [:] // TODO: emojiViewModel?.emojis.asDictionary ?? [:]
|
emojis: [:] // customEmojiViewModel?.emojis.value.asDictionary ?? [:]
|
||||||
)
|
)
|
||||||
let metaContent = MastodonMetaContent.convert(text: content)
|
let metaContent = MastodonMetaContent.convert(text: content)
|
||||||
return metaContent
|
return metaContent
|
||||||
@ -48,7 +48,7 @@ extension ComposeContentViewModel: MetaTextDelegate {
|
|||||||
|
|
||||||
let content = MastodonContent(
|
let content = MastodonContent(
|
||||||
content: textInput,
|
content: textInput,
|
||||||
emojis: [:] // emojiViewModel?.emojis.asDictionary ?? [:]
|
emojis: [:] // customEmojiViewModel?.emojis.value.asDictionary ?? [:]
|
||||||
)
|
)
|
||||||
let metaContent = MastodonMetaContent.convert(text: content)
|
let metaContent = MastodonMetaContent.convert(text: content)
|
||||||
return metaContent
|
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 MastodonMeta
|
||||||
import MastodonCore
|
import MastodonCore
|
||||||
import MastodonSDK
|
import MastodonSDK
|
||||||
|
import MastodonLocalization
|
||||||
|
|
||||||
|
public protocol ComposeContentViewModelDelegate: AnyObject {
|
||||||
|
func composeContentViewModel(_ viewModel: ComposeContentViewModel, handleAutoComplete info: ComposeContentViewModel.AutoCompleteInfo) -> Bool
|
||||||
|
}
|
||||||
|
|
||||||
public final class ComposeContentViewModel: NSObject, ObservableObject {
|
public final class ComposeContentViewModel: NSObject, ObservableObject {
|
||||||
|
|
||||||
@ -28,12 +33,20 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
|
|||||||
// input
|
// input
|
||||||
let context: AppContext
|
let context: AppContext
|
||||||
let kind: Kind
|
let kind: Kind
|
||||||
|
weak var delegate: ComposeContentViewModelDelegate?
|
||||||
|
|
||||||
@Published var viewLayoutFrame = ViewLayoutFrame()
|
@Published var viewLayoutFrame = ViewLayoutFrame()
|
||||||
|
|
||||||
// author (me)
|
// author (me)
|
||||||
@Published var authContext: AuthContext
|
@Published var authContext: AuthContext
|
||||||
|
|
||||||
|
// auto-complete info
|
||||||
|
@Published var autoCompleteRetryLayoutTimes = 0
|
||||||
|
@Published var autoCompleteInfo: AutoCompleteInfo? = nil
|
||||||
|
|
||||||
|
// emoji
|
||||||
|
var customEmojiPickerDiffableDataSource: UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem>?
|
||||||
|
|
||||||
// output
|
// output
|
||||||
|
|
||||||
// limit
|
// limit
|
||||||
@ -42,10 +55,12 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
|
|||||||
// content
|
// content
|
||||||
public weak var contentMetaText: MetaText? {
|
public weak var contentMetaText: MetaText? {
|
||||||
didSet {
|
didSet {
|
||||||
// guard let textView = contentMetaText?.textView else { return }
|
guard let textView = contentMetaText?.textView else { return }
|
||||||
// customEmojiPickerInputViewModel.configure(textInput: textView)
|
customEmojiPickerInputViewModel.configure(textInput: textView)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// for hashtag: "#<hashtag> "
|
||||||
|
// for mention: "@<mention> "
|
||||||
@Published public var initialContent = ""
|
@Published public var initialContent = ""
|
||||||
@Published public var content = ""
|
@Published public var content = ""
|
||||||
@Published public var contentWeightedLength = 0
|
@Published public var contentWeightedLength = 0
|
||||||
@ -56,8 +71,8 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
|
|||||||
// content warning
|
// content warning
|
||||||
weak var contentWarningMetaText: MetaText? {
|
weak var contentWarningMetaText: MetaText? {
|
||||||
didSet {
|
didSet {
|
||||||
//guard let textView = contentWarningMetaText?.textView else { return }
|
guard let textView = contentWarningMetaText?.textView else { return }
|
||||||
//customEmojiPickerInputViewModel.configure(textInput: textView)
|
customEmojiPickerInputViewModel.configure(textInput: textView)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@Published public var isContentWarningActive = false
|
@Published public var isContentWarningActive = false
|
||||||
@ -91,6 +106,9 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
|
|||||||
|
|
||||||
// emoji
|
// emoji
|
||||||
@Published var isEmojiActive = false
|
@Published var isEmojiActive = false
|
||||||
|
let customEmojiViewModel: EmojiService.CustomEmojiViewModel?
|
||||||
|
let customEmojiPickerInputViewModel = CustomEmojiPickerInputViewModel()
|
||||||
|
@Published var isLoadingCustomEmoji = false
|
||||||
|
|
||||||
// visibility
|
// visibility
|
||||||
@Published var visibility: Mastodon.Entity.Status.Visibility
|
@Published var visibility: Mastodon.Entity.Status.Visibility
|
||||||
@ -98,8 +116,16 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
|
|||||||
// UI & UX
|
// UI & UX
|
||||||
@Published var replyToCellFrame: CGRect = .zero
|
@Published var replyToCellFrame: CGRect = .zero
|
||||||
@Published var contentCellFrame: CGRect = .zero
|
@Published var contentCellFrame: CGRect = .zero
|
||||||
|
@Published var contentTextViewFrame: CGRect = .zero
|
||||||
@Published var scrollViewState: ScrollViewState = .fold
|
@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(
|
public init(
|
||||||
context: AppContext,
|
context: AppContext,
|
||||||
@ -144,9 +170,76 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
return visibility
|
return visibility
|
||||||
}()
|
}()
|
||||||
|
self.customEmojiViewModel = context.emojiService.dequeueCustomEmojiViewModel(
|
||||||
|
for: authContext.mastodonAuthenticationBox.domain
|
||||||
|
)
|
||||||
super.init()
|
super.init()
|
||||||
// end 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
|
// bind author
|
||||||
$authContext
|
$authContext
|
||||||
.sink { [weak self] authContext in
|
.sink { [weak self] authContext in
|
||||||
@ -177,12 +270,138 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
|
|||||||
)
|
)
|
||||||
.map { $0 + $1 <= $2 }
|
.map { $0 + $1 <= $2 }
|
||||||
.assign(to: &$isContentValid)
|
.assign(to: &$isContentValid)
|
||||||
}
|
|
||||||
|
// bind attachment
|
||||||
deinit {
|
$attachmentViewModels
|
||||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
.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 {
|
extension ComposeContentViewModel {
|
||||||
@ -192,13 +411,30 @@ extension ComposeContentViewModel {
|
|||||||
case mention(user: ManagedObjectRecord<MastodonUser>)
|
case mention(user: ManagedObjectRecord<MastodonUser>)
|
||||||
case reply(status: ManagedObjectRecord<Status>)
|
case reply(status: ManagedObjectRecord<Status>)
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum ScrollViewState {
|
public enum ScrollViewState {
|
||||||
case fold // snap to input
|
case fold // snap to input
|
||||||
case expand // snap to reply
|
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 {
|
extension ComposeContentViewModel {
|
||||||
func createNewPollOptionIfCould() {
|
func createNewPollOptionIfCould() {
|
||||||
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
|
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()
|
} // end func publisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - UITextViewDelegate
|
extension ComposeContentViewModel {
|
||||||
extension ComposeContentViewModel: UITextViewDelegate {
|
|
||||||
public func textViewDidBeginEditing(_ textView: UITextView) {
|
|
||||||
switch textView {
|
|
||||||
case contentMetaText?.textView:
|
|
||||||
isContentEditing = true
|
|
||||||
case contentWarningMetaText?.textView:
|
|
||||||
isContentWarningEditing = true
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func textViewDidEndEditing(_ textView: UITextView) {
|
public enum AttachmentPrecondition: Error, LocalizedError {
|
||||||
switch textView {
|
case videoAttachWithPhoto
|
||||||
case contentMetaText?.textView:
|
case moreThanOneVideo
|
||||||
isContentEditing = false
|
|
||||||
case contentWarningMetaText?.textView:
|
public var errorDescription: String? {
|
||||||
isContentWarningEditing = false
|
return L10n.Common.Alerts.PublishPostFailure.title
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
public var failureReason: String? {
|
||||||
public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
|
switch self {
|
||||||
switch textView {
|
case .videoAttachWithPhoto:
|
||||||
case contentMetaText?.textView:
|
return L10n.Common.Alerts.PublishPostFailure.AttachmentsMessage.videoAttachWithPhoto
|
||||||
return true
|
case .moreThanOneVideo:
|
||||||
case contentWarningMetaText?.textView:
|
return L10n.Common.Alerts.PublishPostFailure.AttachmentsMessage.moreThanOneVideo
|
||||||
let isReturn = text == "\n"
|
}
|
||||||
if isReturn {
|
}
|
||||||
setContentTextViewFirstResponderIfNeeds()
|
}
|
||||||
|
|
||||||
|
// 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
|
// 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 Combine
|
||||||
import MetaTextKit
|
import MetaTextKit
|
||||||
import MastodonCore
|
import MastodonCore
|
||||||
import MastodonUI
|
|
||||||
|
|
||||||
final class CustomEmojiPickerInputViewModel {
|
final class CustomEmojiPickerInputViewModel {
|
||||||
|
|
||||||
@ -20,8 +19,7 @@ final class CustomEmojiPickerInputViewModel {
|
|||||||
// input
|
// input
|
||||||
weak var customEmojiPickerInputView: CustomEmojiPickerInputView?
|
weak var customEmojiPickerInputView: CustomEmojiPickerInputView?
|
||||||
|
|
||||||
// output
|
@Published var isCustomEmojiComposing = false
|
||||||
let isCustomEmojiComposing = CurrentValueSubject<Bool, Never>(false)
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,27 +49,28 @@ extension CustomEmojiPickerInputViewModel {
|
|||||||
for reference in customEmojiReplaceableTextInputReferences {
|
for reference in customEmojiReplaceableTextInputReferences {
|
||||||
guard let textInput = reference.value else { continue }
|
guard let textInput = reference.value else { continue }
|
||||||
guard textInput.isFirstResponder == true 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)
|
textInput.insertText(text)
|
||||||
|
|
||||||
|
// FIXME: inline emoji
|
||||||
// due to insert text render as attachment
|
// due to insert text render as attachment
|
||||||
// the cursor reset logic not works
|
// the cursor reset logic not works
|
||||||
// hack with hard code +2 offset
|
// hack with hard code +2 offset
|
||||||
assert(text.hasSuffix(": "))
|
// assert(text.hasSuffix(": "))
|
||||||
guard text.hasPrefix(":") && text.hasSuffix(": ") else { continue }
|
// guard text.hasPrefix(":") && text.hasSuffix(": ") else { continue }
|
||||||
|
//
|
||||||
if let _ = textInput as? MetaTextView {
|
// if let _ = textInput as? MetaTextView {
|
||||||
if let newPosition = textInput.position(from: selectedTextRange.start, offset: 2) {
|
// if let newPosition = textInput.position(from: selectedTextRange.start, offset: 2) {
|
||||||
let newSelectedTextRange = textInput.textRange(from: newPosition, to: newPosition)
|
// let newSelectedTextRange = textInput.textRange(from: newPosition, to: newPosition)
|
||||||
textInput.selectedTextRange = newSelectedTextRange
|
// textInput.selectedTextRange = newSelectedTextRange
|
||||||
}
|
// }
|
||||||
} else {
|
// } else {
|
||||||
if let newPosition = textInput.position(from: selectedTextRange.start, offset: text.length) {
|
// if let newPosition = textInput.position(from: selectedTextRange.start, offset: text.length) {
|
||||||
let newSelectedTextRange = textInput.textRange(from: newPosition, to: newPosition)
|
// let newSelectedTextRange = textInput.textRange(from: newPosition, to: newPosition)
|
||||||
textInput.selectedTextRange = newSelectedTextRange
|
// textInput.selectedTextRange = newSelectedTextRange
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
return reference
|
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.text = text
|
||||||
textField.placeholder = {
|
textField.placeholder = {
|
||||||
if index >= 0 {
|
if index >= 0 {
|
||||||
return L10n.Scene.Compose.Poll.optionNumber(index)
|
return L10n.Scene.Compose.Poll.optionNumber(index + 1)
|
||||||
} else {
|
} else {
|
||||||
assertionFailure()
|
assertionFailure()
|
||||||
return ""
|
return ""
|
||||||
|
@ -119,13 +119,31 @@ extension MastodonStatusPublisher: StatusPublisher {
|
|||||||
progress.addChild(attachmentViewModel.progress, withPendingUnitCount: publishAttachmentTaskWeight)
|
progress.addChild(attachmentViewModel.progress, withPendingUnitCount: publishAttachmentTaskWeight)
|
||||||
// upload media
|
// upload media
|
||||||
do {
|
do {
|
||||||
let result = try await attachmentViewModel.upload(context: uploadContext)
|
guard let attachment = attachmentViewModel.uploadResult else {
|
||||||
guard case let .mastodon(response) = result else {
|
// precondition: all media uploaded
|
||||||
assertionFailure()
|
throw AppError.badRequest
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
let attachmentID = response.value.id
|
attachmentIDs.append(attachment.id)
|
||||||
attachmentIDs.append(attachmentID)
|
|
||||||
|
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 {
|
} catch {
|
||||||
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): upload attachment fail: \(error.localizedDescription)")
|
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): upload attachment fail: \(error.localizedDescription)")
|
||||||
_state = .failure(error)
|
_state = .failure(error)
|
||||||
|
@ -7,74 +7,12 @@
|
|||||||
|
|
||||||
import os.log
|
import os.log
|
||||||
import UIKit
|
import UIKit
|
||||||
import Combine
|
|
||||||
import MetaTextKit
|
|
||||||
import UITextView_Placeholder
|
|
||||||
import MastodonAsset
|
|
||||||
import MastodonLocalization
|
|
||||||
import UIHostingConfigurationBackport
|
import UIHostingConfigurationBackport
|
||||||
|
|
||||||
//protocol ComposeStatusContentTableViewCellDelegate: AnyObject {
|
|
||||||
// func composeStatusContentTableViewCell(_ cell: ComposeStatusContentTableViewCell, textViewShouldBeginEditing textView: UITextView) -> Bool
|
|
||||||
//}
|
|
||||||
|
|
||||||
final class ComposeContentTableViewCell: UITableViewCell {
|
final class ComposeContentTableViewCell: UITableViewCell {
|
||||||
|
|
||||||
let logger = Logger(subsystem: "ComposeContentTableViewCell", category: "View")
|
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?) {
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
_init()
|
_init()
|
||||||
@ -93,79 +31,6 @@ extension ComposeContentTableViewCell {
|
|||||||
selectionStyle = .none
|
selectionStyle = .none
|
||||||
layer.zPosition = 999
|
layer.zPosition = 999
|
||||||
backgroundColor = .clear
|
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 isEmojiActive = false
|
||||||
@Published var isContentWarningActive = false
|
@Published var isContentWarningActive = false
|
||||||
|
|
||||||
|
@Published var isAttachmentButtonEnabled = false
|
||||||
|
@Published var isPollButtonEnabled = false
|
||||||
|
|
||||||
@Published public var maxTextInputLimit = 500
|
@Published public var maxTextInputLimit = 500
|
||||||
@Published public var contentWeightedLength = 0
|
@Published public var contentWeightedLength = 0
|
||||||
@Published public var contentWarningWeightedLength = 0
|
@Published public var contentWarningWeightedLength = 0
|
||||||
@ -120,4 +123,19 @@ extension ComposeContentToolbarView.ViewModel {
|
|||||||
return action.inactiveImage
|
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: {
|
||||||
label(for: action)
|
label(for: action)
|
||||||
|
.opacity(viewModel.isAttachmentButtonEnabled ? 1.0 : 0.5)
|
||||||
}
|
}
|
||||||
|
.disabled(!viewModel.isAttachmentButtonEnabled)
|
||||||
.frame(width: 48, height: 48)
|
.frame(width: 48, height: 48)
|
||||||
case .visibility:
|
case .visibility:
|
||||||
Menu {
|
Menu {
|
||||||
@ -61,8 +63,19 @@ struct ComposeContentToolbarView: View {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
label(for: viewModel.visibility.image)
|
label(for: viewModel.visibility.image)
|
||||||
|
.accessibilityLabel(L10n.Scene.Compose.Keyboard.selectVisibilityEntry(viewModel.visibility.title))
|
||||||
}
|
}
|
||||||
.frame(width: 48, height: 48)
|
.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:
|
default:
|
||||||
Button {
|
Button {
|
||||||
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(String(describing: action))")
|
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)")
|
Text("\(remains)")
|
||||||
.foregroundColor(Color(isOverflow ? UIColor.systemRed : UIColor.secondaryLabel))
|
.foregroundColor(Color(isOverflow ? UIColor.systemRed : UIColor.secondaryLabel))
|
||||||
.font(.system(size: isOverflow ? 18 : 16, weight: isOverflow ? .medium : .regular))
|
.font(.system(size: isOverflow ? 18 : 16, weight: isOverflow ? .medium : .regular))
|
||||||
|
.accessibilityLabel(L10n.A11y.Plural.Count.charactersLeft(remains))
|
||||||
}
|
}
|
||||||
.padding(.leading, 4) // 4 + 12 = 16
|
.padding(.leading, 4) // 4 + 12 = 16
|
||||||
.padding(.trailing, 16)
|
.padding(.trailing, 16)
|
||||||
.frame(height: ComposeContentToolbarView.toolbarHeight)
|
.frame(height: ComposeContentToolbarView.toolbarHeight)
|
||||||
.background(Color(viewModel.backgroundColor))
|
.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))
|
Image(uiImage: viewModel.image(for: action))
|
||||||
.foregroundColor(Color(Asset.Scene.Compose.buttonTint.color))
|
.foregroundColor(Color(Asset.Scene.Compose.buttonTint.color))
|
||||||
.frame(width: 24, height: 24, alignment: .center)
|
.frame(width: 24, height: 24, alignment: .center)
|
||||||
|
.accessibilityLabel(viewModel.label(for: action))
|
||||||
}
|
}
|
||||||
|
|
||||||
func label(for image: UIImage) -> some View {
|
func label(for image: UIImage) -> some View {
|
@ -11,15 +11,18 @@ import MastodonAsset
|
|||||||
import MastodonCore
|
import MastodonCore
|
||||||
import MastodonLocalization
|
import MastodonLocalization
|
||||||
import Stripes
|
import Stripes
|
||||||
|
import Kingfisher
|
||||||
|
|
||||||
public struct ComposeContentView: View {
|
public struct ComposeContentView: View {
|
||||||
|
|
||||||
static let logger = Logger(subsystem: "ComposeContentView", category: "View")
|
static let logger = Logger(subsystem: "ComposeContentView", category: "View")
|
||||||
var logger: Logger { ComposeContentView.logger }
|
var logger: Logger { ComposeContentView.logger }
|
||||||
|
|
||||||
|
static let contentViewCoordinateSpace = "ComposeContentView.Content"
|
||||||
static var margin: CGFloat = 16
|
static var margin: CGFloat = 16
|
||||||
|
|
||||||
@ObservedObject var viewModel: ComposeContentViewModel
|
@ObservedObject var viewModel: ComposeContentViewModel
|
||||||
|
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
VStack(spacing: .zero) {
|
VStack(spacing: .zero) {
|
||||||
@ -105,9 +108,25 @@ public struct ComposeContentView: View {
|
|||||||
.frame(minHeight: 100)
|
.frame(minHeight: 100)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.padding(.horizontal, ComposeContentView.margin)
|
.padding(.horizontal, ComposeContentView.margin)
|
||||||
|
.background(
|
||||||
|
GeometryReader { proxy in
|
||||||
|
Color.clear.preference(key: ViewFramePreferenceKey.self, value: proxy.frame(in: .named(ComposeContentView.contentViewCoordinateSpace)))
|
||||||
|
}
|
||||||
|
.onPreferenceChange(ViewFramePreferenceKey.self) { frame in
|
||||||
|
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): content textView frame: \(frame.debugDescription)")
|
||||||
|
let rect = frame.standardized
|
||||||
|
viewModel.contentTextViewFrame = CGRect(
|
||||||
|
origin: frame.origin,
|
||||||
|
size: CGSize(width: floor(rect.width), height: floor(rect.height))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
// poll
|
// poll
|
||||||
pollView
|
pollView
|
||||||
.padding(.horizontal, ComposeContentView.margin)
|
.padding(.horizontal, ComposeContentView.margin)
|
||||||
|
// media
|
||||||
|
mediaView
|
||||||
|
.padding(.horizontal, ComposeContentView.margin)
|
||||||
}
|
}
|
||||||
.background(
|
.background(
|
||||||
GeometryReader { proxy in
|
GeometryReader { proxy in
|
||||||
@ -124,6 +143,7 @@ public struct ComposeContentView: View {
|
|||||||
)
|
)
|
||||||
Spacer()
|
Spacer()
|
||||||
} // end VStack
|
} // end VStack
|
||||||
|
.coordinateSpace(name: ComposeContentView.contentViewCoordinateSpace)
|
||||||
} // end body
|
} // end body
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -147,6 +167,8 @@ extension ComposeContentView {
|
|||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel(L10n.Scene.Compose.Accessibility.postingAs([viewModel.name.string, viewModel.username].joined(separator: ", ")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -165,7 +187,7 @@ extension ComposeContentView {
|
|||||||
index: _index,
|
index: _index,
|
||||||
deleteBackwardResponseTextFieldRelayDelegate: viewModel
|
deleteBackwardResponseTextFieldRelayDelegate: viewModel
|
||||||
) { textField in
|
) { textField in
|
||||||
// viewModel.customEmojiPickerInputViewModel.configure(textInput: textField)
|
viewModel.customEmojiPickerInputViewModel.configure(textInput: textField)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if viewModel.maxPollOptionLimit != viewModel.pollOptions.count {
|
if viewModel.maxPollOptionLimit != viewModel.pollOptions.count {
|
||||||
@ -194,6 +216,24 @@ extension ComposeContentView {
|
|||||||
}
|
}
|
||||||
} // end VStack
|
} // end VStack
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - media
|
||||||
|
var mediaView: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ForEach(viewModel.attachmentViewModels, id: \.self) { attachmentViewModel in
|
||||||
|
AttachmentView(viewModel: attachmentViewModel)
|
||||||
|
.clipShape(Rectangle())
|
||||||
|
.badgeView(
|
||||||
|
Button {
|
||||||
|
viewModel.attachmentViewModels.removeAll(where: { $0 === attachmentViewModel })
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "minus.circle.fill")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} // end ForEach
|
||||||
|
} // end VStack
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//private struct ScrollOffsetPreferenceKey: PreferenceKey {
|
//private struct ScrollOffsetPreferenceKey: PreferenceKey {
|
||||||
|
29
MastodonSDK/Sources/MastodonUI/Vendor/CircleProgressView.swift
vendored
Normal file
29
MastodonSDK/Sources/MastodonUI/Vendor/CircleProgressView.swift
vendored
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
//
|
||||||
|
// CircleProgressView.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by MainasuK on 2022/11/10.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// https://stackoverflow.com/a/71467536/3797903
|
||||||
|
struct CircleProgressView: View {
|
||||||
|
|
||||||
|
let progress: Double
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let lineWidth: CGFloat = 4
|
||||||
|
let tintColor = Color.white
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0.0, to: CGFloat(progress))
|
||||||
|
.stroke(style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt, lineJoin: .bevel))
|
||||||
|
.foregroundColor(tintColor)
|
||||||
|
.rotationEffect(Angle(degrees: 270.0))
|
||||||
|
}
|
||||||
|
.padding(ceil(lineWidth / 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
29
MastodonSDK/Sources/MastodonUI/Vendor/MetaTextView+PasteExtensions.swift
vendored
Normal file
29
MastodonSDK/Sources/MastodonUI/Vendor/MetaTextView+PasteExtensions.swift
vendored
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
//
|
||||||
|
// MetaTextView+PasteExtensions.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by Rick Kerkhof on 30/10/2022.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import MetaTextKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension MetaTextView {
|
||||||
|
public override func paste(_ sender: Any?) {
|
||||||
|
super.paste(sender)
|
||||||
|
|
||||||
|
var nextResponder = self.next;
|
||||||
|
|
||||||
|
// Force the event to bubble through ALL responders
|
||||||
|
// This is a workaround as somewhere down the chain the paste event gets eaten
|
||||||
|
while (nextResponder != nil) {
|
||||||
|
if let nextResponder = nextResponder {
|
||||||
|
if (nextResponder.responds(to: #selector(UIResponderStandardEditActions.paste(_:)))) {
|
||||||
|
nextResponder.perform(#selector(UIResponderStandardEditActions.paste(_:)), with: sender)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextResponder = nextResponder?.next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
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