Merge branch 'develop' into l10n_develop

This commit is contained in:
CMK 2022-05-13 12:11:03 +08:00
commit 7bca92d1d2
298 changed files with 16288 additions and 2496 deletions

View File

@ -1,9 +1,16 @@
#!/bin/bash #!/bin/bash
sudo gem install cocoapods-keys # workaround https://github.com/CocoaPods/CocoaPods/issues/11355
sed -i '' $'1s/^/source "https:\\/\\/github.com\\/CocoaPods\\/Specs.git"\\\n\\\n/' Podfile
# Install Ruby Bundler
gem install bundler:2.3.11
# Install Ruby Gems
bundle install
# stub keys. DO NOT use in production # stub keys. DO NOT use in production
pod keys set notification_endpoint "<endpoint>" bundle exec pod keys set notification_endpoint "<endpoint>"
pod keys set notification_endpoint_debug "<endpoint>" bundle exec pod keys set notification_endpoint_debug "<endpoint>"
pod install bundle exec pod install

View File

@ -10,10 +10,7 @@ import Foundation
import CryptoKit import CryptoKit
import KeychainAccess import KeychainAccess
import Keys import Keys
import MastodonCommon
enum AppName {
public static let groupID = "group.org.joinmastodon.app"
}
public final class AppSecret { public final class AppSecret {

View File

@ -15,8 +15,8 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.3.0</string> <string>1.4.2</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>109</string> <string>127</string>
</dict> </dict>
</plist> </plist>

View File

@ -12,12 +12,13 @@ Intell the latest version of Xcode from the App Store or Apple Developer Downloa
This guide may not suit your machine and actually setup procedure may change in the future. Please file the issue or Pull Request if there are any problems. This guide may not suit your machine and actually setup procedure may change in the future. Please file the issue or Pull Request if there are any problems.
## CocoaPods ## CocoaPods
The app use [CocoaPods]() and [CocoaPods-Keys](https://github.com/orta/cocoapods-keys). The M1 Mac needs virtual ruby env to workaround compatibility issues. The app use [CocoaPods]() and [CocoaPods-Keys](https://github.com/orta/cocoapods-keys). Ruby Gems are managed through Bundler. The M1 Mac needs virtual ruby env to workaround compatibility issues.
#### Intel Mac #### Intel Mac
```zsh ```zsh
sudo gem install cocoapods cocoapods-keys gem install bundler
bundle install
``` ```
#### M1 Mac #### M1 Mac
@ -40,18 +41,19 @@ rbenv global 3.0.3
ruby --version ruby --version
# > ruby 3.0.3p157 (2021-11-24 revision 3fb7d2cadc) [arm64-darwin21] # > ruby 3.0.3p157 (2021-11-24 revision 3fb7d2cadc) [arm64-darwin21]
sudo gem install cocoapods cocoapods-keys gem install bundler
bundle install
``` ```
## Bootstrap ## Bootstrap
```zsh ```zsh
# make a clean build # make a clean build
sudo gem install cocoapods-clean bundle install
pod clean bundle exec pod clean
# make install # make install
pod install --repo-update bundle exec pod install --repo-update
# open workspace # open workspace
open Mastodon.xcworkspace open Mastodon.xcworkspace

6
Gemfile Normal file
View File

@ -0,0 +1,6 @@
source "https://rubygems.org"
gem "cocoapods"
gem "cocoapods-clean"
gem "cocoapods-keys"

109
Gemfile.lock Normal file
View File

@ -0,0 +1,109 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.5)
rexml
RubyInline (3.12.5)
ZenTest (~> 4.3)
ZenTest (4.12.1)
activesupport (6.1.5.1)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
zeitwerk (~> 2.3)
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
algoliasearch (1.27.5)
httpclient (~> 2.8, >= 2.8.3)
json (>= 1.5.1)
atomos (0.1.3)
claide (1.1.0)
cocoapods (1.11.3)
addressable (~> 2.8)
claide (>= 1.0.2, < 2.0)
cocoapods-core (= 1.11.3)
cocoapods-deintegrate (>= 1.0.3, < 2.0)
cocoapods-downloader (>= 1.4.0, < 2.0)
cocoapods-plugins (>= 1.0.0, < 2.0)
cocoapods-search (>= 1.0.0, < 2.0)
cocoapods-trunk (>= 1.4.0, < 2.0)
cocoapods-try (>= 1.1.0, < 2.0)
colored2 (~> 3.1)
escape (~> 0.0.4)
fourflusher (>= 2.3.0, < 3.0)
gh_inspector (~> 1.0)
molinillo (~> 0.8.0)
nap (~> 1.0)
ruby-macho (>= 1.0, < 3.0)
xcodeproj (>= 1.21.0, < 2.0)
cocoapods-clean (0.0.1)
cocoapods-core (1.11.3)
activesupport (>= 5.0, < 7)
addressable (~> 2.8)
algoliasearch (~> 1.0)
concurrent-ruby (~> 1.1)
fuzzy_match (~> 2.0.4)
nap (~> 1.0)
netrc (~> 0.11)
public_suffix (~> 4.0)
typhoeus (~> 1.0)
cocoapods-deintegrate (1.0.5)
cocoapods-downloader (1.6.3)
cocoapods-keys (2.2.1)
dotenv
osx_keychain
cocoapods-plugins (1.0.0)
nap
cocoapods-search (1.0.1)
cocoapods-trunk (1.6.0)
nap (>= 0.8, < 2.0)
netrc (~> 0.11)
cocoapods-try (1.2.0)
colored2 (3.1.2)
concurrent-ruby (1.1.10)
dotenv (2.7.6)
escape (0.0.4)
ethon (0.15.0)
ffi (>= 1.15.0)
ffi (1.15.5)
fourflusher (2.3.1)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
httpclient (2.8.3)
i18n (1.10.0)
concurrent-ruby (~> 1.0)
json (2.6.1)
minitest (5.15.0)
molinillo (0.8.0)
nanaimo (0.3.0)
nap (1.1.0)
netrc (0.11.0)
osx_keychain (1.0.2)
RubyInline (~> 3)
public_suffix (4.0.7)
rexml (3.2.5)
ruby-macho (2.5.1)
typhoeus (1.4.0)
ethon (>= 0.9.0)
tzinfo (2.0.4)
concurrent-ruby (~> 1.0)
xcodeproj (1.21.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
rexml (~> 3.2.4)
zeitwerk (2.5.4)
PLATFORMS
ruby
DEPENDENCIES
cocoapods
cocoapods-clean
cocoapods-keys
BUNDLED WITH
2.3.11

View File

@ -90,6 +90,28 @@
<string>posts</string> <string>posts</string>
</dict> </dict>
</dict> </dict>
<key>plural.count.media</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@media_count@</string>
<key>media_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>zero</key>
<string>0 media</string>
<key>one</key>
<string>1 media</string>
<key>few</key>
<string>%ld media</string>
<key>many</key>
<string>%ld media</string>
<key>other</key>
<string>%ld media</string>
</dict>
</dict>
<key>plural.count.post</key> <key>plural.count.post</key>
<dict> <dict>
<key>NSStringLocalizedFormatKey</key> <key>NSStringLocalizedFormatKey</key>

View File

@ -51,19 +51,25 @@ private func map(language: String) -> String? {
case "eu_ES": return "eu-ES" // Basque case "eu_ES": return "eu-ES" // Basque
case "ca_ES": return "ca" // Catalan case "ca_ES": return "ca" // Catalan
case "zh_CN": return "zh-Hans" // Chinese Simplified case "zh_CN": return "zh-Hans" // Chinese Simplified
case "zh_TW": return "zh-Hant" // Chinese Traditional
case "nl_NL": return "nl" // Dutch case "nl_NL": return "nl" // Dutch
case "en_US": return "en" case "en_US": return "en"
case "fr_FR": return "fr" // French case "fr_FR": return "fr" // French
case "gl_ES": return "gl" // Galician
case "de_DE": return "de" // German case "de_DE": return "de" // German
case "it_IT": return "it" // Italian
case "ja_JP": return "ja" // Japanese case "ja_JP": return "ja" // Japanese
case "kab_KAB": return "kab" // Kabyle case "kab_KAB": return "kab" // Kabyle
case "kmr_TR": return "ku" // Kurmanji (Kurdish) case "kmr_TR": return "ku" // Kurmanji (Kurdish)
case "ru_RU": return "ru" // Russian case "ru_RU": return "ru" // Russian
case "gd_GB": return "gd-GB" // Scottish Gaelic case "gd_GB": return "gd-GB" // Scottish Gaelic
case "ckb_IR": return "ckb" // Sorani (Kurdish)
case "es_ES": return "es" // Spanish case "es_ES": return "es" // Spanish
case "es_AR": return "es-419" // Spanish, Argentina case "es_AR": return "es-419" // Spanish, Argentina
case "sv-SE": return "sv" // Swedish
case "sv_FI": return "sv_FI" // Swedish, Finland case "sv_FI": return "sv_FI" // Swedish, Finland
case "th_TH": return "th" // Thai case "th_TH": return "th" // Thai
case "tr_TR": return "tr" // Turkish
case "vi_VN": return "vi" // Vietnamese case "vi_VN": return "vi" // Vietnamese
default: return nil default: return nil
} }

View File

@ -129,6 +129,7 @@
"show_post": "Show Post", "show_post": "Show Post",
"show_user_profile": "Show user profile", "show_user_profile": "Show user profile",
"content_warning": "Content Warning", "content_warning": "Content Warning",
"sensitive_content": "Sensitive Content",
"media_content_warning": "Tap anywhere to reveal", "media_content_warning": "Tap anywhere to reveal",
"tap_to_reveal": "Tap to reveal", "tap_to_reveal": "Tap to reveal",
"poll": { "poll": {
@ -210,9 +211,9 @@
"log_in": "Log In" "log_in": "Log In"
}, },
"server_picker": { "server_picker": {
"title": "Mastodon is made of users in different communities.", "title": "Mastodon is made of users in different servers.",
"subtitle": "Pick a community based on your interests, region, or a general purpose one.", "subtitle": "Pick a server based on your interests, region, or a general purpose one.",
"subtitle_extend": "Pick a community based on your interests, region, or a general purpose one. Each community is operated by an entirely independent organization or individual.", "subtitle_extend": "Pick a server based on your interests, region, or a general purpose one. Each server is operated by an entirely independent organization or individual.",
"button": { "button": {
"category": { "category": {
"all": "All", "all": "All",
@ -239,7 +240,8 @@
"category": "CATEGORY" "category": "CATEGORY"
}, },
"input": { "input": {
"placeholder": "Search communities" "placeholder": "Search servers",
"search_servers_or_enter_url": "Search communities or enter URL"
}, },
"empty_state": { "empty_state": {
"finding_servers": "Finding available servers...", "finding_servers": "Finding available servers...",
@ -249,6 +251,7 @@
}, },
"register": { "register": {
"title": "Lets get you set up on %s", "title": "Lets get you set up on %s",
"lets_get_you_set_up_on_domain": "Lets get you set up on %s",
"input": { "input": {
"avatar": { "avatar": {
"delete": "Delete" "delete": "Delete"
@ -319,6 +322,7 @@
"confirm_email": { "confirm_email": {
"title": "One last thing.", "title": "One last thing.",
"subtitle": "Tap the link we emailed to you to verify your account.", "subtitle": "Tap the link we emailed to you to verify your account.",
"tap_the_link_we_emailed_to_you_to_verify_your_account": "Tap the link we emailed to you to verify your account",
"button": { "button": {
"open_email_app": "Open Email App", "open_email_app": "Open Email App",
"resend": "Resend" "resend": "Resend"
@ -341,7 +345,11 @@
"offline": "Offline", "offline": "Offline",
"new_posts": "See new posts", "new_posts": "See new posts",
"published": "Published!", "published": "Published!",
"Publishing": "Publishing post..." "Publishing": "Publishing post...",
"accessibility": {
"logo_label": "Logo Button",
"logo_hint": "Tap to scroll to top and tap again to previous location"
}
} }
}, },
"suggestion_account": { "suggestion_account": {
@ -492,6 +500,16 @@
"clear": "Clear" "clear": "Clear"
} }
}, },
"discovery": {
"tabs": {
"posts": "Posts",
"hashtags": "Hashtags",
"news": "News",
"community": "Community",
"for_you": "For You"
},
"intro": "These are the posts gaining traction in your corner of Mastodon."
},
"favorite": { "favorite": {
"title": "Your Favorites" "title": "Your Favorites"
}, },
@ -585,7 +603,49 @@
"send": "Send Report", "send": "Send Report",
"skip_to_send": "Send without comment", "skip_to_send": "Send without comment",
"text_placeholder": "Type or paste additional comments", "text_placeholder": "Type or paste additional comments",
"reported": "REPORTED" "reported": "REPORTED",
"step_one": {
"step_1_of_4": "Step 1 of 4",
"whats_wrong_with_this_post": "What's wrong with this post?",
"whats_wrong_with_this_account": "What's wrong with this account?",
"whats_wrong_with_this_username": "What's wrong with %s?",
"select_the_best_match": "Select the best match",
"i_dont_like_it": "I dont like it",
"it_is_not_something_you_want_to_see": "It is not something you want to see",
"its_spam": "Its spam",
"malicious_links_fake_engagement_or_repetetive_replies": "Malicious links, fake engagement, or repetetive replies",
"it_violates_server_rules": "It violates server rules",
"you_are_aware_that_it_breaks_specific_rules": "You are aware that it breaks specific rules",
"its_something_else": "Its something else",
"the_issue_does_not_fit_into_other_categories": "The issue does not fit into other categories"
},
"step_two": {
"step_2_of_4": "Step 2 of 4",
"which_rules_are_being_violated": "Which rules are being violated?",
"select_all_that_apply": "Select all that apply",
"i_just_dont_like_it": "I just dont like it"
},
"step_three": {
"step_3_of_4": "Step 3 of 4",
"are_there_any_posts_that_back_up_this_report": "Are there any posts that back up this report?",
"select_all_that_apply": "Select all that apply"
},
"step_four": {
"step_4_of_4": "Step 4 of 4",
"is_there_anything_else_we_should_know": "Is there anything else we should know?"
},
"step_final": {
"dont_want_to_see_this": "Dont want to see this?",
"when_you_see_something_you_dont_like_on_mastodon_you_can_remove_the_person_from_your_experience.": "When you see something you dont like on Mastodon, you can remove the person from your experience.",
"unfollow": "Unfollow",
"unfollowed": "Unfollowed",
"unfollow_user": "Unfollow %s",
"mute_user": "Mute %s",
"you_wont_see_their_posts_or_reblogs_in_your_home_feed_they_wont_know_they_ve_been_muted": "You wont see their posts or reblogs in your home feed. They wont know theyve been muted.",
"block_user": "Block %s",
"they_will_no_longer_be_able_to_follow_or_see_your_posts_but_they_can_see_if_theyve_been_blocked": "They will no longer be able to follow or see your posts, but they can see if theyve been blocked.",
"while_we_review_this_you_can_take_action_against_user": "While we review this, you can take action against %s"
}
}, },
"preview": { "preview": {
"keyboard": { "keyboard": {

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1250" LastUpgradeVersion = "1330"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1250" LastUpgradeVersion = "1330"
version = "1.7"> version = "1.7">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1250" LastUpgradeVersion = "1330"
version = "1.7"> version = "1.7">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"

View File

@ -9,33 +9,38 @@
<key>isShown</key> <key>isShown</key>
<true/> <true/>
<key>orderHint</key> <key>orderHint</key>
<integer>4</integer> <integer>5</integer>
</dict> </dict>
<key>CoreDataStack.xcscheme_^#shared#^_</key> <key>CoreDataStack.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>27</integer> <integer>27</integer>
</dict> </dict>
<key>Mastodon - RTL.xcscheme_^#shared#^_</key> <key>Mastodon - Profile.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>19</integer>
</dict>
<key>Mastodon - Release.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>1</integer> <integer>1</integer>
</dict> </dict>
<key>Mastodon - Snapshot.xcscheme_^#shared#^_</key> <key>Mastodon - RTL.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>8</integer>
</dict>
<key>Mastodon - Release.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>2</integer> <integer>2</integer>
</dict> </dict>
<key>Mastodon - ar.xcscheme</key> <key>Mastodon - Snapshot.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>3</integer> <integer>3</integer>
</dict> </dict>
<key>Mastodon - ar.xcscheme</key>
<dict>
<key>orderHint</key>
<integer>4</integer>
</dict>
<key>Mastodon - ar.xcscheme_^#shared#^_</key> <key>Mastodon - ar.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
@ -109,7 +114,7 @@
<key>MastodonIntent.xcscheme_^#shared#^_</key> <key>MastodonIntent.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>24</integer> <integer>31</integer>
</dict> </dict>
<key>MastodonIntents.xcscheme_^#shared#^_</key> <key>MastodonIntents.xcscheme_^#shared#^_</key>
<dict> <dict>
@ -124,12 +129,12 @@
<key>NotificationService.xcscheme_^#shared#^_</key> <key>NotificationService.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>22</integer> <integer>30</integer>
</dict> </dict>
<key>ShareActionExtension.xcscheme_^#shared#^_</key> <key>ShareActionExtension.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>23</integer> <integer>32</integer>
</dict> </dict>
</dict> </dict>
<key>SuppressBuildableAutocreation</key> <key>SuppressBuildableAutocreation</key>

View File

@ -6,8 +6,8 @@
"repositoryURL": "https://github.com/Alamofire/Alamofire.git", "repositoryURL": "https://github.com/Alamofire/Alamofire.git",
"state": { "state": {
"branch": null, "branch": null,
"revision": "f82c23a8a7ef8dc1a49a8bfc6a96883e79121864", "revision": "354dda32d89fc8cd4f5c46487f64957d355f53d8",
"version": "5.5.0" "version": "5.6.1"
} }
}, },
{ {
@ -55,6 +55,15 @@
"version": "1.2.0" "version": "1.2.0"
} }
}, },
{
"package": "FaviconFinder",
"repositoryURL": "https://github.com/will-lumley/FaviconFinder.git",
"state": {
"branch": null,
"revision": "1f74844f77f79b95c0bb0130b3a87d4f340e6d3a",
"version": "3.3.0"
}
},
{ {
"package": "FLAnimatedImage", "package": "FLAnimatedImage",
"repositoryURL": "https://github.com/Flipboard/FLAnimatedImage.git", "repositoryURL": "https://github.com/Flipboard/FLAnimatedImage.git",
@ -96,8 +105,8 @@
"repositoryURL": "https://github.com/TwidereProject/MetaTextKit.git", "repositoryURL": "https://github.com/TwidereProject/MetaTextKit.git",
"state": { "state": {
"branch": null, "branch": null,
"revision": "3ea336d3de7938dc112084c596a646e697b0feee", "revision": "2b9556a78b2986b8c0b04adc6da8ec206b448a0c",
"version": "2.2.1" "version": "2.2.3"
} }
}, },
{ {
@ -105,8 +114,8 @@
"repositoryURL": "https://github.com/kean/Nuke.git", "repositoryURL": "https://github.com/kean/Nuke.git",
"state": { "state": {
"branch": null, "branch": null,
"revision": "0db18dd34998cca18e9a28bcee136f84518007a0", "revision": "0ea7545b5c918285aacc044dc75048625c8257cc",
"version": "10.4.1" "version": "10.8.0"
} }
}, },
{ {
@ -141,8 +150,8 @@
"repositoryURL": "https://github.com/SDWebImage/SDWebImage.git", "repositoryURL": "https://github.com/SDWebImage/SDWebImage.git",
"state": { "state": {
"branch": null, "branch": null,
"revision": "2c53f531f1bedd253f55d85105409c28ed4a922c", "revision": "2e63d0061da449ad0ed130768d05dceb1496de44",
"version": "5.12.3" "version": "5.12.5"
} }
}, },
{ {
@ -172,13 +181,22 @@
"version": "1.0.0" "version": "1.0.0"
} }
}, },
{
"package": "SwiftSoup",
"repositoryURL": "https://github.com/scinfu/SwiftSoup.git",
"state": {
"branch": null,
"revision": "41e7c263fb8c277e980ebcb9b0b5f6031d3d4886",
"version": "2.4.2"
}
},
{ {
"package": "Introspect", "package": "Introspect",
"repositoryURL": "https://github.com/siteline/SwiftUI-Introspect.git", "repositoryURL": "https://github.com/siteline/SwiftUI-Introspect.git",
"state": { "state": {
"branch": null, "branch": null,
"revision": "2e09be8af614401bc9f87d40093ec19ce56ccaf2", "revision": "f2616860a41f9d9932da412a8978fec79c06fe24",
"version": "0.1.3" "version": "0.1.4"
} }
}, },
{ {

View File

@ -144,7 +144,7 @@ extension SceneCoordinator {
case popover(sourceView: UIView) case popover(sourceView: UIView)
case panModal case panModal
case custom(transitioningDelegate: UIViewControllerTransitioningDelegate) case custom(transitioningDelegate: UIViewControllerTransitioningDelegate)
case customPush case customPush(animated: Bool)
case safariPresent(animated: Bool, completion: (() -> Void)? = nil) case safariPresent(animated: Bool, completion: (() -> Void)? = nil)
case alertController(animated: Bool, completion: (() -> Void)? = nil) case alertController(animated: Bool, completion: (() -> Void)? = nil)
case activityViewControllerPresent(animated: Bool, completion: (() -> Void)? = nil) case activityViewControllerPresent(animated: Bool, completion: (() -> Void)? = nil)
@ -158,7 +158,7 @@ extension SceneCoordinator {
case mastodonServerRules(viewModel: MastodonServerRulesViewModel) case mastodonServerRules(viewModel: MastodonServerRulesViewModel)
case mastodonConfirmEmail(viewModel: MastodonConfirmEmailViewModel) case mastodonConfirmEmail(viewModel: MastodonConfirmEmailViewModel)
case mastodonResendEmail(viewModel: MastodonResendEmailViewModel) case mastodonResendEmail(viewModel: MastodonResendEmailViewModel)
case mastodonWebView(viewModel:WebViewModel) case mastodonWebView(viewModel: WebViewModel)
// search // search
case searchDetail(viewModel: SearchDetailViewModel) case searchDetail(viewModel: SearchDetailViewModel)
@ -184,6 +184,8 @@ extension SceneCoordinator {
// report // report
case report(viewModel: ReportViewModel) case report(viewModel: ReportViewModel)
case reportServerRules(viewModel: ReportServerRulesViewModel)
case reportStatus(viewModel: ReportStatusViewModel)
case reportSupplementary(viewModel: ReportSupplementaryViewModel) case reportSupplementary(viewModel: ReportSupplementaryViewModel)
case reportResult(viewModel: ReportResultViewModel) case reportResult(viewModel: ReportResultViewModel)
@ -309,7 +311,7 @@ extension SceneCoordinator {
if scene.isOnboarding { if scene.isOnboarding {
return OnboardingNavigationController(rootViewController: viewController) return OnboardingNavigationController(rootViewController: viewController)
} else { } else {
return UINavigationController(rootViewController: viewController) return AdaptiveStatusBarStyleNavigationController(rootViewController: viewController)
} }
}() }()
modalNavigationController.modalPresentationCapturesStatusBarAppearance = true modalNavigationController.modalPresentationCapturesStatusBarAppearance = true
@ -339,10 +341,10 @@ extension SceneCoordinator {
viewController.transitioningDelegate = transitioningDelegate viewController.transitioningDelegate = transitioningDelegate
(splitViewController ?? presentingViewController)?.present(viewController, animated: true, completion: nil) (splitViewController ?? presentingViewController)?.present(viewController, animated: true, completion: nil)
case .customPush: case .customPush(let animated):
// set delegate in view controller // set delegate in view controller
assert(sender?.navigationController?.delegate != nil) assert(sender?.navigationController?.delegate != nil)
sender?.navigationController?.pushViewController(viewController, animated: true) sender?.navigationController?.pushViewController(viewController, animated: animated)
case .safariPresent(let animated, let completion): case .safariPresent(let animated, let completion):
if UserDefaults.shared.preferredUsingDefaultBrowser, case let .safari(url) = scene { if UserDefaults.shared.preferredUsingDefaultBrowser, case let .safari(url) = scene {
@ -368,10 +370,10 @@ extension SceneCoordinator {
splitViewController?.contentSplitViewController.currentSupplementaryTab = tab splitViewController?.contentSplitViewController.currentSupplementaryTab = tab
splitViewController?.compactMainTabBarViewController.selectedIndex = tab.rawValue splitViewController?.compactMainTabBarViewController.selectedIndex = tab.rawValue
splitViewController?.compactMainTabBarViewController.currentTab.value = tab splitViewController?.compactMainTabBarViewController.currentTab = tab
tabBarController.selectedIndex = tab.rawValue tabBarController.selectedIndex = tab.rawValue
tabBarController.currentTab.value = tab tabBarController.currentTab = tab
} }
} }
@ -447,6 +449,14 @@ private extension SceneCoordinator {
let _viewController = ReportViewController() let _viewController = ReportViewController()
_viewController.viewModel = viewModel _viewController.viewModel = viewModel
viewController = _viewController viewController = _viewController
case .reportServerRules(let viewModel):
let _viewController = ReportServerRulesViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .reportStatus(let viewModel):
let _viewController = ReportStatusViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .reportSupplementary(let viewModel): case .reportSupplementary(let viewModel):
let _viewController = ReportSupplementaryViewController() let _viewController = ReportSupplementaryViewController()
_viewController.viewModel = viewModel _viewController.viewModel = viewModel

View File

@ -0,0 +1,17 @@
//
// DiscoveryItem.swift
// Mastodon
//
// Created by MainasuK on 2022-4-13.
//
import Foundation
import MastodonSDK
import CoreDataStack
enum DiscoveryItem: Hashable {
case hashtag(Mastodon.Entity.Tag)
case link(Mastodon.Entity.Link)
case user(ManagedObjectRecord<MastodonUser>)
case bottomLoader
}

View File

@ -0,0 +1,74 @@
//
// DiscoverySection.swift
// Mastodon
//
// Created by MainasuK on 2022-4-13.
//
import os.log
import UIKit
import MastodonUI
enum DiscoverySection: CaseIterable {
// case posts
case hashtags
case news
case forYou
}
extension DiscoverySection {
static let logger = Logger(subsystem: "DiscoverySection", category: "logic")
class Configuration {
weak var profileCardTableViewCellDelegate: ProfileCardTableViewCellDelegate?
public init(profileCardTableViewCellDelegate: ProfileCardTableViewCellDelegate? = nil) {
self.profileCardTableViewCellDelegate = profileCardTableViewCellDelegate
}
}
static func diffableDataSource(
tableView: UITableView,
context: AppContext,
configuration: Configuration
) -> UITableViewDiffableDataSource<DiscoverySection, DiscoveryItem> {
tableView.register(TrendTableViewCell.self, forCellReuseIdentifier: String(describing: TrendTableViewCell.self))
tableView.register(NewsTableViewCell.self, forCellReuseIdentifier: String(describing: NewsTableViewCell.self))
tableView.register(ProfileCardTableViewCell.self, forCellReuseIdentifier: String(describing: ProfileCardTableViewCell.self))
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item in
switch item {
case .hashtag(let tag):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TrendTableViewCell.self), for: indexPath) as! TrendTableViewCell
cell.trendView.configure(tag: tag)
return cell
case .link(let link):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NewsTableViewCell.self), for: indexPath) as! NewsTableViewCell
cell.newsView.configure(link: link)
return cell
case .user(let record):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ProfileCardTableViewCell.self), for: indexPath) as! ProfileCardTableViewCell
context.managedObjectContext.performAndWait {
guard let user = record.object(in: context.managedObjectContext) else { return }
cell.configure(
tableView: tableView,
user: user,
profileCardTableViewCellDelegate: configuration.profileCardTableViewCellDelegate
)
}
context.authenticationService.activeMastodonAuthentication
.map { $0?.user }
.assign(to: \.me, on: cell.profileCardView.viewModel.relationshipViewModel)
.store(in: &cell.disposeBag)
return cell
case .bottomLoader:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
cell.activityIndicatorView.startAnimating()
return cell
}
}
}
}

View File

@ -29,7 +29,7 @@ extension PickServerSection {
weak dependency, weak dependency,
weak pickServerCellDelegate weak pickServerCellDelegate
] tableView, indexPath, item -> UITableViewCell? in ] tableView, indexPath, item -> UITableViewCell? in
guard let dependency = dependency else { return nil } guard let _ = dependency else { return nil }
switch item { switch item {
case .header: case .header:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: OnboardingHeadlineTableViewCell.self), for: indexPath) as! OnboardingHeadlineTableViewCell let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: OnboardingHeadlineTableViewCell.self), for: indexPath) as! OnboardingHeadlineTableViewCell

View File

@ -69,10 +69,10 @@ extension SearchHistorySection {
let trendHeaderRegister = UICollectionView.SupplementaryRegistration<SearchHistorySectionHeaderCollectionReusableView>(elementKind: UICollectionView.elementKindSectionHeader) { [weak dataSource] supplementaryView, elementKind, indexPath in let trendHeaderRegister = UICollectionView.SupplementaryRegistration<SearchHistorySectionHeaderCollectionReusableView>(elementKind: UICollectionView.elementKindSectionHeader) { [weak dataSource] supplementaryView, elementKind, indexPath in
supplementaryView.delegate = configuration.searchHistorySectionHeaderCollectionReusableViewDelegate supplementaryView.delegate = configuration.searchHistorySectionHeaderCollectionReusableViewDelegate
guard let dataSource = dataSource else { return } guard let _ = dataSource else { return }
let sections = dataSource.snapshot().sectionIdentifiers // let sections = dataSource.snapshot().sectionIdentifiers
guard indexPath.section < sections.count else { return } // guard indexPath.section < sections.count else { return }
let section = sections[indexPath.section] // let section = sections[indexPath.section]
} }
dataSource.supplementaryViewProvider = { (collectionView: UICollectionView, elementKind: String, indexPath: IndexPath) in dataSource.supplementaryViewProvider = { (collectionView: UICollectionView, elementKind: String, indexPath: IndexPath) in

View File

@ -21,26 +21,7 @@ extension SearchSection {
) -> UICollectionViewDiffableDataSource<SearchSection, SearchItem> { ) -> UICollectionViewDiffableDataSource<SearchSection, SearchItem> {
let trendCellRegister = UICollectionView.CellRegistration<TrendCollectionViewCell, Mastodon.Entity.Tag> { cell, indexPath, item in let trendCellRegister = UICollectionView.CellRegistration<TrendCollectionViewCell, Mastodon.Entity.Tag> { cell, indexPath, item in
let primaryLabelText = "#" + item.name
let secondaryLabelText = L10n.Scene.Search.Recommend.HashTag.peopleTalking(item.talkingPeopleCount ?? 0)
cell.primaryLabel.text = primaryLabelText
cell.secondaryLabel.text = secondaryLabelText
cell.lineChartView.data = (item.history ?? [])
.sorted(by: { $0.day < $1.day }) // latest last
.map { entry in
guard let point = Int(entry.accounts) else {
return .zero
}
return CGFloat(point)
}
cell.isAccessibilityElement = true
cell.accessibilityLabel = [
primaryLabelText,
secondaryLabelText
].joined(separator: ", ")
} }
let dataSource = UICollectionViewDiffableDataSource<SearchSection, SearchItem>( let dataSource = UICollectionViewDiffableDataSource<SearchSection, SearchItem>(

View File

@ -51,7 +51,7 @@ extension SettingsSection {
} }
cell.delegate = settingsAppearanceTableViewCellDelegate cell.delegate = settingsAppearanceTableViewCellDelegate
return cell return cell
case .appearancePreference(let record, let appearanceType): case .appearancePreference(let record, _):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsToggleTableViewCell.self), for: indexPath) as! SettingsToggleTableViewCell let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsToggleTableViewCell.self), for: indexPath) as! SettingsToggleTableViewCell
cell.delegate = settingsToggleCellDelegate cell.delegate = settingsToggleCellDelegate
managedObjectContext.performAndWait { managedObjectContext.performAndWait {

View File

@ -9,53 +9,6 @@ import Foundation
import CoreDataStack import CoreDataStack
import MastodonSDK import MastodonSDK
extension MastodonUser {
public var displayNameWithFallback: String {
return !displayName.isEmpty ? displayName : username
}
public var acctWithDomain: String {
if !acct.contains("@") {
// Safe concat due to username cannot contains "@"
return username + "@" + domain
} else {
return acct
}
}
public var domainFromAcct: String {
if !acct.contains("@") {
return domain
} else {
let domain = acct.split(separator: "@").last
return String(domain!)
}
}
}
extension MastodonUser {
public func headerImageURL() -> URL? {
return URL(string: header)
}
public func headerImageURLWithFallback(domain: String) -> URL {
return URL(string: header) ?? URL(string: "https://\(domain)/headers/original/missing.png")!
}
public func avatarImageURL() -> URL? {
let string = UserDefaults.shared.preferredStaticAvatar ? avatarStatic ?? avatar : avatar
return URL(string: string)
}
public func avatarImageURLWithFallback(domain: String) -> URL {
return avatarImageURL() ?? URL(string: "https://\(domain)/avatars/original/missing.png")!
}
}
extension MastodonUser { extension MastodonUser {
public var profileURL: URL { public var profileURL: URL {

View File

@ -7,24 +7,12 @@
import MastodonSDK import MastodonSDK
extension Mastodon.Entity.Tag: Hashable { //extension Mastodon.Entity.Tag: Hashable {
public func hash(into hasher: inout Hasher) { // public func hash(into hasher: inout Hasher) {
hasher.combine(name) // hasher.combine(name)
} // }
//
public static func == (lhs: Mastodon.Entity.Tag, rhs: Mastodon.Entity.Tag) -> Bool { // public static func == (lhs: Mastodon.Entity.Tag, rhs: Mastodon.Entity.Tag) -> Bool {
return lhs.name == rhs.name // return lhs.name == rhs.name
} // }
} //}
extension Mastodon.Entity.Tag {
/// the sum of recent 2 days
public var talkingPeopleCount: Int? {
return history?
.prefix(2)
.compactMap { Int($0.accounts) }
.reduce(0, +)
}
}

View File

@ -1,11 +1,13 @@
// //
// ThemeService+Appearance.swift // ThemeService.swift
// Mastodon // Mastodon
// //
// Created by MainasuK Cirno on 2021-7-19. // Created by MainasuK on 2022-4-13.
// //
import UIKit import UIKit
import MastodonCommon
import MastodonUI
extension ThemeService { extension ThemeService {
func set(themeName: ThemeName) { func set(themeName: ThemeName) {

View File

@ -1,16 +0,0 @@
//
// UINavigationController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-31.
//
import UIKit
// This not works!
// SeeAlso: `AdaptiveStatusBarStyleNavigationController`
extension UINavigationController {
open override var childForStatusBarStyle: UIViewController? {
return visibleViewController
}
}

View File

@ -1,70 +0,0 @@
//
// UIView.swift
// Mastodon
//
// Created by sxiaojian on 2021/2/4.
//
import UIKit
// MARK: - Convenience view creation method
extension UIView {
static let separatorColor: UIColor = {
UIColor(dynamicProvider: { collection in
switch collection.userInterfaceStyle {
case .dark:
return ThemeService.shared.currentTheme.value.separator
default:
return .separator
}
})
}()
static var separatorLine: UIView {
let line = UIView()
line.backgroundColor = UIView.separatorColor
return line
}
static func separatorLineHeight(of view: UIView) -> CGFloat {
return 1.0 / view.traitCollection.displayScale
}
}
// MARK: - Convenience view appearance modification method
extension UIView {
@discardableResult
func applyCornerRadius(radius: CGFloat) -> Self {
layer.masksToBounds = true
layer.cornerRadius = radius
layer.cornerCurve = .continuous
return self
}
@discardableResult
func applyShadow(
color: UIColor,
alpha: Float,
x: CGFloat,
y: CGFloat,
blur: CGFloat,
spread: CGFloat = 0) -> Self
{
layer.masksToBounds = false
layer.shadowColor = color.cgColor
layer.shadowOpacity = alpha
layer.shadowOffset = CGSize(width: x, height: y)
layer.shadowRadius = blur / 2.0
if spread == 0 {
layer.shadowPath = nil
} else {
let dx = -spread
let rect = bounds.insetBy(dx: dx, dy: dx)
layer.shadowPath = UIBezierPath(rect: rect).cgPath
}
return self
}
}

View File

@ -1,14 +1,7 @@
// Generated using Sourcery 1.6.1 https://github.com/krzysztofzablocki/Sourcery // Generated using Sourcery 1.6.1 https://github.com/krzysztofzablocki/Sourcery
// DO NOT EDIT // DO NOT EDIT
// sourcery:inline:DiscoveryCommunityViewController.AutoGenerateTableViewDelegate
// sourcery:inline:UserTimelineViewController.AutoGenerateTableViewDelegate
// Generated using Sourcery // Generated using Sourcery
// DO NOT EDIT // DO NOT EDIT
@ -33,3 +26,13 @@ func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith con
} }
// sourcery:end // sourcery:end

View File

@ -30,7 +30,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.3.0</string> <string>1.4.2</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
<array> <array>
<dict> <dict>
@ -43,7 +43,7 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>109</string> <string>127</string>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
<false/> <false/>
<key>LSApplicationQueriesSchemes</key> <key>LSApplicationQueriesSchemes</key>

View File

@ -22,13 +22,3 @@ extension MastodonEmoji {
) )
} }
} }
extension Collection where Element == MastodonEmoji {
public var asDictionary: MastodonContent.Emojis {
var dictionary: MastodonContent.Emojis = [:]
for emoji in self {
dictionary[emoji.code] = emoji.url
}
return dictionary
}
}

View File

@ -5,17 +5,3 @@
// Created by MainasuK Cirno on 2021-7-5. // Created by MainasuK Cirno on 2021-7-5.
// //
import UIKit
import MastodonExtension
extension UserDefaults {
@objc dynamic var currentThemeNameRawValue: String {
get {
register(defaults: [#function: ThemeName.mastodon.rawValue])
return string(forKey: #function) ?? ThemeName.mastodon.rawValue
}
set { self[#function] = newValue }
}
}

View File

@ -0,0 +1,92 @@
//
// PageboyNavigateable.swift
// Mastodon
//
// Created by MainasuK on 2022-5-11.
//
import UIKit
import Pageboy
import MastodonLocalization
typealias PageboyNavigateable = PageboyNavigateableCore & PageboyNavigateableRelay
protocol PageboyNavigateableCore: AnyObject {
var navigateablePageViewController: PageboyViewController { get }
var pageboyNavigateKeyCommands: [UIKeyCommand] { get }
func pageboyNavigateKeyCommandHandler(_ sender: UIKeyCommand)
func navigate(direction: PageboyNavigationDirection)
}
@objc protocol PageboyNavigateableRelay: AnyObject {
func pageboyNavigateKeyCommandHandlerRelay(_ sender: UIKeyCommand)
}
enum PageboyNavigationDirection: String, CaseIterable {
case previous
case next
var title: String {
switch self {
case .previous: return L10n.Common.Controls.Keyboard.SegmentedControl.previousSection
case .next: return L10n.Common.Controls.Keyboard.SegmentedControl.nextSection
}
}
// UIKeyCommand input
var input: String {
switch self {
case .previous: return "["
case .next: return "]"
}
}
var modifierFlags: UIKeyModifierFlags {
switch self {
case .previous: return [.shift, .command]
case .next: return [.shift, .command]
}
}
var propertyList: Any {
return rawValue
}
}
extension PageboyNavigateableCore where Self: PageboyNavigateableRelay {
var pageboyNavigateKeyCommands: [UIKeyCommand] {
PageboyNavigationDirection.allCases.map { direction in
UIKeyCommand(
title: direction.title,
image: nil,
action: #selector(Self.pageboyNavigateKeyCommandHandlerRelay(_:)),
input: direction.input,
modifierFlags: direction.modifierFlags,
propertyList: direction.propertyList,
alternates: [],
discoverabilityTitle: nil,
attributes: [],
state: .off
)
}
}
func pageboyNavigateKeyCommandHandler(_ sender: UIKeyCommand) {
guard let rawValue = sender.propertyList as? String,
let direction = PageboyNavigationDirection(rawValue: rawValue) else { return }
navigate(direction: direction)
}
}
extension PageboyNavigateableCore {
func navigate(direction: PageboyNavigationDirection) {
switch direction {
case .previous:
navigateablePageViewController.scrollToPage(.previous, animated: true)
case .next:
navigateablePageViewController.scrollToPage(.next, animated: true)
}
}
}

View File

@ -38,11 +38,15 @@ extension DataSourceFacade {
meta: Meta meta: Meta
) async { ) async {
switch meta { switch meta {
// note:
// some server mark the normal url as "u-url" class. highlighted content is a URL
case .url(_, _, let url, _), case .url(_, _, let url, _),
.mention(_, let url, _) where url.lowercased().hasPrefix("http"): .mention(_, let url, _) where url.lowercased().hasPrefix("http"):
// note: // fix non-ascii character URL link can not open issue
// some server mark the normal url as "u-url" class. highlighted content is a URL guard let url = URL(string: url) ?? URL(string: url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? url) else {
guard let url = URL(string: url) else { return } assertionFailure()
return
}
if let domain = provider.context.authenticationService.activeMastodonAuthenticationBox.value?.domain, url.host == domain, if let domain = provider.context.authenticationService.activeMastodonAuthenticationBox.value?.domain, url.host == domain,
url.pathComponents.count >= 4, url.pathComponents.count >= 4,
url.pathComponents[0] == "/", url.pathComponents[0] == "/",

View File

@ -122,12 +122,12 @@ extension DataSourceFacade {
let barButtonItem: UIBarButtonItem? let barButtonItem: UIBarButtonItem?
} }
@MainActor // @MainActor
static func createProfileActionMenu( // static func createProfileActionMenu(
dependency: NeedsDependency, // dependency: NeedsDependency,
user: ManagedObjectRecord<MastodonUser> // user: ManagedObjectRecord<MastodonUser>
) -> UIMenu { // ) -> UIMenu {
var children: [UIMenuElement] = [] // var children: [UIMenuElement] = []
// let name = mastodonUser.displayNameWithFallback // let name = mastodonUser.displayNameWithFallback
// //
// if let shareUser = shareUser { // if let shareUser = shareUser {
@ -339,9 +339,9 @@ extension DataSourceFacade {
// } // }
// children.append(deleteAction) // children.append(deleteAction)
// } // }
//
return UIMenu(title: "", options: [], children: children) // return UIMenu(title: "", options: [], children: children)
} // }
static func createActivityViewController( static func createActivityViewController(
dependency: NeedsDependency, dependency: NeedsDependency,

View File

@ -99,7 +99,7 @@ extension DataSourceFacade {
try await managedObjectContext.performChanges { try await managedObjectContext.performChanges {
guard let authenticationBox = _authenticationBox else { return } guard let authenticationBox = _authenticationBox else { return }
guard let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user else { return } guard let _ = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user else { return }
let request = SearchHistory.sortedFetchRequest let request = SearchHistory.sortedFetchRequest
request.predicate = SearchHistory.predicate( request.predicate = SearchHistory.predicate(
domain: authenticationBox.domain, domain: authenticationBox.domain,

View File

@ -286,24 +286,8 @@ extension DataSourceFacade {
try await dependency.context.managedObjectContext.perform { try await dependency.context.managedObjectContext.perform {
guard let _status = status.object(in: dependency.context.managedObjectContext) else { return } guard let _status = status.object(in: dependency.context.managedObjectContext) else { return }
let status = _status.reblog ?? _status let status = _status.reblog ?? _status
status.update(isSensitiveToggled: !status.isSensitiveToggled)
let allToggled = status.isContentSensitiveToggled && status.isMediaSensitiveToggled
status.update(isContentSensitiveToggled: !allToggled)
status.update(isMediaSensitiveToggled: !allToggled)
} }
} }
// static func responseToToggleMediaSensitiveAction(
// dependency: NeedsDependency,
// status: ManagedObjectRecord<Status>
// ) async throws {
// try await dependency.context.managedObjectContext.perform {
// guard let _status = status.object(in: dependency.context.managedObjectContext) else { return }
// let status = _status.reblog ?? _status
//
// status.update(isMediaSensitiveToggled: !status.isMediaSensitiveToggled)
// }
// }
} }

View File

@ -135,7 +135,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Med
let status = _status.reblog ?? _status let status = _status.reblog ?? _status
return NotificationMediaTransitionContext( return NotificationMediaTransitionContext(
status: .init(objectID: status.objectID), status: .init(objectID: status.objectID),
needsToggleMediaSensitive: status.isMediaSensitiveToggled ? !status.sensitive : status.sensitive needsToggleMediaSensitive: status.isSensitiveToggled ? !status.sensitive : status.sensitive
) )
} }
@ -187,7 +187,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Med
let status = _status.reblog ?? _status let status = _status.reblog ?? _status
return NotificationMediaTransitionContext( return NotificationMediaTransitionContext(
status: .init(objectID: status.objectID), status: .init(objectID: status.objectID),
needsToggleMediaSensitive: status.isMediaSensitiveToggled ? !status.sensitive : status.sensitive needsToggleMediaSensitive: status.isMediaSensitive ? !status.isSensitiveToggled : false
) )
} }
@ -486,7 +486,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider {
provider: self, provider: self,
user: user user: user
) )
case .notification(let notification): case .notification:
assertionFailure("TODO") assertionFailure("TODO")
default: default:
assertionFailure("TODO") assertionFailure("TODO")

View File

@ -143,12 +143,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & MediaPrev
return return
} }
let managedObjectContext = self.context.managedObjectContext let needsToggleMediaSensitive = await !statusView.viewModel.isMediaReveal
let needsToggleMediaSensitive: Bool = try await managedObjectContext.perform {
guard let _status = status.object(in: managedObjectContext) else { return false }
let status = _status.reblog ?? _status
return status.isMediaSensitiveToggled ? !status.sensitive : status.sensitive
}
guard !needsToggleMediaSensitive else { guard !needsToggleMediaSensitive else {
try await DataSourceFacade.responseToToggleSensitiveAction( try await DataSourceFacade.responseToToggleSensitiveAction(
@ -499,7 +494,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider {
provider: self, provider: self,
user: user user: user
) )
case .notification(let notification): case .notification:
assertionFailure("TODO") assertionFailure("TODO")
default: default:
assertionFailure("TODO") assertionFailure("TODO")

View File

@ -115,7 +115,7 @@ extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvid
guard let provider = self as? (DataSourceProvider & MediaPreviewableViewController) else { return } guard let provider = self as? (DataSourceProvider & MediaPreviewableViewController) else { return }
guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow, guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow,
let cell = tableView.cellForRow(at: indexPathForSelectedRow) as? StatusTableViewCell let cell = tableView.cellForRow(at: indexPathForSelectedRow) as? StatusViewContainerTableViewCell
else { return } else { return }
guard let mediaView = cell.statusView.mediaGridContainerView.mediaViews.first else { return } guard let mediaView = cell.statusView.mediaGridContainerView.mediaViews.first else { return }

View File

@ -138,7 +138,7 @@ extension TableViewControllerNavigateableCore where Self: DataSourceProvider {
target: .status, target: .status,
status: record status: record
) )
case .notification(let record): case .notification:
assertionFailure() assertionFailure()
default: default:
assertionFailure() assertionFailure()

View File

@ -93,7 +93,7 @@ extension UITableViewDelegate where Self: DataSourceProvider & MediaPreviewableV
guard let image = mediaView.thumbnail(), guard let image = mediaView.thumbnail(),
let assetURLString = mediaView.configuration?.assetURL, let assetURLString = mediaView.configuration?.assetURL,
let assetURL = URL(string: assetURLString), let assetURL = URL(string: assetURLString),
let resourceType = mediaView.configuration?.resourceType let _ = mediaView.configuration?.resourceType
else { else {
// not provide preview unless thumbnail ready // not provide preview unless thumbnail ready
return nil return nil

View File

@ -0,0 +1,4 @@
"NSCameraUsageDescription" = "Used to take photo for post status";
"NSPhotoLibraryAddUsageDescription" = "Used to save photo into the Photo Library";
"NewPostShortcutItemTitle" = "New Post";
"SearchShortcutItemTitle" = "Search";

View File

@ -0,0 +1,4 @@
"NSCameraUsageDescription" = "Used to take photo for post status";
"NSPhotoLibraryAddUsageDescription" = "Used to save photo into the Photo Library";
"NewPostShortcutItemTitle" = "New Post";
"SearchShortcutItemTitle" = "Search";

View File

@ -0,0 +1,4 @@
"NSCameraUsageDescription" = "Used to take photo for post status";
"NSPhotoLibraryAddUsageDescription" = "Used to save photo into the Photo Library";
"NewPostShortcutItemTitle" = "New Post";
"SearchShortcutItemTitle" = "Search";

View File

@ -0,0 +1,4 @@
"NSCameraUsageDescription" = "Used to take photo for post status";
"NSPhotoLibraryAddUsageDescription" = "Used to save photo into the Photo Library";
"NewPostShortcutItemTitle" = "New Post";
"SearchShortcutItemTitle" = "Search";

View File

@ -0,0 +1,4 @@
"NSCameraUsageDescription" = "Used to take photo for post status";
"NSPhotoLibraryAddUsageDescription" = "Used to save photo into the Photo Library";
"NewPostShortcutItemTitle" = "New Post";
"SearchShortcutItemTitle" = "Search";

View File

@ -0,0 +1,4 @@
"NSCameraUsageDescription" = "Used to take photo for post status";
"NSPhotoLibraryAddUsageDescription" = "Used to save photo into the Photo Library";
"NewPostShortcutItemTitle" = "New Post";
"SearchShortcutItemTitle" = "Search";

View File

@ -118,7 +118,7 @@ extension AccountListViewController {
// the presentingViewController may deinit. // the presentingViewController may deinit.
// Hold it and check the window to prevent PanModel crash // Hold it and check the window to prevent PanModel crash
guard let presentingViewController = presentingViewController else { return } guard let _ = presentingViewController else { return }
guard self.view.window != nil else { return } guard self.view.window != nil else { return }
self.hasLoaded = true self.hasLoaded = true

View File

@ -77,7 +77,7 @@ extension AutoCompleteViewModel.State {
override func didEnter(from previousState: GKState?) { override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState) super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return } guard let viewModel = viewModel, let _ = stateMachine else { return }
let searchText = viewModel.inputText.value let searchText = viewModel.inputText.value
let searchType = AutoCompleteViewModel.SearchType(inputText: searchText) ?? .default let searchType = AutoCompleteViewModel.SearchType(inputText: searchText) ?? .default

View File

@ -10,6 +10,7 @@ import UIKit
import Combine import Combine
import MastodonAsset import MastodonAsset
import MastodonLocalization import MastodonLocalization
import MastodonUI
protocol ComposeStatusPollOptionCollectionViewCellDelegate: AnyObject { protocol ComposeStatusPollOptionCollectionViewCellDelegate: AnyObject {
func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textFieldDidBeginEditing textField: UITextField) func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textFieldDidBeginEditing textField: UITextField)

View File

@ -0,0 +1,64 @@
//
// DiscoveryCommunityViewViewModel.swift
// Mastodon
//
// Created by MainasuK on 2022-4-29.
//
import os.log
import UIKit
import Combine
import GameplayKit
import CoreData
import CoreDataStack
import MastodonSDK
final class DiscoveryCommunityViewViewModel {
let logger = Logger(subsystem: "DiscoveryCommunityViewViewModel", category: "ViewModel")
var disposeBag = Set<AnyCancellable>()
// input
let context: AppContext
let viewDidAppeared = PassthroughSubject<Void, Never>()
let statusFetchedResultsController: StatusFetchedResultsController
// output
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, StatusItem>?
private(set) lazy var stateMachine: GKStateMachine = {
let stateMachine = GKStateMachine(states: [
State.Initial(viewModel: self),
State.Reloading(viewModel: self),
State.Fail(viewModel: self),
State.Idle(viewModel: self),
State.Loading(viewModel: self),
State.NoMore(viewModel: self),
])
stateMachine.enter(State.Initial.self)
return stateMachine
}()
let didLoadLatest = PassthroughSubject<Void, Never>()
init(context: AppContext) {
self.context = context
self.statusFetchedResultsController = StatusFetchedResultsController(
managedObjectContext: context.managedObjectContext,
domain: nil,
additionalTweetPredicate: nil
)
// end init
context.authenticationService.activeMastodonAuthentication
.map { $0?.domain }
.assign(to: \.value, on: statusFetchedResultsController.domain)
.store(in: &disposeBag)
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}

View File

@ -0,0 +1,34 @@
//
// DiscoveryCommunityViewController+DataSourceProvider.swift
// Mastodon
//
// Created by MainasuK on 2022-4-29.
//
import UIKit
extension DiscoveryCommunityViewController: DataSourceProvider {
func item(from source: DataSourceItem.Source) async -> DataSourceItem? {
var _indexPath = source.indexPath
if _indexPath == nil, let cell = source.tableViewCell {
_indexPath = await self.indexPath(for: cell)
}
guard let indexPath = _indexPath else { return nil }
guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else {
return nil
}
switch item {
case .status(let record):
return .status(record: record)
default:
return nil
}
}
@MainActor
private func indexPath(for cell: UITableViewCell) async -> IndexPath? {
return tableView.indexPath(for: cell)
}
}

View File

@ -0,0 +1,171 @@
//
// DiscoveryCommunityViewController.swift
// Mastodon
//
// Created by MainasuK on 2022-4-29.
//
import os.log
import UIKit
import Combine
import MastodonUI
// Local Timeline
final class DiscoveryCommunityViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
let logger = Logger(subsystem: "DiscoveryCommunityViewController", category: "ViewController")
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var disposeBag = Set<AnyCancellable>()
var viewModel: DiscoveryCommunityViewModel!
let mediaPreviewTransitionController = MediaPreviewTransitionController()
lazy var tableView: UITableView = {
let tableView = UITableView()
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 100
tableView.separatorStyle = .none
tableView.backgroundColor = .clear
return tableView
}()
let refreshControl = UIRefreshControl()
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension DiscoveryCommunityViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor
ThemeService.shared.currentTheme
.receive(on: DispatchQueue.main)
.sink { [weak self] theme in
guard let self = self else { return }
self.view.backgroundColor = theme.secondarySystemBackgroundColor
}
.store(in: &disposeBag)
tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
tableView.refreshControl = refreshControl
refreshControl.addTarget(self, action: #selector(DiscoveryCommunityViewController.refreshControlValueChanged(_:)), for: .valueChanged)
viewModel.didLoadLatest
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self = self else { return }
self.refreshControl.endRefreshing()
}
.store(in: &disposeBag)
tableView.delegate = self
viewModel.setupDiffableDataSource(
tableView: tableView,
statusTableViewCellDelegate: self
)
// setup batch fetch
viewModel.listBatchFetchViewModel.setup(scrollView: tableView)
viewModel.listBatchFetchViewModel.shouldFetch
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self = self else { return }
guard self.view.window != nil else { return }
self.viewModel.stateMachine.enter(DiscoveryCommunityViewModel.State.Loading.self)
}
.store(in: &disposeBag)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
tableView.deselectRow(with: transitionCoordinator, animated: animated)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
viewModel.viewDidAppeared.send()
}
}
extension DiscoveryCommunityViewController {
@objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
if !viewModel.stateMachine.enter(DiscoveryCommunityViewModel.State.Reloading.self) {
refreshControl.endRefreshing()
}
}
}
// MARK: - UITableViewDelegate
extension DiscoveryCommunityViewController: UITableViewDelegate, AutoGenerateTableViewDelegate {
// sourcery:inline:CommunityViewController.AutoGenerateTableViewDelegate
// Generated using Sourcery
// DO NOT EDIT
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
aspectTableView(tableView, didSelectRowAt: indexPath)
}
func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point)
}
func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration)
}
func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration)
}
func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator)
}
// sourcery:end
}
// MARK: - StatusTableViewCellDelegate
extension DiscoveryCommunityViewController: StatusTableViewCellDelegate { }
// MARK: ScrollViewContainer
extension DiscoveryCommunityViewController: ScrollViewContainer {
var scrollView: UIScrollView? {
tableView
}
}
extension DiscoveryCommunityViewController {
override var keyCommands: [UIKeyCommand]? {
return navigationKeyCommands + statusNavigationKeyCommands
}
}
// MARK: - StatusTableViewControllerNavigateable
extension DiscoveryCommunityViewController: StatusTableViewControllerNavigateable {
@objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
navigateKeyCommandHandler(sender)
}
@objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
statusKeyCommandHandler(sender)
}
}

View File

@ -0,0 +1,65 @@
//
// DiscoveryCommunityViewModel+Diffable.swift
// Mastodon
//
// Created by MainasuK on 2022-4-29.
//
import UIKit
import Combine
extension DiscoveryCommunityViewModel {
func setupDiffableDataSource(
tableView: UITableView,
statusTableViewCellDelegate: StatusTableViewCellDelegate
) {
diffableDataSource = StatusSection.diffableDataSource(
tableView: tableView,
context: context,
configuration: StatusSection.Configuration(
statusTableViewCellDelegate: statusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: nil,
filterContext: .none,
activeFilters: nil
)
)
stateMachine.enter(State.Reloading.self)
statusFetchedResultsController.$records
.receive(on: DispatchQueue.main)
.sink { [weak self] records in
guard let self = self else { return }
guard let diffableDataSource = self.diffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<StatusSection, StatusItem>()
snapshot.appendSections([.main])
let items = records.map { StatusItem.status(record: $0) }
snapshot.appendItems(items, toSection: .main)
if let currentState = self.stateMachine.currentState {
switch currentState {
case is State.Initial,
is State.Reloading,
is State.Loading,
is State.Idle,
is State.Fail:
if !items.isEmpty {
snapshot.appendItems([.bottomLoader], toSection: .main)
}
case is State.NoMore:
break
default:
assertionFailure()
break
}
}
diffableDataSource.applySnapshot(snapshot, animated: false)
}
.store(in: &disposeBag)
}
}

View File

@ -0,0 +1,206 @@
//
// DiscoveryCommunityViewModel+State.swift
// Mastodon
//
// Created by MainasuK on 2022-4-29.
//
import os.log
import Foundation
import GameplayKit
import MastodonSDK
extension DiscoveryCommunityViewModel {
class State: GKState, NamingState {
let logger = Logger(subsystem: "DiscoveryCommunityViewModel.State", category: "StateMachine")
let id = UUID()
var name: String {
String(describing: Self.self)
}
weak var viewModel: DiscoveryCommunityViewModel?
init(viewModel: DiscoveryCommunityViewModel) {
self.viewModel = viewModel
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
let previousState = previousState as? DiscoveryCommunityViewModel.State
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] enter \(self.name), previous: \(previousState?.name ?? "<nil>")")
}
@MainActor
func enter(state: State.Type) {
stateMachine?.enter(state)
}
deinit {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] \(self.name)")
}
}
}
extension DiscoveryCommunityViewModel.State {
class Initial: DiscoveryCommunityViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Reloading.Type:
return true
default:
return false
}
}
}
class Reloading: DiscoveryCommunityViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Loading.Type:
return true
default:
return false
}
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let _ = viewModel, let stateMachine = stateMachine else { return }
stateMachine.enter(Loading.self)
}
}
class Fail: DiscoveryCommunityViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Loading.Type:
return true
default:
return false
}
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let _ = viewModel, let stateMachine = stateMachine else { return }
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading 3s later…", ((#file as NSString).lastPathComponent), #line, #function)
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading", ((#file as NSString).lastPathComponent), #line, #function)
stateMachine.enter(Loading.self)
}
}
}
class Idle: DiscoveryCommunityViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Reloading.Type, is Loading.Type:
return true
default:
return false
}
}
}
class Loading: DiscoveryCommunityViewModel.State {
var maxID: String?
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Fail.Type:
return true
case is Idle.Type:
return true
case is NoMore.Type:
return true
default:
return false
}
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
switch previousState {
case is Reloading:
maxID = nil
default:
break
}
guard let authenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
stateMachine.enter(Fail.self)
return
}
let maxID = self.maxID
let isReloading = maxID == nil
Task {
do {
let response = try await viewModel.context.apiService.publicTimeline(
query: .init(
local: true,
remote: nil,
onlyMedia: nil,
maxID: maxID,
sinceID: nil,
minID: nil,
limit: 20
),
authenticationBox: authenticationBox
)
let newMaxID = response.link?.maxID
let hasMore = newMaxID != nil
self.maxID = newMaxID
var hasNewStatusesAppend = false
var statusIDs = isReloading ? [] : viewModel.statusFetchedResultsController.statusIDs.value
for status in response.value {
guard !statusIDs.contains(status.id) else { continue }
statusIDs.append(status.id)
hasNewStatusesAppend = true
}
if hasNewStatusesAppend, hasMore {
self.maxID = response.link?.maxID
await enter(state: Idle.self)
} else {
await enter(state: NoMore.self)
}
viewModel.statusFetchedResultsController.statusIDs.value = statusIDs
viewModel.didLoadLatest.send()
} catch {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch user timeline fail: \(error.localizedDescription)")
await enter(state: Fail.self)
}
} // end Task
} // end func
}
class NoMore: DiscoveryCommunityViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Reloading.Type:
return true
default:
return false
}
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
}
}
}

View File

@ -0,0 +1,65 @@
//
// DiscoveryCommunityViewModel.swift
// Mastodon
//
// Created by MainasuK on 2022-4-29.
//
import os.log
import UIKit
import Combine
import GameplayKit
import CoreData
import CoreDataStack
import MastodonSDK
final class DiscoveryCommunityViewModel {
let logger = Logger(subsystem: "DiscoveryCommunityViewModel", category: "ViewModel")
var disposeBag = Set<AnyCancellable>()
// input
let context: AppContext
let viewDidAppeared = PassthroughSubject<Void, Never>()
let statusFetchedResultsController: StatusFetchedResultsController
let listBatchFetchViewModel = ListBatchFetchViewModel()
// output
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, StatusItem>?
private(set) lazy var stateMachine: GKStateMachine = {
let stateMachine = GKStateMachine(states: [
State.Initial(viewModel: self),
State.Reloading(viewModel: self),
State.Fail(viewModel: self),
State.Idle(viewModel: self),
State.Loading(viewModel: self),
State.NoMore(viewModel: self),
])
stateMachine.enter(State.Initial.self)
return stateMachine
}()
let didLoadLatest = PassthroughSubject<Void, Never>()
init(context: AppContext) {
self.context = context
self.statusFetchedResultsController = StatusFetchedResultsController(
managedObjectContext: context.managedObjectContext,
domain: nil,
additionalTweetPredicate: nil
)
// end init
context.authenticationService.activeMastodonAuthentication
.map { $0?.domain }
.assign(to: \.value, on: statusFetchedResultsController.domain)
.store(in: &disposeBag)
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}

View File

@ -0,0 +1,64 @@
//
// DiscoveryCommunityViewModel.swift
// Mastodon
//
// Created by MainasuK on 2022-4-29.
//
import os.log
import UIKit
import Combine
import GameplayKit
import CoreData
import CoreDataStack
import MastodonSDK
final class DiscoveryCommunityViewModel {
let logger = Logger(subsystem: "DiscoveryCommunityViewModel", category: "ViewModel")
var disposeBag = Set<AnyCancellable>()
// input
let context: AppContext
let viewDidAppeared = PassthroughSubject<Void, Never>()
let statusFetchedResultsController: StatusFetchedResultsController
// output
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, StatusItem>?
private(set) lazy var stateMachine: GKStateMachine = {
let stateMachine = GKStateMachine(states: [
State.Initial(viewModel: self),
State.Reloading(viewModel: self),
State.Fail(viewModel: self),
State.Idle(viewModel: self),
State.Loading(viewModel: self),
State.NoMore(viewModel: self),
])
stateMachine.enter(State.Initial.self)
return stateMachine
}()
let didLoadLatest = PassthroughSubject<Void, Never>()
init(context: AppContext) {
self.context = context
self.statusFetchedResultsController = StatusFetchedResultsController(
managedObjectContext: context.managedObjectContext,
domain: nil,
additionalTweetPredicate: nil
)
// end init
context.authenticationService.activeMastodonAuthentication
.map { $0?.domain }
.assign(to: \.value, on: statusFetchedResultsController.domain)
.store(in: &disposeBag)
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}

View File

@ -0,0 +1,157 @@
//
// DiscoveryViewController.swift
// Mastodon
//
// Created by MainasuK on 2022-4-12.
//
import os.log
import UIKit
import Combine
import Tabman
import Pageboy
import MastodonAsset
import MastodonUI
public class DiscoveryViewController: TabmanViewController, NeedsDependency {
public static let containerViewMarginForRegularHorizontalSizeClass: CGFloat = 64
public static let containerViewMarginForCompactHorizontalSizeClass: CGFloat = 16
var disposeBag = Set<AnyCancellable>()
let logger = Logger(subsystem: "DiscoveryViewController", category: "ViewController")
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
private(set) lazy var viewModel = DiscoveryViewModel(
context: context,
coordinator: coordinator
)
private(set) lazy var buttonBar: TMBar.ButtonBar = {
let buttonBar = TMBar.ButtonBar()
buttonBar.backgroundView.style = .custom(view: buttonBarBackgroundView)
buttonBar.layout.interButtonSpacing = 0
buttonBar.layout.contentInset = .zero
buttonBar.indicator.backgroundColor = Asset.Colors.Label.primary.color
buttonBar.indicator.weight = .custom(value: 2)
return buttonBar
}()
let buttonBarBackgroundView: UIView = {
let view = UIView()
let barBottomLine = UIView.separatorLine
barBottomLine.backgroundColor = Asset.Colors.Label.secondary.color.withAlphaComponent(0.5)
barBottomLine.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(barBottomLine)
NSLayoutConstraint.activate([
barBottomLine.leadingAnchor.constraint(equalTo: view.leadingAnchor),
barBottomLine.trailingAnchor.constraint(equalTo: view.trailingAnchor),
barBottomLine.bottomAnchor.constraint(equalTo: view.bottomAnchor),
barBottomLine.heightAnchor.constraint(equalToConstant: 2).priority(.required - 1),
])
return view
}()
func customizeButtonBarAppearance() {
// The implmention use CATextlayer. Adapt for Dark Mode without dynamic colors
// Needs trigger update when `userInterfaceStyle` chagnes
let userInterfaceStyle = traitCollection.userInterfaceStyle
buttonBar.buttons.customize { button in
switch userInterfaceStyle {
case .dark:
// Asset.Colors.Label.primary.color
button.selectedTintColor = UIColor(red: 238.0/255.0, green: 238.0/255.0, blue: 238.0/255.0, alpha: 1.0)
// Asset.Colors.Label.secondary.color
button.tintColor = UIColor(red: 151.0/255.0, green: 157.0/255.0, blue: 173.0/255.0, alpha: 1.0)
default:
// Asset.Colors.Label.primary.color
button.selectedTintColor = UIColor(red: 40.0/255.0, green: 44.0/255.0, blue: 55.0/255.0, alpha: 1.0)
// Asset.Colors.Label.secondary.color
button.tintColor = UIColor(red: 60.0/255.0, green: 60.0/255.0, blue: 67.0/255.0, alpha: 0.6)
}
button.backgroundColor = .clear
button.contentInset = UIEdgeInsets(top: 12, left: 26, bottom: 12, right: 26)
}
}
}
extension DiscoveryViewController {
public override func viewDidLoad() {
super.viewDidLoad()
setupAppearance(theme: ThemeService.shared.currentTheme.value)
ThemeService.shared.currentTheme
.receive(on: DispatchQueue.main)
.sink { [weak self] theme in
guard let self = self else { return }
self.setupAppearance(theme: theme)
}
.store(in: &disposeBag)
dataSource = viewModel
addBar(
buttonBar,
dataSource: viewModel,
at: .top
)
customizeButtonBarAppearance()
viewModel.$viewControllers
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self = self else { return }
self.reloadData()
}
.store(in: &disposeBag)
}
public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
customizeButtonBarAppearance()
}
}
extension DiscoveryViewController {
private func setupAppearance(theme: Theme) {
view.backgroundColor = theme.secondarySystemBackgroundColor
buttonBarBackgroundView.backgroundColor = theme.systemBackgroundColor
}
}
// MARK: - ScrollViewContainer
extension DiscoveryViewController: ScrollViewContainer {
var scrollView: UIScrollView? {
return (currentViewController as? ScrollViewContainer)?.scrollView
}
}
extension DiscoveryViewController {
public override var keyCommands: [UIKeyCommand]? {
return pageboyNavigateKeyCommands
}
}
// MARK: - PageboyNavigateable
extension DiscoveryViewController: PageboyNavigateable {
var navigateablePageViewController: PageboyViewController {
return self
}
@objc func pageboyNavigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
pageboyNavigateKeyCommandHandler(sender)
}
}

View File

@ -0,0 +1,173 @@
//
// DiscoveryViewModel.swift
// Mastodon
//
// Created by MainasuK on 2022-4-12.
//
import UIKit
import Combine
import Tabman
import Pageboy
import MastodonLocalization
final class DiscoveryViewModel {
var disposeBag = Set<AnyCancellable>()
// input
let context: AppContext
let discoveryPostsViewController: DiscoveryPostsViewController
let discoveryHashtagsViewController: DiscoveryHashtagsViewController
let discoveryNewsViewController: DiscoveryNewsViewController
let discoveryCommunityViewController: DiscoveryCommunityViewController
let discoveryForYouViewController: DiscoveryForYouViewController
@Published var viewControllers: [ScrollViewContainer & PageViewController]
init(context: AppContext, coordinator: SceneCoordinator) {
func setupDependency(_ needsDependency: NeedsDependency) {
needsDependency.context = context
needsDependency.coordinator = coordinator
}
self.context = context
discoveryPostsViewController = {
let viewController = DiscoveryPostsViewController()
setupDependency(viewController)
viewController.viewModel = DiscoveryPostsViewModel(context: context)
return viewController
}()
discoveryHashtagsViewController = {
let viewController = DiscoveryHashtagsViewController()
setupDependency(viewController)
viewController.viewModel = DiscoveryHashtagsViewModel(context: context)
return viewController
}()
discoveryNewsViewController = {
let viewController = DiscoveryNewsViewController()
setupDependency(viewController)
viewController.viewModel = DiscoveryNewsViewModel(context: context)
return viewController
}()
discoveryCommunityViewController = {
let viewController = DiscoveryCommunityViewController()
setupDependency(viewController)
viewController.viewModel = DiscoveryCommunityViewModel(context: context)
return viewController
}()
discoveryForYouViewController = {
let viewController = DiscoveryForYouViewController()
setupDependency(viewController)
viewController.viewModel = DiscoveryForYouViewModel(context: context)
return viewController
}()
self.viewControllers = [
discoveryPostsViewController,
discoveryHashtagsViewController,
discoveryNewsViewController,
discoveryCommunityViewController,
discoveryForYouViewController,
]
// end init
discoveryPostsViewController.viewModel.$isServerSupportEndpoint
.receive(on: DispatchQueue.main)
.sink { [weak self] isServerSupportEndpoint in
guard let self = self else { return }
if !isServerSupportEndpoint {
self.viewControllers.removeAll(where: {
$0 === self.discoveryPostsViewController || $0 === self.discoveryPostsViewController
})
}
}
.store(in: &disposeBag)
discoveryNewsViewController.viewModel.$isServerSupportEndpoint
.receive(on: DispatchQueue.main)
.sink { [weak self] isServerSupportEndpoint in
guard let self = self else { return }
if !isServerSupportEndpoint {
self.viewControllers.removeAll(where: { $0 === self.discoveryNewsViewController })
}
}
.store(in: &disposeBag)
}
}
// MARK: - PageboyViewControllerDataSource
extension DiscoveryViewModel: PageboyViewControllerDataSource {
func numberOfViewControllers(in pageboyViewController: PageboyViewController) -> Int {
return viewControllers.count
}
func viewController(for pageboyViewController: PageboyViewController, at index: PageboyViewController.PageIndex) -> UIViewController? {
return viewControllers[index]
}
func defaultPage(for pageboyViewController: PageboyViewController) -> PageboyViewController.Page? {
return .first
}
}
// MARK: - TMBarDataSource
extension DiscoveryViewModel: TMBarDataSource {
func barItem(for bar: TMBar, at index: Int) -> TMBarItemable {
guard !viewControllers.isEmpty, index < viewControllers.count else {
assertionFailure()
return TMBarItem(title: "")
}
return viewControllers[index].tabItem
}
}
protocol PageViewController: UIViewController {
var tabItemTitle: String { get }
var tabItem: TMBarItemable { get }
}
// MARK: - PageViewController
extension DiscoveryPostsViewController: PageViewController {
var tabItemTitle: String { L10n.Scene.Discovery.Tabs.posts }
var tabItem: TMBarItemable {
return TMBarItem(title: tabItemTitle)
}
}
// MARK: - PageViewController
extension DiscoveryHashtagsViewController: PageViewController {
var tabItemTitle: String { L10n.Scene.Discovery.Tabs.hashtags }
var tabItem: TMBarItemable {
return TMBarItem(title: tabItemTitle)
}
}
// MARK: - PageViewController
extension DiscoveryNewsViewController: PageViewController {
var tabItemTitle: String { L10n.Scene.Discovery.Tabs.news }
var tabItem: TMBarItemable {
return TMBarItem(title: tabItemTitle)
}
}
// MARK: - PageViewController
extension DiscoveryCommunityViewController: PageViewController {
var tabItemTitle: String { L10n.Scene.Discovery.Tabs.community }
var tabItem: TMBarItemable {
return TMBarItem(title: tabItemTitle)
}
}
// MARK: - PageViewController
extension DiscoveryForYouViewController: PageViewController {
var tabItemTitle: String { L10n.Scene.Discovery.Tabs.forYou }
var tabItem: TMBarItemable {
return TMBarItem(title: tabItemTitle)
}
}

View File

@ -0,0 +1,146 @@
//
// DiscoveryForYouViewController.swift
// Mastodon
//
// Created by MainasuK on 2022-4-14.
//
import os.log
import UIKit
import Combine
import MastodonUI
final class DiscoveryForYouViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
let logger = Logger(subsystem: "DiscoveryForYouViewController", category: "ViewController")
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var disposeBag = Set<AnyCancellable>()
var viewModel: DiscoveryForYouViewModel!
let mediaPreviewTransitionController = MediaPreviewTransitionController()
lazy var tableView: UITableView = {
let tableView = UITableView()
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 100
tableView.separatorStyle = .none
tableView.backgroundColor = .clear
return tableView
}()
let refreshControl = UIRefreshControl()
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension DiscoveryForYouViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor
ThemeService.shared.currentTheme
.receive(on: DispatchQueue.main)
.sink { [weak self] theme in
guard let self = self else { return }
self.view.backgroundColor = theme.secondarySystemBackgroundColor
}
.store(in: &disposeBag)
tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
tableView.delegate = self
viewModel.setupDiffableDataSource(
tableView: tableView,
profileCardTableViewCellDelegate: self
)
tableView.refreshControl = refreshControl
refreshControl.addTarget(self, action: #selector(DiscoveryForYouViewController.refreshControlValueChanged(_:)), for: .valueChanged)
viewModel.$isFetching
.receive(on: DispatchQueue.main)
.sink { [weak self] isFetching in
guard let self = self else { return }
if !isFetching {
self.refreshControl.endRefreshing()
}
}
.store(in: &disposeBag)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
refreshControl.endRefreshing()
tableView.deselectRow(with: transitionCoordinator, animated: animated)
}
}
extension DiscoveryForYouViewController {
@objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
Task {
try await viewModel.fetch()
}
}
}
// MARK: - UITableViewDelegate
extension DiscoveryForYouViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(indexPath)")
guard case let .user(record) = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { return }
guard let user = record.object(in: context.managedObjectContext) else { return }
let profileViewModel = CachedProfileViewModel(
context: context,
mastodonUser: user
)
coordinator.present(
scene: .profile(viewModel: profileViewModel),
from: self,
transition: .show
)
}
}
// MARK: - ProfileCardTableViewCellDelegate
extension DiscoveryForYouViewController: ProfileCardTableViewCellDelegate {
func profileCardTableViewCell(_ cell: ProfileCardTableViewCell, profileCardView: ProfileCardView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) {
guard let authenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { return }
guard let indexPath = tableView.indexPath(for: cell) else { return }
guard case let .user(record) = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { return }
Task {
try await DataSourceFacade.responseToUserFollowAction(
dependency: self,
user: record,
authenticationBox: authenticationBox
)
} // end Task
}
}
// MARK: ScrollViewContainer
extension DiscoveryForYouViewController: ScrollViewContainer {
var scrollView: UIScrollView? {
tableView
}
}

View File

@ -0,0 +1,47 @@
//
// DiscoveryForYouViewModel+Diffable.swift
// Mastodon
//
// Created by MainasuK on 2022-4-14.
//
import UIKit
import Combine
import MastodonUI
extension DiscoveryForYouViewModel {
func setupDiffableDataSource(
tableView: UITableView,
profileCardTableViewCellDelegate: ProfileCardTableViewCellDelegate
) {
diffableDataSource = DiscoverySection.diffableDataSource(
tableView: tableView,
context: context,
configuration: DiscoverySection.Configuration(
profileCardTableViewCellDelegate: profileCardTableViewCellDelegate
)
)
Task {
try await fetch()
}
userFetchedResultsController.$records
.receive(on: DispatchQueue.main)
.sink { [weak self] records in
guard let self = self else { return }
guard let diffableDataSource = self.diffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<DiscoverySection, DiscoveryItem>()
snapshot.appendSections([.forYou])
let items = records.map { DiscoveryItem.user($0) }
snapshot.appendItems(items, toSection: .forYou)
diffableDataSource.applySnapshot(snapshot, animated: false)
}
.store(in: &disposeBag)
}
}

View File

@ -0,0 +1,75 @@
//
// DiscoveryForYouViewModel.swift
// Mastodon
//
// Created by MainasuK on 2022-4-14.
//
import os.log
import UIKit
import Combine
import GameplayKit
import CoreData
import CoreDataStack
import MastodonSDK
final class DiscoveryForYouViewModel {
var disposeBag = Set<AnyCancellable>()
// input
let context: AppContext
let userFetchedResultsController: UserFetchedResultsController
@Published var isFetching = false
// output
var diffableDataSource: UITableViewDiffableDataSource<DiscoverySection, DiscoveryItem>?
let didLoadLatest = PassthroughSubject<Void, Never>()
init(context: AppContext) {
self.context = context
self.userFetchedResultsController = UserFetchedResultsController(
managedObjectContext: context.managedObjectContext,
domain: nil,
additionalPredicate: nil
)
// end init
context.authenticationService.activeMastodonAuthenticationBox
.map { $0?.domain }
.assign(to: \.domain, on: userFetchedResultsController)
.store(in: &disposeBag)
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension DiscoveryForYouViewModel {
func fetch() async throws {
guard !isFetching else { return }
isFetching = true
defer { isFetching = false }
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
do {
let response = try await context.apiService.suggestionAccountV2(
query: nil,
authenticationBox: authenticationBox
)
let userIDs = response.value.map { $0.account.id }
userFetchedResultsController.userIDs = userIDs
} catch {
// fallback V1
let response2 = try await context.apiService.suggestionAccount(
query: nil,
authenticationBox: authenticationBox
)
let userIDs = response2.value.map { $0.id }
userFetchedResultsController.userIDs = userIDs
}
}
}

View File

@ -0,0 +1,228 @@
//
// DiscoveryHashtagsViewController.swift
// Mastodon
//
// Created by MainasuK on 2022-4-13.
//
import os.log
import UIKit
import Combine
import MastodonUI
final class DiscoveryHashtagsViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
let logger = Logger(subsystem: "TrendPostsViewController", category: "ViewController")
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var disposeBag = Set<AnyCancellable>()
var viewModel: DiscoveryHashtagsViewModel!
let mediaPreviewTransitionController = MediaPreviewTransitionController()
lazy var tableView: UITableView = {
let tableView = UITableView()
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 100
tableView.separatorStyle = .none
tableView.backgroundColor = .clear
return tableView
}()
let refreshControl = UIRefreshControl()
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension DiscoveryHashtagsViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor
ThemeService.shared.currentTheme
.receive(on: DispatchQueue.main)
.sink { [weak self] theme in
guard let self = self else { return }
self.view.backgroundColor = theme.secondarySystemBackgroundColor
}
.store(in: &disposeBag)
tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
tableView.refreshControl = refreshControl
refreshControl.addTarget(self, action: #selector(DiscoveryHashtagsViewController.refreshControlValueChanged(_:)), for: .valueChanged)
tableView.delegate = self
viewModel.setupDiffableDataSource(
tableView: tableView
)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
tableView.deselectRow(with: transitionCoordinator, animated: animated)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
viewModel.viewDidAppeared.send()
}
}
extension DiscoveryHashtagsViewController {
@objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
Task { @MainActor in
do {
try await viewModel.fetch()
} catch {
// do nothing
}
sender.endRefreshing()
} // end Task
}
}
// MARK: - UITableViewDelegate
extension DiscoveryHashtagsViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(indexPath)")
guard case let .hashtag(tag) = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { return }
let hashtagTimelineViewModel = HashtagTimelineViewModel(context: context, hashtag: tag.name)
coordinator.present(
scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel),
from: self,
transition: .show
)
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard let cell = cell as? TrendTableViewCell else { return }
guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
if let lastItem = diffableDataSource.snapshot().itemIdentifiers.last, item == lastItem {
cell.configureSeparator(style: .edge)
}
}
}
// MARK: ScrollViewContainer
extension DiscoveryHashtagsViewController: ScrollViewContainer {
var scrollView: UIScrollView? {
tableView
}
}
extension DiscoveryHashtagsViewController {
override var keyCommands: [UIKeyCommand]? {
return navigationKeyCommands
}
}
// MARK: - TableViewControllerNavigateable
extension DiscoveryHashtagsViewController: TableViewControllerNavigateable {
@objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
navigateKeyCommandHandler(sender)
}
func navigate(direction: TableViewNavigationDirection) {
if let indexPathForSelectedRow = tableView.indexPathForSelectedRow {
// navigate up/down on the current selected item
navigateToTag(direction: direction, indexPath: indexPathForSelectedRow)
} else {
// set first visible item selected
navigateToFirstVisibleTag()
}
}
private func navigateToTag(direction: TableViewNavigationDirection, indexPath: IndexPath) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let items = diffableDataSource.snapshot().itemIdentifiers
guard let selectedItem = diffableDataSource.itemIdentifier(for: indexPath),
let selectedItemIndex = items.firstIndex(of: selectedItem) else {
return
}
let _navigateToItem: DiscoveryItem? = {
var index = selectedItemIndex
while 0..<items.count ~= index {
index = {
switch direction {
case .up: return index - 1
case .down: return index + 1
}
}()
guard 0..<items.count ~= index else { return nil }
let item = items[index]
guard Self.validNavigateableItem(item) else { continue }
return item
}
return nil
}()
guard let item = _navigateToItem, let indexPath = diffableDataSource.indexPath(for: item) else { return }
let scrollPosition: UITableView.ScrollPosition = overrideNavigationScrollPosition ?? Self.navigateScrollPosition(tableView: tableView, indexPath: indexPath)
tableView.selectRow(at: indexPath, animated: true, scrollPosition: scrollPosition)
}
private func navigateToFirstVisibleTag() {
guard let indexPathsForVisibleRows = tableView.indexPathsForVisibleRows else { return }
guard let diffableDataSource = viewModel.diffableDataSource else { return }
var visibleItems: [DiscoveryItem] = indexPathsForVisibleRows.sorted().compactMap { indexPath in
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return nil }
guard Self.validNavigateableItem(item) else { return nil }
return item
}
if indexPathsForVisibleRows.first?.row != 0, visibleItems.count > 1 {
// drop first when visible not the first cell of table
visibleItems.removeFirst()
}
guard let item = visibleItems.first, let indexPath = diffableDataSource.indexPath(for: item) else { return }
let scrollPosition: UITableView.ScrollPosition = overrideNavigationScrollPosition ?? Self.navigateScrollPosition(tableView: tableView, indexPath: indexPath)
tableView.selectRow(at: indexPath, animated: true, scrollPosition: scrollPosition)
}
static func validNavigateableItem(_ item: DiscoveryItem) -> Bool {
switch item {
case .hashtag:
return true
default:
return false
}
}
func open() {
guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow else { return }
guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPathForSelectedRow) else { return }
guard case let .hashtag(tag) = item else { return }
let hashtagTimelineViewModel = HashtagTimelineViewModel(context: context, hashtag: tag.name)
coordinator.present(
scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel),
from: self,
transition: .show
)
}
}

View File

@ -0,0 +1,42 @@
//
// DiscoveryHashtagsViewModel+Diffable.swift
// Mastodon
//
// Created by MainasuK on 2022-4-13.
//
import UIKit
extension DiscoveryHashtagsViewModel {
func setupDiffableDataSource(
tableView: UITableView
) {
diffableDataSource = DiscoverySection.diffableDataSource(
tableView: tableView,
context: context,
configuration: DiscoverySection.Configuration()
)
var snapshot = NSDiffableDataSourceSnapshot<DiscoverySection, DiscoveryItem>()
snapshot.appendSections([.hashtags])
diffableDataSource?.apply(snapshot)
$hashtags
.receive(on: DispatchQueue.main)
.sink { [weak self] hashtags in
guard let self = self else { return }
guard let diffableDataSource = self.diffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<DiscoverySection, DiscoveryItem>()
snapshot.appendSections([.hashtags])
let items = hashtags.map { DiscoveryItem.hashtag($0) }
snapshot.appendItems(items, toSection: .hashtags)
diffableDataSource.apply(snapshot)
}
.store(in: &disposeBag)
}
}

View File

@ -0,0 +1,77 @@
//
// DiscoveryHashtagsViewModel.swift
// Mastodon
//
// Created by MainasuK on 2022-4-13.
//
import os.log
import UIKit
import Combine
import GameplayKit
import CoreData
import CoreDataStack
import MastodonSDK
final class DiscoveryHashtagsViewModel {
let logger = Logger(subsystem: "DiscoveryHashtagsViewModel", category: "ViewModel")
var disposeBag = Set<AnyCancellable>()
// input
let context: AppContext
let viewDidAppeared = PassthroughSubject<Void, Never>()
// output
var diffableDataSource: UITableViewDiffableDataSource<DiscoverySection, DiscoveryItem>?
@Published var hashtags: [Mastodon.Entity.Tag] = []
init(context: AppContext) {
self.context = context
// end init
Publishers.CombineLatest(
context.authenticationService.activeMastodonAuthenticationBox,
viewDidAppeared
)
.compactMap { authenticationBox, _ -> MastodonAuthenticationBox? in
return authenticationBox
}
.throttle(for: 3, scheduler: DispatchQueue.main, latest: true)
.asyncMap { authenticationBox in
try await context.apiService.trendHashtags(domain: authenticationBox.domain, query: nil)
}
.retry(3)
.map { response in Result<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { response } }
.catch { error in Just(Result<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { throw error }) }
.receive(on: DispatchQueue.main)
.sink { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let response):
self.hashtags = response.value.filter { !$0.name.isEmpty }
case .failure:
break
}
}
.store(in: &disposeBag)
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension DiscoveryHashtagsViewModel {
@MainActor
func fetch() async throws {
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
let response = try await context.apiService.trendHashtags(domain: authenticationBox.domain, query: nil)
hashtags = response.value.filter { !$0.name.isEmpty }
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch tags: \(response.value.count)")
}
}

View File

@ -0,0 +1,229 @@
//
// DiscoveryNewsViewController.swift
// Mastodon
//
// Created by MainasuK on 2022-4-13.
//
import os.log
import UIKit
import Combine
import MastodonUI
final class DiscoveryNewsViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
let logger = Logger(subsystem: "TrendPostsViewController", category: "ViewController")
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var disposeBag = Set<AnyCancellable>()
var viewModel: DiscoveryNewsViewModel!
let mediaPreviewTransitionController = MediaPreviewTransitionController()
lazy var tableView: UITableView = {
let tableView = UITableView()
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 100
tableView.separatorStyle = .none
tableView.backgroundColor = .clear
return tableView
}()
let refreshControl = UIRefreshControl()
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension DiscoveryNewsViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor
ThemeService.shared.currentTheme
.receive(on: DispatchQueue.main)
.sink { [weak self] theme in
guard let self = self else { return }
self.view.backgroundColor = theme.secondarySystemBackgroundColor
}
.store(in: &disposeBag)
tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
tableView.delegate = self
viewModel.setupDiffableDataSource(
tableView: tableView
)
tableView.refreshControl = refreshControl
refreshControl.addTarget(self, action: #selector(DiscoveryNewsViewController.refreshControlValueChanged(_:)), for: .valueChanged)
viewModel.didLoadLatest
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self = self else { return }
self.refreshControl.endRefreshing()
}
.store(in: &disposeBag)
// setup batch fetch
viewModel.listBatchFetchViewModel.setup(scrollView: tableView)
viewModel.listBatchFetchViewModel.shouldFetch
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self = self else { return }
guard self.view.window != nil else { return }
self.viewModel.stateMachine.enter(DiscoveryNewsViewModel.State.Loading.self)
}
.store(in: &disposeBag)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
refreshControl.endRefreshing()
tableView.deselectRow(with: transitionCoordinator, animated: animated)
}
}
extension DiscoveryNewsViewController {
@objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
guard viewModel.stateMachine.enter(DiscoveryNewsViewModel.State.Reloading.self) else {
sender.endRefreshing()
return
}
}
}
// MARK: - UITableViewDelegate
extension DiscoveryNewsViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(indexPath)")
guard case let .link(link) = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { return }
guard let url = URL(string: link.url) else { return }
coordinator.present(
scene: .safari(url: url),
from: self,
transition: .safariPresent(animated: true, completion: nil)
)
}
}
// MARK: ScrollViewContainer
extension DiscoveryNewsViewController: ScrollViewContainer {
var scrollView: UIScrollView? {
tableView
}
}
extension DiscoveryNewsViewController {
override var keyCommands: [UIKeyCommand]? {
return navigationKeyCommands
}
}
extension DiscoveryNewsViewController: TableViewControllerNavigateable {
func navigate(direction: TableViewNavigationDirection) {
if let indexPathForSelectedRow = tableView.indexPathForSelectedRow {
// navigate up/down on the current selected item
navigateToLink(direction: direction, indexPath: indexPathForSelectedRow)
} else {
// set first visible item selected
navigateToFirstVisibleLink()
}
}
private func navigateToLink(direction: TableViewNavigationDirection, indexPath: IndexPath) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let items = diffableDataSource.snapshot().itemIdentifiers
guard let selectedItem = diffableDataSource.itemIdentifier(for: indexPath),
let selectedItemIndex = items.firstIndex(of: selectedItem) else {
return
}
let _navigateToItem: DiscoveryItem? = {
var index = selectedItemIndex
while 0..<items.count ~= index {
index = {
switch direction {
case .up: return index - 1
case .down: return index + 1
}
}()
guard 0..<items.count ~= index else { return nil }
let item = items[index]
guard Self.validNavigateableItem(item) else { continue }
return item
}
return nil
}()
guard let item = _navigateToItem, let indexPath = diffableDataSource.indexPath(for: item) else { return }
let scrollPosition: UITableView.ScrollPosition = overrideNavigationScrollPosition ?? Self.navigateScrollPosition(tableView: tableView, indexPath: indexPath)
tableView.selectRow(at: indexPath, animated: true, scrollPosition: scrollPosition)
}
private func navigateToFirstVisibleLink() {
guard let indexPathsForVisibleRows = tableView.indexPathsForVisibleRows else { return }
guard let diffableDataSource = viewModel.diffableDataSource else { return }
var visibleItems: [DiscoveryItem] = indexPathsForVisibleRows.sorted().compactMap { indexPath in
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return nil }
guard Self.validNavigateableItem(item) else { return nil }
return item
}
if indexPathsForVisibleRows.first?.row != 0, visibleItems.count > 1 {
// drop first when visible not the first cell of table
visibleItems.removeFirst()
}
guard let item = visibleItems.first, let indexPath = diffableDataSource.indexPath(for: item) else { return }
let scrollPosition: UITableView.ScrollPosition = overrideNavigationScrollPosition ?? Self.navigateScrollPosition(tableView: tableView, indexPath: indexPath)
tableView.selectRow(at: indexPath, animated: true, scrollPosition: scrollPosition)
}
static func validNavigateableItem(_ item: DiscoveryItem) -> Bool {
switch item {
case .link:
return true
default:
return false
}
}
func open() {
guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow else { return }
guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPathForSelectedRow) else { return }
guard case let .link(link) = item else { return }
guard let url = URL(string: link.url) else { return }
coordinator.present(
scene: .safari(url: url),
from: self,
transition: .safariPresent(animated: true, completion: nil)
)
}
func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
navigateKeyCommandHandler(sender)
}
}

View File

@ -0,0 +1,60 @@
//
// DiscoveryNewsViewModel+Diffable.swift
// Mastodon
//
// Created by MainasuK on 2022-4-13.
//
import UIKit
import Combine
extension DiscoveryNewsViewModel {
func setupDiffableDataSource(
tableView: UITableView
) {
diffableDataSource = DiscoverySection.diffableDataSource(
tableView: tableView,
context: context,
configuration: DiscoverySection.Configuration()
)
stateMachine.enter(State.Reloading.self)
$links
.receive(on: DispatchQueue.main)
.sink { [weak self] links in
guard let self = self else { return }
guard let diffableDataSource = self.diffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<DiscoverySection, DiscoveryItem>()
snapshot.appendSections([.news])
let items = links.map { DiscoveryItem.link($0) }
snapshot.appendItems(items, toSection: .news)
if let currentState = self.stateMachine.currentState {
switch currentState {
case is State.Initial,
is State.Loading,
is State.Idle,
is State.Fail:
if !items.isEmpty {
snapshot.appendItems([.bottomLoader], toSection: .news)
}
case is State.Reloading:
break
case is State.NoMore:
break
default:
assertionFailure()
break
}
}
diffableDataSource.applySnapshot(snapshot, animated: false)
}
.store(in: &disposeBag)
}
}

View File

@ -0,0 +1,213 @@
//
// DiscoveryNewsViewModel+State.swift
// Mastodon
//
// Created by MainasuK on 2022-4-13.
//
import os.log
import Foundation
import GameplayKit
import MastodonSDK
extension DiscoveryNewsViewModel {
class State: GKState, NamingState {
let logger = Logger(subsystem: "DiscoveryNewsViewModel.State", category: "StateMachine")
let id = UUID()
var name: String {
String(describing: Self.self)
}
weak var viewModel: DiscoveryNewsViewModel?
init(viewModel: DiscoveryNewsViewModel) {
self.viewModel = viewModel
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
let previousState = previousState as? DiscoveryNewsViewModel.State
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] enter \(self.name), previous: \(previousState?.name ?? "<nil>")")
}
@MainActor
func enter(state: State.Type) {
stateMachine?.enter(state)
}
deinit {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] \(self.name)")
}
}
}
extension DiscoveryNewsViewModel.State {
class Initial: DiscoveryNewsViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Reloading.Type:
return true
default:
return false
}
}
}
class Reloading: DiscoveryNewsViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Loading.Type:
return true
default:
return false
}
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let _ = viewModel, let stateMachine = stateMachine else { return }
stateMachine.enter(Loading.self)
}
}
class Fail: DiscoveryNewsViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Loading.Type:
return true
default:
return false
}
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let _ = viewModel, let stateMachine = stateMachine else { return }
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading 3s later…", ((#file as NSString).lastPathComponent), #line, #function)
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading", ((#file as NSString).lastPathComponent), #line, #function)
stateMachine.enter(Loading.self)
}
}
}
class Idle: DiscoveryNewsViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Reloading.Type, is Loading.Type:
return true
default:
return false
}
}
}
class Loading: DiscoveryNewsViewModel.State {
var offset: Int?
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Fail.Type:
return true
case is Idle.Type:
return true
case is NoMore.Type:
return true
default:
return false
}
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
switch previousState {
case is Reloading:
offset = nil
default:
break
}
guard let authenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
stateMachine.enter(Fail.self)
return
}
let offset = self.offset
let isReloading = offset == nil
Task {
do {
let response = try await viewModel.context.apiService.trendLinks(
domain: authenticationBox.domain,
query: Mastodon.API.Trends.StatusQuery(
offset: offset,
limit: nil
)
)
let newOffset: Int? = {
guard let offset = response.link?.offset else { return nil }
return self.offset.flatMap { max($0, offset) } ?? offset
}()
let hasMore: Bool = {
guard let newOffset = newOffset else { return false }
return newOffset != self.offset // not the same one
}()
self.offset = newOffset
var hasNewItemsAppend = false
var links = isReloading ? [] : viewModel.links
for link in response.value {
guard !links.contains(link) else { continue }
links.append(link)
hasNewItemsAppend = true
}
if hasNewItemsAppend, hasMore {
await enter(state: Idle.self)
} else {
await enter(state: NoMore.self)
}
viewModel.links = links
viewModel.didLoadLatest.send()
} catch {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch news fail: \(error.localizedDescription)")
if let error = error as? Mastodon.API.Error, error.httpResponseStatus.code == 404 {
viewModel.isServerSupportEndpoint = false
await enter(state: NoMore.self)
} else {
await enter(state: Fail.self)
}
viewModel.didLoadLatest.send()
}
} // end Task
} // end func
}
class NoMore: DiscoveryNewsViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Reloading.Type:
return true
default:
return false
}
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
}
}
}

View File

@ -0,0 +1,74 @@
//
// DiscoveryNewsViewModel.swift
// Mastodon
//
// Created by MainasuK on 2022-4-13.
//
import os.log
import UIKit
import Combine
import GameplayKit
import CoreData
import CoreDataStack
import MastodonSDK
final class DiscoveryNewsViewModel {
var disposeBag = Set<AnyCancellable>()
// input
let context: AppContext
let listBatchFetchViewModel = ListBatchFetchViewModel()
// output
@Published var links: [Mastodon.Entity.Link] = []
var diffableDataSource: UITableViewDiffableDataSource<DiscoverySection, DiscoveryItem>?
private(set) lazy var stateMachine: GKStateMachine = {
let stateMachine = GKStateMachine(states: [
State.Initial(viewModel: self),
State.Reloading(viewModel: self),
State.Fail(viewModel: self),
State.Idle(viewModel: self),
State.Loading(viewModel: self),
State.NoMore(viewModel: self),
])
stateMachine.enter(State.Initial.self)
return stateMachine
}()
let didLoadLatest = PassthroughSubject<Void, Never>()
@Published var isServerSupportEndpoint = true
init(context: AppContext) {
self.context = context
// end init
Task {
await checkServerEndpoint()
} // end Task
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension DiscoveryNewsViewModel {
func checkServerEndpoint() async {
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
do {
_ = try await context.apiService.trendLinks(
domain: authenticationBox.domain,
query: .init(offset: nil, limit: nil)
)
} catch let error as Mastodon.API.Error where error.httpResponseStatus.code == 404 {
isServerSupportEndpoint = false
} catch {
// do nothing
}
}
}

View File

@ -0,0 +1,34 @@
//
// DiscoveryPostsViewController+DataSourceProvider.swift
// Mastodon
//
// Created by MainasuK on 2022-4-12.
//
import UIKit
extension DiscoveryPostsViewController: DataSourceProvider {
func item(from source: DataSourceItem.Source) async -> DataSourceItem? {
var _indexPath = source.indexPath
if _indexPath == nil, let cell = source.tableViewCell {
_indexPath = await self.indexPath(for: cell)
}
guard let indexPath = _indexPath else { return nil }
guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else {
return nil
}
switch item {
case .status(let record):
return .status(record: record)
default:
return nil
}
}
@MainActor
private func indexPath(for cell: UITableViewCell) async -> IndexPath? {
return tableView.indexPath(for: cell)
}
}

View File

@ -0,0 +1,190 @@
//
// DiscoveryPostsViewController.swift
// Mastodon
//
// Created by MainasuK on 2022-4-12.
//
import os.log
import UIKit
import Combine
import MastodonUI
final class DiscoveryPostsViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
let logger = Logger(subsystem: "TrendPostsViewController", category: "ViewController")
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var disposeBag = Set<AnyCancellable>()
var viewModel: DiscoveryPostsViewModel!
let mediaPreviewTransitionController = MediaPreviewTransitionController()
lazy var tableView: UITableView = {
let tableView = UITableView()
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 100
tableView.separatorStyle = .none
tableView.backgroundColor = .clear
return tableView
}()
let refreshControl = UIRefreshControl()
let discoveryIntroBannerView = DiscoveryIntroBannerView()
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension DiscoveryPostsViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor
ThemeService.shared.currentTheme
.receive(on: DispatchQueue.main)
.sink { [weak self] theme in
guard let self = self else { return }
self.view.backgroundColor = theme.secondarySystemBackgroundColor
}
.store(in: &disposeBag)
tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
discoveryIntroBannerView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(discoveryIntroBannerView)
NSLayoutConstraint.activate([
discoveryIntroBannerView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
discoveryIntroBannerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
discoveryIntroBannerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
discoveryIntroBannerView.delegate = self
discoveryIntroBannerView.isHidden = UserDefaults.shared.discoveryIntroBannerNeedsHidden
UserDefaults.shared.publisher(for: \.discoveryIntroBannerNeedsHidden)
.receive(on: DispatchQueue.main)
.assign(to: \.isHidden, on: discoveryIntroBannerView)
.store(in: &disposeBag)
tableView.delegate = self
viewModel.setupDiffableDataSource(
tableView: tableView,
statusTableViewCellDelegate: self
)
tableView.refreshControl = refreshControl
refreshControl.addTarget(self, action: #selector(DiscoveryPostsViewController.refreshControlValueChanged(_:)), for: .valueChanged)
viewModel.didLoadLatest
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self = self else { return }
self.refreshControl.endRefreshing()
}
.store(in: &disposeBag)
// setup batch fetch
viewModel.listBatchFetchViewModel.setup(scrollView: tableView)
viewModel.listBatchFetchViewModel.shouldFetch
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self = self else { return }
guard self.view.window != nil else { return }
self.viewModel.stateMachine.enter(DiscoveryPostsViewModel.State.Loading.self)
}
.store(in: &disposeBag)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
refreshControl.endRefreshing()
tableView.deselectRow(with: transitionCoordinator, animated: animated)
}
}
extension DiscoveryPostsViewController {
@objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
guard viewModel.stateMachine.enter(DiscoveryPostsViewModel.State.Reloading.self) else {
sender.endRefreshing()
return
}
}
}
// MARK: - UITableViewDelegate
extension DiscoveryPostsViewController: UITableViewDelegate, AutoGenerateTableViewDelegate {
// sourcery:inline:DiscoveryPostsViewController.AutoGenerateTableViewDelegate
// Generated using Sourcery
// DO NOT EDIT
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
aspectTableView(tableView, didSelectRowAt: indexPath)
}
func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point)
}
func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration)
}
func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration)
}
func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator)
}
// sourcery:end
}
// MARK: - StatusTableViewCellDelegate
extension DiscoveryPostsViewController: StatusTableViewCellDelegate { }
// MARK: ScrollViewContainer
extension DiscoveryPostsViewController: ScrollViewContainer {
var scrollView: UIScrollView? {
tableView
}
}
// MARK: - DiscoveryIntroBannerViewDelegate
extension DiscoveryPostsViewController: DiscoveryIntroBannerViewDelegate {
func discoveryIntroBannerView(_ bannerView: DiscoveryIntroBannerView, closeButtonDidPressed button: UIButton) {
UserDefaults.shared.discoveryIntroBannerNeedsHidden = true
}
}
extension DiscoveryPostsViewController {
override var keyCommands: [UIKeyCommand]? {
return navigationKeyCommands + statusNavigationKeyCommands
}
}
// MARK: - StatusTableViewControllerNavigateable
extension DiscoveryPostsViewController: StatusTableViewControllerNavigateable {
@objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
navigateKeyCommandHandler(sender)
}
@objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
statusKeyCommandHandler(sender)
}
}

View File

@ -0,0 +1,65 @@
//
// DiscoveryPostsViewModel+Diffable.swift
// Mastodon
//
// Created by MainasuK on 2022-4-12.
//
import UIKit
import Combine
extension DiscoveryPostsViewModel {
func setupDiffableDataSource(
tableView: UITableView,
statusTableViewCellDelegate: StatusTableViewCellDelegate
) {
diffableDataSource = StatusSection.diffableDataSource(
tableView: tableView,
context: context,
configuration: StatusSection.Configuration(
statusTableViewCellDelegate: statusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: nil,
filterContext: .none,
activeFilters: nil
)
)
stateMachine.enter(State.Reloading.self)
statusFetchedResultsController.$records
.receive(on: DispatchQueue.main)
.sink { [weak self] records in
guard let self = self else { return }
guard let diffableDataSource = self.diffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<StatusSection, StatusItem>()
snapshot.appendSections([.main])
let items = records.map { StatusItem.status(record: $0) }
snapshot.appendItems(items, toSection: .main)
if let currentState = self.stateMachine.currentState {
switch currentState {
case is State.Initial,
is State.Reloading,
is State.Loading,
is State.Idle,
is State.Fail:
if !items.isEmpty {
snapshot.appendItems([.bottomLoader], toSection: .main)
}
case is State.NoMore:
break
default:
assertionFailure()
break
}
}
diffableDataSource.applySnapshot(snapshot, animated: false)
}
.store(in: &disposeBag)
}
}

View File

@ -0,0 +1,213 @@
//
// DiscoveryPostsViewModel+State.swift
// Mastodon
//
// Created by MainasuK on 2022-4-12.
//
import os.log
import Foundation
import GameplayKit
import MastodonSDK
extension DiscoveryPostsViewModel {
class State: GKState, NamingState {
let logger = Logger(subsystem: "DiscoveryPostsViewModel.State", category: "StateMachine")
let id = UUID()
var name: String {
String(describing: Self.self)
}
weak var viewModel: DiscoveryPostsViewModel?
init(viewModel: DiscoveryPostsViewModel) {
self.viewModel = viewModel
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
let previousState = previousState as? DiscoveryPostsViewModel.State
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] enter \(self.name), previous: \(previousState?.name ?? "<nil>")")
}
@MainActor
func enter(state: State.Type) {
stateMachine?.enter(state)
}
deinit {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] \(self.name)")
}
}
}
extension DiscoveryPostsViewModel.State {
class Initial: DiscoveryPostsViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Reloading.Type:
return true
default:
return false
}
}
}
class Reloading: DiscoveryPostsViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Loading.Type:
return true
default:
return false
}
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let _ = viewModel, let stateMachine = stateMachine else { return }
stateMachine.enter(Loading.self)
}
}
class Fail: DiscoveryPostsViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Loading.Type:
return true
default:
return false
}
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let _ = viewModel, let stateMachine = stateMachine else { return }
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading 3s later…", ((#file as NSString).lastPathComponent), #line, #function)
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading", ((#file as NSString).lastPathComponent), #line, #function)
stateMachine.enter(Loading.self)
}
}
}
class Idle: DiscoveryPostsViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Reloading.Type, is Loading.Type:
return true
default:
return false
}
}
}
class Loading: DiscoveryPostsViewModel.State {
var offset: Int?
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Fail.Type:
return true
case is Idle.Type:
return true
case is NoMore.Type:
return true
default:
return false
}
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
switch previousState {
case is Reloading:
offset = nil
default:
break
}
guard let authenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
stateMachine.enter(Fail.self)
return
}
let offset = self.offset
let isReloading = offset == nil
Task {
do {
let response = try await viewModel.context.apiService.trendStatuses(
domain: authenticationBox.domain,
query: Mastodon.API.Trends.StatusQuery(
offset: offset,
limit: nil
)
)
let newOffset: Int? = {
guard let offset = response.link?.offset else { return nil }
return self.offset.flatMap { max($0, offset) } ?? offset
}()
let hasMore: Bool = {
guard let newOffset = newOffset else { return false }
return newOffset != self.offset // not the same one
}()
self.offset = newOffset
var hasNewStatusesAppend = false
var statusIDs = isReloading ? [] : viewModel.statusFetchedResultsController.statusIDs.value
for status in response.value {
guard !statusIDs.contains(status.id) else { continue }
statusIDs.append(status.id)
hasNewStatusesAppend = true
}
if hasNewStatusesAppend, hasMore {
await enter(state: Idle.self)
} else {
await enter(state: NoMore.self)
}
viewModel.statusFetchedResultsController.statusIDs.value = statusIDs
viewModel.didLoadLatest.send()
} catch {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch posts fail: \(error.localizedDescription)")
if let error = error as? Mastodon.API.Error, error.httpResponseStatus.code == 404 {
viewModel.isServerSupportEndpoint = false
await enter(state: NoMore.self)
} else {
await enter(state: Fail.self)
}
viewModel.didLoadLatest.send()
}
} // end Task
} // end func
}
class NoMore: DiscoveryPostsViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Reloading.Type:
return true
default:
return false
}
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
}
}
}

View File

@ -0,0 +1,83 @@
//
// DiscoveryPostsViewModel.swift
// Mastodon
//
// Created by MainasuK on 2022-4-12.
//
import os.log
import UIKit
import Combine
import GameplayKit
import CoreData
import CoreDataStack
import MastodonSDK
final class DiscoveryPostsViewModel {
var disposeBag = Set<AnyCancellable>()
// input
let context: AppContext
let statusFetchedResultsController: StatusFetchedResultsController
let listBatchFetchViewModel = ListBatchFetchViewModel()
// output
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, StatusItem>?
private(set) lazy var stateMachine: GKStateMachine = {
let stateMachine = GKStateMachine(states: [
State.Initial(viewModel: self),
State.Reloading(viewModel: self),
State.Fail(viewModel: self),
State.Idle(viewModel: self),
State.Loading(viewModel: self),
State.NoMore(viewModel: self),
])
stateMachine.enter(State.Initial.self)
return stateMachine
}()
let didLoadLatest = PassthroughSubject<Void, Never>()
@Published var isServerSupportEndpoint = true
init(context: AppContext) {
self.context = context
self.statusFetchedResultsController = StatusFetchedResultsController(
managedObjectContext: context.managedObjectContext,
domain: nil,
additionalTweetPredicate: nil
)
// end init
context.authenticationService.activeMastodonAuthentication
.map { $0?.domain }
.assign(to: \.value, on: statusFetchedResultsController.domain)
.store(in: &disposeBag)
Task {
await checkServerEndpoint()
} // end Task
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension DiscoveryPostsViewModel {
func checkServerEndpoint() async {
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
do {
_ = try await context.apiService.trendStatuses(
domain: authenticationBox.domain,
query: .init(offset: nil, limit: nil)
)
} catch let error as Mastodon.API.Error where error.httpResponseStatus.code == 404 {
isServerSupportEndpoint = false
} catch {
// do nothing
}
}
}

View File

@ -0,0 +1,102 @@
//
// DiscoveryIntroBannerView.swift
// Mastodon
//
// Created by MainasuK on 2022-4-19.
//
import os.log
import UIKit
import Combine
import MastodonAsset
import MastodonLocalization
public protocol DiscoveryIntroBannerViewDelegate: AnyObject {
func discoveryIntroBannerView(_ bannerView: DiscoveryIntroBannerView, closeButtonDidPressed button: UIButton)
}
public final class DiscoveryIntroBannerView: UIView {
let logger = Logger(subsystem: "DiscoveryIntroBannerView", category: "View")
var _disposeBag = Set<AnyCancellable>()
public weak var delegate: DiscoveryIntroBannerViewDelegate?
let label: UILabel = {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 16, weight: .regular))
label.textColor = Asset.Colors.Label.primary.color
label.text = L10n.Scene.Discovery.intro
label.numberOfLines = 0
return label
}()
let closeButton: HitTestExpandedButton = {
let button = HitTestExpandedButton(type: .system)
button.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal)
button.tintColor = Asset.Colors.Label.secondary.color
return button
}()
public override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
public required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension DiscoveryIntroBannerView {
private func _init() {
preservesSuperviewLayoutMargins = true
setupAppearance(theme: ThemeService.shared.currentTheme.value)
ThemeService.shared.currentTheme
.receive(on: DispatchQueue.main)
.sink { [weak self] theme in
guard let self = self else { return }
self.setupAppearance(theme: theme)
}
.store(in: &_disposeBag)
closeButton.translatesAutoresizingMaskIntoConstraints = false
addSubview(closeButton)
NSLayoutConstraint.activate([
closeButton.topAnchor.constraint(equalTo: topAnchor, constant: 16).priority(.required - 1),
layoutMarginsGuide.trailingAnchor.constraint(equalTo: closeButton.trailingAnchor),
closeButton.heightAnchor.constraint(equalToConstant: 20).priority(.required - 1),
closeButton.widthAnchor.constraint(equalToConstant: 20).priority(.required - 1),
])
label.translatesAutoresizingMaskIntoConstraints = false
addSubview(label)
NSLayoutConstraint.activate([
label.topAnchor.constraint(equalTo: topAnchor, constant: 16).priority(.required - 1),
label.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
closeButton.leadingAnchor.constraint(equalTo: label.trailingAnchor, constant: 10),
bottomAnchor.constraint(equalTo: label.bottomAnchor, constant: 16).priority(.required - 1),
])
closeButton.addTarget(self, action: #selector(DiscoveryIntroBannerView.closeButtonDidPressed(_:)), for: .touchUpInside)
}
}
extension DiscoveryIntroBannerView {
@objc private func closeButtonDidPressed(_ sender: UIButton) {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
delegate?.discoveryIntroBannerView(self, closeButtonDidPressed: sender)
}
}
extension DiscoveryIntroBannerView {
private func setupAppearance(theme: Theme) {
backgroundColor = theme.systemBackgroundColor
}
}

View File

@ -28,7 +28,7 @@ final class HashtagTimelineViewController: UIViewController, NeedsDependency, Me
let composeBarButtonItem: UIBarButtonItem = { let composeBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem() let barButtonItem = UIBarButtonItem()
barButtonItem.image = UIImage(systemName: "square.and.pencil")?.withRenderingMode(.alwaysTemplate) barButtonItem.image = Asset.ObjectsAndTools.squareAndPencil.image.withRenderingMode(.alwaysTemplate)
return barButtonItem return barButtonItem
}() }()
@ -84,7 +84,6 @@ extension HashtagTimelineViewController {
]) ])
tableView.delegate = self tableView.delegate = self
// tableView.prefetchDataSource = self
viewModel.setupDiffableDataSource( viewModel.setupDiffableDataSource(
tableView: tableView, tableView: tableView,
statusTableViewCellDelegate: self statusTableViewCellDelegate: self
@ -158,27 +157,6 @@ extension HashtagTimelineViewController {
} }
// MARK: - TableViewCellHeightCacheableContainer
//extension HashtagTimelineViewController: TableViewCellHeightCacheableContainer {
// var cellFrameCache: NSCache<NSNumber, NSValue> {
// return viewModel.cellFrameCache
// }
//}
//// MARK: - UIScrollViewDelegate
//extension HashtagTimelineViewController {
// func scrollViewDidScroll(_ scrollView: UIScrollView) {
// aspectScrollViewDidScroll(scrollView)
// }
//}
//extension HashtagTimelineViewController: LoadMoreConfigurableTableViewContainer {
// typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
// typealias LoadingState = HashtagTimelineViewModel.LoadOldestState.Loading
// var loadMoreConfigurableTableView: UITableView { return tableView }
// var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.loadOldestStateMachine }
//}
// MARK: - UITableViewDelegate // MARK: - UITableViewDelegate
extension HashtagTimelineViewController: UITableViewDelegate, AutoGenerateTableViewDelegate { extension HashtagTimelineViewController: UITableViewDelegate, AutoGenerateTableViewDelegate {
// sourcery:inline:HashtagTimelineViewController.AutoGenerateTableViewDelegate // sourcery:inline:HashtagTimelineViewController.AutoGenerateTableViewDelegate
@ -206,82 +184,23 @@ extension HashtagTimelineViewController: UITableViewDelegate, AutoGenerateTableV
} }
// sourcery:end // sourcery:end
// func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
// return aspectTableView(tableView, estimatedHeightForRowAt: indexPath)
// }
//
// func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath)
// }
//
// func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
// }
//
// func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
// aspectTableView(tableView, didSelectRowAt: indexPath)
// }
//
// func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
// return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point)
// }
//
// func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
// return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration)
// }
//
// func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
// return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration)
// }
//
// func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
// aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator)
// }
} }
// MARK: - UITableViewDataSourcePrefetching
//extension HashtagTimelineViewController: UITableViewDataSourcePrefetching {
// func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
// aspectTableView(tableView, prefetchRowsAt: indexPaths)
// }
//}
// MARK: - StatusTableViewCellDelegate // MARK: - StatusTableViewCellDelegate
extension HashtagTimelineViewController: StatusTableViewCellDelegate { } extension HashtagTimelineViewController: StatusTableViewCellDelegate { }
// MARK: - AVPlayerViewControllerDelegate extension HashtagTimelineViewController {
//extension HashtagTimelineViewController: AVPlayerViewControllerDelegate { override var keyCommands: [UIKeyCommand]? {
// return navigationKeyCommands + statusNavigationKeyCommands
// func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { }
// aspectPlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) }
// } // MARK: - StatusTableViewControllerNavigateable
// extension HashtagTimelineViewController: StatusTableViewControllerNavigateable {
// func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { @objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
// aspectPlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) navigateKeyCommandHandler(sender)
// } }
//
//} @objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
statusKeyCommandHandler(sender)
// MARK: - StatusTableViewCellDelegate }
//extension HashtagTimelineViewController: StatusTableViewCellDelegate { }
// weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self }
// func parent() -> UIViewController { return self }
//}
//extension HashtagTimelineViewController {
// override var keyCommands: [UIKeyCommand]? {
// return navigationKeyCommands + statusNavigationKeyCommands
// }
//}
//
//// MARK: - StatusTableViewControllerNavigateable
//extension HashtagTimelineViewController: StatusTableViewControllerNavigateable {
// @objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
// navigateKeyCommandHandler(sender)
// }
//
// @objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
// statusKeyCommandHandler(sender)
// }
//}

View File

@ -58,6 +58,10 @@ extension HomeTimelineViewController {
guard let self = self else { return } guard let self = self else { return }
self.showWelcomeAction(action) self.showWelcomeAction(action)
}, },
UIAction(title: "Register", image: UIImage(systemName: "list.bullet.rectangle.portrait.fill"), attributes: []) { [weak self] action in
guard let self = self else { return }
self.showRegisterAction(action)
},
UIAction(title: "Confirm Email", image: UIImage(systemName: "envelope"), attributes: []) { [weak self] action in UIAction(title: "Confirm Email", image: UIImage(systemName: "envelope"), attributes: []) { [weak self] action in
guard let self = self else { return } guard let self = self else { return }
self.showConfirmEmail(action) self.showConfirmEmail(action)
@ -182,7 +186,7 @@ extension HomeTimelineViewController {
} }
func match(item: StatusItem) -> Bool { func match(item: StatusItem) -> Bool {
let authenticationBox = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value // let authenticationBox = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value
switch item { switch item {
case .feed(let record): case .feed(let record):
guard let feed = record.object(in: AppContext.shared.managedObjectContext) else { return false } guard let feed = record.object(in: AppContext.shared.managedObjectContext) else { return false }
@ -294,6 +298,33 @@ extension HomeTimelineViewController {
@objc private func showWelcomeAction(_ sender: UIAction) { @objc private func showWelcomeAction(_ sender: UIAction) {
coordinator.present(scene: .welcome, from: self, transition: .modal(animated: true, completion: nil)) coordinator.present(scene: .welcome, from: self, transition: .modal(animated: true, completion: nil))
} }
@objc private func showRegisterAction(_ sender: UIAction) {
Task { @MainActor in
try await showRegisterController()
} // end Task
}
@MainActor
func showRegisterController(domain: String = "mstdn.jp") async throws {
let viewController = try await MastodonRegisterViewController.create(
context: context,
coordinator: coordinator,
domain: "mstdn.jp"
)
let navigationController = UINavigationController(rootViewController: viewController)
navigationController.modalPresentationStyle = .fullScreen
present(navigationController, animated: true) {
viewController.navigationItem.leftBarButtonItem = UIBarButtonItem(
systemItem: .close,
primaryAction: UIAction(handler: { [weak viewController] _ in
guard let viewController = viewController else { return }
viewController.dismiss(animated: true)
}),
menu: nil
)
}
}
@objc private func showConfirmEmail(_ sender: UIAction) { @objc private func showConfirmEmail(_ sender: UIAction) {
let mastodonConfirmEmailViewModel = MastodonConfirmEmailViewModel() let mastodonConfirmEmailViewModel = MastodonConfirmEmailViewModel()

View File

@ -17,6 +17,7 @@ import AlamofireImage
import StoreKit import StoreKit
import MastodonAsset import MastodonAsset
import MastodonLocalization import MastodonLocalization
import MastodonUI
final class HomeTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { final class HomeTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
@ -50,19 +51,11 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency, Media
let settingBarButtonItem: UIBarButtonItem = { let settingBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem() let barButtonItem = UIBarButtonItem()
barButtonItem.tintColor = ThemeService.tintColor barButtonItem.tintColor = ThemeService.tintColor
barButtonItem.image = UIImage(systemName: "gear")?.withRenderingMode(.alwaysTemplate) barButtonItem.image = Asset.ObjectsAndTools.gear.image.withRenderingMode(.alwaysTemplate)
barButtonItem.accessibilityLabel = L10n.Common.Controls.Actions.settings barButtonItem.accessibilityLabel = L10n.Common.Controls.Actions.settings
return barButtonItem return barButtonItem
}() }()
let composeBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem()
barButtonItem.tintColor = ThemeService.tintColor
barButtonItem.image = UIImage(systemName: "square.and.pencil")?.withRenderingMode(.alwaysTemplate)
barButtonItem.accessibilityLabel = L10n.Common.Controls.Actions.compose
return barButtonItem
}()
let tableView: UITableView = { let tableView: UITableView = {
let tableView = ControlContainableTableView() let tableView = ControlContainableTableView()
tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self)) tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self))
@ -108,14 +101,14 @@ extension HomeTimelineViewController {
guard let self = self else { return } guard let self = self else { return }
#if DEBUG #if DEBUG
// display debug menu // display debug menu
self.navigationItem.leftBarButtonItem = { self.navigationItem.rightBarButtonItem = {
let barButtonItem = UIBarButtonItem() let barButtonItem = UIBarButtonItem()
barButtonItem.image = UIImage(systemName: "ellipsis.circle") barButtonItem.image = UIImage(systemName: "ellipsis.circle")
barButtonItem.menu = self.debugMenu barButtonItem.menu = self.debugMenu
return barButtonItem return barButtonItem
}() }()
#else #else
self.navigationItem.leftBarButtonItem = displaySettingBarButtonItem ? self.settingBarButtonItem : nil self.navigationItem.rightBarButtonItem = displaySettingBarButtonItem ? self.settingBarButtonItem : nil
#endif #endif
} }
.store(in: &disposeBag) .store(in: &disposeBag)
@ -132,16 +125,6 @@ extension HomeTimelineViewController {
titleView.button.menu = self.debugMenu titleView.button.menu = self.debugMenu
#endif #endif
viewModel.$displayComposeBarButtonItem
.receive(on: DispatchQueue.main)
.sink { [weak self] displayComposeBarButtonItem in
guard let self = self else { return }
self.navigationItem.rightBarButtonItem = displayComposeBarButtonItem ? self.composeBarButtonItem : nil
}
.store(in: &disposeBag)
composeBarButtonItem.target = self
composeBarButtonItem.action = #selector(HomeTimelineViewController.composeBarButtonItemPressed(_:))
navigationItem.titleView = titleView navigationItem.titleView = titleView
titleView.delegate = self titleView.delegate = self
@ -291,7 +274,7 @@ extension HomeTimelineViewController {
tableView.deselectRow(with: transitionCoordinator, animated: animated) tableView.deselectRow(with: transitionCoordinator, animated: animated)
// needs trigger manually after onboarding dismiss // needs trigger manually after onboarding dismiss
setNeedsStatusBarAppearanceUpdate() setNeedsStatusBarAppearanceUpdate()
} }
override func viewDidAppear(_ animated: Bool) { override func viewDidAppear(_ animated: Bool) {
@ -410,18 +393,7 @@ extension HomeTimelineViewController {
let settingsViewModel = SettingsViewModel(context: context, setting: setting) let settingsViewModel = SettingsViewModel(context: context, setting: setting)
coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil)) coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil))
} }
@objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
let composeViewModel = ComposeViewModel(
context: context,
composeKind: .post,
authenticationBox: authenticationBox
)
coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil))
}
@objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
guard viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self) else { guard viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self) else {
sender.endRefreshing() sender.endRefreshing()

View File

@ -33,7 +33,6 @@ final class HomeTimelineViewModel: NSObject {
@Published var lastAutomaticFetchTimestamp: Date? = nil @Published var lastAutomaticFetchTimestamp: Date? = nil
@Published var scrollPositionRecord: ScrollPositionRecord? = nil @Published var scrollPositionRecord: ScrollPositionRecord? = nil
@Published var displaySettingBarButtonItem = true @Published var displaySettingBarButtonItem = true
@Published var displayComposeBarButtonItem = true
weak var tableView: UITableView? weak var tableView: UITableView?
weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate? weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?

View File

@ -108,6 +108,8 @@ extension HomeTimelineNavigationBarTitleView {
logoButton.setImage(Asset.Asset.mastodonTextLogo.image.withRenderingMode(.alwaysTemplate), for: .normal) logoButton.setImage(Asset.Asset.mastodonTextLogo.image.withRenderingMode(.alwaysTemplate), for: .normal)
logoButton.contentMode = .center logoButton.contentMode = .center
logoButton.isHidden = false logoButton.isHidden = false
logoButton.accessibilityLabel = "Logo Button" // TODO :i18n
logoButton.accessibilityHint = "Tap to scroll to top and tap again to previous location"
case .newPostButton: case .newPostButton:
configureButton( configureButton(
title: L10n.Scene.HomeTimeline.NavigationBarState.newPosts, title: L10n.Scene.HomeTimeline.NavigationBarState.newPosts,
@ -115,6 +117,7 @@ extension HomeTimelineNavigationBarTitleView {
backgroundColor: Asset.Colors.brandBlue.color backgroundColor: Asset.Colors.brandBlue.color
) )
button.isHidden = false button.isHidden = false
button.accessibilityLabel = L10n.Scene.HomeTimeline.NavigationBarState.newPosts
case .offlineButton: case .offlineButton:
configureButton( configureButton(
title: L10n.Scene.HomeTimeline.NavigationBarState.offline, title: L10n.Scene.HomeTimeline.NavigationBarState.offline,
@ -122,12 +125,14 @@ extension HomeTimelineNavigationBarTitleView {
backgroundColor: Asset.Colors.danger.color backgroundColor: Asset.Colors.danger.color
) )
button.isHidden = false button.isHidden = false
button.accessibilityLabel = L10n.Scene.HomeTimeline.NavigationBarState.offline
case .publishingPostLabel: case .publishingPostLabel:
label.font = .systemFont(ofSize: 17, weight: .semibold) label.font = .systemFont(ofSize: 17, weight: .semibold)
label.textColor = Asset.Colors.Label.primary.color label.textColor = Asset.Colors.Label.primary.color
label.text = L10n.Scene.HomeTimeline.NavigationBarState.publishing label.text = L10n.Scene.HomeTimeline.NavigationBarState.publishing
label.textAlignment = .center label.textAlignment = .center
label.isHidden = false label.isHidden = false
button.accessibilityLabel = L10n.Scene.HomeTimeline.NavigationBarState.publishing
case .publishedButton: case .publishedButton:
blockingState = state blockingState = state
configureButton( configureButton(
@ -136,6 +141,7 @@ extension HomeTimelineNavigationBarTitleView {
backgroundColor: Asset.Colors.successGreen.color backgroundColor: Asset.Colors.successGreen.color
) )
button.isHidden = false button.isHidden = false
button.accessibilityLabel = L10n.Scene.HomeTimeline.NavigationBarState.published
let presentDuration: TimeInterval = 0.33 let presentDuration: TimeInterval = 0.33
let scaleAnimator = UIViewPropertyAnimator(duration: presentDuration, timingParameters: UISpringTimingParameters()) let scaleAnimator = UIViewPropertyAnimator(duration: presentDuration, timingParameters: UISpringTimingParameters())

View File

@ -39,6 +39,8 @@ final class NotificationTimelineViewController: UIViewController, NeedsDependenc
return tableView return tableView
}() }()
let cellFrameCache = NSCache<NSNumber, NSValue>()
deinit { deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
} }
@ -122,6 +124,16 @@ extension NotificationTimelineViewController {
} }
// MARK: - CellFrameCacheContainer
extension NotificationTimelineViewController: CellFrameCacheContainer {
func keyForCache(tableView: UITableView, indexPath: IndexPath) -> NSNumber? {
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return nil }
let key = NSNumber(value: item.hashValue)
return key
}
}
extension NotificationTimelineViewController { extension NotificationTimelineViewController {
@objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
@ -162,6 +174,13 @@ extension NotificationTimelineViewController: UITableViewDelegate, AutoGenerateT
// sourcery:end // sourcery:end
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
guard let frame = retrieveCellFrame(tableView: tableView, indexPath: indexPath) else {
return 300
}
return ceil(frame.height)
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else {
return return
@ -172,6 +191,10 @@ extension NotificationTimelineViewController: UITableViewDelegate, AutoGenerateT
await viewModel.loadMore(item: item) await viewModel.loadMore(item: item)
} }
} }
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
cacheCellFrame(tableView: tableView, didEndDisplaying: cell, forRowAt: indexPath)
}
} }

View File

@ -229,6 +229,11 @@ extension MastodonConfirmEmailViewController {
} }
} }
// MARK: - PanPopableViewController
extension MastodonConfirmEmailViewController: PanPopableViewController {
var isPanPopable: Bool { false }
}
// MARK: - OnboardingViewControllerAppearance // MARK: - OnboardingViewControllerAppearance
extension MastodonConfirmEmailViewController: OnboardingViewControllerAppearance { } extension MastodonConfirmEmailViewController: OnboardingViewControllerAppearance { }

View File

@ -12,6 +12,7 @@ import GameController
import AuthenticationServices import AuthenticationServices
import MastodonAsset import MastodonAsset
import MastodonLocalization import MastodonLocalization
import MastodonUI
final class MastodonPickServerViewController: UIViewController, NeedsDependency { final class MastodonPickServerViewController: UIViewController, NeedsDependency {
@ -91,7 +92,7 @@ extension MastodonPickServerViewController {
tableView.topAnchor.constraint(equalTo: view.topAnchor), tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), tableView.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor),
]) ])
navigationActionView.translatesAutoresizingMaskIntoConstraints = false navigationActionView.translatesAutoresizingMaskIntoConstraints = false
@ -106,10 +107,10 @@ extension MastodonPickServerViewController {
]) ])
navigationActionView navigationActionView
.observe(\.bounds, options: [.initial, .new]) { [weak self] navigationActionView, _ in .observe(\.bounds, options: [.initial, .new]) { [weak self] _, _ in
guard let self = self else { return } guard let self = self else { return }
let inset = navigationActionView.frame.height let inset = self.navigationActionView.frame.height
self.tableView.contentInset.bottom = inset self.viewModel.additionalTableViewInsets.bottom = inset
} }
.store(in: &observations) .store(in: &observations)
@ -144,6 +145,14 @@ extension MastodonPickServerViewController {
pickServerServerSectionTableHeaderViewDelegate: self, pickServerServerSectionTableHeaderViewDelegate: self,
pickServerCellDelegate: self pickServerCellDelegate: self
) )
KeyboardResponderService
.configure(
scrollView: tableView,
layoutNeedsUpdate: viewModel.viewDidAppear.eraseToAnyPublisher(),
additionalSafeAreaInsets: viewModel.$additionalTableViewInsets.eraseToAnyPublisher()
)
.store(in: &disposeBag)
viewModel viewModel
.selectedServer .selectedServer
@ -238,6 +247,7 @@ extension MastodonPickServerViewController {
super.viewDidAppear(animated) super.viewDidAppear(animated)
tableView.flashScrollIndicators() tableView.flashScrollIndicators()
viewModel.viewDidAppear.send()
} }
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
@ -332,7 +342,10 @@ extension MastodonPickServerViewController {
) else { ) else {
throw APIService.APIError.explicit(.badResponse) throw APIService.APIError.explicit(.badResponse)
} }
return MastodonPickServerViewModel.SignUpResponseSecond(instance: response.instance, authenticateInfo: authenticateInfo) return MastodonPickServerViewModel.SignUpResponseSecond(
instance: response.instance,
authenticateInfo: authenticateInfo
)
} }
.compactMap { [weak self] response -> AnyPublisher<MastodonPickServerViewModel.SignUpResponseThird, Error>? in .compactMap { [weak self] response -> AnyPublisher<MastodonPickServerViewModel.SignUpResponseThird, Error>? in
guard let self = self else { return nil } guard let self = self else { return nil }
@ -344,7 +357,13 @@ extension MastodonPickServerViewController {
clientSecret: authenticateInfo.clientSecret, clientSecret: authenticateInfo.clientSecret,
redirectURI: authenticateInfo.redirectURI redirectURI: authenticateInfo.redirectURI
) )
.map { MastodonPickServerViewModel.SignUpResponseThird(instance: instance, authenticateInfo: authenticateInfo, applicationToken: $0) } .map {
MastodonPickServerViewModel.SignUpResponseThird(
instance: instance,
authenticateInfo: authenticateInfo,
applicationToken: $0
)
}
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
.switchToLatest() .switchToLatest()
@ -416,28 +435,6 @@ extension MastodonPickServerViewController: UITableViewDelegate {
viewModel.selectedServer.send(nil) viewModel.selectedServer.send(nil)
} }
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
switch item {
// case .categoryPicker:
// guard let cell = cell as? PickServerCategoriesCell else { return }
// guard let diffableDataSource = cell.diffableDataSource else { return }
// let snapshot = diffableDataSource.snapshot()
//
// let item = viewModel.selectCategoryItem.value
// guard let section = snapshot.indexOfSection(.main),
// let row = snapshot.indexOfItem(item) else { return }
// cell.collectionView.selectItem(at: IndexPath(item: row, section: section), animated: false, scrollPosition: .centeredHorizontally)
// case .search:
// guard let cell = cell as? PickServerSearchCell else { return }
// cell.searchTextField.text = viewModel.searchText.value
default:
break
}
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
guard let diffableDataSource = viewModel.diffableDataSource else { return nil } guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
let snapshot = diffableDataSource.snapshot() let snapshot = diffableDataSource.snapshot()

View File

@ -45,6 +45,8 @@ class MastodonPickServerViewModel: NSObject {
let indexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([]) let indexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([])
let unindexedServers = CurrentValueSubject<[Mastodon.Entity.Server]?, Never>([]) // set nil when loading let unindexedServers = CurrentValueSubject<[Mastodon.Entity.Server]?, Never>([]) // set nil when loading
let viewWillAppear = PassthroughSubject<Void, Never>() let viewWillAppear = PassthroughSubject<Void, Never>()
let viewDidAppear = CurrentValueSubject<Void, Never>(Void())
@Published var additionalTableViewInsets: UIEdgeInsets = .zero
// output // output
var diffableDataSource: UITableViewDiffableDataSource<PickServerSection, PickServerItem>? var diffableDataSource: UITableViewDiffableDataSource<PickServerSection, PickServerItem>?
@ -114,8 +116,11 @@ extension MastodonPickServerViewModel {
if self.mode == .signUp { if self.mode == .signUp {
indexedServers = indexedServers.filter { !$0.approvalRequired } indexedServers = indexedServers.filter { !$0.approvalRequired }
} }
// Note:
// sort by calculate last week users count
// and make medium size (~800) server to top
// group by language user preferred language first. Then sort by `totalUsers` // group by language user preferred language first
var languageToServersMapping = OrderedDictionary<String, [Mastodon.Entity.Server]>() var languageToServersMapping = OrderedDictionary<String, [Mastodon.Entity.Server]>()
for language in Locale.preferredLanguages { for language in Locale.preferredLanguages {
let local = Locale(identifier: language) let local = Locale(identifier: language)
@ -125,14 +130,22 @@ extension MastodonPickServerViewModel {
// append to dict // append to dict
languageToServersMapping[languageCode] = indexedServers languageToServersMapping[languageCode] = indexedServers
.filter { $0.language.lowercased() == languageCode.lowercased() } .filter { $0.language.lowercased() == languageCode.lowercased() }
.sorted(by: { $0.totalUsers > $1.totalUsers }) .sorted(by: { lh, rh in
let lhValue = abs(log2(800.0) - log2(Double(lh.lastWeekUsers)))
let rhValue = abs(log2(800.0) - log2(Double(rh.lastWeekUsers)))
return lhValue < rhValue
})
} }
// sort remains servers by `totalUsers` // sort remains servers
let remainsServers = indexedServers let remainsServers = indexedServers
.filter { server in .filter { server in
return !languageToServersMapping.contains { _, servers in servers.contains(server) } return !languageToServersMapping.contains { _, servers in servers.contains(server) }
} }
.sorted(by: { $0.totalUsers > $1.totalUsers }) .sorted(by: { lh, rh in
let lhValue = abs(log2(800.0) - log2(Double(lh.lastWeekUsers)))
let rhValue = abs(log2(800.0) - log2(Double(rh.lastWeekUsers)))
return lhValue < rhValue
})
var _indexedServers: [Mastodon.Entity.Server] = [] var _indexedServers: [Mastodon.Entity.Server] = []
for key in languageToServersMapping.keys { for key in languageToServersMapping.keys {

View File

@ -185,12 +185,12 @@ extension PickServerServerSectionTableHeaderView {
override func accessibilityElementCount() -> Int { override func accessibilityElementCount() -> Int {
guard let diffableDataSource = diffableDataSource else { return 0 } guard let diffableDataSource = diffableDataSource else { return 0 }
return diffableDataSource.snapshot().itemIdentifiers.count return diffableDataSource.snapshot().itemIdentifiers.count + 1
} }
override func accessibilityElement(at index: Int) -> Any? { override func accessibilityElement(at index: Int) -> Any? {
guard let item = collectionView.cellForItem(at: IndexPath(item: index, section: 0)) else { return nil } if let item = collectionView.cellForItem(at: IndexPath(item: index, section: 0)) { return item }
return item return searchTextField
} }
} }

View File

@ -115,6 +115,7 @@ extension MastodonRegisterTextFieldTableViewCell {
label.font = MastodonRegisterTextFieldTableViewCell.textFieldLabelFont label.font = MastodonRegisterTextFieldTableViewCell.textFieldLabelFont
label.textColor = Asset.Colors.Label.primary.color label.textColor = Asset.Colors.Label.primary.color
label.text = text label.text = text
label.lineBreakMode = .byTruncatingMiddle
label.translatesAutoresizingMaskIntoConstraints = false label.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(label) containerView.addSubview(label)
@ -123,6 +124,7 @@ extension MastodonRegisterTextFieldTableViewCell {
label.leadingAnchor.constraint(equalTo: paddingView.trailingAnchor), label.leadingAnchor.constraint(equalTo: paddingView.trailingAnchor),
containerView.trailingAnchor.constraint(equalTo: label.trailingAnchor, constant: 16), containerView.trailingAnchor.constraint(equalTo: label.trailingAnchor, constant: 16),
label.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), label.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
label.widthAnchor.constraint(lessThanOrEqualToConstant: 180).priority(.required - 1),
]) ])
return containerView return containerView
}() }()

View File

@ -0,0 +1,301 @@
//
// MastodonRegisterView.swift
// Mastodon
//
// Created by MainasuK on 2022-4-27.
//
import UIKit
import SwiftUI
import MastodonLocalization
import MastodonSDK
import MastodonAsset
struct MastodonRegisterView: View {
@ObservedObject var viewModel: MastodonRegisterViewModel
@State var usernameRightViewWidth: CGFloat = 300
var body: some View {
ScrollView(.vertical) {
let margin: CGFloat = 16
// header
HStack {
Text(L10n.Scene.Register.title(viewModel.domain))
.font(Font(MastodonPickServerViewController.largeTitleFont as CTFont))
.foregroundColor(Color(Asset.Colors.Label.primary.color))
Spacer()
}
.padding(.horizontal, margin)
// Avatar selector
Menu {
// Photo Library
Button {
viewModel.avatarMediaMenuActionPublisher.send(.photoLibrary)
} label: {
Label(L10n.Scene.Compose.MediaSelection.photoLibrary, systemImage: "photo")
}
// Camera
if UIImagePickerController.isSourceTypeAvailable(.camera) {
Button {
viewModel.avatarMediaMenuActionPublisher.send(.camera)
} label: {
Label(L10n.Scene.Compose.MediaSelection.camera, systemImage: "camera")
}
}
// Browse
Button {
viewModel.avatarMediaMenuActionPublisher.send(.browse)
} label: {
Label(L10n.Scene.Compose.MediaSelection.browse, systemImage: "folder")
}
// Delete
if viewModel.avatarImage != nil {
Divider()
if #available(iOS 15.0, *) {
Button(role: .destructive) {
viewModel.avatarMediaMenuActionPublisher.send(.delete)
} label: {
Label(L10n.Scene.Register.Input.Avatar.delete, systemImage: "delete.left")
}
} else {
// Fallback on earlier ve rsions
Button {
viewModel.avatarMediaMenuActionPublisher.send(.delete)
} label: {
Label(L10n.Scene.Register.Input.Avatar.delete, systemImage: "delete.left")
}
}
}
} label: {
let avatarImage = viewModel.avatarImage ?? Asset.Scene.Onboarding.avatarPlaceholder.image
Image(uiImage: avatarImage)
.resizable()
.frame(width: 88, height: 88, alignment: .center)
.overlay(ZStack {
Color.black.opacity(0.5)
.frame(height: 22, alignment: .bottom)
Text(L10n.Common.Controls.Actions.edit)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.white)
}, alignment: .bottom)
.cornerRadius(22)
}
.padding(EdgeInsets(top: 20, leading: 0, bottom: 20, trailing: 0))
// Display Name & Uesrname
VStack(alignment: .leading, spacing: 11) {
TextField(L10n.Scene.Register.Input.DisplayName.placeholder.localizedCapitalized, text: $viewModel.name)
.textContentType(.name)
.disableAutocorrection(true)
.modifier(FormTextFieldModifier(validateState: viewModel.displayNameValidateState))
HStack {
TextField(L10n.Scene.Register.Input.Username.placeholder.localizedCapitalized, text: $viewModel.username)
.textContentType(.username)
.autocapitalization(.none)
.disableAutocorrection(true)
.keyboardType(.asciiCapable)
Text("@\(viewModel.domain)")
.lineLimit(1)
.truncationMode(.middle)
.measureWidth { usernameRightViewWidth = $0 }
.frame(width: min(300.0, usernameRightViewWidth), alignment: .trailing)
}
.modifier(FormTextFieldModifier(validateState: viewModel.usernameValidateState))
.environment(\.layoutDirection, .leftToRight) // force LTR
if let errorPrompt = viewModel.usernameErrorPrompt {
Text(errorPrompt)
.modifier(FormFootnoteModifier())
}
}
.padding(.horizontal, margin)
.padding(.bottom, 22)
// Email & Password & Password hint
VStack(alignment: .leading, spacing: 11) {
TextField(L10n.Scene.Register.Input.Email.placeholder.localizedCapitalized, text: $viewModel.email)
.textContentType(.emailAddress)
.autocapitalization(.none)
.disableAutocorrection(true)
.keyboardType(.emailAddress)
.modifier(FormTextFieldModifier(validateState: viewModel.emailValidateState))
if let errorPrompt = viewModel.emailErrorPrompt {
Text(errorPrompt)
.modifier(FormFootnoteModifier())
}
SecureField(L10n.Scene.Register.Input.Password.placeholder.localizedCapitalized, text: $viewModel.password)
.textContentType(.newPassword)
.modifier(FormTextFieldModifier(validateState: viewModel.passwordValidateState))
Text(L10n.Scene.Register.Input.Password.hint)
.modifier(FormFootnoteModifier(foregroundColor: .secondary))
if let errorPrompt = viewModel.passwordErrorPrompt {
Text(errorPrompt)
.modifier(FormFootnoteModifier())
}
}
.padding(.horizontal, margin)
.padding(.bottom, 22)
// Reason
if viewModel.approvalRequired {
VStack(alignment: .leading, spacing: 11) {
TextField(L10n.Scene.Register.Input.Invite.registrationUserInviteRequest.localizedCapitalized, text: $viewModel.reason)
.modifier(FormTextFieldModifier(validateState: viewModel.reasonValidateState))
if let errorPrompt = viewModel.reasonErrorPrompt {
Text(errorPrompt)
.modifier(FormFootnoteModifier())
}
}
.padding(.horizontal, margin)
}
Spacer()
.frame(minHeight: viewModel.bottomPaddingHeight)
}
.background(
Color(viewModel.backgroundColor)
.onTapGesture {
viewModel.endEditing.send()
}
)
}
struct FormTextFieldModifier: ViewModifier {
var validateState: MastodonRegisterViewModel.ValidateState
func body(content: Content) -> some View {
ZStack {
let shadowColor: Color = {
switch validateState {
case .empty: return .black.opacity(0.125)
case .invalid: return Color(Asset.Colors.TextField.invalid.color)
case .valid: return Color(Asset.Colors.TextField.valid.color)
}
}()
Color(Asset.Scene.Onboarding.textFieldBackground.color)
.cornerRadius(10)
.shadow(color: shadowColor, radius: 1, x: 0, y: 2)
.animation(.easeInOut, value: validateState)
content
.padding()
.background(Color(Asset.Scene.Onboarding.textFieldBackground.color))
.cornerRadius(10)
}
}
}
struct FormFootnoteModifier: ViewModifier {
var foregroundColor = Color(Asset.Colors.TextField.invalid.color)
func body(content: Content) -> some View {
content
.font(.footnote)
.foregroundColor(foregroundColor)
.padding(.horizontal)
}
}
}
struct WidthKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
extension View {
func measureWidth(_ f: @escaping (CGFloat) -> ()) -> some View {
overlay(GeometryReader { proxy in
Color.clear.preference(key: WidthKey.self, value: proxy.size.width)
}
.onPreferenceChange(WidthKey.self, perform: f))
}
}
#if DEBUG
struct MastodonRegisterView_Previews: PreviewProvider {
static var viewMdoel: MastodonRegisterViewModel {
let domain = "mstdn.jp"
return MastodonRegisterViewModel(
context: .shared,
domain: domain,
authenticateInfo: AuthenticationViewModel.AuthenticateInfo(
domain: domain,
application: Mastodon.Entity.Application(
name: "Preview",
website: nil,
vapidKey: nil,
redirectURI: nil,
clientID: "",
clientSecret: ""
),
redirectURI: ""
)!,
instance: Mastodon.Entity.Instance(domain: "mstdn.jp"),
applicationToken: Mastodon.Entity.Token(
accessToken: "",
tokenType: "",
scope: "",
createdAt: Date()
)
)
}
static var viewMdoel2: MastodonRegisterViewModel {
let domain = "mstdn.jp"
return MastodonRegisterViewModel(
context: .shared,
domain: domain,
authenticateInfo: AuthenticationViewModel.AuthenticateInfo(
domain: domain,
application: Mastodon.Entity.Application(
name: "Preview",
website: nil,
vapidKey: nil,
redirectURI: nil,
clientID: "",
clientSecret: ""
),
redirectURI: ""
)!,
instance: Mastodon.Entity.Instance(domain: "mstdn.jp", approvalRequired: true),
applicationToken: Mastodon.Entity.Token(
accessToken: "",
tokenType: "",
scope: "",
createdAt: Date()
)
)
}
static var previews: some View {
Group {
NavigationView {
MastodonRegisterView(viewModel: viewMdoel)
.navigationBarTitle(Text(""))
.navigationBarTitleDisplayMode(.inline)
}
NavigationView {
MastodonRegisterView(viewModel: viewMdoel)
.navigationBarTitle(Text(""))
.navigationBarTitleDisplayMode(.inline)
}
.preferredColorScheme(.dark)
NavigationView {
MastodonRegisterView(viewModel: viewMdoel)
.navigationBarTitle(Text(""))
.navigationBarTitleDisplayMode(.inline)
}
.environment(\.sizeCategory, .accessibilityExtraLarge)
NavigationView {
MastodonRegisterView(viewModel: viewMdoel2)
.navigationBarTitle(Text(""))
.navigationBarTitleDisplayMode(.inline)
}
}
}
}
#endif

View File

@ -11,6 +11,8 @@ import MastodonSDK
import os.log import os.log
import PhotosUI import PhotosUI
import UIKit import UIKit
import SwiftUI
import MastodonUI
import MastodonAsset import MastodonAsset
import MastodonLocalization import MastodonLocalization
@ -27,6 +29,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var viewModel: MastodonRegisterViewModel! var viewModel: MastodonRegisterViewModel!
private(set) lazy var mastodonRegisterView = MastodonRegisterView(viewModel: viewModel)
// picker // picker
private(set) lazy var imagePicker: PHPickerViewController = { private(set) lazy var imagePicker: PHPickerViewController = {
@ -51,22 +54,6 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
return documentPickerController return documentPickerController
}() }()
let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
let tableView: UITableView = {
let tableView = UITableView()
tableView.rowHeight = UITableView.automaticDimension
tableView.separatorStyle = .none
tableView.backgroundColor = .clear
tableView.keyboardDismissMode = .onDrag
if #available(iOS 15.0, *) {
tableView.sectionHeaderTopPadding = .leastNonzeroMagnitude
} else {
// Fallback on earlier versions
}
return tableView
}()
let navigationActionView: NavigationActionView = { let navigationActionView: NavigationActionView = {
let navigationActionView = NavigationActionView() let navigationActionView = NavigationActionView()
navigationActionView.backgroundColor = Asset.Scene.Onboarding.background.color navigationActionView.backgroundColor = Asset.Scene.Onboarding.background.color
@ -87,17 +74,21 @@ extension MastodonRegisterViewController {
navigationItem.leftBarButtonItem = UIBarButtonItem() navigationItem.leftBarButtonItem = UIBarButtonItem()
setupOnboardingAppearance() setupOnboardingAppearance()
viewModel.backgroundColor = view.backgroundColor ?? .clear
defer { defer {
setupNavigationBarBackgroundView() setupNavigationBarBackgroundView()
} }
tableView.translatesAutoresizingMaskIntoConstraints = false let hostingViewController = UIHostingController(rootView: mastodonRegisterView)
view.addSubview(tableView) hostingViewController.view.preservesSuperviewLayoutMargins = true
addChild(hostingViewController)
hostingViewController.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(hostingViewController.view)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.topAnchor), hostingViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), hostingViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), hostingViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), hostingViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
]) ])
navigationActionView.translatesAutoresizingMaskIntoConstraints = false navigationActionView.translatesAutoresizingMaskIntoConstraints = false
@ -115,7 +106,7 @@ extension MastodonRegisterViewController {
.observe(\.bounds, options: [.initial, .new]) { [weak self] navigationActionView, _ in .observe(\.bounds, options: [.initial, .new]) { [weak self] navigationActionView, _ in
guard let self = self else { return } guard let self = self else { return }
let inset = navigationActionView.frame.height let inset = navigationActionView.frame.height
self.tableView.contentInset.bottom = inset self.viewModel.bottomPaddingHeight = inset
} }
.store(in: &observations) .store(in: &observations)
@ -129,19 +120,14 @@ extension MastodonRegisterViewController {
self.navigationActionView.nextButton.isEnabled = isAllValid self.navigationActionView.nextButton.isEnabled = isAllValid
} }
.store(in: &disposeBag) .store(in: &disposeBag)
viewModel.setupDiffableDataSource(tableView: tableView)
// KeyboardResponderService
// .configure(
// scrollView: tableView,
// layoutNeedsUpdate: viewModel.viewDidAppear.eraseToAnyPublisher()
// )
// .store(in: &disposeBag)
// gesture viewModel.endEditing
view.addGestureRecognizer(tapGestureRecognizer) .receive(on: DispatchQueue.main)
tapGestureRecognizer.addTarget(self, action: #selector(tapGestureRecognizerHandler)) .sink { [weak self] _ in
guard let self = self else { return }
self.view.endEditing(true)
}
.store(in: &disposeBag)
// // return // // return
// if viewModel.approvalRequired { // if viewModel.approvalRequired {
@ -149,80 +135,22 @@ extension MastodonRegisterViewController {
// } else { // } else {
// passwordTextField.returnKeyType = .done // passwordTextField.returnKeyType = .done
// } // }
//
// viewModel.usernameValidateState viewModel.$error
// .receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
// .sink { [weak self] validateState in .sink { [weak self] error in
// guard let self = self else { return } guard let self = self else { return }
// self.setTextFieldValidAppearance(self.usernameTextField, validateState: validateState) guard let error = error as? Mastodon.API.Error else { return }
// } let alertController = UIAlertController(for: error, title: "Sign Up Failure", preferredStyle: .alert)
// .store(in: &disposeBag) let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
// viewModel.usernameErrorPrompt alertController.addAction(okAction)
// .receive(on: DispatchQueue.main) self.coordinator.present(
// .sink { [weak self] prompt in scene: .alertController(alertController: alertController),
// guard let self = self else { return } from: nil,
// self.usernameErrorPromptLabel.attributedText = prompt transition: .alertController(animated: true, completion: nil)
// } )
// .store(in: &disposeBag) }
// viewModel.displayNameValidateState .store(in: &disposeBag)
// .receive(on: DispatchQueue.main)
// .sink { [weak self] validateState in
// guard let self = self else { return }
// self.setTextFieldValidAppearance(self.displayNameTextField, validateState: validateState)
// }
// .store(in: &disposeBag)
// viewModel.emailValidateState
// .receive(on: DispatchQueue.main)
// .sink { [weak self] validateState in
// guard let self = self else { return }
// self.setTextFieldValidAppearance(self.emailTextField, validateState: validateState)
// }
// .store(in: &disposeBag)
// viewModel.emailErrorPrompt
// .receive(on: DispatchQueue.main)
// .sink { [weak self] prompt in
// guard let self = self else { return }
// self.emailErrorPromptLabel.attributedText = prompt
// }
// .store(in: &disposeBag)
// viewModel.passwordValidateState
// .receive(on: DispatchQueue.main)
// .sink { [weak self] validateState in
// guard let self = self else { return }
// self.setTextFieldValidAppearance(self.passwordTextField, validateState: validateState)
// self.passwordCheckLabel.attributedText = MastodonRegisterViewModel.attributeStringForPassword(validateState: validateState)
// }
// .store(in: &disposeBag)
// viewModel.passwordErrorPrompt
// .receive(on: DispatchQueue.main)
// .sink { [weak self] prompt in
// guard let self = self else { return }
// self.passwordErrorPromptLabel.attributedText = prompt
// }
// .store(in: &disposeBag)
// viewModel.reasonErrorPrompt
// .receive(on: DispatchQueue.main)
// .sink { [weak self] prompt in
// guard let self = self else { return }
// self.reasonErrorPromptLabel.attributedText = prompt
// }
// .store(in: &disposeBag)
// viewModel.error
// .receive(on: DispatchQueue.main)
// .sink { [weak self] error in
// guard let self = self else { return }
// guard let error = error as? Mastodon.API.Error else { return }
// let alertController = UIAlertController(for: error, title: "Sign Up Failure", preferredStyle: .alert)
// let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
// alertController.addAction(okAction)
// self.coordinator.present(
// scene: .alertController(alertController: alertController),
// from: nil,
// transition: .alertController(animated: true, completion: nil)
// )
// }
// .store(in: &disposeBag)
//
viewModel.avatarMediaMenuActionPublisher viewModel.avatarMediaMenuActionPublisher
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
@ -260,10 +188,6 @@ extension MastodonRegisterViewController {
extension MastodonRegisterViewController { extension MastodonRegisterViewController {
@objc private func tapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
view.endEditing(true)
}
@objc private func backButtonPressed(_ sender: UIButton) { @objc private func backButtonPressed(_ sender: UIButton) {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
navigationController?.popViewController(animated: true) navigationController?.popViewController(animated: true)
@ -403,65 +327,3 @@ extension MastodonRegisterViewController {
} }
} }
extension MastodonRegisterViewController: UITextFieldDelegate {
// func textFieldDidBeginEditing(_ textField: UITextField) {
// let text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
//
// switch textField {
// case usernameTextField:
// viewModel.username.value = text
// case displayNameTextField:
// viewModel.displayName.value = text
// case emailTextField:
// viewModel.email.value = text
// case passwordTextField:
// viewModel.password.value = text
// case reasonTextField:
// viewModel.reason.value = text
// default:
// break
// }
// }
//
// func textFieldDidEndEditing(_ textField: UITextField) {
// let text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
//
// switch textField {
// case usernameTextField:
// viewModel.username.value = text
// case displayNameTextField:
// viewModel.displayName.value = text
// case emailTextField:
// viewModel.email.value = text
// case passwordTextField:
// viewModel.password.value = text
// case reasonTextField:
// viewModel.reason.value = text
// default:
// break
// }
// }
//
// func textFieldShouldReturn(_ textField: UITextField) -> Bool {
// switch textField {
// case usernameTextField:
// displayNameTextField.becomeFirstResponder()
// case displayNameTextField:
// emailTextField.becomeFirstResponder()
// case emailTextField:
// passwordTextField.becomeFirstResponder()
// case passwordTextField:
// if viewModel.approvalRequired {
// reasonTextField.becomeFirstResponder()
// } else {
// passwordTextField.resignFirstResponder()
// }
// case reasonTextField:
// reasonTextField.resignFirstResponder()
// default:
// break
// }
// return true
// }
}

View File

@ -143,7 +143,7 @@ extension MastodonRegisterViewModel {
snapshot.appendItems([.header(domain: domain)], toSection: .main) snapshot.appendItems([.header(domain: domain)], toSection: .main)
snapshot.appendItems([.avatar, .name, .username, .email, .password, .hint], toSection: .main) snapshot.appendItems([.avatar, .name, .username, .email, .password, .hint], toSection: .main)
if approvalRequired { if approvalRequired {
snapshot.appendItems([.reason], toSection: .main) snapshot.appendItems([.reason], toSection: .main)
} }
diffableDataSource?.applySnapshot(snapshot, animated: false, completion: nil) diffableDataSource?.applySnapshot(snapshot, animated: false, completion: nil)
} }
@ -164,51 +164,6 @@ extension MastodonRegisterViewModel {
.store(in: &cell.disposeBag) .store(in: &cell.disposeBag)
} }
enum AvatarMediaMenuAction {
case photoLibrary
case camera
case browse
case delete
}
private func createAvatarMediaContextMenu() -> UIMenu {
var children: [UIMenuElement] = []
// Photo Library
let photoLibraryAction = UIAction(title: L10n.Scene.Compose.MediaSelection.photoLibrary, image: UIImage(systemName: "rectangle.on.rectangle"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in
guard let self = self else { return }
self.avatarMediaMenuActionPublisher.send(.photoLibrary)
}
children.append(photoLibraryAction)
// Camera
if UIImagePickerController.isSourceTypeAvailable(.camera) {
let cameraAction = UIAction(title: L10n.Scene.Compose.MediaSelection.camera, image: UIImage(systemName: "camera"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] _ in
guard let self = self else { return }
self.avatarMediaMenuActionPublisher.send(.camera)
})
children.append(cameraAction)
}
// Browse
let browseAction = UIAction(title: L10n.Scene.Compose.MediaSelection.browse, image: UIImage(systemName: "ellipsis"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in
guard let self = self else { return }
self.avatarMediaMenuActionPublisher.send(.browse)
}
children.append(browseAction)
// Delete
if avatarImage != nil {
let deleteAction = UIAction(title: L10n.Scene.Register.Input.Avatar.delete, image: UIImage(systemName: "delete.left"), identifier: nil, discoverabilityTitle: nil, attributes: [.destructive], state: .off) { [weak self] _ in
guard let self = self else { return }
self.avatarMediaMenuActionPublisher.send(.delete)
}
children.append(deleteAction)
}
return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children)
}
private func configureTextFieldCell( private func configureTextFieldCell(
cell: MastodonRegisterTextFieldTableViewCell, cell: MastodonRegisterTextFieldTableViewCell,
validateState: Published<ValidateState>.Publisher validateState: Published<ValidateState>.Publisher

View File

@ -12,7 +12,7 @@ import UIKit
import MastodonAsset import MastodonAsset
import MastodonLocalization import MastodonLocalization
final class MastodonRegisterViewModel { final class MastodonRegisterViewModel: ObservableObject {
var disposeBag = Set<AnyCancellable>() var disposeBag = Set<AnyCancellable>()
// input // input
@ -23,6 +23,7 @@ final class MastodonRegisterViewModel {
let applicationToken: Mastodon.Entity.Token let applicationToken: Mastodon.Entity.Token
let viewDidAppear = CurrentValueSubject<Void, Never>(Void()) let viewDidAppear = CurrentValueSubject<Void, Never>(Void())
@Published var backgroundColor: UIColor = Asset.Scene.Onboarding.background.color
@Published var avatarImage: UIImage? = nil @Published var avatarImage: UIImage? = nil
@Published var name = "" @Published var name = ""
@Published var username = "" @Published var username = ""
@ -30,10 +31,12 @@ final class MastodonRegisterViewModel {
@Published var password = "" @Published var password = ""
@Published var reason = "" @Published var reason = ""
let usernameErrorPrompt = CurrentValueSubject<NSAttributedString?, Never>(nil) @Published var usernameErrorPrompt: String? = nil
let emailErrorPrompt = CurrentValueSubject<NSAttributedString?, Never>(nil) @Published var emailErrorPrompt: String? = nil
let passwordErrorPrompt = CurrentValueSubject<NSAttributedString?, Never>(nil) @Published var passwordErrorPrompt: String? = nil
let reasonErrorPrompt = CurrentValueSubject<NSAttributedString?, Never>(nil) @Published var reasonErrorPrompt: String? = nil
@Published var bottomPaddingHeight: CGFloat = .zero
// output // output
var diffableDataSource: UITableViewDiffableDataSource<RegisterSection, RegisterItem>? var diffableDataSource: UITableViewDiffableDataSource<RegisterSection, RegisterItem>?
@ -51,6 +54,7 @@ final class MastodonRegisterViewModel {
@Published var error: Error? = nil @Published var error: Error? = nil
let avatarMediaMenuActionPublisher = PassthroughSubject<AvatarMediaMenuAction, Never>() let avatarMediaMenuActionPublisher = PassthroughSubject<AvatarMediaMenuAction, Never>()
let endEditing = PassthroughSubject<Void, Never>()
init( init(
context: AppContext, context: AppContext,
@ -97,45 +101,46 @@ final class MastodonRegisterViewModel {
.assign(to: \.usernameValidateState, on: self) .assign(to: \.usernameValidateState, on: self)
.store(in: &disposeBag) .store(in: &disposeBag)
// TODO: check username available // check username available
// username $username
// .filter { !$0.isEmpty } .filter { !$0.isEmpty }
// .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
// .removeDuplicates() .removeDuplicates()
// .compactMap { [weak self] text -> AnyPublisher<Result<Mastodon.Response.Content<Mastodon.Entity.Account>, Error>, Never>? in .compactMap { [weak self] text -> AnyPublisher<Result<Mastodon.Response.Content<Mastodon.Entity.Account>, Error>, Never>? in
// guard let self = self else { return nil } guard let self = self else { return nil }
// let query = Mastodon.API.Account.AccountLookupQuery(acct: text) let query = Mastodon.API.Account.AccountLookupQuery(acct: text)
// return context.apiService.accountLookup(domain: domain, query: query, authorization: self.applicationAuthorization) return context.apiService.accountLookup(domain: domain, query: query, authorization: self.applicationAuthorization)
// .map { .map {
// response -> Result<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> in response -> Result<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> in
// Result.success(response) Result.success(response)
// } }
// .catch { error in .catch { error in
// Just(Result.failure(error)) Just(Result.failure(error))
// } }
// .eraseToAnyPublisher() .eraseToAnyPublisher()
// } }
// .switchToLatest() .switchToLatest()
// .sink { [weak self] result in .receive(on: DispatchQueue.main)
// guard let self = self else { return } .sink { [weak self] result in
// switch result { guard let self = self else { return }
// case .success: switch result {
// let text = L10n.Scene.Register.Error.Reason.taken(L10n.Scene.Register.Error.Item.username) case .success:
// self.usernameErrorPrompt.value = MastodonRegisterViewModel.errorPromptAttributedString(for: text) let text = L10n.Scene.Register.Error.Reason.taken(L10n.Scene.Register.Error.Item.username)
// self.usernameValidateState.value = .invalid self.usernameErrorPrompt = text
// case .failure: self.usernameValidateState = .invalid
// break case .failure:
// } break
// } }
// .store(in: &disposeBag) }
// .store(in: &disposeBag)
// usernameValidateState
// .sink { [weak self] validateState in $usernameValidateState
// if validateState == .valid { .sink { [weak self] validateState in
// self?.usernameErrorPrompt.value = nil if validateState == .valid {
// } self?.usernameErrorPrompt = nil
// } }
// .store(in: &disposeBag) }
.store(in: &disposeBag)
$email $email
.map { email in .map { email in
@ -163,27 +168,31 @@ final class MastodonRegisterViewModel {
.store(in: &disposeBag) .store(in: &disposeBag)
} }
// error $error
// .sink { [weak self] error in .sink { [weak self] error in
// guard let self = self else { return } guard let self = self else { return }
// let error = error as? Mastodon.API.Error let error = error as? Mastodon.API.Error
// let mastodonError = error?.mastodonError let mastodonError = error?.mastodonError
// if case let .generic(genericMastodonError) = mastodonError, if case let .generic(genericMastodonError) = mastodonError,
// let details = genericMastodonError.details let details = genericMastodonError.details
// { {
// self.usernameErrorPrompt.value = details.usernameErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } self.usernameErrorPrompt = details.usernameErrorDescriptions.first
// self.emailErrorPrompt.value = details.emailErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } details.usernameErrorDescriptions.first.flatMap { _ in self.usernameValidateState = .invalid }
// self.passwordErrorPrompt.value = details.passwordErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } self.emailErrorPrompt = details.emailErrorDescriptions.first
// self.reasonErrorPrompt.value = details.reasonErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } details.emailErrorDescriptions.first.flatMap { _ in self.emailValidateState = .invalid }
// } else { self.passwordErrorPrompt = details.passwordErrorDescriptions.first
// self.usernameErrorPrompt.value = nil details.passwordErrorDescriptions.first.flatMap { _ in self.passwordValidateState = .invalid }
// self.emailErrorPrompt.value = nil self.reasonErrorPrompt = details.reasonErrorDescriptions.first
// self.passwordErrorPrompt.value = nil details.reasonErrorDescriptions.first.flatMap { _ in self.reasonValidateState = .invalid }
// self.reasonErrorPrompt.value = nil } else {
// } self.usernameErrorPrompt = nil
// } self.emailErrorPrompt = nil
// .store(in: &disposeBag) self.passwordErrorPrompt = nil
// self.reasonErrorPrompt = nil
}
}
.store(in: &disposeBag)
let publisherOne = Publishers.CombineLatest4( let publisherOne = Publishers.CombineLatest4(
$usernameValidateState, $usernameValidateState,
$displayNameValidateState, $displayNameValidateState,
@ -213,7 +222,7 @@ final class MastodonRegisterViewModel {
} }
extension MastodonRegisterViewModel { extension MastodonRegisterViewModel {
enum ValidateState { enum ValidateState: Hashable {
case empty case empty
case invalid case invalid
case valid case valid
@ -271,3 +280,52 @@ extension MastodonRegisterViewModel {
return attributeString return attributeString
} }
} }
extension MastodonRegisterViewModel {
enum AvatarMediaMenuAction {
case photoLibrary
case camera
case browse
case delete
}
private func createAvatarMediaContextMenu() -> UIMenu {
var children: [UIMenuElement] = []
// Photo Library
let photoLibraryAction = UIAction(title: L10n.Scene.Compose.MediaSelection.photoLibrary, image: UIImage(systemName: "rectangle.on.rectangle"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in
guard let self = self else { return }
self.avatarMediaMenuActionPublisher.send(.photoLibrary)
}
children.append(photoLibraryAction)
// Camera
if UIImagePickerController.isSourceTypeAvailable(.camera) {
let cameraAction = UIAction(title: L10n.Scene.Compose.MediaSelection.camera, image: UIImage(systemName: "camera"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] _ in
guard let self = self else { return }
self.avatarMediaMenuActionPublisher.send(.camera)
})
children.append(cameraAction)
}
// Browse
let browseAction = UIAction(title: L10n.Scene.Compose.MediaSelection.browse, image: UIImage(systemName: "ellipsis"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in
guard let self = self else { return }
self.avatarMediaMenuActionPublisher.send(.browse)
}
children.append(browseAction)
// Delete
if avatarImage != nil {
let deleteAction = UIAction(title: L10n.Scene.Register.Input.Avatar.delete, image: UIImage(systemName: "delete.left"), identifier: nil, discoverabilityTitle: nil, attributes: [.destructive], state: .off) { [weak self] _ in
guard let self = self else { return }
self.avatarMediaMenuActionPublisher.send(.delete)
}
children.append(deleteAction)
}
return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children)
}
}

View File

@ -0,0 +1,50 @@
//
// MastodonServerRulesViewController+Debug.swift
// Mastodon
//
// Created by MainasuK on 2022-4-27.
//
import UIKit
#if DEBUG
extension MastodonRegisterViewController {
@MainActor
static func create(
context: AppContext,
coordinator: SceneCoordinator,
domain: String
) async throws -> MastodonRegisterViewController {
let viewController = MastodonRegisterViewController()
viewController.context = context
viewController.coordinator = coordinator
let instanceResponse = try await context.apiService.instance(domain: domain).singleOutput()
let applicationResponse = try await context.apiService.createApplication(domain: domain).singleOutput()
let accessTokenResponse = try await context.apiService.applicationAccessToken(
domain: domain,
clientID: applicationResponse.value.clientID!,
clientSecret: applicationResponse.value.clientSecret!,
redirectURI: applicationResponse.value.redirectURI!
).singleOutput()
viewController.viewModel = MastodonRegisterViewModel(
context: context,
domain: domain,
authenticateInfo: .init(
domain: domain,
application: applicationResponse.value
)!,
instance: instanceResponse.value,
applicationToken: accessTokenResponse.value
)
return viewController
}
}
#endif

View File

@ -90,7 +90,10 @@ extension ProfileHeaderViewModel {
extension ProfileHeaderViewModel { extension ProfileHeaderViewModel {
static func normalize(note: String?) -> String? { static func normalize(note: String?) -> String? {
guard let note = note?.trimmingCharacters(in: .whitespacesAndNewlines),!note.isEmpty else { let _note = note?.replacingOccurrences(of: "<br>|<br />", with: "\u{2028}", options: .regularExpression, range: nil)
.replacingOccurrences(of: "</p>", with: "</p>\u{2029}", range: nil)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard let note = _note, !note.isEmpty else {
return nil return nil
} }

View File

@ -13,8 +13,9 @@ import MetaTextKit
import MastodonAsset import MastodonAsset
import MastodonLocalization import MastodonLocalization
import MastodonUI import MastodonUI
import Tabman
import CoreDataStack import CoreDataStack
import Tabman
import Pageboy
protocol ProfileViewModelEditable { protocol ProfileViewModelEditable {
func isEdited() -> Bool func isEdited() -> Bool
@ -42,19 +43,34 @@ final class ProfileViewController: UIViewController, NeedsDependency, MediaPrevi
}() }()
private(set) lazy var settingBarButtonItem: UIBarButtonItem = { private(set) lazy var settingBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "gear"), style: .plain, target: self, action: #selector(ProfileViewController.settingBarButtonItemPressed(_:))) let barButtonItem = UIBarButtonItem(
image: Asset.ObjectsAndTools.gear.image.withRenderingMode(.alwaysTemplate),
style: .plain,
target: self,
action: #selector(ProfileViewController.settingBarButtonItemPressed(_:))
)
barButtonItem.tintColor = .white barButtonItem.tintColor = .white
return barButtonItem return barButtonItem
}() }()
private(set) lazy var shareBarButtonItem: UIBarButtonItem = { private(set) lazy var shareBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "square.and.arrow.up"), style: .plain, target: self, action: #selector(ProfileViewController.shareBarButtonItemPressed(_:))) let barButtonItem = UIBarButtonItem(
image: Asset.Arrow.squareAndArrowUp.image.withRenderingMode(.alwaysTemplate),
style: .plain,
target: self,
action: #selector(ProfileViewController.shareBarButtonItemPressed(_:))
)
barButtonItem.tintColor = .white barButtonItem.tintColor = .white
return barButtonItem return barButtonItem
}() }()
private(set) lazy var favoriteBarButtonItem: UIBarButtonItem = { private(set) lazy var favoriteBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "star"), style: .plain, target: self, action: #selector(ProfileViewController.favoriteBarButtonItemPressed(_:))) let barButtonItem = UIBarButtonItem(
image: Asset.ObjectsAndTools.star.image.withRenderingMode(.alwaysTemplate),
style: .plain,
target: self,
action: #selector(ProfileViewController.favoriteBarButtonItemPressed(_:))
)
barButtonItem.tintColor = .white barButtonItem.tintColor = .white
return barButtonItem return barButtonItem
}() }()
@ -402,6 +418,7 @@ extension ProfileViewController {
} }
extension ProfileViewController { extension ProfileViewController {
private func updateBarButtonInsets() { private func updateBarButtonInsets() {
let margin: CGFloat = { let margin: CGFloat = {
switch traitCollection.userInterfaceIdiom { switch traitCollection.userInterfaceIdiom {
@ -618,7 +635,7 @@ extension ProfileViewController {
return nil return nil
} }
let name = user.displayNameWithFallback let name = user.displayNameWithFallback
let record = ManagedObjectRecord<MastodonUser>(objectID: user.objectID) let _ = ManagedObjectRecord<MastodonUser>(objectID: user.objectID)
let menu = MastodonMenu.setupMenu( let menu = MastodonMenu.setupMenu(
actions: [ actions: [
.muteUser(.init(name: name, isMuting: self.viewModel.isMuting.value)), .muteUser(.init(name: name, isMuting: self.viewModel.isMuting.value)),
@ -633,7 +650,7 @@ extension ProfileViewController {
.sink { [weak self] completion in .sink { [weak self] completion in
guard let self = self else { return } guard let self = self else { return }
switch completion { switch completion {
case .failure(let error): case .failure:
self.moreMenuBarButtonItem.menu = nil self.moreMenuBarButtonItem.menu = nil
case .finished: case .finished:
break break
@ -937,6 +954,7 @@ extension ProfileViewController: ProfileHeaderViewDelegate {
viewModel.isUpdating.value = true viewModel.isUpdating.value = true
Task { Task {
do { do {
// TODO: handle error
_ = try await viewModel.updateProfileInfo( _ = try await viewModel.updateProfileInfo(
headerProfileInfo: profileHeaderViewModel.editProfileInfo, headerProfileInfo: profileHeaderViewModel.editProfileInfo,
aboutProfileInfo: profileAboutViewModel.editProfileInfo aboutProfileInfo: profileAboutViewModel.editProfileInfo
@ -1138,25 +1156,28 @@ extension ProfileViewController: ScrollViewContainer {
} }
} }
//extension ProfileViewController { extension ProfileViewController {
//
// override var keyCommands: [UIKeyCommand]? { override var keyCommands: [UIKeyCommand]? {
// if !viewModel.isEditing.value { if !viewModel.isEditing.value {
// return segmentedControlNavigateKeyCommands return pageboyNavigateKeyCommands
// } }
//
// return nil return nil
// } }
//
//} }
// MARK: - PageboyNavigateable
extension ProfileViewController: PageboyNavigateable {
var navigateablePageViewController: PageboyViewController {
return profileSegmentedViewController.pagingViewController
}
@objc func pageboyNavigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
pageboyNavigateKeyCommandHandler(sender)
}
}
// MARK: - SegmentedControlNavigateable
//extension ProfileViewController: SegmentedControlNavigateable {
// var navigateableSegmentedControl: UISegmentedControl {
// profileHeaderViewController.pageSegmentedControl
// }
//
// @objc func segmentedControlNavigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
// segmentedControlNavigateKeyCommandHandler(sender)
// }
//}

View File

@ -13,10 +13,11 @@ import MastodonSDK
import MastodonMeta import MastodonMeta
import MastodonAsset import MastodonAsset
import MastodonLocalization import MastodonLocalization
import MastodonUI
// please override this base class // please override this base class
class ProfileViewModel: NSObject { class ProfileViewModel: NSObject {
let logger = Logger(subsystem: "ProfileViewModel", category: "ViewModel") let logger = Logger(subsystem: "ProfileViewModel", category: "ViewModel")
typealias UserID = String typealias UserID = String
@ -372,101 +373,6 @@ extension ProfileViewModel {
} }
extension ProfileViewModel {
enum RelationshipAction: Int, CaseIterable {
case none // set hide from UI
case follow
case request
case pending
case following
case muting
case blocked
case blocking
case suspended
case edit
case editing
case updating
var option: RelationshipActionOptionSet {
return RelationshipActionOptionSet(rawValue: 1 << rawValue)
}
}
// construct option set on the enum for safe iterator
struct RelationshipActionOptionSet: OptionSet {
let rawValue: Int
static let none = RelationshipAction.none.option
static let follow = RelationshipAction.follow.option
static let request = RelationshipAction.request.option
static let pending = RelationshipAction.pending.option
static let following = RelationshipAction.following.option
static let muting = RelationshipAction.muting.option
static let blocked = RelationshipAction.blocked.option
static let blocking = RelationshipAction.blocking.option
static let suspended = RelationshipAction.suspended.option
static let edit = RelationshipAction.edit.option
static let editing = RelationshipAction.editing.option
static let updating = RelationshipAction.updating.option
static let editOptions: RelationshipActionOptionSet = [.edit, .editing, .updating]
func highPriorityAction(except: RelationshipActionOptionSet) -> RelationshipAction? {
let set = subtracting(except)
for action in RelationshipAction.allCases.reversed() where set.contains(action.option) {
return action
}
return nil
}
var title: String {
guard let highPriorityAction = self.highPriorityAction(except: []) else {
assertionFailure()
return " "
}
switch highPriorityAction {
case .none: return " "
case .follow: return L10n.Common.Controls.Friendship.follow
case .request: return L10n.Common.Controls.Friendship.request
case .pending: return L10n.Common.Controls.Friendship.pending
case .following: return L10n.Common.Controls.Friendship.following
case .muting: return L10n.Common.Controls.Friendship.muted
case .blocked: return L10n.Common.Controls.Friendship.follow // blocked by user
case .blocking: return L10n.Common.Controls.Friendship.blocked
case .suspended: return L10n.Common.Controls.Friendship.follow
case .edit: return L10n.Common.Controls.Friendship.editInfo
case .editing: return L10n.Common.Controls.Actions.done
case .updating: return " "
}
}
@available(*, deprecated, message: "")
var backgroundColor: UIColor {
guard let highPriorityAction = self.highPriorityAction(except: []) else {
assertionFailure()
return Asset.Colors.brandBlue.color
}
switch highPriorityAction {
case .none: return Asset.Colors.brandBlue.color
case .follow: return Asset.Colors.brandBlue.color
case .request: return Asset.Colors.brandBlue.color
case .pending: return Asset.Colors.brandBlue.color
case .following: return Asset.Colors.brandBlue.color
case .muting: return Asset.Colors.alertYellow.color
case .blocked: return Asset.Colors.brandBlue.color
case .blocking: return Asset.Colors.danger.color
case .suspended: return Asset.Colors.brandBlue.color
case .edit: return Asset.Colors.brandBlue.color
case .editing: return Asset.Colors.brandBlue.color
case .updating: return Asset.Colors.brandBlue.color
}
}
}
}
extension ProfileViewModel { extension ProfileViewModel {
func updateProfileInfo( func updateProfileInfo(
headerProfileInfo: ProfileHeaderViewModel.ProfileInfo, headerProfileInfo: ProfileHeaderViewModel.ProfileInfo,

View File

@ -44,7 +44,7 @@ final class ProfilePagingViewModel: NSObject {
let barItems: [TMBarItemable] = { let barItems: [TMBarItemable] = {
let items = [ let items = [
TMBarItem(title: L10n.Scene.Profile.SegmentedControl.posts), TMBarItem(title: L10n.Scene.Profile.SegmentedControl.posts),
TMBarItem(title: L10n.Scene.Profile.SegmentedControl.postsAndReplies), // TODO: i18n TMBarItem(title: L10n.Scene.Profile.SegmentedControl.postsAndReplies),
TMBarItem(title: L10n.Scene.Profile.SegmentedControl.media), TMBarItem(title: L10n.Scene.Profile.SegmentedControl.media),
TMBarItem(title: L10n.Scene.Profile.SegmentedControl.about), TMBarItem(title: L10n.Scene.Profile.SegmentedControl.about),
] ]

View File

@ -36,7 +36,7 @@ final class UserTimelineViewController: UIViewController, NeedsDependency, Media
let cellFrameCache = NSCache<NSNumber, NSValue>() let cellFrameCache = NSCache<NSNumber, NSValue>()
deinit { deinit {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
} }
} }

View File

@ -195,6 +195,12 @@ extension UserTimelineViewModel.State {
// trigger data source update. otherwise, spinner always display // trigger data source update. otherwise, spinner always display
viewModel.isSuspended.value = viewModel.isSuspended.value viewModel.isSuspended.value = viewModel.isSuspended.value
// remove bottom loader
guard let diffableDataSource = viewModel.diffableDataSource else { return }
var snapshot = diffableDataSource.snapshot()
snapshot.deleteItems([.bottomLoader])
diffableDataSource.apply(snapshot)
} }
} }
} }

View File

@ -0,0 +1,206 @@
//
// ReportViewController.swift
// Mastodon
//
// Created by ihugo on 2021/4/20.
//
import os.log
import UIKit
import Combine
import CoreDataStack
import MastodonAsset
import MastodonLocalization
class ReportViewController: UIViewController, NeedsDependency, ReportViewControllerAppearance {
let logger = Logger(subsystem: "ReportViewController", category: "ViewController")
var disposeBag = Set<AnyCancellable>()
private var observations = Set<NSKeyValueObservation>()
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var viewModel: ReportViewModel!
lazy var cancelBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .cancel,
target: self,
action: #selector(ReportViewController.cancelBarButtonItemDidPressed(_:))
)
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension ReportViewController {
override func viewDidLoad() {
super.viewDidLoad()
setupAppearance()
defer { setupNavigationBarBackgroundView() }
navigationItem.rightBarButtonItem = cancelBarButtonItem
viewModel.reportReasonViewModel.delegate = self
viewModel.reportServerRulesViewModel.delegate = self
viewModel.reportStatusViewModel.delegate = self
viewModel.reportSupplementaryViewModel.delegate = self
let reportReasonViewController = ReportReasonViewController()
reportReasonViewController.context = context
reportReasonViewController.coordinator = coordinator
reportReasonViewController.viewModel = viewModel.reportReasonViewModel
addChild(reportReasonViewController)
reportReasonViewController.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(reportReasonViewController.view)
reportReasonViewController.didMove(toParent: self)
NSLayoutConstraint.activate([
reportReasonViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
reportReasonViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
reportReasonViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
reportReasonViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
}
extension ReportViewController {
@objc private func cancelBarButtonItemDidPressed(_ sender: UIBarButtonItem) {
dismiss(animated: true, completion: nil)
}
}
// MARK: - UIAdaptivePresentationControllerDelegate
extension ReportViewController: UIAdaptivePresentationControllerDelegate {
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
return viewModel.isReportSuccess
}
}
// MARK: - ReportReasonViewControllerDelegate
extension ReportViewController: ReportReasonViewControllerDelegate {
func reportReasonViewController(_ viewController: ReportReasonViewController, nextButtonPressed button: UIButton) {
guard let reason = viewController.viewModel.selectReason else { return }
switch reason {
case .dislike:
let reportResultViewModel = ReportResultViewModel(
context: context,
user: viewModel.user,
isReported: false
)
coordinator.present(
scene: .reportResult(viewModel: reportResultViewModel),
from: self,
transition: .show
)
case .violateRule:
coordinator.present(
scene: .reportServerRules(viewModel: viewModel.reportServerRulesViewModel),
from: self,
transition: .show
)
case .spam, .other:
coordinator.present(
scene: .reportStatus(viewModel: viewModel.reportStatusViewModel),
from: self,
transition: .show
)
}
}
}
// MARK: - ReportServerRulesViewControllerDelegate
extension ReportViewController: ReportServerRulesViewControllerDelegate {
func reportServerRulesViewController(_ viewController: ReportServerRulesViewController, nextButtonPressed button: UIButton) {
if viewController.viewModel.isDislike {
let reportResultViewModel = ReportResultViewModel(
context: context,
user: viewModel.user,
isReported: false
)
coordinator.present(
scene: .reportResult(viewModel: reportResultViewModel),
from: self,
transition: .show
)
} else if viewController.viewModel.selectRule != nil {
coordinator.present(
scene: .reportStatus(viewModel: viewModel.reportStatusViewModel),
from: self,
transition: .show
)
} else {
assertionFailure()
}
}
}
// MARK: - ReportStatusViewControllerDelegate
extension ReportViewController: ReportStatusViewControllerDelegate {
func reportStatusViewController(_ viewController: ReportStatusViewController, skipButtonDidPressed button: UIButton) {
coordinateToReportSupplementary()
}
func reportStatusViewController(_ viewController: ReportStatusViewController, nextButtonDidPressed button: UIButton) {
coordinateToReportSupplementary()
}
private func coordinateToReportSupplementary() {
coordinator.present(
scene: .reportSupplementary(viewModel: viewModel.reportSupplementaryViewModel),
from: self,
transition: .show
)
}
}
// MARK: - ReportSupplementaryViewControllerDelegate
extension ReportViewController: ReportSupplementaryViewControllerDelegate {
func reportSupplementaryViewController(_ viewController: ReportSupplementaryViewController, skipButtonDidPressed button: UIButton) {
report()
}
func reportSupplementaryViewController(_ viewController: ReportSupplementaryViewController, nextButtonDidPressed button: UIButton) {
report()
}
private func report() {
Task { @MainActor in
do {
let _ = try await viewModel.report()
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): report success")
let reportResultViewModel = ReportResultViewModel(
context: context,
user: viewModel.user,
isReported: true
)
coordinator.present(
scene: .reportResult(viewModel: reportResultViewModel),
from: self,
transition: .show
)
} catch {
let alertController = UIAlertController(for: error, title: nil, preferredStyle: .alert)
let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
alertController.addAction(okAction)
self.coordinator.present(
scene: .alertController(alertController: alertController),
from: nil,
transition: .alertController(animated: true, completion: nil)
)
}
} // end Task
}
}

View File

@ -0,0 +1,190 @@
//
// ReportViewModel.swift
// Mastodon
//
// Created by ihugo on 2021/4/19.
//
import Combine
import CoreData
import CoreDataStack
import Foundation
import GameplayKit
import MastodonSDK
import OrderedCollections
import os.log
import UIKit
import MastodonLocalization
class ReportViewModel {
var disposeBag = Set<AnyCancellable>()
let reportReasonViewModel: ReportReasonViewModel
let reportServerRulesViewModel: ReportServerRulesViewModel
let reportStatusViewModel: ReportStatusViewModel
let reportSupplementaryViewModel: ReportSupplementaryViewModel
// input
let context: AppContext
let user: ManagedObjectRecord<MastodonUser>
let status: ManagedObjectRecord<Status>?
// output
@Published var isReporting = false
@Published var isReportSuccess = false
init(
context: AppContext,
user: ManagedObjectRecord<MastodonUser>,
status: ManagedObjectRecord<Status>?
) {
self.context = context
self.user = user
self.status = status
self.reportReasonViewModel = ReportReasonViewModel(context: context)
self.reportServerRulesViewModel = ReportServerRulesViewModel(context: context)
self.reportStatusViewModel = ReportStatusViewModel(context: context, user: user, status: status)
self.reportSupplementaryViewModel = ReportSupplementaryViewModel(context: context, user: user)
// end init
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
return
}
// setup reason viewModel
if status != nil {
reportReasonViewModel.headline = L10n.Scene.Report.StepOne.whatsWrongWithThisPost
} else {
Task { @MainActor in
let managedObjectContext = context.managedObjectContext
let _username: String? = try? await managedObjectContext.perform {
let user = user.object(in: managedObjectContext)
return user?.acctWithDomain
}
if let username = _username {
reportReasonViewModel.headline = L10n.Scene.Report.StepOne.whatsWrongWithThisUsername(username)
} else {
reportReasonViewModel.headline = L10n.Scene.Report.StepOne.whatsWrongWithThisAccount
}
} // end Task
}
// bind server rules
Task { @MainActor in
do {
let response = try await context.apiService.instance(domain: authenticationBox.domain)
.timeout(3, scheduler: DispatchQueue.main)
.singleOutput()
let rules = response.value.rules ?? []
reportReasonViewModel.serverRules = rules
reportServerRulesViewModel.serverRules = rules
} catch {
reportReasonViewModel.serverRules = []
reportServerRulesViewModel.serverRules = []
}
} // end Task
$isReporting
.assign(to: &reportSupplementaryViewModel.$isBusy)
}
}
extension ReportViewModel {
@MainActor
func report() async throws {
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value,
!isReporting
else {
assertionFailure()
return
}
let managedObjectContext = context.managedObjectContext
let _query: Mastodon.API.Reports.FileReportQuery? = try await managedObjectContext.perform {
guard let user = self.user.object(in: managedObjectContext) else { return nil }
// the status picker is essential step in report flow
// only check isSkip or not
let statusIDs: [Status.ID]? = {
if self.reportStatusViewModel.isSkip {
let _id: Status.ID? = self.reportStatusViewModel.status.flatMap { record -> Status.ID? in
guard let status = record.object(in: managedObjectContext) else { return nil }
return status.id
}
return _id.flatMap { [$0] }
} else {
return self.reportStatusViewModel.selectStatuses.compactMap { record -> Status.ID? in
guard let status = record.object(in: managedObjectContext) else { return nil }
return status.id
}
}
}()
// the user comment is essential step in report flow
// only check isSkip or not
let comment: String? = {
var suffixes: [String] = []
let content: String?
// the server rules is NOT essential step in report flow
// append suffix depends which reason
if let reason = self.reportReasonViewModel.selectReason {
switch reason {
case .spam:
suffixes.append(reason.rawValue)
case .violateRule:
suffixes.append(reason.rawValue)
if let rule = self.reportServerRulesViewModel.selectRule {
suffixes.append(rule.text)
} else {
assertionFailure("should select valid rule")
}
case .dislike:
assertionFailure("should not enter the report flow")
case .other:
break
}
}
content = self.reportSupplementaryViewModel.isSkip ? nil : self.reportSupplementaryViewModel.commentContext.comment
let suffix: String? = {
let text = suffixes.joined(separator: ". ")
guard !text.isEmpty else { return nil }
return "<" + text + ">"
}()
let comment = [content, suffix]
.compactMap { $0 }
.joined(separator: " ")
return comment.isEmpty ? nil : comment
}()
return Mastodon.API.Reports.FileReportQuery(
accountID: user.id,
statusIDs: statusIDs,
comment: comment,
forward: true
)
}
guard let query = _query else { return }
do {
isReporting = true
#if DEBUG
try await Task.sleep(nanoseconds: .second * 3)
#else
let _ = try await context.apiService.report(
query: query,
authenticationBox: authenticationBox
)
#endif
isReportSuccess = true
} catch {
isReporting = false
throw error
}
}
}

View File

@ -0,0 +1,112 @@
//
// ReportReasonView.swift
// Mastodon
//
// Created by MainasuK on 2022-5-10.
//
import UIKit
import SwiftUI
import MastodonLocalization
import MastodonSDK
import MastodonAsset
struct ReportReasonView: View {
@ObservedObject var viewModel: ReportReasonViewModel
var body: some View {
ScrollView(.vertical) {
HStack {
VStack(alignment: .leading, spacing: 8) {
Text(L10n.Scene.Report.StepOne.step1Of4)
.foregroundColor(Color(Asset.Colors.Label.secondary.color))
.font(Font(UIFontMetrics(forTextStyle: .largeTitle).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)) as CTFont))
Text(viewModel.headline)
.foregroundColor(Color(Asset.Colors.Label.primary.color))
.font(Font(UIFontMetrics(forTextStyle: .largeTitle).scaledFont(for: .systemFont(ofSize: 28, weight: .bold)) as CTFont))
Text(L10n.Scene.Report.StepOne.selectTheBestMatch)
.foregroundColor(Color(Asset.Colors.Label.secondary.color))
.font(Font(UIFontMetrics(forTextStyle: .largeTitle).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)) as CTFont))
}
Spacer()
}
.padding()
VStack(spacing: 16) {
if let serverRules = viewModel.serverRules {
ForEach(ReportReasonViewModel.Reason.allCases, id: \.self) { reason in
switch reason {
case .violateRule where serverRules.isEmpty:
EmptyView()
default:
ReportReasonRowView(reason: reason, isSelect: reason == viewModel.selectReason)
.background(
Color(viewModel.backgroundColor)
)
.onTapGesture {
viewModel.selectReason = reason
}
}
}
} else {
ProgressView()
}
}
.padding()
.transition(.opacity)
.animation(.easeInOut)
Spacer()
.frame(minHeight: viewModel.bottomPaddingHeight)
}
.background(
Color(viewModel.backgroundColor)
)
}
}
struct ReportReasonRowView: View {
var reason: ReportReasonViewModel.Reason
var isSelect: Bool
var body: some View {
HStack(spacing: 14) {
Image(systemName: isSelect ? "checkmark.circle.fill" : "circle")
.resizable()
.frame(width: 28, height: 28, alignment: .center)
VStack(alignment: .leading, spacing: 4) {
Text(reason.title)
.foregroundColor(Color(Asset.Colors.Label.primary.color))
.font(.headline)
Text(reason.subtitle)
.font(.subheadline)
.foregroundColor(Color(Asset.Colors.Label.secondary.color))
}
Spacer()
}
}
}
#if DEBUG
struct ReportReasonView_Previews: PreviewProvider {
static var previews: some View {
Group {
NavigationView {
ReportReasonView(viewModel: ReportReasonViewModel(context: .shared))
.navigationBarTitle(Text(""))
.navigationBarTitleDisplayMode(.inline)
}
NavigationView {
ReportReasonView(viewModel: ReportReasonViewModel(context: .shared))
.navigationBarTitle(Text(""))
.navigationBarTitleDisplayMode(.inline)
}
.preferredColorScheme(.dark)
}
}
}
#endif

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