Merge remote-tracking branch 'upstream/develop' into account-switcher-a11y

This commit is contained in:
Jed Fox 2022-11-14 08:45:36 -05:00
commit 97b6a3de4c
No known key found for this signature in database
GPG Key ID: 0B61D18EA54B47E1
122 changed files with 3015 additions and 5279 deletions

View File

@ -14,7 +14,7 @@ We use `xcodebuild` CLI tool to trigger UITest.
Set the `name` in `-destination` option to add device for snapshot. For example:
`-destination 'platform=iOS Simulator,name=iPad Pro (12.9-inch) (5th generation)' \`
You can list the avaiable simulator:
You can list the available simulators:
```zsh
# list the destinations
xcodebuild \

View File

@ -68,6 +68,28 @@
<string>%ld characters</string>
</dict>
</dict>
<key>a11y.plural.count.characters_left</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@character_count@ left</string>
<key>character_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>zero</key>
<string>no characters</string>
<key>one</key>
<string>1 character</string>
<key>few</key>
<string>%ld characters</string>
<key>many</key>
<string>%ld characters</string>
<key>other</key>
<string>%ld characters</string>
</dict>
</dict>
<key>plural.count.followed_by_and_mutual</key>
<dict>
<key>NSStringLocalizedFormatKey</key>

View File

@ -50,6 +50,28 @@
<string>%ld characters</string>
</dict>
</dict>
<key>a11y.plural.count.characters_left</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@character_count@ left</string>
<key>character_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>zero</key>
<string>no characters</string>
<key>one</key>
<string>1 character</string>
<key>few</key>
<string>%ld characters</string>
<key>many</key>
<string>%ld characters</string>
<key>other</key>
<string>%ld characters</string>
</dict>
</dict>
<key>plural.count.followed_by_and_mutual</key>
<dict>
<key>NSStringLocalizedFormatKey</key>

View File

@ -136,6 +136,12 @@
"vote": "Vote",
"closed": "Closed"
},
"meta_entity": {
"url": "Link: %s",
"hashtag": "Hastag %s",
"mention": "Show Profile: %s",
"email": "Email address: %s"
},
"actions": {
"reply": "Reply",
"reblog": "Reblog",
@ -407,7 +413,9 @@
"custom_emoji_picker": "Custom Emoji Picker",
"enable_content_warning": "Enable Content Warning",
"disable_content_warning": "Disable Content Warning",
"post_visibility_menu": "Post Visibility Menu"
"post_visibility_menu": "Post Visibility Menu",
"post_options": "Post Options",
"posting_as": "Posting as %s"
},
"keyboard": {
"discard_post": "Discard Post",

View File

@ -136,6 +136,12 @@
"vote": "Vote",
"closed": "Closed"
},
"meta_entity": {
"url": "Link: %s",
"hashtag": "Hashtag: %s",
"mention": "Show Profile: %s",
"email": "Email address: %s"
},
"actions": {
"reply": "Reply",
"reblog": "Reblog",
@ -376,7 +382,11 @@
"video": "video",
"attachment_broken": "This %s is broken and cant be\nuploaded to Mastodon.",
"description_photo": "Describe the photo for the visually-impaired...",
"description_video": "Describe the video for the visually-impaired..."
"description_video": "Describe the video for the visually-impaired...",
"load_failed": "Load Failed",
"upload_failed": "Upload Failed",
"can_not_recognize_this_media_attachment": "Can not regonize this media attachment",
"attachment_too_large": "Attachment too large"
},
"poll": {
"duration_time": "Duration: %s",
@ -407,7 +417,9 @@
"custom_emoji_picker": "Custom Emoji Picker",
"enable_content_warning": "Enable Content Warning",
"disable_content_warning": "Disable Content Warning",
"post_visibility_menu": "Post Visibility Menu"
"post_visibility_menu": "Post Visibility Menu",
"post_options": "Post Options",
"posting_as": "Posting as %s"
},
"keyboard": {
"discard_post": "Discard Post",

View File

@ -151,7 +151,6 @@
DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */; };
DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */; };
DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */; };
DB221B16260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB221B15260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift */; };
DB22C92228E700A10082A9E9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = DB22C92128E700A10082A9E9 /* MastodonSDK */; };
DB22C92428E700A80082A9E9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = DB22C92328E700A80082A9E9 /* MastodonSDK */; };
DB22C92628E700AF0082A9E9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = DB22C92528E700AF0082A9E9 /* MastodonSDK */; };
@ -185,9 +184,6 @@
DB427DED25BAA00100D1B89D /* MastodonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DEC25BAA00100D1B89D /* MastodonTests.swift */; };
DB427DF825BAA00100D1B89D /* MastodonUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DF725BAA00100D1B89D /* MastodonUITests.swift */; };
DB443CD42694627B00159B29 /* AppearanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB443CD32694627B00159B29 /* AppearanceView.swift */; };
DB44767B260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB44767A260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift */; };
DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB447690260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift */; };
DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB447696260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift */; };
DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481B825EE289600BEFB67 /* UITableView.swift */; };
DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */; };
DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */; };
@ -257,7 +253,6 @@
DB64BA452851F23000ADF1B7 /* MastodonAuthentication+Fetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB64BA442851F23000ADF1B7 /* MastodonAuthentication+Fetch.swift */; };
DB64BA482851F29300ADF1B7 /* Account+Fetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB64BA472851F29300ADF1B7 /* Account+Fetch.swift */; };
DB65C63727A2AF6C008BAC2E /* ReportItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB65C63627A2AF6C008BAC2E /* ReportItem.swift */; };
DB66728C25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66728B25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift */; };
DB6746EB278ED8B0008A6B94 /* PollOptionView+Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6746EA278ED8B0008A6B94 /* PollOptionView+Configuration.swift */; };
DB6746ED278F45F0008A6B94 /* AutoGenerateProtocolRelayDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6746EC278F45F0008A6B94 /* AutoGenerateProtocolRelayDelegate.swift */; };
DB6746F0278F463B008A6B94 /* AutoGenerateProtocolDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6746EF278F463B008A6B94 /* AutoGenerateProtocolDelegate.swift */; };
@ -337,7 +332,6 @@
DB98EB6927B21A7C0082E365 /* ReportResultActionTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98EB6827B21A7C0082E365 /* ReportResultActionTableViewCell.swift */; };
DB98EB6B27B243470082E365 /* SettingsAppearanceTableViewCell+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98EB6A27B243470082E365 /* SettingsAppearanceTableViewCell+ViewModel.swift */; };
DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */; };
DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */; };
DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BE825E4F5340051B173 /* SearchViewController.swift */; };
DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */; };
DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */; };
@ -376,12 +370,11 @@
DBB8AB4626AECDE200F6D281 /* SendPostIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB8AB4526AECDE200F6D281 /* SendPostIntentHandler.swift */; };
DBB8AB4F26AED13F00F6D281 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB427DDE25BAA00100D1B89D /* Assets.xcassets */; };
DBB9759C262462E1004620BD /* ThreadMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB9759B262462E1004620BD /* ThreadMetaView.swift */; };
DBBC24DC26A54BCB00398BB9 /* MastodonRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */; };
DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; };
DBC6461526A170AB00B0E31B /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC6461426A170AB00B0E31B /* ComposeViewController.swift */; };
DBC3872429214121001EC0FD /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC3872329214121001EC0FD /* ShareViewController.swift */; };
DBC6461826A170AB00B0E31B /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DBC6461626A170AB00B0E31B /* MainInterface.storyboard */; };
DBC6461C26A170AB00B0E31B /* ShareActionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = DBC6461226A170AB00B0E31B /* ShareActionExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
DBC6462326A1712000B0E31B /* ComposeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC6462226A1712000B0E31B /* ComposeViewModel.swift */; };
DBC6462326A1712000B0E31B /* ShareViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC6462226A1712000B0E31B /* ShareViewModel.swift */; };
DBC6462826A1736300B0E31B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB427DDE25BAA00100D1B89D /* Assets.xcassets */; };
DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */; };
DBCA0EBC282BB38A0029E2B0 /* PageboyNavigateable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCA0EBB282BB38A0029E2B0 /* PageboyNavigateable.swift */; };
@ -681,7 +674,6 @@
DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonPickServerViewModel+Diffable.swift"; sourceTree = "<group>"; };
DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerItem.swift; sourceTree = "<group>"; };
DB1FD45F25F278AF004CFCFC /* CategoryPickerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerSection.swift; sourceTree = "<group>"; };
DB221B15260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerInputViewModel.swift; sourceTree = "<group>"; };
DB2B3ABD25E37E15007045F9 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = "<group>"; };
DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollExpiresOptionCollectionViewCell.swift; sourceTree = "<group>"; };
@ -718,9 +710,6 @@
DB427DF725BAA00100D1B89D /* MastodonUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonUITests.swift; sourceTree = "<group>"; };
DB427DF925BAA00100D1B89D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
DB443CD32694627B00159B29 /* AppearanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceView.swift; sourceTree = "<group>"; };
DB44767A260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerInputView.swift; sourceTree = "<group>"; };
DB447690260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerItemCollectionViewCell.swift; sourceTree = "<group>"; };
DB447696260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerHeaderCollectionReusableView.swift; sourceTree = "<group>"; };
DB4481B825EE289600BEFB67 /* UITableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UITableView.swift; sourceTree = "<group>"; };
DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIAlertController.swift; sourceTree = "<group>"; };
DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIBarButtonItem.swift; sourceTree = "<group>"; };
@ -820,7 +809,6 @@
DB64BA442851F23000ADF1B7 /* MastodonAuthentication+Fetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonAuthentication+Fetch.swift"; sourceTree = "<group>"; };
DB64BA472851F29300ADF1B7 /* Account+Fetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Account+Fetch.swift"; sourceTree = "<group>"; };
DB65C63627A2AF6C008BAC2E /* ReportItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportItem.swift; sourceTree = "<group>"; };
DB66728B25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+DataSource.swift"; sourceTree = "<group>"; };
DB6746EA278ED8B0008A6B94 /* PollOptionView+Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PollOptionView+Configuration.swift"; sourceTree = "<group>"; };
DB6746EC278F45F0008A6B94 /* AutoGenerateProtocolRelayDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoGenerateProtocolRelayDelegate.swift; sourceTree = "<group>"; };
DB6746EF278F463B008A6B94 /* AutoGenerateProtocolDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoGenerateProtocolDelegate.swift; sourceTree = "<group>"; };
@ -912,7 +900,6 @@
DB98EB6827B21A7C0082E365 /* ReportResultActionTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportResultActionTableViewCell.swift; sourceTree = "<group>"; };
DB98EB6A27B243470082E365 /* SettingsAppearanceTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsAppearanceTableViewCell+ViewModel.swift"; sourceTree = "<group>"; };
DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttachmentContainerView+EmptyStateView.swift"; sourceTree = "<group>"; };
DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+PublishState.swift"; sourceTree = "<group>"; };
DB9D6BE825E4F5340051B173 /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = "<group>"; };
DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = "<group>"; };
DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = "<group>"; };
@ -961,14 +948,12 @@
DBB525632612C988002F1F29 /* MeProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeProfileViewModel.swift; sourceTree = "<group>"; };
DBB8AB4526AECDE200F6D281 /* SendPostIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendPostIntentHandler.swift; sourceTree = "<group>"; };
DBB9759B262462E1004620BD /* ThreadMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadMetaView.swift; sourceTree = "<group>"; };
DBBC24A726A52F9000398BB9 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = "<group>"; };
DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonRegex.swift; sourceTree = "<group>"; };
DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = "<group>"; };
DBC3872329214121001EC0FD /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
DBC6461226A170AB00B0E31B /* ShareActionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareActionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
DBC6461426A170AB00B0E31B /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = "<group>"; };
DBC6461726A170AB00B0E31B /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = "<group>"; };
DBC6461926A170AB00B0E31B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
DBC6462226A1712000B0E31B /* ComposeViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = "<group>"; };
DBC6462226A1712000B0E31B /* ShareViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShareViewModel.swift; sourceTree = "<group>"; };
DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentWarningEditorView.swift; sourceTree = "<group>"; };
DBC9E3A3282E13BB0063A4D9 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = eu.lproj/Intents.strings; sourceTree = "<group>"; };
DBC9E3A4282E13BB0063A4D9 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = eu.lproj/InfoPlist.strings; sourceTree = "<group>"; };
@ -1042,15 +1027,7 @@
DBFEEC98279BDCDE004F81DD /* ProfileAboutViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileAboutViewModel.swift; sourceTree = "<group>"; };
DBFEEC9A279BDDD9004F81DD /* ProfileAboutViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileAboutViewModel+Diffable.swift"; sourceTree = "<group>"; };
DBFEEC9C279C12C1004F81DD /* ProfileFieldEditCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldEditCollectionViewCell.swift; sourceTree = "<group>"; };
DBFEF05526A576EE006D7ED1 /* StatusEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditorView.swift; sourceTree = "<group>"; };
DBFEF05626A576EE006D7ED1 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
DBFEF05726A576EE006D7ED1 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = "<group>"; };
DBFEF05826A576EE006D7ED1 /* ContentWarningEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningEditorView.swift; sourceTree = "<group>"; };
DBFEF05926A576EE006D7ED1 /* StatusAuthorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusAuthorView.swift; sourceTree = "<group>"; };
DBFEF05A26A576EE006D7ED1 /* StatusAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusAttachmentView.swift; sourceTree = "<group>"; };
DBFEF06226A577F2006D7ED1 /* StatusAttachmentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusAttachmentViewModel.swift; sourceTree = "<group>"; };
DBFEF06726A58D07006D7ED1 /* ShareActionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareActionExtension.entitlements; sourceTree = "<group>"; };
DBFEF06C26A67FB7006D7ED1 /* StatusAttachmentViewModel+UploadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusAttachmentViewModel+UploadState.swift"; sourceTree = "<group>"; };
DDB1B139FA8EA26F510D58B6 /* Pods-AppShared.asdk - release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AppShared.asdk - release.xcconfig"; path = "Target Support Files/Pods-AppShared/Pods-AppShared.asdk - release.xcconfig"; sourceTree = "<group>"; };
DF65937EC1FF64462BC002EE /* Pods-MastodonTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.profile.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.profile.xcconfig"; sourceTree = "<group>"; };
E5C7236E58D14A0322FE00F2 /* Pods-Mastodon-MastodonUITests.asdk - debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.asdk - debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.asdk - debug.xcconfig"; sourceTree = "<group>"; };
@ -1342,7 +1319,7 @@
path = Protocol;
sourceTree = "<group>";
};
2D76319C25C151DE00929FB9 /* Diffiable */ = {
2D76319C25C151DE00929FB9 /* Diffable */ = {
isa = PBXGroup;
children = (
DB4F097826A039B400D62E92 /* Onboarding */,
@ -1357,7 +1334,7 @@
DB3E6FE52806A5BA00B035AE /* Discovery */,
DB0617FA27855B660030EE79 /* Settings */,
);
path = Diffiable;
path = Diffable;
sourceTree = "<group>";
};
2D7631A425C1532200929FB9 /* Share */ = {
@ -1762,7 +1739,7 @@
children = (
DB89BA1025C10FF5008580ED /* Mastodon.entitlements */,
DB427DE325BAA00100D1B89D /* Info.plist */,
2D76319C25C151DE00929FB9 /* Diffiable */,
2D76319C25C151DE00929FB9 /* Diffable */,
DB8AF55525C1379F002E6C99 /* Scene */,
DB8AF54125C13647002E6C99 /* Coordinator */,
DB8AF56225C138BC002E6C99 /* Extension */,
@ -1878,8 +1855,6 @@
DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */,
DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */,
DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */,
DB44767A260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift */,
DB221B15260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift */,
DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */,
);
path = View;
@ -2146,8 +2121,6 @@
DB789A2125F9F76D0071ACA0 /* CollectionViewCell */,
DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */,
DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */,
DB66728B25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift */,
DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */,
);
path = Compose;
sourceTree = "<group>";
@ -2159,8 +2132,6 @@
DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */,
DB87D4502609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift */,
DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */,
DB447690260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift */,
DB447696260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift */,
);
path = CollectionViewCell;
sourceTree = "<group>";
@ -2520,7 +2491,6 @@
DBBC24D526A54BCB00398BB9 /* Helper */ = {
isa = PBXGroup;
children = (
DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */,
DBF3B7402733EB9400E21627 /* MastodonLocalCode.swift */,
);
path = Helper;
@ -2687,28 +2657,11 @@
path = Cell;
sourceTree = "<group>";
};
DBFEF05426A576EE006D7ED1 /* View */ = {
isa = PBXGroup;
children = (
DBBC24A726A52F9000398BB9 /* ComposeToolbarView.swift */,
DBFEF05526A576EE006D7ED1 /* StatusEditorView.swift */,
DBFEF05626A576EE006D7ED1 /* ComposeView.swift */,
DBFEF05726A576EE006D7ED1 /* ComposeViewModel.swift */,
DBFEF05826A576EE006D7ED1 /* ContentWarningEditorView.swift */,
DBFEF05926A576EE006D7ED1 /* StatusAuthorView.swift */,
DBFEF05A26A576EE006D7ED1 /* StatusAttachmentView.swift */,
DBFEF06226A577F2006D7ED1 /* StatusAttachmentViewModel.swift */,
DBFEF06C26A67FB7006D7ED1 /* StatusAttachmentViewModel+UploadState.swift */,
);
path = View;
sourceTree = "<group>";
};
DBFEF06126A57721006D7ED1 /* Scene */ = {
isa = PBXGroup;
children = (
DBFEF05426A576EE006D7ED1 /* View */,
DBC6462226A1712000B0E31B /* ComposeViewModel.swift */,
DBC6461426A170AB00B0E31B /* ComposeViewController.swift */,
DBC6462226A1712000B0E31B /* ShareViewModel.swift */,
DBC3872329214121001EC0FD /* ShareViewController.swift */,
);
path = Scene;
sourceTree = "<group>";
@ -3198,7 +3151,6 @@
0F1E2D0B2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift in Sources */,
DBDFF1902805543100557A48 /* DiscoveryPostsViewController.swift in Sources */,
DB697DD9278F4CED004EF2F7 /* HomeTimelineViewController+DataSourceProvider.swift in Sources */,
DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */,
DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */,
2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */,
DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */,
@ -3246,7 +3198,6 @@
DBFEEC9D279C12C1004F81DD /* ProfileFieldEditCollectionViewCell.swift in Sources */,
DB3E6FEC2806D7F100B035AE /* DiscoveryNewsViewController.swift in Sources */,
DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */,
DBBC24DC26A54BCB00398BB9 /* MastodonRegex.swift in Sources */,
DBCBED1726132DB500B49291 /* UserTimelineViewModel+Diffable.swift in Sources */,
2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */,
2DAC9E3E262FC2400062E1A6 /* SuggestionAccountViewModel.swift in Sources */,
@ -3301,7 +3252,6 @@
DB63F7492799126300455B82 /* FollowerListViewController+DataSourceProvider.swift in Sources */,
2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */,
2DAC9E38262FC2320062E1A6 /* SuggestionAccountViewController.swift in Sources */,
DB66728C25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift in Sources */,
DB6180E02639194B0018D199 /* MediaPreviewPagingViewController.swift in Sources */,
DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */,
5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */,
@ -3339,7 +3289,6 @@
DB98EB6227B215EB0082E365 /* ReportResultViewController.swift in Sources */,
DB6B74F4272FBAE700C70B6E /* FollowerListViewModel+Diffable.swift in Sources */,
DB6B74F2272FB67600C70B6E /* FollowerListViewModel.swift in Sources */,
DB44767B260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift in Sources */,
0F20222D261457EE000C64BF /* HashtagTimelineViewModel+State.swift in Sources */,
DB0009A626AEE5DC009B9D2D /* Intents.intentdefinition in Sources */,
5B90C462262599800002E742 /* SettingsSectionHeader.swift in Sources */,
@ -3353,7 +3302,6 @@
DBFEEC9B279BDDD9004F81DD /* ProfileAboutViewModel+Diffable.swift in Sources */,
DBB525562611EDCA002F1F29 /* UserTimelineViewModel.swift in Sources */,
DB0618012785732C0030EE79 /* ServerRulesTableViewCell.swift in Sources */,
DB221B16260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift in Sources */,
DB98EB5C27B10A730082E365 /* ReportSupplementaryViewModel.swift in Sources */,
DB0617EF277F12720030EE79 /* NavigationActionView.swift in Sources */,
DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */,
@ -3437,7 +3385,6 @@
2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */,
2D4AD89C263165B500613EFC /* SuggestionAccountCollectionViewCell.swift in Sources */,
DB98EB6927B21A7C0082E365 /* ReportResultActionTableViewCell.swift in Sources */,
DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */,
DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */,
DB697DDF278F524F004EF2F7 /* DataSourceFacade+Profile.swift in Sources */,
DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */,
@ -3461,7 +3408,6 @@
DB63F74D27993F5B00455B82 /* SearchHistoryUserCollectionViewCell.swift in Sources */,
DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */,
DB1D84382657B275000346B3 /* SegmentedControlNavigateable.swift in Sources */,
DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */,
0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */,
DB7A9F932818F33C0016AF98 /* MastodonServerRulesViewController+Debug.swift in Sources */,
2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */,
@ -3567,9 +3513,9 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
DBC6462326A1712000B0E31B /* ComposeViewModel.swift in Sources */,
DBC6462326A1712000B0E31B /* ShareViewModel.swift in Sources */,
DBB3BA2B26A81D060004F2D4 /* FLAnimatedImageView.swift in Sources */,
DBC6461526A170AB00B0E31B /* ComposeViewController.swift in Sources */,
DBC3872429214121001EC0FD /* ShareViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@ -117,7 +117,7 @@
<key>NotificationService.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>18</integer>
<integer>16</integer>
</dict>
<key>ShareActionExtension.xcscheme_^#shared#^_</key>
<dict>

View File

@ -90,6 +90,15 @@
"version" : "2.2.5"
}
},
{
"identity" : "nextlevelsessionexporter",
"kind" : "remoteSourceControl",
"location" : "https://github.com/NextLevel/NextLevelSessionExporter.git",
"state" : {
"revision" : "b6c0cce1aa37fe1547d694f958fac3c3524b74da",
"version" : "0.4.6"
}
},
{
"identity" : "nuke",
"kind" : "remoteSourceControl",

File diff suppressed because it is too large Load Diff

View File

@ -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)
// }
//}

View File

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

View File

@ -18,7 +18,7 @@ import MastodonLocalization
import MastodonMeta
import MastodonUI
final class ComposeViewModel: NSObject {
final class ComposeViewModel {
let logger = Logger(subsystem: "ComposeViewModel", category: "ViewModel")
@ -30,91 +30,13 @@ final class ComposeViewModel: NSObject {
let context: AppContext
let authContext: AuthContext
let kind: ComposeContentViewModel.Kind
// var authenticationBox: MastodonAuthenticationBox {
// authContext.mastodonAuthenticationBox
// }
//
// @Published var isPollComposing = false
// @Published var isCustomEmojiComposing = false
// @Published var isContentWarningComposing = false
//
// @Published var selectedStatusVisibility: ComposeToolbarView.VisibilitySelectionType
// @Published var repliedToCellFrame: CGRect = .zero
// @Published var autoCompleteRetryLayoutTimes = 0
// @Published var autoCompleteInfo: ComposeViewController.AutoCompleteInfo? = nil
let traitCollectionDidChangePublisher = CurrentValueSubject<Void, Never>(Void()) // use CurrentValueSubject to make initial event emit
// var isViewAppeared = false
// output
// let instanceConfiguration: Mastodon.Entity.Instance.Configuration?
// var composeContentLimit: Int {
// guard let maxCharacters = instanceConfiguration?.statuses?.maxCharacters else { return 500 }
// return max(1, maxCharacters)
// }
// var maxMediaAttachments: Int {
// guard let maxMediaAttachments = instanceConfiguration?.statuses?.maxMediaAttachments else {
// return 4
// }
// // FIXME: update timeline media preview UI
// return min(4, max(1, maxMediaAttachments))
// // return max(1, maxMediaAttachments)
// }
// var maxPollOptions: Int {
// guard let maxOptions = instanceConfiguration?.polls?.maxOptions else { return 4 }
// return max(2, maxOptions)
// }
//
// let composeStatusAttribute = ComposeStatusItem.ComposeStatusAttribute()
// let composeStatusContentTableViewCell = ComposeStatusContentTableViewCell()
// let composeStatusAttachmentTableViewCell = ComposeStatusAttachmentTableViewCell()
// let composeStatusPollTableViewCell = ComposeStatusPollTableViewCell()
//
// // var dataSource: UITableViewDiffableDataSource<ComposeStatusSection, ComposeStatusItem>?
// var customEmojiPickerDiffableDataSource: UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem>?
// private(set) lazy var publishStateMachine: GKStateMachine = {
// // exclude timeline middle fetcher state
// let stateMachine = GKStateMachine(states: [
// PublishState.Initial(viewModel: self),
// PublishState.Publishing(viewModel: self),
// PublishState.Fail(viewModel: self),
// PublishState.Discard(viewModel: self),
// PublishState.Finish(viewModel: self),
// ])
// stateMachine.enter(PublishState.Initial.self)
// return stateMachine
// }()
// private(set) lazy var publishStateMachinePublisher = CurrentValueSubject<PublishState?, Never>(nil)
// private(set) var publishDate = Date() // update it when enter Publishing state
//
// // TODO: group post material into Hashable class
// var idempotencyKey = CurrentValueSubject<String, Never>(UUID().uuidString)
//
// // UI & UX
// @Published var title: String
// @Published var shouldDismiss = true
// @Published var isPublishBarButtonItemEnabled = false
// @Published var isMediaToolbarButtonEnabled = true
// @Published var isPollToolbarButtonEnabled = true
// @Published var characterCount = 0
// @Published var collectionViewState: CollectionViewState = .fold
//
// // for hashtag: "#<hashtag> "
// // for mention: "@<mention> "
// var preInsertedContent: String?
//
// // custom emojis
// let customEmojiViewModel: EmojiService.CustomEmojiViewModel?
// let customEmojiPickerInputViewModel = CustomEmojiPickerInputViewModel()
// @Published var isLoadingCustomEmoji = false
//
// // attachment
// @Published var attachmentServices: [MastodonAttachmentService] = []
//
// // polls
// @Published var pollOptionAttributes: [ComposeStatusPollItem.PollOptionAttribute] = []
// let pollExpiresOptionAttribute = ComposeStatusPollItem.PollExpiresOptionAttribute()
// UI & UX
@Published var title: String
init(
context: AppContext,
@ -124,63 +46,14 @@ final class ComposeViewModel: NSObject {
self.context = context
self.authContext = authContext
self.kind = kind
// end init
// self.title = {
// switch composeKind {
// case .post, .hashtag, .mention: return L10n.Scene.Compose.Title.newPost
// case .reply: return L10n.Scene.Compose.Title.newReply
// }
// }()
// self.selectedStatusVisibility = {
// // default private when user locked
// var visibility: ComposeToolbarView.VisibilitySelectionType = {
// guard let author = authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user
// else {
// return .public
// }
// return author.locked ? .private : .public
// }()
// // set visibility for reply post
// switch composeKind {
// case .reply(let record):
// context.managedObjectContext.performAndWait {
// guard let status = record.object(in: context.managedObjectContext) else {
// assertionFailure()
// return
// }
// let repliedStatusVisibility = status.visibility
// switch repliedStatusVisibility {
// case .public, .unlisted:
// // keep default
// break
// case .private:
// visibility = .private
// case .direct:
// visibility = .direct
// case ._other:
// assertionFailure()
// break
// }
// }
// default:
// break
// }
// return visibility
// }()
// // set limit
// self.instanceConfiguration = {
// var configuration: Mastodon.Entity.Instance.Configuration? = nil
// context.managedObjectContext.performAndWait {
// guard let authentication = authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext) else { return }
// configuration = authentication.instance?.configuration
// }
// return configuration
// }()
// self.customEmojiViewModel = context.emojiService.dequeueCustomEmojiViewModel(for: authContext.mastodonAuthenticationBox.domain)
// super.init()
// // end init
//
// setup(cell: composeStatusContentTableViewCell)
self.title = {
switch kind {
case .post, .hashtag, .mention: return L10n.Scene.Compose.Title.newPost
case .reply: return L10n.Scene.Compose.Title.newReply
}
}()
}
deinit {
@ -188,194 +61,3 @@ final class ComposeViewModel: NSObject {
}
}
extension ComposeViewModel {
// func createNewPollOptionIfPossible() {
// guard pollOptionAttributes.count < maxPollOptions else { return }
//
// let attribute = ComposeStatusPollItem.PollOptionAttribute()
// pollOptionAttributes = pollOptionAttributes + [attribute]
// }
//
// func updatePublishDate() {
// publishDate = Date()
// }
}
//extension ComposeViewModel {
//
// enum AttachmentPrecondition: Error, LocalizedError {
// case videoAttachWithPhoto
// case moreThanOneVideo
//
// var errorDescription: String? {
// return L10n.Common.Alerts.PublishPostFailure.title
// }
//
// var failureReason: String? {
// switch self {
// case .videoAttachWithPhoto:
// return L10n.Common.Alerts.PublishPostFailure.AttachmentsMessage.videoAttachWithPhoto
// case .moreThanOneVideo:
// return L10n.Common.Alerts.PublishPostFailure.AttachmentsMessage.moreThanOneVideo
// }
// }
// }
//
// // check exclusive limit:
// // - up to 1 video
// // - up to N photos
// func checkAttachmentPrecondition() throws {
// let attachmentServices = self.attachmentServices
// guard !attachmentServices.isEmpty else { return }
// var photoAttachmentServices: [MastodonAttachmentService] = []
// var videoAttachmentServices: [MastodonAttachmentService] = []
// attachmentServices.forEach { service in
// guard let file = service.file.value else {
// assertionFailure()
// return
// }
// switch file {
// case .jpeg, .png, .gif:
// photoAttachmentServices.append(service)
// case .other:
// videoAttachmentServices.append(service)
// }
// }
//
// if !videoAttachmentServices.isEmpty {
// guard videoAttachmentServices.count == 1 else {
// throw AttachmentPrecondition.moreThanOneVideo
// }
// guard photoAttachmentServices.isEmpty else {
// throw AttachmentPrecondition.videoAttachWithPhoto
// }
// }
// }
//
//}
//
//// MARK: - MastodonAttachmentServiceDelegate
//extension ComposeViewModel: MastodonAttachmentServiceDelegate {
// func mastodonAttachmentService(_ service: MastodonAttachmentService, uploadStateDidChange state: MastodonAttachmentService.UploadState?) {
// // trigger new output event
// attachmentServices = attachmentServices
// }
//}
//
//// MARK: - ComposePollAttributeDelegate
//extension ComposeViewModel: ComposePollAttributeDelegate {
// func composePollAttribute(_ attribute: ComposeStatusPollItem.PollOptionAttribute, pollOptionDidChange: String?) {
// // trigger update
// pollOptionAttributes = pollOptionAttributes
// }
//}
//
//extension ComposeViewModel {
// private func setup(
// cell: ComposeStatusContentTableViewCell
// ) {
// setupStatusHeader(cell: cell)
// setupStatusAuthor(cell: cell)
// setupStatusContent(cell: cell)
// }
//
// private func setupStatusHeader(
// cell: ComposeStatusContentTableViewCell
// ) {
// // configure header
// let managedObjectContext = context.managedObjectContext
// managedObjectContext.performAndWait {
// guard case let .reply(record) = self.composeKind,
// let replyTo = record.object(in: managedObjectContext)
// else {
// cell.statusView.viewModel.header = .none
// return
// }
//
// let info: StatusView.ViewModel.Header.ReplyInfo
// do {
// let content = MastodonContent(
// content: replyTo.author.displayNameWithFallback,
// emojis: replyTo.author.emojis.asDictionary
// )
// let metaContent = try MastodonMetaContent.convert(document: content)
// info = .init(header: metaContent)
// } catch {
// let metaContent = PlaintextMetaContent(string: replyTo.author.displayNameWithFallback)
// info = .init(header: metaContent)
// }
// cell.statusView.viewModel.header = .reply(info: info)
// }
// }
//
// private func setupStatusAuthor(
// cell: ComposeStatusContentTableViewCell
// ) {
// self.context.managedObjectContext.performAndWait {
// guard let author = authenticationBox.authenticationRecord.object(in: self.context.managedObjectContext)?.user else { return }
// cell.statusView.configureAuthor(author: author)
// }
// }
//
// private func setupStatusContent(
// cell: ComposeStatusContentTableViewCell
// ) {
// switch composeKind {
// case .reply(let record):
// context.managedObjectContext.performAndWait {
// guard let status = record.object(in: context.managedObjectContext) else { return }
// let author = self.authenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user
//
// var mentionAccts: [String] = []
// if author?.id != status.author.id {
// mentionAccts.append("@" + status.author.acct)
// }
// let mentions = status.mentions
// .filter { author?.id != $0.id }
// for mention in mentions {
// let acct = "@" + mention.acct
// guard !mentionAccts.contains(acct) else { continue }
// mentionAccts.append(acct)
// }
// for acct in mentionAccts {
// UITextChecker.learnWord(acct)
// }
// if let spoilerText = status.spoilerText, !spoilerText.isEmpty {
// self.isContentWarningComposing = true
// self.composeStatusAttribute.contentWarningContent = spoilerText
// }
//
// let initialComposeContent = mentionAccts.joined(separator: " ")
// let preInsertedContent: String? = initialComposeContent.isEmpty ? nil : initialComposeContent + " "
// self.preInsertedContent = preInsertedContent
// self.composeStatusAttribute.composeContent = preInsertedContent
// }
// case .hashtag(let hashtag):
// let initialComposeContent = "#" + hashtag
// UITextChecker.learnWord(initialComposeContent)
// let preInsertedContent = initialComposeContent + " "
// self.preInsertedContent = preInsertedContent
// self.composeStatusAttribute.composeContent = preInsertedContent
// case .mention(let record):
// context.managedObjectContext.performAndWait {
// guard let user = record.object(in: context.managedObjectContext) else { return }
// let initialComposeContent = "@" + user.acct
// UITextChecker.learnWord(initialComposeContent)
// let preInsertedContent = initialComposeContent + " "
// self.preInsertedContent = preInsertedContent
// self.composeStatusAttribute.composeContent = preInsertedContent
// }
// case .post:
// self.preInsertedContent = nil
// }
//
// // configure content warning
// if let composeContent = composeStatusAttribute.composeContent {
// cell.metaText.textView.text = composeContent
// }
//
// // configure content warning
// cell.statusContentWarningEditorView.textView.text = composeStatusAttribute.contentWarningContent
// }
//}

View File

@ -7,7 +7,6 @@
import Foundation
import Combine
import Combine
import CoreData
import CoreDataStack
import GameplayKit

View File

@ -552,6 +552,9 @@ extension ProfileViewController {
userTimelineViewController.viewModel.stateMachine.enter(UserTimelineViewModel.State.Reloading.self)
}
// trigger authenticated user account update
viewModel.context.authenticationService.updateActiveUserAccountPublisher.send()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
sender.endRefreshing()
}

View File

@ -8,7 +8,6 @@
import UIKit
import SwiftUI
import MastodonSDK
import MastodonUI
import MastodonAsset
import MastodonCore
import MastodonUI

View File

@ -311,6 +311,12 @@ extension MainTabBarController {
let _profileTabItem = self.tabBar.items?.first { item in item.tag == Tab.me.tag }
guard let profileTabItem = _profileTabItem else { return }
profileTabItem.accessibilityHint = L10n.Scene.AccountList.tabBarHint(user.displayNameWithFallback)
context.authenticationService.updateActiveUserAccountPublisher
.sink { [weak self] in
self?.updateUserAccount()
}
.store(in: &disposeBag)
} else {
self.avatarURLObserver = nil
}
@ -485,6 +491,26 @@ extension MainTabBarController {
avatarButton.setNeedsLayout()
}
private func updateUserAccount() {
guard let authContext = authContext else { return }
Task { @MainActor in
let profileResponse = try await context.apiService.authenticatedUserInfo(
authenticationBox: authContext.mastodonAuthenticationBox
)
if let user = authContext.mastodonAuthenticationBox.authenticationRecord.object(
in: context.managedObjectContext
)?.user {
user.update(
property: .init(
entity: profileResponse.value,
domain: authContext.mastodonAuthenticationBox.domain
)
)
}
}
}
}
extension MainTabBarController {

View File

@ -99,6 +99,10 @@ extension StatusTableViewCell {
return true
}
override var accessibilityCustomActions: [UIAccessibilityCustomAction]? {
get { statusView.accessibilityCustomActions }
set { }
}
}
// MARK: - AdaptiveContainerMarginTableViewCell

View File

@ -109,6 +109,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// trigger status filter update
AppContext.shared.statusFilterService.filterUpdatePublisher.send()
// trigger authenticated user account update
AppContext.shared.authenticationService.updateActiveUserAccountPublisher.send()
if let shortcutItem = savedShortCutItem {
Task {

View File

@ -25,9 +25,9 @@ let package = Package(
],
dependencies: [
.package(name: "ArkanaKeys", path: "../dependencies/ArkanaKeys"),
.package(name: "FaviconFinder", url: "https://github.com/will-lumley/FaviconFinder.git", from: "3.2.2"),
.package(name: "Introspect", url: "https://github.com/siteline/SwiftUI-Introspect.git", from: "0.1.3"),
.package(name: "UITextView+Placeholder", url: "https://github.com/MainasuK/UITextView-Placeholder.git", from: "1.4.1"),
.package(url: "https://github.com/will-lumley/FaviconFinder.git", from: "3.2.2"),
.package(url: "https://github.com/siteline/SwiftUI-Introspect.git", from: "0.1.3"),
.package(url: "https://github.com/MainasuK/UITextView-Placeholder.git", from: "1.4.1"),
.package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.4.0"),
.package(url: "https://github.com/Alamofire/AlamofireImage.git", from: "4.1.0"),
.package(url: "https://github.com/apple/swift-collections.git", from: "1.0.3"),
@ -49,6 +49,7 @@ let package = Package(
.package(url: "https://github.com/SDWebImage/SDWebImage.git", from: "5.12.0"),
.package(url: "https://github.com/eneko/Stripes.git", from: "0.2.0"),
.package(url: "https://github.com/onevcat/Kingfisher.git", from: "7.4.1"),
.package(url: "https://github.com/NextLevel/NextLevelSessionExporter.git", from: "0.4.6"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
@ -112,8 +113,8 @@ let package = Package(
.product(name: "FLAnimatedImage", package: "FLAnimatedImage"),
.product(name: "FaviconFinder", package: "FaviconFinder"),
.product(name: "Nuke", package: "Nuke"),
.product(name: "Introspect", package: "Introspect"),
.product(name: "UITextView+Placeholder", package: "UITextView+Placeholder"),
.product(name: "Introspect", package: "SwiftUI-Introspect"),
.product(name: "UITextView+Placeholder", package: "UITextView-Placeholder"),
.product(name: "UIHostingConfigurationBackport", package: "UIHostingConfigurationBackport"),
.product(name: "TabBarPager", package: "TabBarPager"),
.product(name: "ThirdPartyMailer", package: "ThirdPartyMailer"),
@ -124,6 +125,7 @@ let package = Package(
.product(name: "PanModal", package: "PanModal"),
.product(name: "Stripes", package: "Stripes"),
.product(name: "Kingfisher", package: "Kingfisher"),
.product(name: "NextLevelSessionExporter", package: "NextLevelSessionExporter"),
]
),
.testTarget(

View File

@ -0,0 +1,9 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View File

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

View File

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

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "Arrow Clockwise.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "Dismiss.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

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

View File

@ -130,6 +130,11 @@ public enum Asset {
}
public enum Scene {
public enum Compose {
public enum Attachment {
public static let indicatorButtonBackground = ColorAsset(name: "Scene/Compose/Attachment/indicator.button.background")
public static let retry = ImageAsset(name: "Scene/Compose/Attachment/retry")
public static let stop = ImageAsset(name: "Scene/Compose/Attachment/stop")
}
public static let earth = ImageAsset(name: "Scene/Compose/Earth")
public static let mention = ImageAsset(name: "Scene/Compose/Mention")
public static let more = ImageAsset(name: "Scene/Compose/More")

View File

@ -10,6 +10,10 @@ import CoreDataStack
import MastodonSDK
extension MastodonUser.Property {
public init(entity: Mastodon.Entity.Account, domain: String) {
self.init(entity: entity, domain: domain, networkDate: Date())
}
init(entity: Mastodon.Entity.Account, domain: String, networkDate: Date) {
self.init(
identifier: entity.id + "@" + domain,

View File

@ -8,28 +8,28 @@
import Foundation
import MastodonSDK
enum CustomEmojiPickerItem {
public enum CustomEmojiPickerItem {
case emoji(attribute: CustomEmojiAttribute)
}
extension CustomEmojiPickerItem: Equatable, Hashable { }
extension CustomEmojiPickerItem {
final class CustomEmojiAttribute: Equatable, Hashable {
let id = UUID()
public final class CustomEmojiAttribute: Equatable, Hashable {
public let id = UUID()
let emoji: Mastodon.Entity.Emoji
public let emoji: Mastodon.Entity.Emoji
init(emoji: Mastodon.Entity.Emoji) {
public init(emoji: Mastodon.Entity.Emoji) {
self.emoji = emoji
}
static func == (lhs: CustomEmojiPickerItem.CustomEmojiAttribute, rhs: CustomEmojiPickerItem.CustomEmojiAttribute) -> Bool {
public static func == (lhs: CustomEmojiPickerItem.CustomEmojiAttribute, rhs: CustomEmojiPickerItem.CustomEmojiAttribute) -> Bool {
return lhs.id == rhs.id &&
lhs.emoji.shortcode == rhs.emoji.shortcode
}
func hash(into hasher: inout Hasher) {
public func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}

View File

@ -13,6 +13,15 @@ import MastodonCommon
import MastodonSDK
extension APIService {
public func authenticatedUserInfo(
authenticationBox: MastodonAuthenticationBox
) async throws -> Mastodon.Response.Content<Mastodon.Entity.Account> {
try await accountInfo(
domain: authenticationBox.domain,
userID: authenticationBox.userID,
authorization: authenticationBox.userAuthorization
)
}
public func accountInfo(
domain: String,

View File

@ -25,6 +25,7 @@ public final class AuthenticationService: NSObject {
// output
@Published public var mastodonAuthentications: [ManagedObjectRecord<MastodonAuthentication>] = []
@Published public var mastodonAuthenticationBoxes: [MastodonAuthenticationBox] = []
public let updateActiveUserAccountPublisher = PassthroughSubject<Void, Never>()
init(
managedObjectContext: NSManagedObjectContext,

View File

@ -24,7 +24,7 @@ public final class InstanceService {
weak var authenticationService: AuthenticationService?
// output
init(
apiService: APIService,
authenticationService: AuthenticationService

View File

@ -314,6 +314,24 @@ public enum L10n {
/// Undo reblog
public static let unreblog = L10n.tr("Localizable", "Common.Controls.Status.Actions.Unreblog")
}
public enum MetaEntity {
/// Email address: %@
public static func email(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Status.MetaEntity.Email", String(describing: p1))
}
/// Hastag %@
public static func hashtag(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Status.MetaEntity.Hashtag", String(describing: p1))
}
/// Show Profile: %@
public static func mention(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Status.MetaEntity.Mention", String(describing: p1))
}
/// Link: %@
public static func url(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Status.MetaEntity.Url", String(describing: p1))
}
}
public enum Poll {
/// Closed
public static let closed = L10n.tr("Localizable", "Common.Controls.Status.Poll.Closed")
@ -429,6 +447,12 @@ public enum L10n {
public static let disableContentWarning = L10n.tr("Localizable", "Scene.Compose.Accessibility.DisableContentWarning")
/// Enable Content Warning
public static let enableContentWarning = L10n.tr("Localizable", "Scene.Compose.Accessibility.EnableContentWarning")
/// Posting as %@
public static func postingAs(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.Compose.Accessibility.PostingAs", String(describing: p1))
}
/// Post Options
public static let postOptions = L10n.tr("Localizable", "Scene.Compose.Accessibility.PostOptions")
/// Post Visibility Menu
public static let postVisibilityMenu = L10n.tr("Localizable", "Scene.Compose.Accessibility.PostVisibilityMenu")
/// Remove Poll
@ -1256,6 +1280,10 @@ public enum L10n {
public enum A11y {
public enum Plural {
public enum Count {
/// Plural format key: "%#@character_count@ left"
public static func charactersLeft(_ p1: Int) -> String {
return L10n.tr("Localizable", "a11y.plural.count.characters_left", p1)
}
/// Plural format key: "Input limit exceeds %#@character_count@"
public static func inputLimitExceeds(_ p1: Int) -> String {
return L10n.tr("Localizable", "a11y.plural.count.input_limit_exceeds", p1)

View File

@ -108,6 +108,10 @@ Please check your internet connection.";
"Common.Controls.Status.Actions.Unreblog" = "Undo reblog";
"Common.Controls.Status.ContentWarning" = "Content Warning";
"Common.Controls.Status.MediaContentWarning" = "Tap anywhere to reveal";
"Common.Controls.Status.MetaEntity.Email" = "Email address: %@";
"Common.Controls.Status.MetaEntity.Hashtag" = "Hastag %@";
"Common.Controls.Status.MetaEntity.Mention" = "Show Profile: %@";
"Common.Controls.Status.MetaEntity.Url" = "Link: %@";
"Common.Controls.Status.Poll.Closed" = "Closed";
"Common.Controls.Status.Poll.Vote" = "Vote";
"Common.Controls.Status.SensitiveContent" = "Sensitive Content";
@ -157,7 +161,9 @@ Your profile looks like this to them.";
"Scene.Compose.Accessibility.CustomEmojiPicker" = "Custom Emoji Picker";
"Scene.Compose.Accessibility.DisableContentWarning" = "Disable Content Warning";
"Scene.Compose.Accessibility.EnableContentWarning" = "Enable Content Warning";
"Scene.Compose.Accessibility.PostOptions" = "Post Options";
"Scene.Compose.Accessibility.PostVisibilityMenu" = "Post Visibility Menu";
"Scene.Compose.Accessibility.PostingAs" = "Posting as %@";
"Scene.Compose.Accessibility.RemovePoll" = "Remove Poll";
"Scene.Compose.Attachment.AttachmentBroken" = "This %@ is broken and cant be
uploaded to Mastodon.";

View File

@ -50,6 +50,28 @@
<string>%ld characters</string>
</dict>
</dict>
<key>a11y.plural.count.characters_left</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@character_count@ left</string>
<key>character_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>zero</key>
<string>no characters</string>
<key>one</key>
<string>1 character</string>
<key>few</key>
<string>%ld characters</string>
<key>many</key>
<string>%ld characters</string>
<key>other</key>
<string>%ld characters</string>
</dict>
</dict>
<key>plural.count.followed_by_and_mutual</key>
<dict>
<key>NSStringLocalizedFormatKey</key>

View File

@ -43,6 +43,20 @@ extension Mastodon.API.V2.Media {
request.timeoutInterval = 180 // should > 200 Kb/s for 40 MiB media attachment
let serialStream = query.serialStream
request.httpBodyStream = serialStream.boundStreams.input
// total unit count in bytes count
// will small than actally count due to multipart protocol meta
serialStream.progress.totalUnitCount = {
var size = 0
size += query.file?.sizeInByte ?? 0
size += query.thumbnail?.sizeInByte ?? 0
return Int64(size)
}()
query.progress.addChild(
serialStream.progress,
withPendingUnitCount: query.progress.totalUnitCount
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: Mastodon.Entity.Attachment.self, from: data, response: response)

View File

@ -54,7 +54,7 @@ extension Mastodon.Query.MediaAttachment {
return data.map { "data:" + mimeType + ";base64," + $0.base64EncodedString() }
}
var sizeInByte: Int? {
public var sizeInByte: Int? {
switch self {
case .jpeg(let data), .gif(let data), .png(let data):
return data?.count

View File

@ -82,6 +82,10 @@ final class SerialStream: NSObject {
self.progress.completedUnitCount += Int64(writeResult)
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): estimate progress: \(self.progress.completedUnitCount)/\(self.progress.totalUnitCount)")
if writeResult == -1 {
break
}
}
}

View File

@ -5,55 +5,55 @@
// Created by MainasuK on 22/10/10.
//
import Foundation
import UIKit
import MastodonCore
extension CustomEmojiPickerSection {
// static func collectionViewDiffableDataSource(
// collectionView: UICollectionView,
// dependency: NeedsDependency
// ) -> UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem> {
// let dataSource = UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem>(collectionView: collectionView) { [weak dependency] collectionView, indexPath, item -> UICollectionViewCell? in
// guard let _ = dependency else { return nil }
// switch item {
// case .emoji(let attribute):
// let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: CustomEmojiPickerItemCollectionViewCell.self), for: indexPath) as! CustomEmojiPickerItemCollectionViewCell
// let placeholder = UIImage.placeholder(size: CustomEmojiPickerItemCollectionViewCell.itemSize, color: .systemFill)
// .af.imageRounded(withCornerRadius: 4)
//
// let isAnimated = !UserDefaults.shared.preferredStaticEmoji
// let url = URL(string: isAnimated ? attribute.emoji.url : attribute.emoji.staticURL)
// cell.emojiImageView.sd_setImage(
// with: url,
// placeholderImage: placeholder,
// options: [],
// context: nil
// )
// cell.accessibilityLabel = attribute.emoji.shortcode
// return cell
// }
// }
//
// dataSource.supplementaryViewProvider = { [weak dataSource] collectionView, kind, indexPath -> UICollectionReusableView? in
// guard let dataSource = dataSource else { return nil }
// let sections = dataSource.snapshot().sectionIdentifiers
// guard indexPath.section < sections.count else { return nil }
// let section = sections[indexPath.section]
//
// switch kind {
// case String(describing: CustomEmojiPickerHeaderCollectionReusableView.self):
// let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: String(describing: CustomEmojiPickerHeaderCollectionReusableView.self), for: indexPath) as! CustomEmojiPickerHeaderCollectionReusableView
// switch section {
// case .emoji(let name):
// header.titleLabel.text = name
// }
// return header
// default:
// assertionFailure()
// return nil
// }
// }
//
// return dataSource
// }
static func collectionViewDiffableDataSource(
collectionView: UICollectionView,
context: AppContext
) -> UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem> {
let dataSource = UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem>(collectionView: collectionView) { [weak context] collectionView, indexPath, item -> UICollectionViewCell? in
guard let _ = context else { return nil }
switch item {
case .emoji(let attribute):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: CustomEmojiPickerItemCollectionViewCell.self), for: indexPath) as! CustomEmojiPickerItemCollectionViewCell
let placeholder = UIImage.placeholder(size: CustomEmojiPickerItemCollectionViewCell.itemSize, color: .systemFill)
.af.imageRounded(withCornerRadius: 4)
let isAnimated = !UserDefaults.shared.preferredStaticEmoji
let url = URL(string: isAnimated ? attribute.emoji.url : attribute.emoji.staticURL)
cell.emojiImageView.sd_setImage(
with: url,
placeholderImage: placeholder,
options: [],
context: nil
)
cell.accessibilityLabel = attribute.emoji.shortcode
return cell
}
}
dataSource.supplementaryViewProvider = { [weak dataSource] collectionView, kind, indexPath -> UICollectionReusableView? in
guard let dataSource = dataSource else { return nil }
let sections = dataSource.snapshot().sectionIdentifiers
guard indexPath.section < sections.count else { return nil }
let section = sections[indexPath.section]
switch kind {
case String(describing: CustomEmojiPickerHeaderCollectionReusableView.self):
let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: String(describing: CustomEmojiPickerHeaderCollectionReusableView.self), for: indexPath) as! CustomEmojiPickerHeaderCollectionReusableView
switch section {
case .emoji(let name):
header.titleLabel.text = name
}
return header
default:
assertionFailure()
return nil
}
}
return dataSource
}
}

View File

@ -0,0 +1,27 @@
//
// MetaEntity+Accessibility.swift
//
//
// Created by Jed Fox on 2022-11-03.
//
import Meta
import MastodonLocalization
extension Meta.Entity {
var accessibilityCustomActionLabel: String? {
switch meta {
case .url(_, trimmed: _, url: let url, userInfo: _):
return L10n.Common.Controls.Status.MetaEntity.url(url)
case .hashtag(_, hashtag: let hashtag, userInfo: _):
return L10n.Common.Controls.Status.MetaEntity.hashtag(hashtag)
case .mention(_, mention: let mention, userInfo: _):
return L10n.Common.Controls.Status.MetaEntity.mention(mention)
case .email(let email, userInfo: _):
return L10n.Common.Controls.Status.MetaEntity.email(email)
// emoji are not actionable
case .emoji:
return nil
}
}
}

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

View File

@ -10,237 +10,194 @@ import UIKit
import SwiftUI
import Introspect
import AVKit
import MastodonAsset
import MastodonLocalization
import Introspect
public struct AttachmentView: View {
static let size = CGSize(width: 56, height: 56)
static let cornerRadius: CGFloat = 8
@ObservedObject var viewModel: AttachmentViewModel
let action: (Action) -> Void
@State var isCaptionEditorPresented = false
@State var caption = ""
var blurEffect: UIBlurEffect {
UIBlurEffect(style: .systemUltraThinMaterialDark)
}
public var body: some View {
Text("Hello")
// Menu {
// menu
// } label: {
// let image = viewModel.thumbnail ?? .placeholder(color: .systemGray3)
// Image(uiImage: image)
// .resizable()
// .aspectRatio(contentMode: .fill)
// .frame(width: AttachmentView.size.width, height: AttachmentView.size.height)
// .overlay {
// ZStack {
// // spinner
// if viewModel.output == nil {
// Color.clear
// .background(.ultraThinMaterial)
// ProgressView()
// .progressViewStyle(CircularProgressViewStyle())
// .foregroundStyle(.regularMaterial)
// }
// // border
// RoundedRectangle(cornerRadius: AttachmentView.cornerRadius)
// .stroke(Color.black.opacity(0.05))
// }
// .transition(.opacity)
// }
// .overlay(alignment: .bottom) {
// HStack(alignment: .bottom) {
// // alt
// VStack(spacing: 2) {
// switch viewModel.output {
// case .video:
// Image(uiImage: Asset.Media.playerRectangle.image)
// .resizable()
// .frame(width: 16, height: 12)
// default:
// EmptyView()
// }
// if !viewModel.caption.isEmpty {
// Image(uiImage: Asset.Media.altRectangle.image)
// .resizable()
// .frame(width: 16, height: 12)
// }
// }
// Spacer()
// // option
// Image(systemName: "ellipsis")
// .resizable()
// .frame(width: 12, height: 12)
// .symbolVariant(.circle)
// .symbolVariant(.fill)
// .symbolRenderingMode(.palette)
// .foregroundStyle(.white, .black)
// }
// .padding(6)
// }
// .cornerRadius(AttachmentView.cornerRadius)
// } // end Menu
// .sheet(isPresented: $isCaptionEditorPresented) {
// captionSheet
// } // end caption sheet
// .sheet(isPresented: $viewModel.isPreviewPresented) {
// previewSheet
// } // end preview sheet
Color.clear.aspectRatio(358.0/232.0, contentMode: .fill)
.overlay(
ZStack {
let image = viewModel.thumbnail ?? .placeholder(color: .secondarySystemFill)
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
}
)
.overlay(
ZStack {
Color.clear
.overlay(
VStack(alignment: .leading) {
let placeholder: String = {
switch viewModel.output {
case .image: return L10n.Scene.Compose.Attachment.descriptionPhoto
case .video: return L10n.Scene.Compose.Attachment.descriptionVideo
case nil: return ""
}
}()
Spacer()
TextField(placeholder, text: $viewModel.caption)
.lineLimit(1)
.textFieldStyle(.plain)
.foregroundColor(.white)
.placeholder(placeholder, when: viewModel.caption.isEmpty)
.padding(8)
}
)
// loading
if viewModel.output == nil, viewModel.error == nil {
ProgressView()
.progressViewStyle(.circular)
}
// load failed
// cannot re-entry
if viewModel.output == nil, let error = viewModel.error {
VisualEffectView(effect: blurEffect)
VStack {
Text("Load Failed") // TODO: i18n
.font(.system(size: 13, weight: .semibold))
Text(error.localizedDescription)
.font(.system(size: 12, weight: .regular))
}
}
// loaded
// uploading or upload failed
// could retry upload when error emit
if viewModel.output != nil, viewModel.uploadState != .finish {
VisualEffectView(effect: blurEffect)
VStack {
let action: AttachmentViewModel.Action = {
if let _ = viewModel.error {
return .retry
} else {
return .remove
}
}()
Button {
viewModel.delegate?.attachmentViewModel(viewModel, actionButtonDidPressed: action)
} label: {
let image: UIImage = {
switch action {
case .remove:
return Asset.Scene.Compose.Attachment.stop.image.withRenderingMode(.alwaysTemplate)
case .retry:
return Asset.Scene.Compose.Attachment.retry.image.withRenderingMode(.alwaysTemplate)
}
}()
Image(uiImage: image)
.foregroundColor(.white)
.padding()
.background(Color(Asset.Scene.Compose.Attachment.indicatorButtonBackground.color))
.overlay(
Group {
switch viewModel.uploadState {
case .compressing:
CircleProgressView(progress: viewModel.videoCompressProgress)
.animation(.default, value: viewModel.videoCompressProgress)
case .uploading:
CircleProgressView(progress: viewModel.fractionCompleted)
.animation(.default, value: viewModel.fractionCompleted)
default:
EmptyView()
}
}
)
.clipShape(Circle())
.padding()
}
let title: String = {
switch action {
case .remove:
switch viewModel.uploadState {
case .compressing:
return "Comporessing..." // TODO: i18n
default:
if viewModel.fractionCompleted < 0.9 {
let totalSizeInByte = viewModel.outputSizeInByte
let uploadSizeInByte = Double(totalSizeInByte) * min(1.0, viewModel.fractionCompleted + 0.1) // 9:1
let total = viewModel.byteCountFormatter.string(fromByteCount: Int64(totalSizeInByte))
let upload = viewModel.byteCountFormatter.string(fromByteCount: Int64(uploadSizeInByte))
return "\(upload) / \(total)"
} else {
return "Server Processing..." // TODO: i18n
}
}
case .retry:
return "Upload Failed" // TODO: i18n
}
}()
let subtitle: String = {
switch action {
case .remove:
if viewModel.progress.fractionCompleted < 1, viewModel.uploadState == .uploading {
if viewModel.progress.fractionCompleted < 0.9 {
return viewModel.remainTimeLocalizedString ?? ""
} else {
return ""
}
} else if viewModel.videoCompressProgress < 1, viewModel.uploadState == .compressing {
return viewModel.percentageFormatter.string(from: NSNumber(floatLiteral: viewModel.videoCompressProgress)) ?? ""
} else {
return ""
}
case .retry:
return viewModel.error?.localizedDescription ?? ""
}
}()
Text(title)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.white)
.padding(.horizontal)
Text(subtitle)
.font(.system(size: 12, weight: .regular))
.foregroundColor(.white)
.padding(.horizontal)
.lineLimit(nil)
.multilineTextAlignment(.center)
.frame(maxWidth: 240)
}
}
} // end ZStack
)
} // end body
// var menu: some View {
// Group {
// Button(
// action: {
// action(.preview)
// },
// label: {
// Label(L10n.Scene.Compose.Media.preview, systemImage: "photo")
// }
// )
// // caption
// let canAddCaption: Bool = {
// switch viewModel.output {
// case .image: return true
// case .video: return false
// case .none: return false
// }
// }()
// if canAddCaption {
// Button(
// action: {
// action(.caption)
// caption = viewModel.caption
// isCaptionEditorPresented.toggle()
// },
// label: {
// let title = viewModel.caption.isEmpty ? L10n.Scene.Compose.Media.Caption.add : L10n.Scene.Compose.Media.Caption.update
// Label(title, systemImage: "text.bubble")
// // FIXME: https://stackoverflow.com/questions/72318730/how-to-customize-swiftui-menu
// // add caption subtitle
// }
// )
// }
// Divider()
// // remove
// Button(
// role: .destructive,
// action: {
// action(.remove)
// },
// label: {
// Label(L10n.Scene.Compose.Media.remove, systemImage: "minus.circle")
// }
// )
// }
// }
// var captionSheet: some View {
// NavigationView {
// ScrollView(.vertical) {
// VStack {
// // preview
// switch viewModel.output {
// case .image:
// let image = viewModel.thumbnail ?? .placeholder(color: .systemGray3)
// Image(uiImage: image)
// .resizable()
// .aspectRatio(contentMode: .fill)
// case .video(let url, _):
// let player = AVPlayer(url: url)
// VideoPlayer(player: player)
// .frame(height: 300)
// case .none:
// EmptyView()
// }
// // caption textField
// TextField(
// text: $caption,
// prompt: Text(L10n.Scene.Compose.Media.Caption.addADescriptionForThisImage)
// ) {
// Text(L10n.Scene.Compose.Media.Caption.update)
// }
// .padding()
// .introspectTextField { textField in
// textField.becomeFirstResponder()
// }
// }
// }
// .navigationTitle(L10n.Scene.Compose.Media.Caption.update)
// .navigationBarTitleDisplayMode(.inline)
// .toolbar {
// ToolbarItem(placement: .navigationBarLeading) {
// Button {
// isCaptionEditorPresented.toggle()
// } label: {
// Image(systemName: "xmark.circle.fill")
// .resizable()
// .frame(width: 30, height: 30, alignment: .center)
// .symbolRenderingMode(.hierarchical)
// .foregroundStyle(Color(uiColor: .secondaryLabel), Color(uiColor: .tertiaryLabel))
// }
// }
// ToolbarItem(placement: .navigationBarTrailing) {
// Button {
// viewModel.caption = caption.trimmingCharacters(in: .whitespacesAndNewlines)
// isCaptionEditorPresented.toggle()
// } label: {
// Text(L10n.Common.Controls.Actions.save)
// }
// }
// }
// } // end NavigationView
// }
// design for share extension
// preferred UIKit preview in app
// var previewSheet: some View {
// NavigationView {
// ScrollView(.vertical) {
// VStack {
// // preview
// switch viewModel.output {
// case .image:
// let image = viewModel.thumbnail ?? .placeholder(color: .systemGray3)
// Image(uiImage: image)
// .resizable()
// .aspectRatio(contentMode: .fill)
// case .video(let url, _):
// let player = AVPlayer(url: url)
// VideoPlayer(player: player)
// .frame(height: 300)
// case .none:
// EmptyView()
// }
// Spacer()
// }
// }
// .navigationTitle(L10n.Scene.Compose.Media.preview)
// .navigationBarTitleDisplayMode(.inline)
// .toolbar {
// ToolbarItem(placement: .navigationBarLeading) {
// Button {
// viewModel.isPreviewPresented.toggle()
// } label: {
// Image(systemName: "xmark.circle.fill")
// .resizable()
// .frame(width: 30, height: 30, alignment: .center)
// .symbolRenderingMode(.hierarchical)
// .foregroundStyle(Color(uiColor: .secondaryLabel), Color(uiColor: .tertiaryLabel))
// }
// }
// }
// } // end NavigationView
// }
}
extension AttachmentView {
public enum Action: Hashable {
case preview
case caption
case remove
// https://stackoverflow.com/a/57715771/3797903
extension View {
fileprivate func placeholder<Content: View>(
when shouldShow: Bool,
alignment: Alignment = .leading,
@ViewBuilder placeholder: () -> Content) -> some View {
ZStack(alignment: alignment) {
placeholder().opacity(shouldShow ? 1 : 0)
self
}
}
fileprivate func placeholder(
_ text: String,
when shouldShow: Bool,
alignment: Alignment = .leading) -> some View {
placeholder(when: shouldShow, alignment: alignment) {
Text(text)
.foregroundColor(.white.opacity(0.7))
.lineLimit(1)
}
}
}

View File

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

View File

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

View File

@ -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: []
)
}
}

View File

@ -52,153 +52,65 @@ extension Data {
}
}
// Twitter Only
//extension AttachmentViewModel {
// class SliceResult {
//
// let fileURL: URL
// let chunks: Chunked<FileHandle.AsyncBytes>
// let chunkCount: Int
// let type: UTType
// let sizeInBytes: UInt64
//
// public init?(
// url: URL,
// type: UTType
// ) {
// guard let chunks = try? FileHandle(forReadingFrom: url).bytes.chunked else { return nil }
// let _sizeInBytes: UInt64? = {
// let attribute = try? FileManager.default.attributesOfItem(atPath: url.path)
// return attribute?[.size] as? UInt64
// }()
// guard let sizeInBytes = _sizeInBytes else { return nil }
//
// self.fileURL = url
// self.chunks = chunks
// self.chunkCount = SliceResult.chunkCount(chunkSize: UInt64(chunks.chunkSize), sizeInBytes: sizeInBytes)
// self.type = type
// self.sizeInBytes = sizeInBytes
// }
//
// public init?(
// imageData: Data,
// type: UTType
// ) {
// let _fileURL = try? FileManager.default.createTemporaryFileURL(
// filename: UUID().uuidString,
// pathExtension: imageData.kf.imageFormat == .PNG ? "png" : "jpeg"
// )
// guard let fileURL = _fileURL else { return nil }
//
// do {
// try imageData.write(to: fileURL)
// } catch {
// return nil
// }
//
// guard let chunks = try? FileHandle(forReadingFrom: fileURL).bytes.chunked else {
// return nil
// }
// let sizeInBytes = UInt64(imageData.count)
//
// self.fileURL = fileURL
// self.chunks = chunks
// self.chunkCount = SliceResult.chunkCount(chunkSize: UInt64(chunks.chunkSize), sizeInBytes: sizeInBytes)
// self.type = type
// self.sizeInBytes = sizeInBytes
// }
//
// static func chunkCount(chunkSize: UInt64, sizeInBytes: UInt64) -> Int {
// guard sizeInBytes > 0 else { return 0 }
// let count = sizeInBytes / chunkSize
// let remains = sizeInBytes % chunkSize
// let result = remains > 0 ? count + 1 : count
// return Int(result)
// }
//
// }
//
// static func slice(output: Output, sizeLimit: SizeLimit) -> SliceResult? {
// // needs execute in background
// assert(!Thread.isMainThread)
//
// // try png then use JPEG compress with Q=0.8
// // then slice into 1MiB chunks
// switch output {
// case .image(let data, _):
// let maxPayloadSizeInBytes = sizeLimit.image
//
// // use processed imageData to remove EXIF
// guard let image = UIImage(data: data),
// var imageData = image.pngData()
// else { return nil }
//
// var didRemoveEXIF = false
// repeat {
// guard let image = KFCrossPlatformImage(data: imageData) else { return nil }
// if imageData.kf.imageFormat == .PNG {
// // A. png image
// guard let pngData = image.pngData() else { return nil }
// didRemoveEXIF = true
// if pngData.count > maxPayloadSizeInBytes {
// guard let compressedJpegData = image.jpegData(compressionQuality: 0.8) else { return nil }
// os_log("%{public}s[%{public}ld], %{public}s: compress png %.2fMiB -> jpeg %.2fMiB", ((#file as NSString).lastPathComponent), #line, #function, Double(imageData.count) / 1024 / 1024, Double(compressedJpegData.count) / 1024 / 1024)
// imageData = compressedJpegData
// } else {
// os_log("%{public}s[%{public}ld], %{public}s: png %.2fMiB", ((#file as NSString).lastPathComponent), #line, #function, Double(pngData.count) / 1024 / 1024)
// imageData = pngData
// }
// } else {
// // B. other image
// if !didRemoveEXIF {
// guard let jpegData = image.jpegData(compressionQuality: 0.8) else { return nil }
// os_log("%{public}s[%{public}ld], %{public}s: compress jpeg %.2fMiB -> jpeg %.2fMiB", ((#file as NSString).lastPathComponent), #line, #function, Double(imageData.count) / 1024 / 1024, Double(jpegData.count) / 1024 / 1024)
// imageData = jpegData
// didRemoveEXIF = true
// } else {
// let targetSize = CGSize(width: image.size.width * 0.8, height: image.size.height * 0.8)
// let scaledImage = image.af.imageScaled(to: targetSize)
// guard let compressedJpegData = scaledImage.jpegData(compressionQuality: 0.8) else { return nil }
// os_log("%{public}s[%{public}ld], %{public}s: compress jpeg %.2fMiB -> jpeg %.2fMiB", ((#file as NSString).lastPathComponent), #line, #function, Double(imageData.count) / 1024 / 1024, Double(compressedJpegData.count) / 1024 / 1024)
// imageData = compressedJpegData
// }
// }
// } while (imageData.count > maxPayloadSizeInBytes)
//
// return SliceResult(
// imageData: imageData,
// type: imageData.kf.imageFormat == .PNG ? UTType.png : UTType.jpeg
// )
//
//// case .gif(let url):
//// fatalError()
// case .video(let url, _):
// return SliceResult(
// url: url,
// type: .movie
// )
// }
// }
//}
extension AttachmentViewModel {
public enum UploadState {
case none
case compressing
case ready
case uploading
case fail
case finish
}
struct UploadContext {
let apiService: APIService
let authContext: AuthContext
}
enum UploadResult {
case mastodon(Mastodon.Response.Content<Mastodon.Entity.Attachment>)
}
public typealias UploadResult = Mastodon.Entity.Attachment
}
extension AttachmentViewModel {
func upload(context: UploadContext) async throws -> UploadResult {
return try await uploadMastodonMedia(
context: context
)
@MainActor
func upload(isRetry: Bool = false) async throws {
do {
let result = try await upload(
context: .init(
apiService: self.api,
authContext: self.authContext
),
isRetry: isRetry
)
update(uploadResult: result)
} catch {
self.error = error
}
}
@MainActor
private func upload(context: UploadContext, isRetry: Bool) async throws -> UploadResult {
if isRetry {
guard uploadState == .fail else { throw AppError.badRequest }
self.error = nil
self.fractionCompleted = 0
} else {
guard uploadState == .ready else { throw AppError.badRequest }
}
do {
update(uploadState: .uploading)
let result = try await uploadMastodonMedia(
context: context
)
update(uploadState: .finish)
return result
} catch {
update(uploadState: .fail)
throw error
}
}
// MainActor is required here to trigger stream upload task
@MainActor
private func uploadMastodonMedia(
context: UploadContext
) async throws -> UploadResult {
@ -260,7 +172,7 @@ extension AttachmentViewModel {
if attachmentUploadResponse.statusCode == 202 {
// note:
// the Mastodon server append the attachments in order by upload time
// can not upload concurrency
// can not upload parallels
let waitProcessRetryLimit = checkUploadTaskRetryLimit
var waitProcessRetryCount: Int64 = 0
@ -283,7 +195,7 @@ extension AttachmentViewModel {
// escape here
progress.completedUnitCount = progress.totalUnitCount
return .mastodon(attachmentStatusResponse)
return attachmentStatusResponse.value
} else {
AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): attachment processing. Retry \(waitProcessRetryCount)/\(waitProcessRetryLimit)")
@ -296,7 +208,7 @@ extension AttachmentViewModel {
} else {
AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): upload attachment success: \(attachmentUploadResponse.value.url ?? "<nil>")")
return .mastodon(attachmentUploadResponse)
return attachmentUploadResponse.value
}
}
}

View File

@ -11,36 +11,112 @@ import Combine
import PhotosUI
import Kingfisher
import MastodonCore
import func QuartzCore.CACurrentMediaTime
public protocol AttachmentViewModelDelegate: AnyObject {
func attachmentViewModel(_ viewModel: AttachmentViewModel, uploadStateValueDidChange state: AttachmentViewModel.UploadState)
func attachmentViewModel(_ viewModel: AttachmentViewModel, actionButtonDidPressed action: AttachmentViewModel.Action)
}
final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable {
static let logger = Logger(subsystem: "AttachmentViewModel", category: "ViewModel")
let logger = Logger(subsystem: "AttachmentViewModel", category: "ViewModel")
public let id = UUID()
var disposeBag = Set<AnyCancellable>()
var observations = Set<NSKeyValueObservation>()
weak var delegate: AttachmentViewModelDelegate?
let byteCountFormatter: ByteCountFormatter = {
let formatter = ByteCountFormatter()
formatter.allowsNonnumericFormatting = true
formatter.countStyle = .memory
return formatter
}()
let percentageFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .percent
return formatter
}()
// input
public let api: APIService
public let authContext: AuthContext
public let input: Input
@Published var caption = ""
@Published var sizeLimit = SizeLimit()
@Published public var isPreviewPresented = false
// var compressVideoTask: Task<URL, Error>?
// output
@Published public private(set) var output: Output?
@Published public private(set) var thumbnail: UIImage? // original size image thumbnail
@Published var error: Error?
let progress = Progress() // upload progress
@Published public private(set) var outputSizeInByte: Int64 = 0
public init(input: Input) {
@Published public private(set) var uploadState: UploadState = .none
@Published public private(set) var uploadResult: UploadResult?
@Published var error: Error?
var uploadTask: Task<(), Never>?
@Published var videoCompressProgress: Double = 0
let progress = Progress() // upload progress
@Published var fractionCompleted: Double = 0
private var lastTimestamp: TimeInterval?
private var lastUploadSizeInByte: Int64 = 0
private var averageUploadSpeedInByte: Int64 = 0
private var remainTimeInterval: Double?
@Published var remainTimeLocalizedString: String?
public init(
api: APIService,
authContext: AuthContext,
input: Input,
delegate: AttachmentViewModelDelegate
) {
self.api = api
self.authContext = authContext
self.input = input
self.delegate = delegate
super.init()
// end init
defer {
load(input: input)
}
Timer.publish(every: 1.0 / 60.0, on: .main, in: .common) // 60 FPS
.autoconnect()
.share()
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self = self else { return }
self.step()
}
.store(in: &disposeBag)
progress
.observe(\.fractionCompleted, options: [.initial, .new]) { [weak self] progress, _ in
guard let self = self else { return }
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): publish progress \(progress.fractionCompleted)")
DispatchQueue.main.async {
self.fractionCompleted = progress.fractionCompleted
}
}
.store(in: &observations)
// Note: this observation is redundant if .fractionCompleted listener always emit event when reach 1.0 progress
// progress
// .observe(\.isFinished, options: [.initial, .new]) { [weak self] progress, _ in
// guard let self = self else { return }
// self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): publish progress \(progress.fractionCompleted)")
// DispatchQueue.main.async {
// self.objectWillChange.send()
// }
// }
// .store(in: &observations)
$output
.map { output -> UIImage? in
@ -53,22 +129,121 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
return nil
}
}
.receive(on: DispatchQueue.main)
.assign(to: &$thumbnail)
defer {
let uploadTask = Task { @MainActor in
do {
var output = try await load(input: input)
switch output {
case .video(let fileURL, let mimeType):
self.output = output
self.update(uploadState: .compressing)
let compressedFileURL = try await comporessVideo(url: fileURL)
output = .video(compressedFileURL, mimeType: mimeType)
try? FileManager.default.removeItem(at: fileURL) // remove old file
default:
break
}
self.outputSizeInByte = output.asAttachment.sizeInByte.flatMap { Int64($0) } ?? 0
self.output = output
self.update(uploadState: .ready)
self.delegate?.attachmentViewModel(self, uploadStateValueDidChange: self.uploadState)
} catch {
self.error = error
}
} // end Task
self.uploadTask = uploadTask
Task {
await uploadTask.value
}
}
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
uploadTask?.cancel()
switch output {
case .image:
// FIXME:
break
case .video(let url, _):
try? FileManager.default.removeItem(at: url)
case nil :
case nil:
break
}
}
}
// calculate the upload speed
// ref: https://stackoverflow.com/a/3841706/3797903
extension AttachmentViewModel {
static var SpeedSmoothingFactor = 0.4
static let remainsTimeFormatter: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .full
return formatter
}()
@objc private func step() {
let uploadProgress = min(progress.fractionCompleted + 0.1, 1) // the progress split into 9:1 blocks (download : waiting)
guard let lastTimestamp = self.lastTimestamp else {
self.lastTimestamp = CACurrentMediaTime()
self.lastUploadSizeInByte = Int64(Double(outputSizeInByte) * uploadProgress)
return
}
let duration = CACurrentMediaTime() - lastTimestamp
guard duration >= 1.0 else { return } // update every 1 sec
let old = self.lastUploadSizeInByte
self.lastUploadSizeInByte = Int64(Double(outputSizeInByte) * uploadProgress)
let newSpeed = self.lastUploadSizeInByte - old
let lastAverageSpeed = self.averageUploadSpeedInByte
let newAverageSpeed = Int64(AttachmentViewModel.SpeedSmoothingFactor * Double(newSpeed) + (1 - AttachmentViewModel.SpeedSmoothingFactor) * Double(lastAverageSpeed))
let remainSizeInByte = Double(outputSizeInByte) * (1 - uploadProgress)
let speed = Double(newAverageSpeed)
if speed != .zero {
// estimate by speed
let uploadRemainTimeInSecond = remainSizeInByte / speed
// estimate by progress 1s for 10%
let remainPercentage = 1 - uploadProgress
let estimateRemainTimeByProgress = remainPercentage / 0.1
// max estimate
var remainTimeInSecond = max(estimateRemainTimeByProgress, uploadRemainTimeInSecond)
// do not increate timer when < 5 sec
if let remainTimeInterval = self.remainTimeInterval, remainTimeInSecond < 5 {
remainTimeInSecond = min(remainTimeInterval, remainTimeInSecond)
self.remainTimeInterval = remainTimeInSecond
} else {
self.remainTimeInterval = remainTimeInSecond
}
let string = AttachmentViewModel.remainsTimeFormatter.localizedString(fromTimeInterval: remainTimeInSecond)
remainTimeLocalizedString = string
// print("remains: \(remainSizeInByte), speed: \(newAverageSpeed), \(string)")
} else {
remainTimeLocalizedString = nil
}
self.lastTimestamp = CACurrentMediaTime()
self.averageUploadSpeedInByte = newAverageSpeed
}
}
extension AttachmentViewModel {
public enum Input: Hashable {
case image(UIImage)
@ -86,13 +261,6 @@ extension AttachmentViewModel {
case png
case jpg
}
public var twitterMediaCategory: TwitterMediaCategory {
switch self {
case .image: return .image
case .video: return .amplifyVideo
}
}
}
public struct SizeLimit {
@ -111,291 +279,38 @@ extension AttachmentViewModel {
}
}
public enum AttachmentError: Error {
public enum AttachmentError: Error, LocalizedError {
case invalidAttachmentType
case attachmentTooLarge
}
public enum TwitterMediaCategory: String {
case image = "TWEET_IMAGE"
case GIF = "TWEET_GIF"
case video = "TWEET_VIDEO"
case amplifyVideo = "AMPLIFY_VIDEO"
}
}
extension AttachmentViewModel {
private func load(input: Input) {
switch input {
case .image(let image):
guard let data = image.pngData() else {
error = AttachmentError.invalidAttachmentType
return
}
output = .image(data, imageKind: .png)
case .url(let url):
Task { @MainActor in
do {
let output = try await AttachmentViewModel.load(url: url)
self.output = output
} catch {
self.error = error
}
} // end Task
case .pickerResult(let pickerResult):
Task { @MainActor in
do {
let output = try await AttachmentViewModel.load(itemProvider: pickerResult.itemProvider)
self.output = output
} catch {
self.error = error
}
} // end Task
case .itemProvider(let itemProvider):
Task { @MainActor in
do {
let output = try await AttachmentViewModel.load(itemProvider: itemProvider)
self.output = output
} catch {
self.error = error
}
} // end Task
}
}
private static func load(url: URL) async throws -> Output {
guard let uti = UTType(filenameExtension: url.pathExtension) else {
throw AttachmentError.invalidAttachmentType
}
if uti.conforms(to: .image) {
guard url.startAccessingSecurityScopedResource() else {
throw AttachmentError.invalidAttachmentType
public var errorDescription: String? {
switch self {
case .invalidAttachmentType:
return "Can not regonize this media attachment" // TODO: i18n
case .attachmentTooLarge:
return "Attachment too large"
}
defer { url.stopAccessingSecurityScopedResource() }
let imageData = try Data(contentsOf: url)
return .image(imageData, imageKind: imageData.kf.imageFormat == .PNG ? .png : .jpg)
} else if uti.conforms(to: .movie) {
guard url.startAccessingSecurityScopedResource() else {
throw AttachmentError.invalidAttachmentType
}
defer { url.stopAccessingSecurityScopedResource() }
let fileName = UUID().uuidString
let tempDirectoryURL = FileManager.default.temporaryDirectory
let fileURL = tempDirectoryURL.appendingPathComponent(fileName).appendingPathExtension(url.pathExtension)
try FileManager.default.createDirectory(at: tempDirectoryURL, withIntermediateDirectories: true, attributes: nil)
try FileManager.default.copyItem(at: url, to: fileURL)
return .video(fileURL, mimeType: UTType.movie.preferredMIMEType ?? "video/mp4")
} else {
throw AttachmentError.invalidAttachmentType
}
}
private static func load(itemProvider: NSItemProvider) async throws -> Output {
if itemProvider.isImage() {
guard let result = try await itemProvider.loadImageData() else {
throw AttachmentError.invalidAttachmentType
}
let imageKind: Output.ImageKind = {
if let type = result.type {
if type == UTType.png {
return .png
}
if type == UTType.jpeg {
return .jpg
}
}
let imageData = result.data
if imageData.kf.imageFormat == .PNG {
return .png
}
if imageData.kf.imageFormat == .JPEG {
return .jpg
}
assertionFailure("unknown image kind")
return .jpg
}()
return .image(result.data, imageKind: imageKind)
} else if itemProvider.isMovie() {
guard let result = try await itemProvider.loadVideoData() else {
throw AttachmentError.invalidAttachmentType
}
return .video(result.url, mimeType: "video/mp4")
} else {
assertionFailure()
throw AttachmentError.invalidAttachmentType
}
}
}
extension AttachmentViewModel {
static func createThumbnailForVideo(url: URL) -> UIImage? {
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
let asset = AVURLAsset(url: url)
let assetImageGenerator = AVAssetImageGenerator(asset: asset)
assetImageGenerator.appliesPreferredTrackTransform = true // fix orientation
do {
let cgImage = try assetImageGenerator.copyCGImage(at: CMTimeMake(value: 0, timescale: 1), actualTime: nil)
let image = UIImage(cgImage: cgImage)
return image
} catch {
AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): thumbnail generate fail: \(error.localizedDescription)")
return nil
}
public enum Action: Hashable {
case remove
case retry
}
}
// MARK: - TypeIdentifiedItemProvider
extension AttachmentViewModel: TypeIdentifiedItemProvider {
public static var typeIdentifier: String {
// must in UTI format
// https://developer.apple.com/library/archive/qa/qa1796/_index.html
return "com.twidere.AttachmentViewModel"
}
}
// MARK: - NSItemProviderWriting
extension AttachmentViewModel: NSItemProviderWriting {
/// Attachment uniform type idendifiers
///
/// The latest one for in-app drag and drop.
/// And use generic `image` and `movie` type to
/// allows transformable media in different formats
public static var writableTypeIdentifiersForItemProvider: [String] {
return [
UTType.image.identifier,
UTType.movie.identifier,
AttachmentViewModel.typeIdentifier,
]
}
public var writableTypeIdentifiersForItemProvider: [String] {
// should append elements in priority order from high to low
var typeIdentifiers: [String] = []
// FIXME: check jpg or png
switch input {
case .image:
typeIdentifiers.append(UTType.png.identifier)
case .url(let url):
let _uti = UTType(filenameExtension: url.pathExtension)
if let uti = _uti {
if uti.conforms(to: .image) {
typeIdentifiers.append(UTType.png.identifier)
} else if uti.conforms(to: .movie) {
typeIdentifiers.append(UTType.mpeg4Movie.identifier)
}
}
case .pickerResult(let item):
if item.itemProvider.isImage() {
typeIdentifiers.append(UTType.png.identifier)
} else if item.itemProvider.isMovie() {
typeIdentifiers.append(UTType.mpeg4Movie.identifier)
}
case .itemProvider(let itemProvider):
if itemProvider.isImage() {
typeIdentifiers.append(UTType.png.identifier)
} else if itemProvider.isMovie() {
typeIdentifiers.append(UTType.mpeg4Movie.identifier)
}
}
typeIdentifiers.append(AttachmentViewModel.typeIdentifier)
return typeIdentifiers
}
public func loadData(
withTypeIdentifier typeIdentifier: String,
forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void
) -> Progress? {
switch typeIdentifier {
case AttachmentViewModel.typeIdentifier:
do {
let archiver = NSKeyedArchiver(requiringSecureCoding: false)
try archiver.encodeEncodable(id, forKey: NSKeyedArchiveRootObjectKey)
archiver.finishEncoding()
let data = archiver.encodedData
completionHandler(data, nil)
} catch {
assertionFailure()
completionHandler(nil, nil)
}
default:
break
}
let loadingProgress = Progress(totalUnitCount: 100)
Publishers.CombineLatest(
$output,
$error
)
.sink { [weak self] output, error in
guard let self = self else { return }
// continue when load completed
guard output != nil || error != nil else { return }
switch output {
case .image(let data, _):
switch typeIdentifier {
case UTType.png.identifier:
loadingProgress.completedUnitCount = 100
completionHandler(data, nil)
default:
completionHandler(nil, nil)
}
case .video(let url, _):
switch typeIdentifier {
case UTType.png.identifier:
let _image = AttachmentViewModel.createThumbnailForVideo(url: url)
let _data = _image?.pngData()
loadingProgress.completedUnitCount = 100
completionHandler(_data, nil)
case UTType.mpeg4Movie.identifier:
let task = URLSession.shared.dataTask(with: url) { data, response, error in
completionHandler(data, error)
}
task.progress.observe(\.fractionCompleted) { progress, change in
loadingProgress.completedUnitCount = Int64(100 * progress.fractionCompleted)
}
.store(in: &self.observations)
task.resume()
default:
completionHandler(nil, nil)
}
case nil:
completionHandler(nil, error)
}
}
.store(in: &disposeBag)
return loadingProgress
}
}
extension NSItemProvider {
fileprivate func isImage() -> Bool {
return hasRepresentationConforming(
toTypeIdentifier: UTType.image.identifier,
fileOptions: []
)
}
fileprivate func isMovie() -> Bool {
return hasRepresentationConforming(
toTypeIdentifier: UTType.movie.identifier,
fileOptions: []
)
extension AttachmentViewModel {
@MainActor
func update(uploadState: UploadState) {
self.uploadState = uploadState
self.delegate?.attachmentViewModel(self, uploadStateValueDidChange: self.uploadState)
}
@MainActor
func update(uploadResult: UploadResult) {
self.uploadResult = uploadResult
}
}

View File

@ -88,7 +88,7 @@ extension AutoCompleteViewController {
])
tableView.delegate = self
// viewModel.setupDiffableDataSource(tableView: tableView)
viewModel.setupDiffableDataSource(tableView: tableView)
// bind to layout chevron
viewModel.symbolBoundingRect

View File

@ -6,17 +6,18 @@
//
import UIKit
import MastodonCore
extension AutoCompleteViewModel {
// func setupDiffableDataSource(
// tableView: UITableView
// ) {
// diffableDataSource = AutoCompleteSection.tableViewDiffableDataSource(for: tableView)
//
// var snapshot = NSDiffableDataSourceSnapshot<AutoCompleteSection, AutoCompleteItem>()
// snapshot.appendSections([.main])
// diffableDataSource?.apply(snapshot)
// }
func setupDiffableDataSource(
tableView: UITableView
) {
diffableDataSource = AutoCompleteSection.tableViewDiffableDataSource(tableView: tableView)
var snapshot = NSDiffableDataSourceSnapshot<AutoCompleteSection, AutoCompleteItem>()
snapshot.appendSections([.main])
diffableDataSource?.apply(snapshot)
}
}

View File

@ -102,7 +102,7 @@ extension AutoCompleteViewModel.State {
return
}
guard let customEmojiViewModel = viewModel.customEmojiViewModel.value else {
guard let customEmojiViewModel = viewModel.customEmojiViewModel else {
await enter(state: Fail.self)
return
}

View File

@ -20,7 +20,7 @@ final class AutoCompleteViewModel {
let authContext: AuthContext
public let inputText = CurrentValueSubject<String, Never>("") // contains "@" or "#" prefix
public let symbolBoundingRect = CurrentValueSubject<CGRect, Never>(.zero)
public let customEmojiViewModel = CurrentValueSubject<EmojiService.CustomEmojiViewModel?, Never>(nil)
public let customEmojiViewModel: EmojiService.CustomEmojiViewModel?
// output
public var autoCompleteItems = CurrentValueSubject<[AutoCompleteItem], Never>([])
@ -40,6 +40,8 @@ final class AutoCompleteViewModel {
init(context: AppContext, authContext: AuthContext) {
self.context = context
self.authContext = authContext
self.customEmojiViewModel = context.emojiService.dequeueCustomEmojiViewModel(for: authContext.mastodonAuthenticationBox.domain)
// end init
autoCompleteItems
.receive(on: DispatchQueue.main)

View File

@ -8,7 +8,6 @@
import UIKit
import Combine
import MastodonCore
import MastodonUI
final class AutoCompleteTopChevronView: UIView {

View File

@ -14,12 +14,15 @@ import MastodonCore
public final class ComposeContentViewController: UIViewController {
static let minAutoCompleteVisibleHeight: CGFloat = 100
let logger = Logger(subsystem: "ComposeContentViewController", category: "ViewController")
var disposeBag = Set<AnyCancellable>()
public var viewModel: ComposeContentViewModel!
private(set) lazy var composeContentToolbarViewModel = ComposeContentToolbarView.ViewModel(delegate: self)
// tableView container
let tableView: ComposeTableView = {
let tableView = ComposeTableView()
tableView.estimatedRowHeight = UITableView.automaticDimension
@ -29,6 +32,16 @@ public final class ComposeContentViewController: UIViewController {
return tableView
}()
// auto complete
private(set) lazy var autoCompleteViewController: AutoCompleteViewController = {
let viewController = AutoCompleteViewController()
viewController.viewModel = AutoCompleteViewModel(context: viewModel.context, authContext: viewModel.authContext)
viewController.delegate = self
// viewController.viewModel.customEmojiViewModel.value = viewModel.customEmojiViewModel
return viewController
}()
// toolbar
lazy var composeContentToolbarView = ComposeContentToolbarView(viewModel: composeContentToolbarViewModel)
var composeContentToolbarViewBottomLayoutConstraint: NSLayoutConstraint!
let composeContentToolbarBackgroundView = UIView()
@ -60,6 +73,15 @@ public final class ComposeContentViewController: UIViewController {
documentPickerController.delegate = self
return documentPickerController
}()
// emoji picker inputView
let customEmojiPickerInputView: CustomEmojiPickerInputView = {
let view = CustomEmojiPickerInputView(
frame: CGRect(x: 0, y: 0, width: 0, height: 300),
inputViewStyle: .keyboard
)
return view
}()
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
@ -71,6 +93,8 @@ extension ComposeContentViewController {
public override func viewDidLoad() {
super.viewDidLoad()
viewModel.delegate = self
// setup view
self.setupBackgroundColor(theme: ThemeService.shared.currentTheme.value)
ThemeService.shared.currentTheme
@ -94,6 +118,12 @@ extension ComposeContentViewController {
tableView.delegate = self
viewModel.setupDataSource(tableView: tableView)
// setup emoji picker
customEmojiPickerInputView.collectionView.delegate = self
viewModel.customEmojiPickerInputViewModel.customEmojiPickerInputView = customEmojiPickerInputView
viewModel.setupCustomEmojiPickerDiffableDataSource(collectionView: customEmojiPickerInputView.collectionView)
// setup toolbar
let toolbarHostingView = UIHostingController(rootView: composeContentToolbarView)
toolbarHostingView.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(toolbarHostingView.view)
@ -116,49 +146,43 @@ extension ComposeContentViewController {
view.bottomAnchor.constraint(equalTo: composeContentToolbarBackgroundView.bottomAnchor),
])
let keyboardHasShortcutBar = CurrentValueSubject<Bool, Never>(traitCollection.userInterfaceIdiom == .pad) // update default value later
// bind keyboard
let keyboardEventPublishers = Publishers.CombineLatest3(
KeyboardResponderService.shared.isShow,
KeyboardResponderService.shared.state,
KeyboardResponderService.shared.endFrame
)
// Publishers.CombineLatest3(
// viewModel.$isCustomEmojiComposing,
// )
keyboardEventPublishers
.sink(receiveValue: { [weak self] keyboardEvents in
Publishers.CombineLatest3(
keyboardEventPublishers,
viewModel.$isEmojiActive,
viewModel.$autoCompleteInfo
)
.sink(receiveValue: { [weak self] keyboardEvents, isEmojiActive, autoCompleteInfo in
guard let self = self else { return }
let (isShow, state, endFrame) = keyboardEvents
// switch self.traitCollection.userInterfaceIdiom {
// case .pad:
// keyboardHasShortcutBar.value = state != .floating
// default:
// keyboardHasShortcutBar.value = false
// }
//
let extraMargin: CGFloat = {
var margin = ComposeContentToolbarView.toolbarHeight
// if autoCompleteInfo != nil {
//// margin += ComposeViewController.minAutoCompleteVisibleHeight
// }
if autoCompleteInfo != nil {
margin += ComposeContentViewController.minAutoCompleteVisibleHeight
}
return margin
}()
//
guard isShow, state == .dock else {
self.tableView.contentInset.bottom = extraMargin
self.tableView.verticalScrollIndicatorInsets.bottom = extraMargin
// if let superView = self.autoCompleteViewController.tableView.superview {
// let autoCompleteTableViewBottomInset: CGFloat = {
// let tableViewFrameInWindow = superView.convert(self.autoCompleteViewController.tableView.frame, to: nil)
// let padding = tableViewFrameInWindow.maxY + self.composeToolbarView.frame.height + AutoCompleteViewController.chevronViewHeight - self.view.frame.maxY
// return max(0, padding)
// }()
// self.autoCompleteViewController.tableView.contentInset.bottom = autoCompleteTableViewBottomInset
// self.autoCompleteViewController.tableView.verticalScrollIndicatorInsets.bottom = autoCompleteTableViewBottomInset
// }
if let superView = self.autoCompleteViewController.tableView.superview {
let autoCompleteTableViewBottomInset: CGFloat = {
let tableViewFrameInWindow = superView.convert(self.autoCompleteViewController.tableView.frame, to: nil)
let padding = tableViewFrameInWindow.maxY + ComposeContentToolbarView.toolbarHeight + AutoCompleteViewController.chevronViewHeight - self.view.frame.maxY
return max(0, padding)
}()
self.autoCompleteViewController.tableView.contentInset.bottom = autoCompleteTableViewBottomInset
self.autoCompleteViewController.tableView.verticalScrollIndicatorInsets.bottom = autoCompleteTableViewBottomInset
}
UIView.animate(withDuration: 0.3) {
self.composeContentToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom
@ -169,17 +193,16 @@ extension ComposeContentViewController {
return
}
// isShow AND dock state
// self.systemKeyboardHeight = endFrame.height
// adjust inset for auto-complete
// let autoCompleteTableViewBottomInset: CGFloat = {
// guard let superview = self.autoCompleteViewController.tableView.superview else { return .zero }
// let tableViewFrameInWindow = superview.convert(self.autoCompleteViewController.tableView.frame, to: nil)
// let padding = tableViewFrameInWindow.maxY + self.composeToolbarView.frame.height + AutoCompleteViewController.chevronViewHeight - endFrame.minY
// return max(0, padding)
// }()
// self.autoCompleteViewController.tableView.contentInset.bottom = autoCompleteTableViewBottomInset
// self.autoCompleteViewController.tableView.verticalScrollIndicatorInsets.bottom = autoCompleteTableViewBottomInset
let autoCompleteTableViewBottomInset: CGFloat = {
guard let superview = self.autoCompleteViewController.tableView.superview else { return .zero }
let tableViewFrameInWindow = superview.convert(self.autoCompleteViewController.tableView.frame, to: nil)
let padding = tableViewFrameInWindow.maxY + ComposeContentToolbarView.toolbarHeight + AutoCompleteViewController.chevronViewHeight - endFrame.minY
return max(0, padding)
}()
self.autoCompleteViewController.tableView.contentInset.bottom = autoCompleteTableViewBottomInset
self.autoCompleteViewController.tableView.verticalScrollIndicatorInsets.bottom = autoCompleteTableViewBottomInset
// adjust inset for tableView
let contentFrame = self.view.convert(self.tableView.frame, to: nil)
@ -218,14 +241,63 @@ extension ComposeContentViewController {
}
.store(in: &disposeBag)
// bind auto-complete
viewModel.$autoCompleteInfo
.receive(on: DispatchQueue.main)
.sink { [weak self] info in
guard let self = self else { return }
guard let textView = self.viewModel.contentMetaText?.textView else { return }
if self.autoCompleteViewController.view.superview == nil {
self.autoCompleteViewController.view.frame = self.view.bounds
// add to container view. seealso: `viewDidLayoutSubviews()`
self.viewModel.composeContentTableViewCell.contentView.addSubview(self.autoCompleteViewController.view)
self.addChild(self.autoCompleteViewController)
self.autoCompleteViewController.didMove(toParent: self)
self.autoCompleteViewController.view.isHidden = true
self.tableView.autoCompleteViewController = self.autoCompleteViewController
}
self.updateAutoCompleteViewControllerLayout()
self.autoCompleteViewController.view.isHidden = info == nil
guard let info = info else { return }
let symbolBoundingRectInContainer = textView.convert(info.symbolBoundingRect, to: self.autoCompleteViewController.chevronView)
print(info.symbolBoundingRect)
self.autoCompleteViewController.view.frame.origin.y = info.textBoundingRect.maxY + self.viewModel.contentTextViewFrame.minY
self.autoCompleteViewController.viewModel.symbolBoundingRect.value = symbolBoundingRectInContainer
self.autoCompleteViewController.viewModel.inputText.value = String(info.inputText)
}
.store(in: &disposeBag)
// bind emoji picker
viewModel.customEmojiViewModel?.emojis
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] emojis in
guard let self = self else { return }
if emojis.isEmpty {
self.customEmojiPickerInputView.activityIndicatorView.startAnimating()
} else {
self.customEmojiPickerInputView.activityIndicatorView.stopAnimating()
}
})
.store(in: &disposeBag)
// bind toolbar
bindToolbarViewModel()
// bind attachment picker
viewModel.$attachmentViewModels
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self = self else { return }
self.resetImagePicker()
}
.store(in: &disposeBag)
}
public override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
viewModel.viewLayoutFrame.update(view: view)
updateAutoCompleteViewControllerLayout()
}
public override func viewSafeAreaInsetsDidChange() {
@ -257,6 +329,8 @@ extension ComposeContentViewController {
}
private func bindToolbarViewModel() {
viewModel.$isAttachmentButtonEnabled.assign(to: &composeContentToolbarViewModel.$isAttachmentButtonEnabled)
viewModel.$isPollButtonEnabled.assign(to: &composeContentToolbarViewModel.$isPollButtonEnabled)
viewModel.$isPollActive.assign(to: &composeContentToolbarViewModel.$isPollActive)
viewModel.$isEmojiActive.assign(to: &composeContentToolbarViewModel.$isEmojiActive)
viewModel.$isContentWarningActive.assign(to: &composeContentToolbarViewModel.$isContentWarningActive)
@ -264,6 +338,29 @@ extension ComposeContentViewController {
viewModel.$contentWeightedLength.assign(to: &composeContentToolbarViewModel.$contentWeightedLength)
viewModel.$contentWarningWeightedLength.assign(to: &composeContentToolbarViewModel.$contentWarningWeightedLength)
}
private func updateAutoCompleteViewControllerLayout() {
// pin autoCompleteViewController frame to current view
if let containerView = autoCompleteViewController.view.superview {
let viewFrameInWindow = containerView.convert(autoCompleteViewController.view.frame, to: view)
if viewFrameInWindow.origin.x != 0 {
autoCompleteViewController.view.frame.origin.x = -viewFrameInWindow.origin.x
}
autoCompleteViewController.view.frame.size.width = view.frame.width
}
}
private func resetImagePicker() {
let selectionLimit = max(1, viewModel.maxMediaAttachmentLimit - viewModel.attachmentViewModels.count)
let configuration = ComposeContentViewController.createPhotoLibraryPickerConfiguration(selectionLimit: selectionLimit)
photoLibraryPicker = createImagePicker(configuration: configuration)
}
private func createImagePicker(configuration: PHPickerConfiguration) -> PHPickerViewController {
let imagePicker = PHPickerViewController(configuration: configuration)
imagePicker.delegate = self
return imagePicker
}
}
// MARK: - UIScrollViewDelegate
@ -325,16 +422,15 @@ extension ComposeContentViewController: PHPickerViewControllerDelegate {
public func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true, completion: nil)
// TODO:
// let attachmentServices: [MastodonAttachmentService] = results.map { result in
// let service = MastodonAttachmentService(
// context: context,
// pickerResult: result,
// initialAuthenticationBox: viewModel.authenticationBox
// )
// return service
// }
// viewModel.attachmentServices = viewModel.attachmentServices + attachmentServices
let attachmentViewModels: [AttachmentViewModel] = results.map { result in
AttachmentViewModel(
api: viewModel.context.apiService,
authContext: viewModel.authContext,
input: .pickerResult(result),
delegate: viewModel
)
}
viewModel.attachmentViewModels += attachmentViewModels
}
}
@ -345,12 +441,13 @@ extension ComposeContentViewController: UIImagePickerControllerDelegate & UINavi
guard let image = info[.originalImage] as? UIImage else { return }
// let attachmentService = MastodonAttachmentService(
// context: context,
// image: image,
// initialAuthenticationBox: viewModel.authenticationBox
// )
// viewModel.attachmentServices = viewModel.attachmentServices + [attachmentService]
let attachmentViewModel = AttachmentViewModel(
api: viewModel.context.apiService,
authContext: viewModel.authContext,
input: .image(image),
delegate: viewModel
)
viewModel.attachmentViewModels += [attachmentViewModel]
}
public func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
@ -364,12 +461,13 @@ extension ComposeContentViewController: UIDocumentPickerDelegate {
public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first else { return }
// let attachmentService = MastodonAttachmentService(
// context: context,
// documentURL: url,
// initialAuthenticationBox: viewModel.authenticationBox
// )
// viewModel.attachmentServices = viewModel.attachmentServices + [attachmentService]
let attachmentViewModel = AttachmentViewModel(
api: viewModel.context.apiService,
authContext: viewModel.authContext,
input: .url(url),
delegate: viewModel
)
viewModel.attachmentViewModels += [attachmentViewModel]
}
}
@ -428,3 +526,123 @@ extension ComposeContentViewController: ComposeContentToolbarViewDelegate {
}
}
}
// MARK: - AutoCompleteViewControllerDelegate
extension ComposeContentViewController: AutoCompleteViewControllerDelegate {
func autoCompleteViewController(
_ viewController: AutoCompleteViewController,
didSelectItem item: AutoCompleteItem
) {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): did select item: \(String(describing: item))")
guard let info = viewModel.autoCompleteInfo else { return }
guard let metaText = viewModel.contentMetaText else { return }
let _replacedText: String? = {
var text: String
switch item {
case .hashtag(let hashtag):
text = "#" + hashtag.name
case .hashtagV1(let hashtagName):
text = "#" + hashtagName
case .account(let account):
text = "@" + account.acct
case .emoji(let emoji):
text = ":" + emoji.shortcode + ":"
case .bottomLoader:
return nil
}
return text
}()
guard let replacedText = _replacedText else { return }
guard let text = metaText.textView.text else { return }
let range = NSRange(info.toHighlightEndRange, in: text)
metaText.textStorage.replaceCharacters(in: range, with: replacedText)
viewModel.autoCompleteInfo = nil
// set selected range
let newRange = NSRange(location: range.location + (replacedText as NSString).length, length: 0)
guard metaText.textStorage.length <= newRange.location else { return }
metaText.textView.selectedRange = newRange
// append a space and trigger textView delegate update
DispatchQueue.main.async {
metaText.textView.insertText(" ")
}
}
}
// MARK: - UICollectionViewDelegate
extension ComposeContentViewController: UICollectionViewDelegate {
public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: select %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription)
switch collectionView {
case customEmojiPickerInputView.collectionView:
guard let diffableDataSource = viewModel.customEmojiPickerDiffableDataSource else { return }
let item = diffableDataSource.itemIdentifier(for: indexPath)
guard case let .emoji(attribute) = item else { return }
let emoji = attribute.emoji
// make click sound
UIDevice.current.playInputClick()
// retrieve active text input and insert emoji
// the trailing space is REQUIRED to make regex happy
_ = viewModel.customEmojiPickerInputViewModel.insertText(":\(emoji.shortcode): ")
default:
assertionFailure()
}
} // end func
}
// MARK: - ComposeContentViewModelDelegate
extension ComposeContentViewController: ComposeContentViewModelDelegate {
public func composeContentViewModel(
_ viewModel: ComposeContentViewModel,
handleAutoComplete info: ComposeContentViewModel.AutoCompleteInfo
) -> Bool {
let snapshot = autoCompleteViewController.viewModel.diffableDataSource.snapshot()
guard let item = snapshot.itemIdentifiers.first else { return false }
// FIXME: redundant code
guard let metaText = viewModel.contentMetaText else { return false }
guard let text = metaText.textView.text else { return false }
let _replacedText: String? = {
var text: String
switch item {
case .hashtag(let hashtag):
text = "#" + hashtag.name
case .hashtagV1(let hashtagName):
text = "#" + hashtagName
case .account(let account):
text = "@" + account.acct
case .emoji(let emoji):
text = ":" + emoji.shortcode + ":"
case .bottomLoader:
return nil
}
return text
}()
guard let replacedText = _replacedText else { return false }
let range = NSRange(info.toHighlightEndRange, in: text)
metaText.textStorage.replaceCharacters(in: range, with: replacedText)
viewModel.autoCompleteInfo = nil
// set selected range
let newRange = NSRange(location: range.location + (replacedText as NSString).length, length: 0)
guard metaText.textStorage.length <= newRange.location else { return true }
metaText.textView.selectedRange = newRange
// append a space and trigger textView delegate update
DispatchQueue.main.async {
metaText.textView.insertText(" ")
}
return true
}
}

View File

@ -66,14 +66,15 @@ extension ComposeContentViewModel {
guard let replyTo = status.object(in: context.managedObjectContext) else { return }
cell.statusView.configure(status: replyTo)
}
case .hashtag(let hashtag):
case .hashtag:
break
case .mention(let user):
case .mention:
break
}
}
}
// MARK: - UITableViewDataSource
extension ComposeContentViewModel: UITableViewDataSource {
public func numberOfSections(in tableView: UITableView) -> Int {
return Section.allCases.count
@ -99,3 +100,42 @@ extension ComposeContentViewModel: UITableViewDataSource {
}
}
}
extension ComposeContentViewModel {
func setupCustomEmojiPickerDiffableDataSource(
collectionView: UICollectionView
) {
let diffableDataSource = CustomEmojiPickerSection.collectionViewDiffableDataSource(
collectionView: collectionView,
context: context
)
self.customEmojiPickerDiffableDataSource = diffableDataSource
let domain = authContext.mastodonAuthenticationBox.domain.uppercased()
customEmojiViewModel?.emojis
.receive(on: DispatchQueue.main)
.sink { [weak self, weak diffableDataSource] emojis in
guard let _ = self else { return }
guard let diffableDataSource = diffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<CustomEmojiPickerSection, CustomEmojiPickerItem>()
let customEmojiSection = CustomEmojiPickerSection.emoji(name: domain)
snapshot.appendSections([customEmojiSection])
let items: [CustomEmojiPickerItem] = {
var items = [CustomEmojiPickerItem]()
for emoji in emojis where emoji.visibleInPicker {
let attribute = CustomEmojiPickerItem.CustomEmojiAttribute(emoji: emoji)
let item = CustomEmojiPickerItem.emoji(attribute: attribute)
items.append(item)
}
return items
}()
snapshot.appendItems(items, toSection: customEmojiSection)
diffableDataSource.apply(snapshot)
}
.store(in: &disposeBag)
}
}

View File

@ -37,7 +37,7 @@ extension ComposeContentViewModel: MetaTextDelegate {
let content = MastodonContent(
content: textInput,
emojis: [:] // TODO: emojiViewModel?.emojis.asDictionary ?? [:]
emojis: [:] // customEmojiViewModel?.emojis.value.asDictionary ?? [:]
)
let metaContent = MastodonMetaContent.convert(text: content)
return metaContent
@ -48,7 +48,7 @@ extension ComposeContentViewModel: MetaTextDelegate {
let content = MastodonContent(
content: textInput,
emojis: [:] // emojiViewModel?.emojis.asDictionary ?? [:]
emojis: [:] // customEmojiViewModel?.emojis.value.asDictionary ?? [:]
)
let metaContent = MastodonMetaContent.convert(text: content)
return metaContent

View File

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

View File

@ -14,6 +14,11 @@ import MetaTextKit
import MastodonMeta
import MastodonCore
import MastodonSDK
import MastodonLocalization
public protocol ComposeContentViewModelDelegate: AnyObject {
func composeContentViewModel(_ viewModel: ComposeContentViewModel, handleAutoComplete info: ComposeContentViewModel.AutoCompleteInfo) -> Bool
}
public final class ComposeContentViewModel: NSObject, ObservableObject {
@ -28,12 +33,20 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
// input
let context: AppContext
let kind: Kind
weak var delegate: ComposeContentViewModelDelegate?
@Published var viewLayoutFrame = ViewLayoutFrame()
// author (me)
@Published var authContext: AuthContext
// auto-complete info
@Published var autoCompleteRetryLayoutTimes = 0
@Published var autoCompleteInfo: AutoCompleteInfo? = nil
// emoji
var customEmojiPickerDiffableDataSource: UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem>?
// output
// limit
@ -42,10 +55,12 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
// content
public weak var contentMetaText: MetaText? {
didSet {
// guard let textView = contentMetaText?.textView else { return }
// customEmojiPickerInputViewModel.configure(textInput: textView)
guard let textView = contentMetaText?.textView else { return }
customEmojiPickerInputViewModel.configure(textInput: textView)
}
}
// for hashtag: "#<hashtag> "
// for mention: "@<mention> "
@Published public var initialContent = ""
@Published public var content = ""
@Published public var contentWeightedLength = 0
@ -56,8 +71,8 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
// content warning
weak var contentWarningMetaText: MetaText? {
didSet {
//guard let textView = contentWarningMetaText?.textView else { return }
//customEmojiPickerInputViewModel.configure(textInput: textView)
guard let textView = contentWarningMetaText?.textView else { return }
customEmojiPickerInputViewModel.configure(textInput: textView)
}
}
@Published public var isContentWarningActive = false
@ -91,6 +106,9 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
// emoji
@Published var isEmojiActive = false
let customEmojiViewModel: EmojiService.CustomEmojiViewModel?
let customEmojiPickerInputViewModel = CustomEmojiPickerInputViewModel()
@Published var isLoadingCustomEmoji = false
// visibility
@Published var visibility: Mastodon.Entity.Status.Visibility
@ -98,8 +116,16 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
// UI & UX
@Published var replyToCellFrame: CGRect = .zero
@Published var contentCellFrame: CGRect = .zero
@Published var contentTextViewFrame: CGRect = .zero
@Published var scrollViewState: ScrollViewState = .fold
@Published var characterCount: Int = 0
@Published public private(set) var isPublishBarButtonItemEnabled = true
@Published var isAttachmentButtonEnabled = false
@Published var isPollButtonEnabled = false
@Published public private(set) var shouldDismiss = true
public init(
context: AppContext,
@ -144,9 +170,76 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
}
return visibility
}()
self.customEmojiViewModel = context.emojiService.dequeueCustomEmojiViewModel(
for: authContext.mastodonAuthenticationBox.domain
)
super.init()
// end init
// setup initial value
switch kind {
case .reply(let record):
context.managedObjectContext.performAndWait {
guard let status = record.object(in: context.managedObjectContext) else {
assertionFailure()
return
}
let author = authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user
var mentionAccts: [String] = []
if author?.id != status.author.id {
mentionAccts.append("@" + status.author.acct)
}
let mentions = status.mentions
.filter { author?.id != $0.id }
for mention in mentions {
let acct = "@" + mention.acct
guard !mentionAccts.contains(acct) else { continue }
mentionAccts.append(acct)
}
for acct in mentionAccts {
UITextChecker.learnWord(acct)
}
if let spoilerText = status.spoilerText, !spoilerText.isEmpty {
self.isContentWarningActive = true
self.contentWarning = spoilerText
}
let initialComposeContent = mentionAccts.joined(separator: " ")
let preInsertedContent: String? = initialComposeContent.isEmpty ? nil : initialComposeContent + " "
self.initialContent = preInsertedContent ?? ""
self.content = preInsertedContent ?? ""
}
case .hashtag(let hashtag):
let initialComposeContent = "#" + hashtag
UITextChecker.learnWord(initialComposeContent)
let preInsertedContent = initialComposeContent + " "
self.initialContent = preInsertedContent
self.content = preInsertedContent
case .mention(let record):
context.managedObjectContext.performAndWait {
guard let user = record.object(in: context.managedObjectContext) else { return }
let initialComposeContent = "@" + user.acct
UITextChecker.learnWord(initialComposeContent)
let preInsertedContent = initialComposeContent + " "
self.initialContent = preInsertedContent
self.content = preInsertedContent
}
case .post:
break
}
bind()
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension ComposeContentViewModel {
private func bind() {
// bind author
$authContext
.sink { [weak self] authContext in
@ -177,12 +270,138 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
)
.map { $0 + $1 <= $2 }
.assign(to: &$isContentValid)
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
// bind attachment
$attachmentViewModels
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self = self else { return }
Task {
try await self.uploadMediaInQueue()
}
}
.store(in: &disposeBag)
// bind emoji inputView
$isEmojiActive.assign(to: &customEmojiPickerInputViewModel.$isCustomEmojiComposing)
// bind toolbar
Publishers.CombineLatest3(
$isPollActive,
$attachmentViewModels,
$maxMediaAttachmentLimit
)
.receive(on: DispatchQueue.main)
.sink { [weak self] isPollActive, attachmentViewModels, maxMediaAttachmentLimit in
guard let self = self else { return }
let shouldMediaDisable = isPollActive || attachmentViewModels.count >= maxMediaAttachmentLimit
let shouldPollDisable = attachmentViewModels.count > 0
self.isAttachmentButtonEnabled = !shouldMediaDisable
self.isPollButtonEnabled = !shouldPollDisable
}
.store(in: &disposeBag)
// bind status content character count
Publishers.CombineLatest3(
$contentWeightedLength,
$contentWarningWeightedLength,
$isContentWarningActive
)
.map { contentWeightedLength, contentWarningWeightedLength, isContentWarningActive -> Int in
var count = contentWeightedLength
if isContentWarningActive {
count += contentWarningWeightedLength
}
return count
}
.assign(to: &$characterCount)
// bind compose bar button item UI state
let isComposeContentEmpty = $content
.map { $0.isEmpty }
let isComposeContentValid = Publishers.CombineLatest(
$characterCount,
$maxTextInputLimit
)
.map { characterCount, maxTextInputLimit in
characterCount <= maxTextInputLimit
}
let isMediaEmpty = $attachmentViewModels
.map { $0.isEmpty }
let isMediaUploadAllSuccess = $attachmentViewModels
.map { attachmentViewModels in
return Publishers.MergeMany(attachmentViewModels.map { $0.$uploadState })
.delay(for: 0.5, scheduler: DispatchQueue.main) // convert to outputs with delay. Due to @Published emit before changes
.map { _ in attachmentViewModels.map { $0.uploadState } }
}
.switchToLatest()
.map { outputs in
guard outputs.allSatisfy({ $0 == .finish }) else { return false }
return true
}
let isPollOptionsAllValid = $pollOptions
.map { options in
return Publishers.MergeMany(options.map { $0.$text })
.delay(for: 0.5, scheduler: DispatchQueue.main) // convert to outputs with delay. Due to @Published emit before changes
.map { _ in options.map { $0.text } }
}
.switchToLatest()
.map { outputs in
return outputs.allSatisfy { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
}
let isPublishBarButtonItemEnabledPrecondition1 = Publishers.CombineLatest4(
isComposeContentEmpty,
isComposeContentValid,
isMediaEmpty,
isMediaUploadAllSuccess
)
.map { isComposeContentEmpty, isComposeContentValid, isMediaEmpty, isMediaUploadAllSuccess -> Bool in
if isMediaEmpty {
return isComposeContentValid && !isComposeContentEmpty
} else {
return isComposeContentValid && isMediaUploadAllSuccess
}
}
.eraseToAnyPublisher()
let isPublishBarButtonItemEnabledPrecondition2 = Publishers.CombineLatest4(
isComposeContentEmpty,
isComposeContentValid,
$isPollActive,
isPollOptionsAllValid
)
.map { isComposeContentEmpty, isComposeContentValid, isPollComposing, isPollOptionsAllValid -> Bool in
if isPollComposing {
return isComposeContentValid && !isComposeContentEmpty && isPollOptionsAllValid
} else {
return isComposeContentValid && !isComposeContentEmpty
}
}
.eraseToAnyPublisher()
Publishers.CombineLatest(
isPublishBarButtonItemEnabledPrecondition1,
isPublishBarButtonItemEnabledPrecondition2
)
.map { $0 && $1 }
.assign(to: &$isPublishBarButtonItemEnabled)
// bind modal dismiss state
$content
.receive(on: DispatchQueue.main)
.map { content in
if content.isEmpty {
return true
}
// if the trimmed content equal to initial content
return content.trimmingCharacters(in: .whitespacesAndNewlines) == self.initialContent
}
.assign(to: &$shouldDismiss)
}
}
extension ComposeContentViewModel {
@ -192,13 +411,30 @@ extension ComposeContentViewModel {
case mention(user: ManagedObjectRecord<MastodonUser>)
case reply(status: ManagedObjectRecord<Status>)
}
public enum ScrollViewState {
case fold // snap to input
case expand // snap to reply
}
}
extension ComposeContentViewModel {
public struct AutoCompleteInfo {
// model
let inputText: Substring
// range
let symbolRange: Range<String.Index>
let symbolString: Substring
let toCursorRange: Range<String.Index>
let toCursorString: Substring
let toHighlightEndRange: Range<String.Index>
let toHighlightEndString: Substring
// geometry
var textBoundingRect: CGRect = .zero
var symbolBoundingRect: CGRect = .zero
}
}
extension ComposeContentViewModel {
func createNewPollOptionIfCould() {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
@ -275,70 +511,58 @@ extension ComposeContentViewModel {
} // end func publisher()
}
// MARK: - UITextViewDelegate
extension ComposeContentViewModel: UITextViewDelegate {
public func textViewDidBeginEditing(_ textView: UITextView) {
switch textView {
case contentMetaText?.textView:
isContentEditing = true
case contentWarningMetaText?.textView:
isContentWarningEditing = true
default:
break
}
}
extension ComposeContentViewModel {
public func textViewDidEndEditing(_ textView: UITextView) {
switch textView {
case contentMetaText?.textView:
isContentEditing = false
case contentWarningMetaText?.textView:
isContentWarningEditing = false
default:
break
public enum AttachmentPrecondition: Error, LocalizedError {
case videoAttachWithPhoto
case moreThanOneVideo
public var errorDescription: String? {
return L10n.Common.Alerts.PublishPostFailure.title
}
}
public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
switch textView {
case contentMetaText?.textView:
return true
case contentWarningMetaText?.textView:
let isReturn = text == "\n"
if isReturn {
setContentTextViewFirstResponderIfNeeds()
public var failureReason: String? {
switch self {
case .videoAttachWithPhoto:
return L10n.Common.Alerts.PublishPostFailure.AttachmentsMessage.videoAttachWithPhoto
case .moreThanOneVideo:
return L10n.Common.Alerts.PublishPostFailure.AttachmentsMessage.moreThanOneVideo
}
}
}
// check exclusive limit:
// - up to 1 video
// - up to N photos
public func checkAttachmentPrecondition() throws {
let attachmentViewModels = self.attachmentViewModels
guard !attachmentViewModels.isEmpty else { return }
var photoAttachmentViewModels: [AttachmentViewModel] = []
var videoAttachmentViewModels: [AttachmentViewModel] = []
attachmentViewModels.forEach { attachmentViewModel in
guard let output = attachmentViewModel.output else {
assertionFailure()
return
}
switch output {
case .image:
photoAttachmentViewModels.append(attachmentViewModel)
case .video:
videoAttachmentViewModels.append(attachmentViewModel)
}
}
if !videoAttachmentViewModels.isEmpty {
guard videoAttachmentViewModels.count == 1 else {
throw AttachmentPrecondition.moreThanOneVideo
}
guard photoAttachmentViewModels.isEmpty else {
throw AttachmentPrecondition.videoAttachWithPhoto
}
return !isReturn
default:
assertionFailure()
return true
}
}
func insertContentText(text: String) {
guard let contentMetaText = self.contentMetaText else { return }
// FIXME: smart prefix and suffix
let string = contentMetaText.textStorage.string
let isEmpty = string.isEmpty
let hasPrefix = string.hasPrefix(" ")
if hasPrefix || isEmpty {
contentMetaText.textView.insertText(text)
} else {
contentMetaText.textView.insertText(" " + text)
}
}
func setContentTextViewFirstResponderIfNeeds() {
guard let contentMetaText = self.contentMetaText else { return }
guard !contentMetaText.textView.isFirstResponder else { return }
contentMetaText.textView.becomeFirstResponder()
}
func setContentWarningTextViewFirstResponderIfNeeds() {
guard let contentWarningMetaText = self.contentWarningMetaText else { return }
guard !contentWarningMetaText.textView.isFirstResponder else { return }
contentWarningMetaText.textView.becomeFirstResponder()
}
}
// MARK: - DeleteBackwardResponseTextFieldRelayDelegate
@ -392,3 +616,56 @@ extension ComposeContentViewModel: DeleteBackwardResponseTextFieldRelayDelegate
}
}
// MARK: - AttachmentViewModelDelegate
extension ComposeContentViewModel: AttachmentViewModelDelegate {
public func attachmentViewModel(
_ viewModel: AttachmentViewModel,
uploadStateValueDidChange state: AttachmentViewModel.UploadState
) {
Task {
try await uploadMediaInQueue()
}
}
@MainActor
func uploadMediaInQueue() async throws {
for (i, attachmentViewModel) in attachmentViewModels.enumerated() {
switch attachmentViewModel.uploadState {
case .none:
return
case .compressing:
return
case .ready:
let count = self.attachmentViewModels.count
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): upload \(i)/\(count) attachment")
try await attachmentViewModel.upload()
return
case .uploading:
return
case .fail:
return
case .finish:
continue
}
}
}
public func attachmentViewModel(
_ viewModel: AttachmentViewModel,
actionButtonDidPressed action: AttachmentViewModel.Action
) {
switch action {
case .retry:
Task {
try await viewModel.upload(isRetry: true)
}
case .remove:
attachmentViewModels.removeAll(where: { $0 === viewModel })
Task {
try await uploadMediaInQueue()
}
}
}
}

View File

@ -9,7 +9,6 @@ import UIKit
import Combine
import MetaTextKit
import MastodonCore
import MastodonUI
final class CustomEmojiPickerInputViewModel {
@ -20,8 +19,7 @@ final class CustomEmojiPickerInputViewModel {
// input
weak var customEmojiPickerInputView: CustomEmojiPickerInputView?
// output
let isCustomEmojiComposing = CurrentValueSubject<Bool, Never>(false)
@Published var isCustomEmojiComposing = false
}
@ -51,27 +49,28 @@ extension CustomEmojiPickerInputViewModel {
for reference in customEmojiReplaceableTextInputReferences {
guard let textInput = reference.value else { continue }
guard textInput.isFirstResponder == true else { continue }
guard let selectedTextRange = textInput.selectedTextRange else { continue }
// guard let selectedTextRange = textInput.selectedTextRange else { continue }
textInput.insertText(text)
// FIXME: inline emoji
// due to insert text render as attachment
// the cursor reset logic not works
// hack with hard code +2 offset
assert(text.hasSuffix(": "))
guard text.hasPrefix(":") && text.hasSuffix(": ") else { continue }
if let _ = textInput as? MetaTextView {
if let newPosition = textInput.position(from: selectedTextRange.start, offset: 2) {
let newSelectedTextRange = textInput.textRange(from: newPosition, to: newPosition)
textInput.selectedTextRange = newSelectedTextRange
}
} else {
if let newPosition = textInput.position(from: selectedTextRange.start, offset: text.length) {
let newSelectedTextRange = textInput.textRange(from: newPosition, to: newPosition)
textInput.selectedTextRange = newSelectedTextRange
}
}
// assert(text.hasSuffix(": "))
// guard text.hasPrefix(":") && text.hasSuffix(": ") else { continue }
//
// if let _ = textInput as? MetaTextView {
// if let newPosition = textInput.position(from: selectedTextRange.start, offset: 2) {
// let newSelectedTextRange = textInput.textRange(from: newPosition, to: newPosition)
// textInput.selectedTextRange = newSelectedTextRange
// }
// } else {
// if let newPosition = textInput.position(from: selectedTextRange.start, offset: text.length) {
// let newSelectedTextRange = textInput.textRange(from: newPosition, to: newPosition)
// textInput.selectedTextRange = newSelectedTextRange
// }
// }
return reference
}
@ -81,3 +80,16 @@ extension CustomEmojiPickerInputViewModel {
}
extension CustomEmojiPickerInputViewModel {
public func configure(textInput: CustomEmojiReplaceableTextInput) {
$isCustomEmojiComposing
.receive(on: DispatchQueue.main)
.sink { [weak self] isCustomEmojiComposing in
guard let self = self else { return }
textInput.inputView = isCustomEmojiComposing ? self.customEmojiPickerInputView : nil
textInput.reloadInputViews()
self.append(customEmojiReplaceableTextInput: textInput)
}
.store(in: &disposeBag)
}
}

View File

@ -39,7 +39,7 @@ public struct PollOptionTextField: UIViewRepresentable {
textField.text = text
textField.placeholder = {
if index >= 0 {
return L10n.Scene.Compose.Poll.optionNumber(index)
return L10n.Scene.Compose.Poll.optionNumber(index + 1)
} else {
assertionFailure()
return ""

View File

@ -119,13 +119,31 @@ extension MastodonStatusPublisher: StatusPublisher {
progress.addChild(attachmentViewModel.progress, withPendingUnitCount: publishAttachmentTaskWeight)
// upload media
do {
let result = try await attachmentViewModel.upload(context: uploadContext)
guard case let .mastodon(response) = result else {
assertionFailure()
continue
guard let attachment = attachmentViewModel.uploadResult else {
// precondition: all media uploaded
throw AppError.badRequest
}
let attachmentID = response.value.id
attachmentIDs.append(attachmentID)
attachmentIDs.append(attachment.id)
let caption = attachmentViewModel.caption
guard !caption.isEmpty else { continue }
_ = try await api.updateMedia(
domain: authContext.mastodonAuthenticationBox.domain,
attachmentID: attachment.id,
query: .init(
file: nil,
thumbnail: nil,
description: caption,
focus: nil
),
mastodonAuthenticationBox: authContext.mastodonAuthenticationBox
).singleOutput()
// TODO: allow background upload
// let attachment = try await attachmentViewModel.upload(context: uploadContext)
// let attachmentID = attachment.id
// attachmentIDs.append(attachmentID)
} catch {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): upload attachment fail: \(error.localizedDescription)")
_state = .failure(error)

View File

@ -7,74 +7,12 @@
import os.log
import UIKit
import Combine
import MetaTextKit
import UITextView_Placeholder
import MastodonAsset
import MastodonLocalization
import UIHostingConfigurationBackport
//protocol ComposeStatusContentTableViewCellDelegate: AnyObject {
// func composeStatusContentTableViewCell(_ cell: ComposeStatusContentTableViewCell, textViewShouldBeginEditing textView: UITextView) -> Bool
//}
final class ComposeContentTableViewCell: UITableViewCell {
let logger = Logger(subsystem: "ComposeContentTableViewCell", category: "View")
// var disposeBag = Set<AnyCancellable>()
// weak var delegate: ComposeStatusContentTableViewCellDelegate?
//
// let statusView = StatusView()
//
// let statusContentWarningEditorView = StatusContentWarningEditorView()
//
// let textEditorViewContainerView = UIView()
//
// static let metaTextViewTag: Int = 333
// let metaText: MetaText = {
// let metaText = MetaText()
// metaText.textView.backgroundColor = .clear
// metaText.textView.isScrollEnabled = false
// metaText.textView.keyboardType = .twitter
// metaText.textView.textDragInteraction?.isEnabled = false // disable drag for link and attachment
// metaText.textView.textContainer.lineFragmentPadding = 10 // leading inset
// metaText.textView.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular))
// metaText.textView.attributedPlaceholder = {
// var attributes = metaText.textAttributes
// attributes[.foregroundColor] = Asset.Colors.Label.secondary.color
// return NSAttributedString(
// string: L10n.Scene.Compose.contentInputPlaceholder,
// attributes: attributes
// )
// }()
// metaText.paragraphStyle = {
// let style = NSMutableParagraphStyle()
// style.lineSpacing = 5
// style.paragraphSpacing = 0
// return style
// }()
// metaText.textAttributes = [
// .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)),
// .foregroundColor: Asset.Colors.Label.primary.color,
// ]
// metaText.linkAttributes = [
// .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)),
// .foregroundColor: Asset.Colors.brand.color,
// ]
// return metaText
// }()
//
// // output
// let contentWarningContent = PassthroughSubject<String, Never>()
//
// override func prepareForReuse() {
// super.prepareForReuse()
//
// metaText.delegate = nil
// metaText.textView.delegate = nil
// }
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
_init()
@ -93,79 +31,6 @@ extension ComposeContentTableViewCell {
selectionStyle = .none
layer.zPosition = 999
backgroundColor = .clear
// let containerStackView = UIStackView()
// containerStackView.axis = .vertical
// containerStackView.translatesAutoresizingMaskIntoConstraints = false
// contentView.addSubview(containerStackView)
// NSLayoutConstraint.activate([
// containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor),
// containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
// containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
// containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
// ])
// containerStackView.preservesSuperviewLayoutMargins = true
//
// containerStackView.addArrangedSubview(statusContentWarningEditorView)
// statusContentWarningEditorView.setContentHuggingPriority(.required - 1, for: .vertical)
//
// let statusContainerView = UIView()
// statusContainerView.preservesSuperviewLayoutMargins = true
// containerStackView.addArrangedSubview(statusContainerView)
// statusView.translatesAutoresizingMaskIntoConstraints = false
// statusContainerView.addSubview(statusView)
// NSLayoutConstraint.activate([
// statusView.topAnchor.constraint(equalTo: statusContainerView.topAnchor, constant: 20),
// statusView.leadingAnchor.constraint(equalTo: statusContainerView.leadingAnchor),
// statusView.trailingAnchor.constraint(equalTo: statusContainerView.trailingAnchor),
// statusView.bottomAnchor.constraint(equalTo: statusContainerView.bottomAnchor),
// ])
// statusView.setup(style: .composeStatusAuthor)
//
// containerStackView.addArrangedSubview(textEditorViewContainerView)
// metaText.textView.translatesAutoresizingMaskIntoConstraints = false
// textEditorViewContainerView.addSubview(metaText.textView)
// NSLayoutConstraint.activate([
// metaText.textView.topAnchor.constraint(equalTo: textEditorViewContainerView.topAnchor),
// metaText.textView.leadingAnchor.constraint(equalTo: textEditorViewContainerView.layoutMarginsGuide.leadingAnchor),
// metaText.textView.trailingAnchor.constraint(equalTo: textEditorViewContainerView.layoutMarginsGuide.trailingAnchor),
// metaText.textView.bottomAnchor.constraint(equalTo: textEditorViewContainerView.bottomAnchor),
// metaText.textView.heightAnchor.constraint(greaterThanOrEqualToConstant: 64).priority(.defaultHigh),
// ])
// statusContentWarningEditorView.textView.delegate = self
}
}
// MARK: - UITextViewDelegate
//extension ComposeStatusContentTableViewCell: UITextViewDelegate {
//
// func textViewShouldBeginEditing(_ textView: UITextView) -> Bool {
// return delegate?.composeStatusContentTableViewCell(self, textViewShouldBeginEditing: textView) ?? true
// }
//
// func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
// switch textView {
// case statusContentWarningEditorView.textView:
// // disable input line break
// guard text != "\n" else { return false }
// return true
// default:
// assertionFailure()
// return true
// }
// }
//
// func textViewDidChange(_ textView: UITextView) {
// logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): text: \(textView.text ?? "<nil>")")
// guard textView === statusContentWarningEditorView.textView else { return }
// // replace line break with space
// // needs check input state to prevent break the IME
// if textView.markedTextRange == nil {
// textView.text = textView.text.replacingOccurrences(of: "\n", with: " ")
// }
// contentWarningContent.send(textView.text)
// }
//
//}

View File

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

View File

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

View File

@ -27,6 +27,9 @@ extension ComposeContentToolbarView {
@Published var isEmojiActive = false
@Published var isContentWarningActive = false
@Published var isAttachmentButtonEnabled = false
@Published var isPollButtonEnabled = false
@Published public var maxTextInputLimit = 500
@Published public var contentWeightedLength = 0
@Published public var contentWarningWeightedLength = 0
@ -120,4 +123,19 @@ extension ComposeContentToolbarView.ViewModel {
return action.inactiveImage
}
}
func label(for action: Action) -> String {
switch action {
case .attachment:
return L10n.Scene.Compose.Accessibility.appendAttachment
case .poll:
return isPollActive ? L10n.Scene.Compose.Accessibility.removePoll : L10n.Scene.Compose.Accessibility.appendPoll
case .emoji:
return L10n.Scene.Compose.Accessibility.customEmojiPicker
case .contentWarning:
return isContentWarningActive ? L10n.Scene.Compose.Accessibility.disableContentWarning : L10n.Scene.Compose.Accessibility.enableContentWarning
case .visibility:
return L10n.Scene.Compose.Accessibility.postVisibilityMenu
}
}
}

View File

@ -44,7 +44,9 @@ struct ComposeContentToolbarView: View {
}
} label: {
label(for: action)
.opacity(viewModel.isAttachmentButtonEnabled ? 1.0 : 0.5)
}
.disabled(!viewModel.isAttachmentButtonEnabled)
.frame(width: 48, height: 48)
case .visibility:
Menu {
@ -61,8 +63,19 @@ struct ComposeContentToolbarView: View {
}
} label: {
label(for: viewModel.visibility.image)
.accessibilityLabel(L10n.Scene.Compose.Keyboard.selectVisibilityEntry(viewModel.visibility.title))
}
.frame(width: 48, height: 48)
case .poll:
Button {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(String(describing: action))")
viewModel.delegate?.composeContentToolbarView(viewModel, toolbarItemDidPressed: action)
} label: {
label(for: action)
.opacity(viewModel.isPollButtonEnabled ? 1.0 : 0.5)
}
.disabled(!viewModel.isPollButtonEnabled)
.frame(width: 48, height: 48)
default:
Button {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(String(describing: action))")
@ -86,11 +99,14 @@ struct ComposeContentToolbarView: View {
Text("\(remains)")
.foregroundColor(Color(isOverflow ? UIColor.systemRed : UIColor.secondaryLabel))
.font(.system(size: isOverflow ? 18 : 16, weight: isOverflow ? .medium : .regular))
.accessibilityLabel(L10n.A11y.Plural.Count.charactersLeft(remains))
}
.padding(.leading, 4) // 4 + 12 = 16
.padding(.trailing, 16)
.frame(height: ComposeContentToolbarView.toolbarHeight)
.background(Color(viewModel.backgroundColor))
.accessibilityElement(children: .contain)
.accessibilityLabel(L10n.Scene.Compose.Accessibility.postOptions)
}
}
@ -100,6 +116,7 @@ extension ComposeContentToolbarView {
Image(uiImage: viewModel.image(for: action))
.foregroundColor(Color(Asset.Scene.Compose.buttonTint.color))
.frame(width: 24, height: 24, alignment: .center)
.accessibilityLabel(viewModel.label(for: action))
}
func label(for image: UIImage) -> some View {

Some files were not shown because too many files have changed in this diff Show More