Merge branch 'develop' into l10n_develop
This commit is contained in:
commit
7bca92d1d2
|
@ -1,9 +1,16 @@
|
|||
#!/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
|
||||
pod keys set notification_endpoint "<endpoint>"
|
||||
pod keys set notification_endpoint_debug "<endpoint>"
|
||||
bundle exec pod keys set notification_endpoint "<endpoint>"
|
||||
bundle exec pod keys set notification_endpoint_debug "<endpoint>"
|
||||
|
||||
pod install
|
||||
bundle exec pod install
|
||||
|
|
|
@ -10,10 +10,7 @@ import Foundation
|
|||
import CryptoKit
|
||||
import KeychainAccess
|
||||
import Keys
|
||||
|
||||
enum AppName {
|
||||
public static let groupID = "group.org.joinmastodon.app"
|
||||
}
|
||||
import MastodonCommon
|
||||
|
||||
public final class AppSecret {
|
||||
|
||||
|
|
|
@ -15,8 +15,8 @@
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.3.0</string>
|
||||
<string>1.4.2</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>109</string>
|
||||
<string>127</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
```zsh
|
||||
sudo gem install cocoapods cocoapods-keys
|
||||
gem install bundler
|
||||
bundle install
|
||||
```
|
||||
|
||||
#### M1 Mac
|
||||
|
@ -40,18 +41,19 @@ rbenv global 3.0.3
|
|||
ruby --version
|
||||
# > ruby 3.0.3p157 (2021-11-24 revision 3fb7d2cadc) [arm64-darwin21]
|
||||
|
||||
sudo gem install cocoapods cocoapods-keys
|
||||
gem install bundler
|
||||
bundle install
|
||||
```
|
||||
|
||||
## Bootstrap
|
||||
|
||||
```zsh
|
||||
# make a clean build
|
||||
sudo gem install cocoapods-clean
|
||||
pod clean
|
||||
bundle install
|
||||
bundle exec pod clean
|
||||
|
||||
# make install
|
||||
pod install --repo-update
|
||||
bundle exec pod install --repo-update
|
||||
|
||||
# open workspace
|
||||
open Mastodon.xcworkspace
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
source "https://rubygems.org"
|
||||
|
||||
gem "cocoapods"
|
||||
gem "cocoapods-clean"
|
||||
gem "cocoapods-keys"
|
||||
|
|
@ -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
|
|
@ -90,6 +90,28 @@
|
|||
<string>posts</string>
|
||||
</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>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
|
|
|
@ -51,19 +51,25 @@ private func map(language: String) -> String? {
|
|||
case "eu_ES": return "eu-ES" // Basque
|
||||
case "ca_ES": return "ca" // Catalan
|
||||
case "zh_CN": return "zh-Hans" // Chinese Simplified
|
||||
case "zh_TW": return "zh-Hant" // Chinese Traditional
|
||||
case "nl_NL": return "nl" // Dutch
|
||||
case "en_US": return "en"
|
||||
case "fr_FR": return "fr" // French
|
||||
case "gl_ES": return "gl" // Galician
|
||||
case "de_DE": return "de" // German
|
||||
case "it_IT": return "it" // Italian
|
||||
case "ja_JP": return "ja" // Japanese
|
||||
case "kab_KAB": return "kab" // Kabyle
|
||||
case "kmr_TR": return "ku" // Kurmanji (Kurdish)
|
||||
case "ru_RU": return "ru" // Russian
|
||||
case "gd_GB": return "gd-GB" // Scottish Gaelic
|
||||
case "ckb_IR": return "ckb" // Sorani (Kurdish)
|
||||
case "es_ES": return "es" // Spanish
|
||||
case "es_AR": return "es-419" // Spanish, Argentina
|
||||
case "sv-SE": return "sv" // Swedish
|
||||
case "sv_FI": return "sv_FI" // Swedish, Finland
|
||||
case "th_TH": return "th" // Thai
|
||||
case "tr_TR": return "tr" // Turkish
|
||||
case "vi_VN": return "vi" // Vietnamese
|
||||
default: return nil
|
||||
}
|
||||
|
|
|
@ -129,6 +129,7 @@
|
|||
"show_post": "Show Post",
|
||||
"show_user_profile": "Show user profile",
|
||||
"content_warning": "Content Warning",
|
||||
"sensitive_content": "Sensitive Content",
|
||||
"media_content_warning": "Tap anywhere to reveal",
|
||||
"tap_to_reveal": "Tap to reveal",
|
||||
"poll": {
|
||||
|
@ -210,9 +211,9 @@
|
|||
"log_in": "Log In"
|
||||
},
|
||||
"server_picker": {
|
||||
"title": "Mastodon is made of users in different communities.",
|
||||
"subtitle": "Pick a community 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.",
|
||||
"title": "Mastodon is made of users in different servers.",
|
||||
"subtitle": "Pick a server based on your interests, region, or a general purpose one.",
|
||||
"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": {
|
||||
"category": {
|
||||
"all": "All",
|
||||
|
@ -239,7 +240,8 @@
|
|||
"category": "CATEGORY"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Search communities"
|
||||
"placeholder": "Search servers",
|
||||
"search_servers_or_enter_url": "Search communities or enter URL"
|
||||
},
|
||||
"empty_state": {
|
||||
"finding_servers": "Finding available servers...",
|
||||
|
@ -249,6 +251,7 @@
|
|||
},
|
||||
"register": {
|
||||
"title": "Let’s get you set up on %s",
|
||||
"lets_get_you_set_up_on_domain": "Let’s get you set up on %s",
|
||||
"input": {
|
||||
"avatar": {
|
||||
"delete": "Delete"
|
||||
|
@ -319,6 +322,7 @@
|
|||
"confirm_email": {
|
||||
"title": "One last thing.",
|
||||
"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": {
|
||||
"open_email_app": "Open Email App",
|
||||
"resend": "Resend"
|
||||
|
@ -341,7 +345,11 @@
|
|||
"offline": "Offline",
|
||||
"new_posts": "See new posts",
|
||||
"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": {
|
||||
|
@ -492,6 +500,16 @@
|
|||
"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": {
|
||||
"title": "Your Favorites"
|
||||
},
|
||||
|
@ -585,7 +603,49 @@
|
|||
"send": "Send Report",
|
||||
"skip_to_send": "Send without comment",
|
||||
"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 don’t like it",
|
||||
"it_is_not_something_you_want_to_see": "It is not something you want to see",
|
||||
"its_spam": "It’s 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": "It’s 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_don’t_like_it": "I just don’t 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": "Don’t 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 don’t 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 won’t see their posts or reblogs in your home feed. They won’t know they’ve 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 they’ve been blocked.",
|
||||
"while_we_review_this_you_can_take_action_against_user": "While we review this, you can take action against %s"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"keyboard": {
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1250"
|
||||
LastUpgradeVersion = "1330"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1250"
|
||||
LastUpgradeVersion = "1330"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1250"
|
||||
LastUpgradeVersion = "1330"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
|
|
@ -9,33 +9,38 @@
|
|||
<key>isShown</key>
|
||||
<true/>
|
||||
<key>orderHint</key>
|
||||
<integer>4</integer>
|
||||
<integer>5</integer>
|
||||
</dict>
|
||||
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>27</integer>
|
||||
</dict>
|
||||
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>19</integer>
|
||||
</dict>
|
||||
<key>Mastodon - Release.xcscheme_^#shared#^_</key>
|
||||
<key>Mastodon - Profile.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
</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>
|
||||
<key>orderHint</key>
|
||||
<integer>2</integer>
|
||||
</dict>
|
||||
<key>Mastodon - ar.xcscheme</key>
|
||||
<key>Mastodon - Snapshot.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>3</integer>
|
||||
</dict>
|
||||
<key>Mastodon - ar.xcscheme</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>4</integer>
|
||||
</dict>
|
||||
<key>Mastodon - ar.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
|
@ -109,7 +114,7 @@
|
|||
<key>MastodonIntent.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>24</integer>
|
||||
<integer>31</integer>
|
||||
</dict>
|
||||
<key>MastodonIntents.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
|
@ -124,12 +129,12 @@
|
|||
<key>NotificationService.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>22</integer>
|
||||
<integer>30</integer>
|
||||
</dict>
|
||||
<key>ShareActionExtension.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>23</integer>
|
||||
<integer>32</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
|
|
|
@ -6,8 +6,8 @@
|
|||
"repositoryURL": "https://github.com/Alamofire/Alamofire.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "f82c23a8a7ef8dc1a49a8bfc6a96883e79121864",
|
||||
"version": "5.5.0"
|
||||
"revision": "354dda32d89fc8cd4f5c46487f64957d355f53d8",
|
||||
"version": "5.6.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -55,6 +55,15 @@
|
|||
"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",
|
||||
"repositoryURL": "https://github.com/Flipboard/FLAnimatedImage.git",
|
||||
|
@ -96,8 +105,8 @@
|
|||
"repositoryURL": "https://github.com/TwidereProject/MetaTextKit.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "3ea336d3de7938dc112084c596a646e697b0feee",
|
||||
"version": "2.2.1"
|
||||
"revision": "2b9556a78b2986b8c0b04adc6da8ec206b448a0c",
|
||||
"version": "2.2.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -105,8 +114,8 @@
|
|||
"repositoryURL": "https://github.com/kean/Nuke.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "0db18dd34998cca18e9a28bcee136f84518007a0",
|
||||
"version": "10.4.1"
|
||||
"revision": "0ea7545b5c918285aacc044dc75048625c8257cc",
|
||||
"version": "10.8.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -141,8 +150,8 @@
|
|||
"repositoryURL": "https://github.com/SDWebImage/SDWebImage.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "2c53f531f1bedd253f55d85105409c28ed4a922c",
|
||||
"version": "5.12.3"
|
||||
"revision": "2e63d0061da449ad0ed130768d05dceb1496de44",
|
||||
"version": "5.12.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -172,13 +181,22 @@
|
|||
"version": "1.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "SwiftSoup",
|
||||
"repositoryURL": "https://github.com/scinfu/SwiftSoup.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "41e7c263fb8c277e980ebcb9b0b5f6031d3d4886",
|
||||
"version": "2.4.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "Introspect",
|
||||
"repositoryURL": "https://github.com/siteline/SwiftUI-Introspect.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "2e09be8af614401bc9f87d40093ec19ce56ccaf2",
|
||||
"version": "0.1.3"
|
||||
"revision": "f2616860a41f9d9932da412a8978fec79c06fe24",
|
||||
"version": "0.1.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -144,7 +144,7 @@ extension SceneCoordinator {
|
|||
case popover(sourceView: UIView)
|
||||
case panModal
|
||||
case custom(transitioningDelegate: UIViewControllerTransitioningDelegate)
|
||||
case customPush
|
||||
case customPush(animated: Bool)
|
||||
case safariPresent(animated: Bool, completion: (() -> Void)? = nil)
|
||||
case alertController(animated: Bool, completion: (() -> Void)? = nil)
|
||||
case activityViewControllerPresent(animated: Bool, completion: (() -> Void)? = nil)
|
||||
|
@ -158,7 +158,7 @@ extension SceneCoordinator {
|
|||
case mastodonServerRules(viewModel: MastodonServerRulesViewModel)
|
||||
case mastodonConfirmEmail(viewModel: MastodonConfirmEmailViewModel)
|
||||
case mastodonResendEmail(viewModel: MastodonResendEmailViewModel)
|
||||
case mastodonWebView(viewModel:WebViewModel)
|
||||
case mastodonWebView(viewModel: WebViewModel)
|
||||
|
||||
// search
|
||||
case searchDetail(viewModel: SearchDetailViewModel)
|
||||
|
@ -184,6 +184,8 @@ extension SceneCoordinator {
|
|||
|
||||
// report
|
||||
case report(viewModel: ReportViewModel)
|
||||
case reportServerRules(viewModel: ReportServerRulesViewModel)
|
||||
case reportStatus(viewModel: ReportStatusViewModel)
|
||||
case reportSupplementary(viewModel: ReportSupplementaryViewModel)
|
||||
case reportResult(viewModel: ReportResultViewModel)
|
||||
|
||||
|
@ -309,7 +311,7 @@ extension SceneCoordinator {
|
|||
if scene.isOnboarding {
|
||||
return OnboardingNavigationController(rootViewController: viewController)
|
||||
} else {
|
||||
return UINavigationController(rootViewController: viewController)
|
||||
return AdaptiveStatusBarStyleNavigationController(rootViewController: viewController)
|
||||
}
|
||||
}()
|
||||
modalNavigationController.modalPresentationCapturesStatusBarAppearance = true
|
||||
|
@ -339,10 +341,10 @@ extension SceneCoordinator {
|
|||
viewController.transitioningDelegate = transitioningDelegate
|
||||
(splitViewController ?? presentingViewController)?.present(viewController, animated: true, completion: nil)
|
||||
|
||||
case .customPush:
|
||||
case .customPush(let animated):
|
||||
// set delegate in view controller
|
||||
assert(sender?.navigationController?.delegate != nil)
|
||||
sender?.navigationController?.pushViewController(viewController, animated: true)
|
||||
sender?.navigationController?.pushViewController(viewController, animated: animated)
|
||||
|
||||
case .safariPresent(let animated, let completion):
|
||||
if UserDefaults.shared.preferredUsingDefaultBrowser, case let .safari(url) = scene {
|
||||
|
@ -368,10 +370,10 @@ extension SceneCoordinator {
|
|||
splitViewController?.contentSplitViewController.currentSupplementaryTab = tab
|
||||
|
||||
splitViewController?.compactMainTabBarViewController.selectedIndex = tab.rawValue
|
||||
splitViewController?.compactMainTabBarViewController.currentTab.value = tab
|
||||
splitViewController?.compactMainTabBarViewController.currentTab = tab
|
||||
|
||||
tabBarController.selectedIndex = tab.rawValue
|
||||
tabBarController.currentTab.value = tab
|
||||
tabBarController.currentTab = tab
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -447,6 +449,14 @@ private extension SceneCoordinator {
|
|||
let _viewController = ReportViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
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):
|
||||
let _viewController = ReportSupplementaryViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -29,7 +29,7 @@ extension PickServerSection {
|
|||
weak dependency,
|
||||
weak pickServerCellDelegate
|
||||
] tableView, indexPath, item -> UITableViewCell? in
|
||||
guard let dependency = dependency else { return nil }
|
||||
guard let _ = dependency else { return nil }
|
||||
switch item {
|
||||
case .header:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: OnboardingHeadlineTableViewCell.self), for: indexPath) as! OnboardingHeadlineTableViewCell
|
||||
|
|
|
@ -69,10 +69,10 @@ extension SearchHistorySection {
|
|||
let trendHeaderRegister = UICollectionView.SupplementaryRegistration<SearchHistorySectionHeaderCollectionReusableView>(elementKind: UICollectionView.elementKindSectionHeader) { [weak dataSource] supplementaryView, elementKind, indexPath in
|
||||
supplementaryView.delegate = configuration.searchHistorySectionHeaderCollectionReusableViewDelegate
|
||||
|
||||
guard let dataSource = dataSource else { return }
|
||||
let sections = dataSource.snapshot().sectionIdentifiers
|
||||
guard indexPath.section < sections.count else { return }
|
||||
let section = sections[indexPath.section]
|
||||
guard let _ = dataSource else { return }
|
||||
// let sections = dataSource.snapshot().sectionIdentifiers
|
||||
// guard indexPath.section < sections.count else { return }
|
||||
// let section = sections[indexPath.section]
|
||||
}
|
||||
|
||||
dataSource.supplementaryViewProvider = { (collectionView: UICollectionView, elementKind: String, indexPath: IndexPath) in
|
||||
|
|
|
@ -21,26 +21,7 @@ extension SearchSection {
|
|||
) -> UICollectionViewDiffableDataSource<SearchSection, SearchItem> {
|
||||
|
||||
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>(
|
||||
|
|
|
@ -51,7 +51,7 @@ extension SettingsSection {
|
|||
}
|
||||
cell.delegate = settingsAppearanceTableViewCellDelegate
|
||||
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
|
||||
cell.delegate = settingsToggleCellDelegate
|
||||
managedObjectContext.performAndWait {
|
||||
|
|
|
@ -9,53 +9,6 @@ import Foundation
|
|||
import CoreDataStack
|
||||
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 {
|
||||
|
||||
public var profileURL: URL {
|
||||
|
|
|
@ -7,24 +7,12 @@
|
|||
|
||||
import MastodonSDK
|
||||
|
||||
extension Mastodon.Entity.Tag: Hashable {
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(name)
|
||||
}
|
||||
|
||||
public static func == (lhs: Mastodon.Entity.Tag, rhs: Mastodon.Entity.Tag) -> Bool {
|
||||
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, +)
|
||||
}
|
||||
|
||||
}
|
||||
//extension Mastodon.Entity.Tag: Hashable {
|
||||
// public func hash(into hasher: inout Hasher) {
|
||||
// hasher.combine(name)
|
||||
// }
|
||||
//
|
||||
// public static func == (lhs: Mastodon.Entity.Tag, rhs: Mastodon.Entity.Tag) -> Bool {
|
||||
// return lhs.name == rhs.name
|
||||
// }
|
||||
//}
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
//
|
||||
// ThemeService+Appearance.swift
|
||||
// ThemeService.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-7-19.
|
||||
// Created by MainasuK on 2022-4-13.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import MastodonCommon
|
||||
import MastodonUI
|
||||
|
||||
extension ThemeService {
|
||||
func set(themeName: ThemeName) {
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,7 @@
|
|||
// Generated using Sourcery 1.6.1 — https://github.com/krzysztofzablocki/Sourcery
|
||||
// DO NOT EDIT
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// sourcery:inline:UserTimelineViewController.AutoGenerateTableViewDelegate
|
||||
// sourcery:inline:DiscoveryCommunityViewController.AutoGenerateTableViewDelegate
|
||||
|
||||
// Generated using Sourcery
|
||||
// DO NOT EDIT
|
||||
|
@ -33,3 +26,13 @@ func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith con
|
|||
}
|
||||
// sourcery:end
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -30,7 +30,7 @@
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.3.0</string>
|
||||
<string>1.4.2</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
@ -43,7 +43,7 @@
|
|||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>109</string>
|
||||
<string>127</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,17 +5,3 @@
|
|||
// 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 }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -38,11 +38,15 @@ extension DataSourceFacade {
|
|||
meta: Meta
|
||||
) async {
|
||||
switch meta {
|
||||
case .url(_, _, let url, _),
|
||||
.mention(_, let url, _) where url.lowercased().hasPrefix("http"):
|
||||
// note:
|
||||
// some server mark the normal url as "u-url" class. highlighted content is a URL
|
||||
guard let url = URL(string: url) else { return }
|
||||
case .url(_, _, let url, _),
|
||||
.mention(_, let url, _) where url.lowercased().hasPrefix("http"):
|
||||
// fix non-ascii character URL link can not open issue
|
||||
guard let url = URL(string: url) ?? URL(string: url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? url) else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
if let domain = provider.context.authenticationService.activeMastodonAuthenticationBox.value?.domain, url.host == domain,
|
||||
url.pathComponents.count >= 4,
|
||||
url.pathComponents[0] == "/",
|
||||
|
|
|
@ -122,12 +122,12 @@ extension DataSourceFacade {
|
|||
let barButtonItem: UIBarButtonItem?
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func createProfileActionMenu(
|
||||
dependency: NeedsDependency,
|
||||
user: ManagedObjectRecord<MastodonUser>
|
||||
) -> UIMenu {
|
||||
var children: [UIMenuElement] = []
|
||||
// @MainActor
|
||||
// static func createProfileActionMenu(
|
||||
// dependency: NeedsDependency,
|
||||
// user: ManagedObjectRecord<MastodonUser>
|
||||
// ) -> UIMenu {
|
||||
// var children: [UIMenuElement] = []
|
||||
// let name = mastodonUser.displayNameWithFallback
|
||||
//
|
||||
// if let shareUser = shareUser {
|
||||
|
@ -339,9 +339,9 @@ extension DataSourceFacade {
|
|||
// }
|
||||
// children.append(deleteAction)
|
||||
// }
|
||||
|
||||
return UIMenu(title: "", options: [], children: children)
|
||||
}
|
||||
//
|
||||
// return UIMenu(title: "", options: [], children: children)
|
||||
// }
|
||||
|
||||
static func createActivityViewController(
|
||||
dependency: NeedsDependency,
|
||||
|
|
|
@ -99,7 +99,7 @@ extension DataSourceFacade {
|
|||
|
||||
try await managedObjectContext.performChanges {
|
||||
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
|
||||
request.predicate = SearchHistory.predicate(
|
||||
domain: authenticationBox.domain,
|
||||
|
|
|
@ -286,24 +286,8 @@ extension DataSourceFacade {
|
|||
try await dependency.context.managedObjectContext.perform {
|
||||
guard let _status = status.object(in: dependency.context.managedObjectContext) else { return }
|
||||
let status = _status.reblog ?? _status
|
||||
|
||||
let allToggled = status.isContentSensitiveToggled && status.isMediaSensitiveToggled
|
||||
|
||||
status.update(isContentSensitiveToggled: !allToggled)
|
||||
status.update(isMediaSensitiveToggled: !allToggled)
|
||||
status.update(isSensitiveToggled: !status.isSensitiveToggled)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// }
|
||||
// }
|
||||
|
||||
}
|
||||
|
|
|
@ -135,7 +135,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Med
|
|||
let status = _status.reblog ?? _status
|
||||
return NotificationMediaTransitionContext(
|
||||
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
|
||||
return NotificationMediaTransitionContext(
|
||||
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,
|
||||
user: user
|
||||
)
|
||||
case .notification(let notification):
|
||||
case .notification:
|
||||
assertionFailure("TODO")
|
||||
default:
|
||||
assertionFailure("TODO")
|
||||
|
|
|
@ -143,12 +143,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & MediaPrev
|
|||
return
|
||||
}
|
||||
|
||||
let managedObjectContext = self.context.managedObjectContext
|
||||
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
|
||||
}
|
||||
let needsToggleMediaSensitive = await !statusView.viewModel.isMediaReveal
|
||||
|
||||
guard !needsToggleMediaSensitive else {
|
||||
try await DataSourceFacade.responseToToggleSensitiveAction(
|
||||
|
@ -499,7 +494,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider {
|
|||
provider: self,
|
||||
user: user
|
||||
)
|
||||
case .notification(let notification):
|
||||
case .notification:
|
||||
assertionFailure("TODO")
|
||||
default:
|
||||
assertionFailure("TODO")
|
||||
|
|
|
@ -115,7 +115,7 @@ extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvid
|
|||
|
||||
guard let provider = self as? (DataSourceProvider & MediaPreviewableViewController) else { return }
|
||||
guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow,
|
||||
let cell = tableView.cellForRow(at: indexPathForSelectedRow) as? StatusTableViewCell
|
||||
let cell = tableView.cellForRow(at: indexPathForSelectedRow) as? StatusViewContainerTableViewCell
|
||||
else { return }
|
||||
|
||||
guard let mediaView = cell.statusView.mediaGridContainerView.mediaViews.first else { return }
|
||||
|
|
|
@ -138,7 +138,7 @@ extension TableViewControllerNavigateableCore where Self: DataSourceProvider {
|
|||
target: .status,
|
||||
status: record
|
||||
)
|
||||
case .notification(let record):
|
||||
case .notification:
|
||||
assertionFailure()
|
||||
default:
|
||||
assertionFailure()
|
||||
|
|
|
@ -93,7 +93,7 @@ extension UITableViewDelegate where Self: DataSourceProvider & MediaPreviewableV
|
|||
guard let image = mediaView.thumbnail(),
|
||||
let assetURLString = mediaView.configuration?.assetURL,
|
||||
let assetURL = URL(string: assetURLString),
|
||||
let resourceType = mediaView.configuration?.resourceType
|
||||
let _ = mediaView.configuration?.resourceType
|
||||
else {
|
||||
// not provide preview unless thumbnail ready
|
||||
return nil
|
||||
|
|
|
@ -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";
|
|
@ -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";
|
|
@ -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";
|
|
@ -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";
|
|
@ -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";
|
|
@ -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";
|
|
@ -118,7 +118,7 @@ extension AccountListViewController {
|
|||
|
||||
// the presentingViewController may deinit.
|
||||
// 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 }
|
||||
|
||||
self.hasLoaded = true
|
||||
|
|
|
@ -77,7 +77,7 @@ extension AutoCompleteViewModel.State {
|
|||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
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 searchType = AutoCompleteViewModel.SearchType(inputText: searchText) ?? .default
|
||||
|
|
|
@ -10,6 +10,7 @@ import UIKit
|
|||
import Combine
|
||||
import MastodonAsset
|
||||
import MastodonLocalization
|
||||
import MastodonUI
|
||||
|
||||
protocol ComposeStatusPollOptionCollectionViewCellDelegate: AnyObject {
|
||||
func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textFieldDidBeginEditing textField: UITextField)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)")
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -28,7 +28,7 @@ final class HashtagTimelineViewController: UIViewController, NeedsDependency, Me
|
|||
|
||||
let composeBarButtonItem: UIBarButtonItem = {
|
||||
let barButtonItem = UIBarButtonItem()
|
||||
barButtonItem.image = UIImage(systemName: "square.and.pencil")?.withRenderingMode(.alwaysTemplate)
|
||||
barButtonItem.image = Asset.ObjectsAndTools.squareAndPencil.image.withRenderingMode(.alwaysTemplate)
|
||||
return barButtonItem
|
||||
}()
|
||||
|
||||
|
@ -84,7 +84,6 @@ extension HashtagTimelineViewController {
|
|||
])
|
||||
|
||||
tableView.delegate = self
|
||||
// tableView.prefetchDataSource = self
|
||||
viewModel.setupDiffableDataSource(
|
||||
tableView: tableView,
|
||||
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
|
||||
extension HashtagTimelineViewController: UITableViewDelegate, AutoGenerateTableViewDelegate {
|
||||
// sourcery:inline:HashtagTimelineViewController.AutoGenerateTableViewDelegate
|
||||
|
@ -206,82 +184,23 @@ extension HashtagTimelineViewController: UITableViewDelegate, AutoGenerateTableV
|
|||
}
|
||||
// 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
|
||||
extension HashtagTimelineViewController: StatusTableViewCellDelegate { }
|
||||
|
||||
// MARK: - AVPlayerViewControllerDelegate
|
||||
//extension HashtagTimelineViewController: AVPlayerViewControllerDelegate {
|
||||
//
|
||||
// func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
|
||||
// aspectPlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
|
||||
// }
|
||||
//
|
||||
// func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
|
||||
// aspectPlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
|
||||
// }
|
||||
//
|
||||
//}
|
||||
extension HashtagTimelineViewController {
|
||||
override var keyCommands: [UIKeyCommand]? {
|
||||
return navigationKeyCommands + statusNavigationKeyCommands
|
||||
}
|
||||
}
|
||||
// MARK: - StatusTableViewControllerNavigateable
|
||||
extension HashtagTimelineViewController: StatusTableViewControllerNavigateable {
|
||||
@objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
|
||||
navigateKeyCommandHandler(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)
|
||||
// }
|
||||
//}
|
||||
@objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
|
||||
statusKeyCommandHandler(sender)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -58,6 +58,10 @@ extension HomeTimelineViewController {
|
|||
guard let self = self else { return }
|
||||
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
|
||||
guard let self = self else { return }
|
||||
self.showConfirmEmail(action)
|
||||
|
@ -182,7 +186,7 @@ extension HomeTimelineViewController {
|
|||
}
|
||||
|
||||
func match(item: StatusItem) -> Bool {
|
||||
let authenticationBox = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value
|
||||
// let authenticationBox = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value
|
||||
switch item {
|
||||
case .feed(let record):
|
||||
guard let feed = record.object(in: AppContext.shared.managedObjectContext) else { return false }
|
||||
|
@ -295,6 +299,33 @@ extension HomeTimelineViewController {
|
|||
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) {
|
||||
let mastodonConfirmEmailViewModel = MastodonConfirmEmailViewModel()
|
||||
coordinator.present(scene: .mastodonConfirmEmail(viewModel: mastodonConfirmEmailViewModel), from: nil, transition: .modal(animated: true, completion: nil))
|
||||
|
|
|
@ -17,6 +17,7 @@ import AlamofireImage
|
|||
import StoreKit
|
||||
import MastodonAsset
|
||||
import MastodonLocalization
|
||||
import MastodonUI
|
||||
|
||||
final class HomeTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
|
||||
|
||||
|
@ -50,19 +51,11 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency, Media
|
|||
let settingBarButtonItem: UIBarButtonItem = {
|
||||
let barButtonItem = UIBarButtonItem()
|
||||
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
|
||||
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 = ControlContainableTableView()
|
||||
tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self))
|
||||
|
@ -108,14 +101,14 @@ extension HomeTimelineViewController {
|
|||
guard let self = self else { return }
|
||||
#if DEBUG
|
||||
// display debug menu
|
||||
self.navigationItem.leftBarButtonItem = {
|
||||
self.navigationItem.rightBarButtonItem = {
|
||||
let barButtonItem = UIBarButtonItem()
|
||||
barButtonItem.image = UIImage(systemName: "ellipsis.circle")
|
||||
barButtonItem.menu = self.debugMenu
|
||||
return barButtonItem
|
||||
}()
|
||||
#else
|
||||
self.navigationItem.leftBarButtonItem = displaySettingBarButtonItem ? self.settingBarButtonItem : nil
|
||||
self.navigationItem.rightBarButtonItem = displaySettingBarButtonItem ? self.settingBarButtonItem : nil
|
||||
#endif
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
@ -132,16 +125,6 @@ extension HomeTimelineViewController {
|
|||
titleView.button.menu = self.debugMenu
|
||||
#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
|
||||
titleView.delegate = self
|
||||
|
||||
|
@ -411,17 +394,6 @@ extension HomeTimelineViewController {
|
|||
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) {
|
||||
guard viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self) else {
|
||||
sender.endRefreshing()
|
||||
|
|
|
@ -33,7 +33,6 @@ final class HomeTimelineViewModel: NSObject {
|
|||
@Published var lastAutomaticFetchTimestamp: Date? = nil
|
||||
@Published var scrollPositionRecord: ScrollPositionRecord? = nil
|
||||
@Published var displaySettingBarButtonItem = true
|
||||
@Published var displayComposeBarButtonItem = true
|
||||
|
||||
weak var tableView: UITableView?
|
||||
weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?
|
||||
|
|
|
@ -108,6 +108,8 @@ extension HomeTimelineNavigationBarTitleView {
|
|||
logoButton.setImage(Asset.Asset.mastodonTextLogo.image.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
logoButton.contentMode = .center
|
||||
logoButton.isHidden = false
|
||||
logoButton.accessibilityLabel = "Logo Button" // TODO :i18n
|
||||
logoButton.accessibilityHint = "Tap to scroll to top and tap again to previous location"
|
||||
case .newPostButton:
|
||||
configureButton(
|
||||
title: L10n.Scene.HomeTimeline.NavigationBarState.newPosts,
|
||||
|
@ -115,6 +117,7 @@ extension HomeTimelineNavigationBarTitleView {
|
|||
backgroundColor: Asset.Colors.brandBlue.color
|
||||
)
|
||||
button.isHidden = false
|
||||
button.accessibilityLabel = L10n.Scene.HomeTimeline.NavigationBarState.newPosts
|
||||
case .offlineButton:
|
||||
configureButton(
|
||||
title: L10n.Scene.HomeTimeline.NavigationBarState.offline,
|
||||
|
@ -122,12 +125,14 @@ extension HomeTimelineNavigationBarTitleView {
|
|||
backgroundColor: Asset.Colors.danger.color
|
||||
)
|
||||
button.isHidden = false
|
||||
button.accessibilityLabel = L10n.Scene.HomeTimeline.NavigationBarState.offline
|
||||
case .publishingPostLabel:
|
||||
label.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
label.textColor = Asset.Colors.Label.primary.color
|
||||
label.text = L10n.Scene.HomeTimeline.NavigationBarState.publishing
|
||||
label.textAlignment = .center
|
||||
label.isHidden = false
|
||||
button.accessibilityLabel = L10n.Scene.HomeTimeline.NavigationBarState.publishing
|
||||
case .publishedButton:
|
||||
blockingState = state
|
||||
configureButton(
|
||||
|
@ -136,6 +141,7 @@ extension HomeTimelineNavigationBarTitleView {
|
|||
backgroundColor: Asset.Colors.successGreen.color
|
||||
)
|
||||
button.isHidden = false
|
||||
button.accessibilityLabel = L10n.Scene.HomeTimeline.NavigationBarState.published
|
||||
|
||||
let presentDuration: TimeInterval = 0.33
|
||||
let scaleAnimator = UIViewPropertyAnimator(duration: presentDuration, timingParameters: UISpringTimingParameters())
|
||||
|
|
|
@ -39,6 +39,8 @@ final class NotificationTimelineViewController: UIViewController, NeedsDependenc
|
|||
return tableView
|
||||
}()
|
||||
|
||||
let cellFrameCache = NSCache<NSNumber, NSValue>()
|
||||
|
||||
deinit {
|
||||
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 {
|
||||
|
||||
@objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
|
||||
|
@ -162,6 +174,13 @@ extension NotificationTimelineViewController: UITableViewDelegate, AutoGenerateT
|
|||
|
||||
// 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) {
|
||||
guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else {
|
||||
return
|
||||
|
@ -173,6 +192,10 @@ extension NotificationTimelineViewController: UITableViewDelegate, AutoGenerateT
|
|||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
cacheCellFrame(tableView: tableView, didEndDisplaying: cell, forRowAt: indexPath)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - NotificationTableViewCellDelegate
|
||||
|
|
|
@ -229,6 +229,11 @@ extension MastodonConfirmEmailViewController {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - PanPopableViewController
|
||||
extension MastodonConfirmEmailViewController: PanPopableViewController {
|
||||
var isPanPopable: Bool { false }
|
||||
}
|
||||
|
||||
// MARK: - OnboardingViewControllerAppearance
|
||||
extension MastodonConfirmEmailViewController: OnboardingViewControllerAppearance { }
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ import GameController
|
|||
import AuthenticationServices
|
||||
import MastodonAsset
|
||||
import MastodonLocalization
|
||||
import MastodonUI
|
||||
|
||||
final class MastodonPickServerViewController: UIViewController, NeedsDependency {
|
||||
|
||||
|
@ -91,7 +92,7 @@ extension MastodonPickServerViewController {
|
|||
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.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor),
|
||||
])
|
||||
|
||||
navigationActionView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
@ -106,10 +107,10 @@ extension MastodonPickServerViewController {
|
|||
])
|
||||
|
||||
navigationActionView
|
||||
.observe(\.bounds, options: [.initial, .new]) { [weak self] navigationActionView, _ in
|
||||
.observe(\.bounds, options: [.initial, .new]) { [weak self] _, _ in
|
||||
guard let self = self else { return }
|
||||
let inset = navigationActionView.frame.height
|
||||
self.tableView.contentInset.bottom = inset
|
||||
let inset = self.navigationActionView.frame.height
|
||||
self.viewModel.additionalTableViewInsets.bottom = inset
|
||||
}
|
||||
.store(in: &observations)
|
||||
|
||||
|
@ -145,6 +146,14 @@ extension MastodonPickServerViewController {
|
|||
pickServerCellDelegate: self
|
||||
)
|
||||
|
||||
KeyboardResponderService
|
||||
.configure(
|
||||
scrollView: tableView,
|
||||
layoutNeedsUpdate: viewModel.viewDidAppear.eraseToAnyPublisher(),
|
||||
additionalSafeAreaInsets: viewModel.$additionalTableViewInsets.eraseToAnyPublisher()
|
||||
)
|
||||
.store(in: &disposeBag)
|
||||
|
||||
viewModel
|
||||
.selectedServer
|
||||
.map { $0 != nil }
|
||||
|
@ -238,6 +247,7 @@ extension MastodonPickServerViewController {
|
|||
super.viewDidAppear(animated)
|
||||
|
||||
tableView.flashScrollIndicators()
|
||||
viewModel.viewDidAppear.send()
|
||||
}
|
||||
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
|
@ -332,7 +342,10 @@ extension MastodonPickServerViewController {
|
|||
) else {
|
||||
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
|
||||
guard let self = self else { return nil }
|
||||
|
@ -344,7 +357,13 @@ extension MastodonPickServerViewController {
|
|||
clientSecret: authenticateInfo.clientSecret,
|
||||
redirectURI: authenticateInfo.redirectURI
|
||||
)
|
||||
.map { MastodonPickServerViewModel.SignUpResponseThird(instance: instance, authenticateInfo: authenticateInfo, applicationToken: $0) }
|
||||
.map {
|
||||
MastodonPickServerViewModel.SignUpResponseThird(
|
||||
instance: instance,
|
||||
authenticateInfo: authenticateInfo,
|
||||
applicationToken: $0
|
||||
)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.switchToLatest()
|
||||
|
@ -416,28 +435,6 @@ extension MastodonPickServerViewController: UITableViewDelegate {
|
|||
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? {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
|
||||
let snapshot = diffableDataSource.snapshot()
|
||||
|
|
|
@ -45,6 +45,8 @@ class MastodonPickServerViewModel: NSObject {
|
|||
let indexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([])
|
||||
let unindexedServers = CurrentValueSubject<[Mastodon.Entity.Server]?, Never>([]) // set nil when loading
|
||||
let viewWillAppear = PassthroughSubject<Void, Never>()
|
||||
let viewDidAppear = CurrentValueSubject<Void, Never>(Void())
|
||||
@Published var additionalTableViewInsets: UIEdgeInsets = .zero
|
||||
|
||||
// output
|
||||
var diffableDataSource: UITableViewDiffableDataSource<PickServerSection, PickServerItem>?
|
||||
|
@ -114,8 +116,11 @@ extension MastodonPickServerViewModel {
|
|||
if self.mode == .signUp {
|
||||
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]>()
|
||||
for language in Locale.preferredLanguages {
|
||||
let local = Locale(identifier: language)
|
||||
|
@ -125,14 +130,22 @@ extension MastodonPickServerViewModel {
|
|||
// append to dict
|
||||
languageToServersMapping[languageCode] = indexedServers
|
||||
.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
|
||||
.filter { server in
|
||||
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] = []
|
||||
for key in languageToServersMapping.keys {
|
||||
|
|
|
@ -185,12 +185,12 @@ extension PickServerServerSectionTableHeaderView {
|
|||
|
||||
override func accessibilityElementCount() -> Int {
|
||||
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? {
|
||||
guard let item = collectionView.cellForItem(at: IndexPath(item: index, section: 0)) else { return nil }
|
||||
return item
|
||||
if let item = collectionView.cellForItem(at: IndexPath(item: index, section: 0)) { return item }
|
||||
return searchTextField
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -115,6 +115,7 @@ extension MastodonRegisterTextFieldTableViewCell {
|
|||
label.font = MastodonRegisterTextFieldTableViewCell.textFieldLabelFont
|
||||
label.textColor = Asset.Colors.Label.primary.color
|
||||
label.text = text
|
||||
label.lineBreakMode = .byTruncatingMiddle
|
||||
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerView.addSubview(label)
|
||||
|
@ -123,6 +124,7 @@ extension MastodonRegisterTextFieldTableViewCell {
|
|||
label.leadingAnchor.constraint(equalTo: paddingView.trailingAnchor),
|
||||
containerView.trailingAnchor.constraint(equalTo: label.trailingAnchor, constant: 16),
|
||||
label.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
|
||||
label.widthAnchor.constraint(lessThanOrEqualToConstant: 180).priority(.required - 1),
|
||||
])
|
||||
return containerView
|
||||
}()
|
||||
|
|
|
@ -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
|
|
@ -11,6 +11,8 @@ import MastodonSDK
|
|||
import os.log
|
||||
import PhotosUI
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
import MastodonUI
|
||||
import MastodonAsset
|
||||
import MastodonLocalization
|
||||
|
||||
|
@ -27,6 +29,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
|
|||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
var viewModel: MastodonRegisterViewModel!
|
||||
private(set) lazy var mastodonRegisterView = MastodonRegisterView(viewModel: viewModel)
|
||||
|
||||
// picker
|
||||
private(set) lazy var imagePicker: PHPickerViewController = {
|
||||
|
@ -51,22 +54,6 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
|
|||
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()
|
||||
navigationActionView.backgroundColor = Asset.Scene.Onboarding.background.color
|
||||
|
@ -87,17 +74,21 @@ extension MastodonRegisterViewController {
|
|||
navigationItem.leftBarButtonItem = UIBarButtonItem()
|
||||
|
||||
setupOnboardingAppearance()
|
||||
viewModel.backgroundColor = view.backgroundColor ?? .clear
|
||||
defer {
|
||||
setupNavigationBarBackgroundView()
|
||||
}
|
||||
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(tableView)
|
||||
let hostingViewController = UIHostingController(rootView: mastodonRegisterView)
|
||||
hostingViewController.view.preservesSuperviewLayoutMargins = true
|
||||
addChild(hostingViewController)
|
||||
hostingViewController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(hostingViewController.view)
|
||||
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),
|
||||
hostingViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
hostingViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
hostingViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
hostingViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
|
||||
navigationActionView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
@ -115,7 +106,7 @@ extension MastodonRegisterViewController {
|
|||
.observe(\.bounds, options: [.initial, .new]) { [weak self] navigationActionView, _ in
|
||||
guard let self = self else { return }
|
||||
let inset = navigationActionView.frame.height
|
||||
self.tableView.contentInset.bottom = inset
|
||||
self.viewModel.bottomPaddingHeight = inset
|
||||
}
|
||||
.store(in: &observations)
|
||||
|
||||
|
@ -130,18 +121,13 @@ extension MastodonRegisterViewController {
|
|||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
viewModel.setupDiffableDataSource(tableView: tableView)
|
||||
|
||||
// KeyboardResponderService
|
||||
// .configure(
|
||||
// scrollView: tableView,
|
||||
// layoutNeedsUpdate: viewModel.viewDidAppear.eraseToAnyPublisher()
|
||||
// )
|
||||
// .store(in: &disposeBag)
|
||||
|
||||
// gesture
|
||||
view.addGestureRecognizer(tapGestureRecognizer)
|
||||
tapGestureRecognizer.addTarget(self, action: #selector(tapGestureRecognizerHandler))
|
||||
viewModel.endEditing
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.view.endEditing(true)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
// // return
|
||||
// if viewModel.approvalRequired {
|
||||
|
@ -149,80 +135,22 @@ extension MastodonRegisterViewController {
|
|||
// } else {
|
||||
// passwordTextField.returnKeyType = .done
|
||||
// }
|
||||
//
|
||||
// viewModel.usernameValidateState
|
||||
// .receive(on: DispatchQueue.main)
|
||||
// .sink { [weak self] validateState in
|
||||
// guard let self = self else { return }
|
||||
// self.setTextFieldValidAppearance(self.usernameTextField, validateState: validateState)
|
||||
// }
|
||||
// .store(in: &disposeBag)
|
||||
// viewModel.usernameErrorPrompt
|
||||
// .receive(on: DispatchQueue.main)
|
||||
// .sink { [weak self] prompt in
|
||||
// guard let self = self else { return }
|
||||
// self.usernameErrorPromptLabel.attributedText = prompt
|
||||
// }
|
||||
// .store(in: &disposeBag)
|
||||
// viewModel.displayNameValidateState
|
||||
// .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.$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
|
||||
.receive(on: DispatchQueue.main)
|
||||
|
@ -260,10 +188,6 @@ extension MastodonRegisterViewController {
|
|||
|
||||
extension MastodonRegisterViewController {
|
||||
|
||||
@objc private func tapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
|
||||
view.endEditing(true)
|
||||
}
|
||||
|
||||
@objc private func backButtonPressed(_ sender: UIButton) {
|
||||
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
|
||||
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
|
||||
// }
|
||||
}
|
||||
|
|
|
@ -164,51 +164,6 @@ extension MastodonRegisterViewModel {
|
|||
.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(
|
||||
cell: MastodonRegisterTextFieldTableViewCell,
|
||||
validateState: Published<ValidateState>.Publisher
|
||||
|
|
|
@ -12,7 +12,7 @@ import UIKit
|
|||
import MastodonAsset
|
||||
import MastodonLocalization
|
||||
|
||||
final class MastodonRegisterViewModel {
|
||||
final class MastodonRegisterViewModel: ObservableObject {
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
// input
|
||||
|
@ -23,6 +23,7 @@ final class MastodonRegisterViewModel {
|
|||
let applicationToken: Mastodon.Entity.Token
|
||||
let viewDidAppear = CurrentValueSubject<Void, Never>(Void())
|
||||
|
||||
@Published var backgroundColor: UIColor = Asset.Scene.Onboarding.background.color
|
||||
@Published var avatarImage: UIImage? = nil
|
||||
@Published var name = ""
|
||||
@Published var username = ""
|
||||
|
@ -30,10 +31,12 @@ final class MastodonRegisterViewModel {
|
|||
@Published var password = ""
|
||||
@Published var reason = ""
|
||||
|
||||
let usernameErrorPrompt = CurrentValueSubject<NSAttributedString?, Never>(nil)
|
||||
let emailErrorPrompt = CurrentValueSubject<NSAttributedString?, Never>(nil)
|
||||
let passwordErrorPrompt = CurrentValueSubject<NSAttributedString?, Never>(nil)
|
||||
let reasonErrorPrompt = CurrentValueSubject<NSAttributedString?, Never>(nil)
|
||||
@Published var usernameErrorPrompt: String? = nil
|
||||
@Published var emailErrorPrompt: String? = nil
|
||||
@Published var passwordErrorPrompt: String? = nil
|
||||
@Published var reasonErrorPrompt: String? = nil
|
||||
|
||||
@Published var bottomPaddingHeight: CGFloat = .zero
|
||||
|
||||
// output
|
||||
var diffableDataSource: UITableViewDiffableDataSource<RegisterSection, RegisterItem>?
|
||||
|
@ -51,6 +54,7 @@ final class MastodonRegisterViewModel {
|
|||
@Published var error: Error? = nil
|
||||
|
||||
let avatarMediaMenuActionPublisher = PassthroughSubject<AvatarMediaMenuAction, Never>()
|
||||
let endEditing = PassthroughSubject<Void, Never>()
|
||||
|
||||
init(
|
||||
context: AppContext,
|
||||
|
@ -97,45 +101,46 @@ final class MastodonRegisterViewModel {
|
|||
.assign(to: \.usernameValidateState, on: self)
|
||||
.store(in: &disposeBag)
|
||||
|
||||
// TODO: check username available
|
||||
// username
|
||||
// .filter { !$0.isEmpty }
|
||||
// .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
||||
// .removeDuplicates()
|
||||
// .compactMap { [weak self] text -> AnyPublisher<Result<Mastodon.Response.Content<Mastodon.Entity.Account>, Error>, Never>? in
|
||||
// guard let self = self else { return nil }
|
||||
// let query = Mastodon.API.Account.AccountLookupQuery(acct: text)
|
||||
// return context.apiService.accountLookup(domain: domain, query: query, authorization: self.applicationAuthorization)
|
||||
// .map {
|
||||
// response -> Result<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> in
|
||||
// Result.success(response)
|
||||
// }
|
||||
// .catch { error in
|
||||
// Just(Result.failure(error))
|
||||
// }
|
||||
// .eraseToAnyPublisher()
|
||||
// }
|
||||
// .switchToLatest()
|
||||
// .sink { [weak self] result in
|
||||
// guard let self = self else { return }
|
||||
// switch result {
|
||||
// case .success:
|
||||
// let text = L10n.Scene.Register.Error.Reason.taken(L10n.Scene.Register.Error.Item.username)
|
||||
// self.usernameErrorPrompt.value = MastodonRegisterViewModel.errorPromptAttributedString(for: text)
|
||||
// self.usernameValidateState.value = .invalid
|
||||
// case .failure:
|
||||
// break
|
||||
// }
|
||||
// }
|
||||
// .store(in: &disposeBag)
|
||||
//
|
||||
// usernameValidateState
|
||||
// .sink { [weak self] validateState in
|
||||
// if validateState == .valid {
|
||||
// self?.usernameErrorPrompt.value = nil
|
||||
// }
|
||||
// }
|
||||
// .store(in: &disposeBag)
|
||||
// check username available
|
||||
$username
|
||||
.filter { !$0.isEmpty }
|
||||
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
||||
.removeDuplicates()
|
||||
.compactMap { [weak self] text -> AnyPublisher<Result<Mastodon.Response.Content<Mastodon.Entity.Account>, Error>, Never>? in
|
||||
guard let self = self else { return nil }
|
||||
let query = Mastodon.API.Account.AccountLookupQuery(acct: text)
|
||||
return context.apiService.accountLookup(domain: domain, query: query, authorization: self.applicationAuthorization)
|
||||
.map {
|
||||
response -> Result<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> in
|
||||
Result.success(response)
|
||||
}
|
||||
.catch { error in
|
||||
Just(Result.failure(error))
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.switchToLatest()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
switch result {
|
||||
case .success:
|
||||
let text = L10n.Scene.Register.Error.Reason.taken(L10n.Scene.Register.Error.Item.username)
|
||||
self.usernameErrorPrompt = text
|
||||
self.usernameValidateState = .invalid
|
||||
case .failure:
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
$usernameValidateState
|
||||
.sink { [weak self] validateState in
|
||||
if validateState == .valid {
|
||||
self?.usernameErrorPrompt = nil
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
$email
|
||||
.map { email in
|
||||
|
@ -163,27 +168,31 @@ final class MastodonRegisterViewModel {
|
|||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
// error
|
||||
// .sink { [weak self] error in
|
||||
// guard let self = self else { return }
|
||||
// let error = error as? Mastodon.API.Error
|
||||
// let mastodonError = error?.mastodonError
|
||||
// if case let .generic(genericMastodonError) = mastodonError,
|
||||
// let details = genericMastodonError.details
|
||||
// {
|
||||
// self.usernameErrorPrompt.value = details.usernameErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) }
|
||||
// self.emailErrorPrompt.value = details.emailErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) }
|
||||
// self.passwordErrorPrompt.value = details.passwordErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) }
|
||||
// self.reasonErrorPrompt.value = details.reasonErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) }
|
||||
// } else {
|
||||
// self.usernameErrorPrompt.value = nil
|
||||
// self.emailErrorPrompt.value = nil
|
||||
// self.passwordErrorPrompt.value = nil
|
||||
// self.reasonErrorPrompt.value = nil
|
||||
// }
|
||||
// }
|
||||
// .store(in: &disposeBag)
|
||||
//
|
||||
$error
|
||||
.sink { [weak self] error in
|
||||
guard let self = self else { return }
|
||||
let error = error as? Mastodon.API.Error
|
||||
let mastodonError = error?.mastodonError
|
||||
if case let .generic(genericMastodonError) = mastodonError,
|
||||
let details = genericMastodonError.details
|
||||
{
|
||||
self.usernameErrorPrompt = details.usernameErrorDescriptions.first
|
||||
details.usernameErrorDescriptions.first.flatMap { _ in self.usernameValidateState = .invalid }
|
||||
self.emailErrorPrompt = details.emailErrorDescriptions.first
|
||||
details.emailErrorDescriptions.first.flatMap { _ in self.emailValidateState = .invalid }
|
||||
self.passwordErrorPrompt = details.passwordErrorDescriptions.first
|
||||
details.passwordErrorDescriptions.first.flatMap { _ in self.passwordValidateState = .invalid }
|
||||
self.reasonErrorPrompt = details.reasonErrorDescriptions.first
|
||||
details.reasonErrorDescriptions.first.flatMap { _ in self.reasonValidateState = .invalid }
|
||||
} else {
|
||||
self.usernameErrorPrompt = nil
|
||||
self.emailErrorPrompt = nil
|
||||
self.passwordErrorPrompt = nil
|
||||
self.reasonErrorPrompt = nil
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
let publisherOne = Publishers.CombineLatest4(
|
||||
$usernameValidateState,
|
||||
$displayNameValidateState,
|
||||
|
@ -213,7 +222,7 @@ final class MastodonRegisterViewModel {
|
|||
}
|
||||
|
||||
extension MastodonRegisterViewModel {
|
||||
enum ValidateState {
|
||||
enum ValidateState: Hashable {
|
||||
case empty
|
||||
case invalid
|
||||
case valid
|
||||
|
@ -271,3 +280,52 @@ extension MastodonRegisterViewModel {
|
|||
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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
@ -90,7 +90,10 @@ extension ProfileHeaderViewModel {
|
|||
extension ProfileHeaderViewModel {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
@ -13,8 +13,9 @@ import MetaTextKit
|
|||
import MastodonAsset
|
||||
import MastodonLocalization
|
||||
import MastodonUI
|
||||
import Tabman
|
||||
import CoreDataStack
|
||||
import Tabman
|
||||
import Pageboy
|
||||
|
||||
protocol ProfileViewModelEditable {
|
||||
func isEdited() -> Bool
|
||||
|
@ -42,19 +43,34 @@ final class ProfileViewController: UIViewController, NeedsDependency, MediaPrevi
|
|||
}()
|
||||
|
||||
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
|
||||
return barButtonItem
|
||||
}()
|
||||
|
||||
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
|
||||
return barButtonItem
|
||||
}()
|
||||
|
||||
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
|
||||
return barButtonItem
|
||||
}()
|
||||
|
@ -402,6 +418,7 @@ extension ProfileViewController {
|
|||
}
|
||||
|
||||
extension ProfileViewController {
|
||||
|
||||
private func updateBarButtonInsets() {
|
||||
let margin: CGFloat = {
|
||||
switch traitCollection.userInterfaceIdiom {
|
||||
|
@ -618,7 +635,7 @@ extension ProfileViewController {
|
|||
return nil
|
||||
}
|
||||
let name = user.displayNameWithFallback
|
||||
let record = ManagedObjectRecord<MastodonUser>(objectID: user.objectID)
|
||||
let _ = ManagedObjectRecord<MastodonUser>(objectID: user.objectID)
|
||||
let menu = MastodonMenu.setupMenu(
|
||||
actions: [
|
||||
.muteUser(.init(name: name, isMuting: self.viewModel.isMuting.value)),
|
||||
|
@ -633,7 +650,7 @@ extension ProfileViewController {
|
|||
.sink { [weak self] completion in
|
||||
guard let self = self else { return }
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
case .failure:
|
||||
self.moreMenuBarButtonItem.menu = nil
|
||||
case .finished:
|
||||
break
|
||||
|
@ -937,6 +954,7 @@ extension ProfileViewController: ProfileHeaderViewDelegate {
|
|||
viewModel.isUpdating.value = true
|
||||
Task {
|
||||
do {
|
||||
// TODO: handle error
|
||||
_ = try await viewModel.updateProfileInfo(
|
||||
headerProfileInfo: profileHeaderViewModel.editProfileInfo,
|
||||
aboutProfileInfo: profileAboutViewModel.editProfileInfo
|
||||
|
@ -1138,25 +1156,28 @@ extension ProfileViewController: ScrollViewContainer {
|
|||
}
|
||||
}
|
||||
|
||||
//extension ProfileViewController {
|
||||
//
|
||||
// override var keyCommands: [UIKeyCommand]? {
|
||||
// if !viewModel.isEditing.value {
|
||||
// return segmentedControlNavigateKeyCommands
|
||||
// }
|
||||
//
|
||||
// return nil
|
||||
// }
|
||||
//
|
||||
//}
|
||||
extension ProfileViewController {
|
||||
|
||||
override var keyCommands: [UIKeyCommand]? {
|
||||
if !viewModel.isEditing.value {
|
||||
return pageboyNavigateKeyCommands
|
||||
}
|
||||
|
||||
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)
|
||||
// }
|
||||
//}
|
||||
|
|
|
@ -13,6 +13,7 @@ import MastodonSDK
|
|||
import MastodonMeta
|
||||
import MastodonAsset
|
||||
import MastodonLocalization
|
||||
import MastodonUI
|
||||
|
||||
// please override this base class
|
||||
class ProfileViewModel: NSObject {
|
||||
|
@ -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 {
|
||||
func updateProfileInfo(
|
||||
headerProfileInfo: ProfileHeaderViewModel.ProfileInfo,
|
||||
|
|
|
@ -44,7 +44,7 @@ final class ProfilePagingViewModel: NSObject {
|
|||
let barItems: [TMBarItemable] = {
|
||||
let items = [
|
||||
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.about),
|
||||
]
|
||||
|
|
|
@ -36,7 +36,7 @@ final class UserTimelineViewController: UIViewController, NeedsDependency, Media
|
|||
let cellFrameCache = NSCache<NSNumber, NSValue>()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -195,6 +195,12 @@ extension UserTimelineViewModel.State {
|
|||
|
||||
// trigger data source update. otherwise, spinner always display
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
Loading…
Reference in New Issue