Merge branch 'develop' into l10n_develop
This commit is contained in:
commit
7bca92d1d2
|
@ -1,9 +1,16 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
sudo gem install cocoapods-keys
|
# workaround https://github.com/CocoaPods/CocoaPods/issues/11355
|
||||||
|
sed -i '' $'1s/^/source "https:\\/\\/github.com\\/CocoaPods\\/Specs.git"\\\n\\\n/' Podfile
|
||||||
|
|
||||||
|
# Install Ruby Bundler
|
||||||
|
gem install bundler:2.3.11
|
||||||
|
|
||||||
|
# Install Ruby Gems
|
||||||
|
bundle install
|
||||||
|
|
||||||
# stub keys. DO NOT use in production
|
# stub keys. DO NOT use in production
|
||||||
pod keys set notification_endpoint "<endpoint>"
|
bundle exec pod keys set notification_endpoint "<endpoint>"
|
||||||
pod keys set notification_endpoint_debug "<endpoint>"
|
bundle exec pod keys set notification_endpoint_debug "<endpoint>"
|
||||||
|
|
||||||
pod install
|
bundle exec pod install
|
||||||
|
|
|
@ -10,10 +10,7 @@ import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import KeychainAccess
|
import KeychainAccess
|
||||||
import Keys
|
import Keys
|
||||||
|
import MastodonCommon
|
||||||
enum AppName {
|
|
||||||
public static let groupID = "group.org.joinmastodon.app"
|
|
||||||
}
|
|
||||||
|
|
||||||
public final class AppSecret {
|
public final class AppSecret {
|
||||||
|
|
||||||
|
|
|
@ -15,8 +15,8 @@
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.3.0</string>
|
<string>1.4.2</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>109</string>
|
<string>127</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
@ -12,12 +12,13 @@ Intell the latest version of Xcode from the App Store or Apple Developer Downloa
|
||||||
This guide may not suit your machine and actually setup procedure may change in the future. Please file the issue or Pull Request if there are any problems.
|
This guide may not suit your machine and actually setup procedure may change in the future. Please file the issue or Pull Request if there are any problems.
|
||||||
|
|
||||||
## CocoaPods
|
## CocoaPods
|
||||||
The app use [CocoaPods]() and [CocoaPods-Keys](https://github.com/orta/cocoapods-keys). The M1 Mac needs virtual ruby env to workaround compatibility issues.
|
The app use [CocoaPods]() and [CocoaPods-Keys](https://github.com/orta/cocoapods-keys). Ruby Gems are managed through Bundler. The M1 Mac needs virtual ruby env to workaround compatibility issues.
|
||||||
|
|
||||||
#### Intel Mac
|
#### Intel Mac
|
||||||
|
|
||||||
```zsh
|
```zsh
|
||||||
sudo gem install cocoapods cocoapods-keys
|
gem install bundler
|
||||||
|
bundle install
|
||||||
```
|
```
|
||||||
|
|
||||||
#### M1 Mac
|
#### M1 Mac
|
||||||
|
@ -40,18 +41,19 @@ rbenv global 3.0.3
|
||||||
ruby --version
|
ruby --version
|
||||||
# > ruby 3.0.3p157 (2021-11-24 revision 3fb7d2cadc) [arm64-darwin21]
|
# > ruby 3.0.3p157 (2021-11-24 revision 3fb7d2cadc) [arm64-darwin21]
|
||||||
|
|
||||||
sudo gem install cocoapods cocoapods-keys
|
gem install bundler
|
||||||
|
bundle install
|
||||||
```
|
```
|
||||||
|
|
||||||
## Bootstrap
|
## Bootstrap
|
||||||
|
|
||||||
```zsh
|
```zsh
|
||||||
# make a clean build
|
# make a clean build
|
||||||
sudo gem install cocoapods-clean
|
bundle install
|
||||||
pod clean
|
bundle exec pod clean
|
||||||
|
|
||||||
# make install
|
# make install
|
||||||
pod install --repo-update
|
bundle exec pod install --repo-update
|
||||||
|
|
||||||
# open workspace
|
# open workspace
|
||||||
open Mastodon.xcworkspace
|
open Mastodon.xcworkspace
|
||||||
|
|
|
@ -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>
|
<string>posts</string>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>plural.count.media</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSStringLocalizedFormatKey</key>
|
||||||
|
<string>%#@media_count@</string>
|
||||||
|
<key>media_count</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSStringFormatSpecTypeKey</key>
|
||||||
|
<string>NSStringPluralRuleType</string>
|
||||||
|
<key>NSStringFormatValueTypeKey</key>
|
||||||
|
<string>ld</string>
|
||||||
|
<key>zero</key>
|
||||||
|
<string>0 media</string>
|
||||||
|
<key>one</key>
|
||||||
|
<string>1 media</string>
|
||||||
|
<key>few</key>
|
||||||
|
<string>%ld media</string>
|
||||||
|
<key>many</key>
|
||||||
|
<string>%ld media</string>
|
||||||
|
<key>other</key>
|
||||||
|
<string>%ld media</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
<key>plural.count.post</key>
|
<key>plural.count.post</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
<key>NSStringLocalizedFormatKey</key>
|
||||||
|
|
|
@ -51,19 +51,25 @@ private func map(language: String) -> String? {
|
||||||
case "eu_ES": return "eu-ES" // Basque
|
case "eu_ES": return "eu-ES" // Basque
|
||||||
case "ca_ES": return "ca" // Catalan
|
case "ca_ES": return "ca" // Catalan
|
||||||
case "zh_CN": return "zh-Hans" // Chinese Simplified
|
case "zh_CN": return "zh-Hans" // Chinese Simplified
|
||||||
|
case "zh_TW": return "zh-Hant" // Chinese Traditional
|
||||||
case "nl_NL": return "nl" // Dutch
|
case "nl_NL": return "nl" // Dutch
|
||||||
case "en_US": return "en"
|
case "en_US": return "en"
|
||||||
case "fr_FR": return "fr" // French
|
case "fr_FR": return "fr" // French
|
||||||
|
case "gl_ES": return "gl" // Galician
|
||||||
case "de_DE": return "de" // German
|
case "de_DE": return "de" // German
|
||||||
|
case "it_IT": return "it" // Italian
|
||||||
case "ja_JP": return "ja" // Japanese
|
case "ja_JP": return "ja" // Japanese
|
||||||
case "kab_KAB": return "kab" // Kabyle
|
case "kab_KAB": return "kab" // Kabyle
|
||||||
case "kmr_TR": return "ku" // Kurmanji (Kurdish)
|
case "kmr_TR": return "ku" // Kurmanji (Kurdish)
|
||||||
case "ru_RU": return "ru" // Russian
|
case "ru_RU": return "ru" // Russian
|
||||||
case "gd_GB": return "gd-GB" // Scottish Gaelic
|
case "gd_GB": return "gd-GB" // Scottish Gaelic
|
||||||
|
case "ckb_IR": return "ckb" // Sorani (Kurdish)
|
||||||
case "es_ES": return "es" // Spanish
|
case "es_ES": return "es" // Spanish
|
||||||
case "es_AR": return "es-419" // Spanish, Argentina
|
case "es_AR": return "es-419" // Spanish, Argentina
|
||||||
|
case "sv-SE": return "sv" // Swedish
|
||||||
case "sv_FI": return "sv_FI" // Swedish, Finland
|
case "sv_FI": return "sv_FI" // Swedish, Finland
|
||||||
case "th_TH": return "th" // Thai
|
case "th_TH": return "th" // Thai
|
||||||
|
case "tr_TR": return "tr" // Turkish
|
||||||
case "vi_VN": return "vi" // Vietnamese
|
case "vi_VN": return "vi" // Vietnamese
|
||||||
default: return nil
|
default: return nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -129,6 +129,7 @@
|
||||||
"show_post": "Show Post",
|
"show_post": "Show Post",
|
||||||
"show_user_profile": "Show user profile",
|
"show_user_profile": "Show user profile",
|
||||||
"content_warning": "Content Warning",
|
"content_warning": "Content Warning",
|
||||||
|
"sensitive_content": "Sensitive Content",
|
||||||
"media_content_warning": "Tap anywhere to reveal",
|
"media_content_warning": "Tap anywhere to reveal",
|
||||||
"tap_to_reveal": "Tap to reveal",
|
"tap_to_reveal": "Tap to reveal",
|
||||||
"poll": {
|
"poll": {
|
||||||
|
@ -210,9 +211,9 @@
|
||||||
"log_in": "Log In"
|
"log_in": "Log In"
|
||||||
},
|
},
|
||||||
"server_picker": {
|
"server_picker": {
|
||||||
"title": "Mastodon is made of users in different communities.",
|
"title": "Mastodon is made of users in different servers.",
|
||||||
"subtitle": "Pick a community based on your interests, region, or a general purpose one.",
|
"subtitle": "Pick a server based on your interests, region, or a general purpose one.",
|
||||||
"subtitle_extend": "Pick a community based on your interests, region, or a general purpose one. Each community is operated by an entirely independent organization or individual.",
|
"subtitle_extend": "Pick a server based on your interests, region, or a general purpose one. Each server is operated by an entirely independent organization or individual.",
|
||||||
"button": {
|
"button": {
|
||||||
"category": {
|
"category": {
|
||||||
"all": "All",
|
"all": "All",
|
||||||
|
@ -239,7 +240,8 @@
|
||||||
"category": "CATEGORY"
|
"category": "CATEGORY"
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
"placeholder": "Search communities"
|
"placeholder": "Search servers",
|
||||||
|
"search_servers_or_enter_url": "Search communities or enter URL"
|
||||||
},
|
},
|
||||||
"empty_state": {
|
"empty_state": {
|
||||||
"finding_servers": "Finding available servers...",
|
"finding_servers": "Finding available servers...",
|
||||||
|
@ -249,6 +251,7 @@
|
||||||
},
|
},
|
||||||
"register": {
|
"register": {
|
||||||
"title": "Let’s get you set up on %s",
|
"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": {
|
"input": {
|
||||||
"avatar": {
|
"avatar": {
|
||||||
"delete": "Delete"
|
"delete": "Delete"
|
||||||
|
@ -319,6 +322,7 @@
|
||||||
"confirm_email": {
|
"confirm_email": {
|
||||||
"title": "One last thing.",
|
"title": "One last thing.",
|
||||||
"subtitle": "Tap the link we emailed to you to verify your account.",
|
"subtitle": "Tap the link we emailed to you to verify your account.",
|
||||||
|
"tap_the_link_we_emailed_to_you_to_verify_your_account": "Tap the link we emailed to you to verify your account",
|
||||||
"button": {
|
"button": {
|
||||||
"open_email_app": "Open Email App",
|
"open_email_app": "Open Email App",
|
||||||
"resend": "Resend"
|
"resend": "Resend"
|
||||||
|
@ -341,7 +345,11 @@
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"new_posts": "See new posts",
|
"new_posts": "See new posts",
|
||||||
"published": "Published!",
|
"published": "Published!",
|
||||||
"Publishing": "Publishing post..."
|
"Publishing": "Publishing post...",
|
||||||
|
"accessibility": {
|
||||||
|
"logo_label": "Logo Button",
|
||||||
|
"logo_hint": "Tap to scroll to top and tap again to previous location"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"suggestion_account": {
|
"suggestion_account": {
|
||||||
|
@ -492,6 +500,16 @@
|
||||||
"clear": "Clear"
|
"clear": "Clear"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"discovery": {
|
||||||
|
"tabs": {
|
||||||
|
"posts": "Posts",
|
||||||
|
"hashtags": "Hashtags",
|
||||||
|
"news": "News",
|
||||||
|
"community": "Community",
|
||||||
|
"for_you": "For You"
|
||||||
|
},
|
||||||
|
"intro": "These are the posts gaining traction in your corner of Mastodon."
|
||||||
|
},
|
||||||
"favorite": {
|
"favorite": {
|
||||||
"title": "Your Favorites"
|
"title": "Your Favorites"
|
||||||
},
|
},
|
||||||
|
@ -585,7 +603,49 @@
|
||||||
"send": "Send Report",
|
"send": "Send Report",
|
||||||
"skip_to_send": "Send without comment",
|
"skip_to_send": "Send without comment",
|
||||||
"text_placeholder": "Type or paste additional comments",
|
"text_placeholder": "Type or paste additional comments",
|
||||||
"reported": "REPORTED"
|
"reported": "REPORTED",
|
||||||
|
"step_one": {
|
||||||
|
"step_1_of_4": "Step 1 of 4",
|
||||||
|
"whats_wrong_with_this_post": "What's wrong with this post?",
|
||||||
|
"whats_wrong_with_this_account": "What's wrong with this account?",
|
||||||
|
"whats_wrong_with_this_username": "What's wrong with %s?",
|
||||||
|
"select_the_best_match": "Select the best match",
|
||||||
|
"i_dont_like_it": "I 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": {
|
"preview": {
|
||||||
"keyboard": {
|
"keyboard": {
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1250"
|
LastUpgradeVersion = "1330"
|
||||||
version = "1.3">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1250"
|
LastUpgradeVersion = "1330"
|
||||||
version = "1.7">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1250"
|
LastUpgradeVersion = "1330"
|
||||||
version = "1.7">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
|
|
@ -9,33 +9,38 @@
|
||||||
<key>isShown</key>
|
<key>isShown</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>4</integer>
|
<integer>5</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>27</integer>
|
<integer>27</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
|
<key>Mastodon - Profile.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
|
||||||
<key>orderHint</key>
|
|
||||||
<integer>19</integer>
|
|
||||||
</dict>
|
|
||||||
<key>Mastodon - Release.xcscheme_^#shared#^_</key>
|
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>1</integer>
|
<integer>1</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>Mastodon - Snapshot.xcscheme_^#shared#^_</key>
|
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>8</integer>
|
||||||
|
</dict>
|
||||||
|
<key>Mastodon - Release.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>2</integer>
|
<integer>2</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>Mastodon - ar.xcscheme</key>
|
<key>Mastodon - Snapshot.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>3</integer>
|
<integer>3</integer>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>Mastodon - ar.xcscheme</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>4</integer>
|
||||||
|
</dict>
|
||||||
<key>Mastodon - ar.xcscheme_^#shared#^_</key>
|
<key>Mastodon - ar.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
|
@ -109,7 +114,7 @@
|
||||||
<key>MastodonIntent.xcscheme_^#shared#^_</key>
|
<key>MastodonIntent.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>24</integer>
|
<integer>31</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>MastodonIntents.xcscheme_^#shared#^_</key>
|
<key>MastodonIntents.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
@ -124,12 +129,12 @@
|
||||||
<key>NotificationService.xcscheme_^#shared#^_</key>
|
<key>NotificationService.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>22</integer>
|
<integer>30</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>ShareActionExtension.xcscheme_^#shared#^_</key>
|
<key>ShareActionExtension.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>23</integer>
|
<integer>32</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
<key>SuppressBuildableAutocreation</key>
|
<key>SuppressBuildableAutocreation</key>
|
||||||
|
|
|
@ -6,8 +6,8 @@
|
||||||
"repositoryURL": "https://github.com/Alamofire/Alamofire.git",
|
"repositoryURL": "https://github.com/Alamofire/Alamofire.git",
|
||||||
"state": {
|
"state": {
|
||||||
"branch": null,
|
"branch": null,
|
||||||
"revision": "f82c23a8a7ef8dc1a49a8bfc6a96883e79121864",
|
"revision": "354dda32d89fc8cd4f5c46487f64957d355f53d8",
|
||||||
"version": "5.5.0"
|
"version": "5.6.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -55,6 +55,15 @@
|
||||||
"version": "1.2.0"
|
"version": "1.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"package": "FaviconFinder",
|
||||||
|
"repositoryURL": "https://github.com/will-lumley/FaviconFinder.git",
|
||||||
|
"state": {
|
||||||
|
"branch": null,
|
||||||
|
"revision": "1f74844f77f79b95c0bb0130b3a87d4f340e6d3a",
|
||||||
|
"version": "3.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"package": "FLAnimatedImage",
|
"package": "FLAnimatedImage",
|
||||||
"repositoryURL": "https://github.com/Flipboard/FLAnimatedImage.git",
|
"repositoryURL": "https://github.com/Flipboard/FLAnimatedImage.git",
|
||||||
|
@ -96,8 +105,8 @@
|
||||||
"repositoryURL": "https://github.com/TwidereProject/MetaTextKit.git",
|
"repositoryURL": "https://github.com/TwidereProject/MetaTextKit.git",
|
||||||
"state": {
|
"state": {
|
||||||
"branch": null,
|
"branch": null,
|
||||||
"revision": "3ea336d3de7938dc112084c596a646e697b0feee",
|
"revision": "2b9556a78b2986b8c0b04adc6da8ec206b448a0c",
|
||||||
"version": "2.2.1"
|
"version": "2.2.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -105,8 +114,8 @@
|
||||||
"repositoryURL": "https://github.com/kean/Nuke.git",
|
"repositoryURL": "https://github.com/kean/Nuke.git",
|
||||||
"state": {
|
"state": {
|
||||||
"branch": null,
|
"branch": null,
|
||||||
"revision": "0db18dd34998cca18e9a28bcee136f84518007a0",
|
"revision": "0ea7545b5c918285aacc044dc75048625c8257cc",
|
||||||
"version": "10.4.1"
|
"version": "10.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -141,8 +150,8 @@
|
||||||
"repositoryURL": "https://github.com/SDWebImage/SDWebImage.git",
|
"repositoryURL": "https://github.com/SDWebImage/SDWebImage.git",
|
||||||
"state": {
|
"state": {
|
||||||
"branch": null,
|
"branch": null,
|
||||||
"revision": "2c53f531f1bedd253f55d85105409c28ed4a922c",
|
"revision": "2e63d0061da449ad0ed130768d05dceb1496de44",
|
||||||
"version": "5.12.3"
|
"version": "5.12.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -172,13 +181,22 @@
|
||||||
"version": "1.0.0"
|
"version": "1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"package": "SwiftSoup",
|
||||||
|
"repositoryURL": "https://github.com/scinfu/SwiftSoup.git",
|
||||||
|
"state": {
|
||||||
|
"branch": null,
|
||||||
|
"revision": "41e7c263fb8c277e980ebcb9b0b5f6031d3d4886",
|
||||||
|
"version": "2.4.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"package": "Introspect",
|
"package": "Introspect",
|
||||||
"repositoryURL": "https://github.com/siteline/SwiftUI-Introspect.git",
|
"repositoryURL": "https://github.com/siteline/SwiftUI-Introspect.git",
|
||||||
"state": {
|
"state": {
|
||||||
"branch": null,
|
"branch": null,
|
||||||
"revision": "2e09be8af614401bc9f87d40093ec19ce56ccaf2",
|
"revision": "f2616860a41f9d9932da412a8978fec79c06fe24",
|
||||||
"version": "0.1.3"
|
"version": "0.1.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -144,7 +144,7 @@ extension SceneCoordinator {
|
||||||
case popover(sourceView: UIView)
|
case popover(sourceView: UIView)
|
||||||
case panModal
|
case panModal
|
||||||
case custom(transitioningDelegate: UIViewControllerTransitioningDelegate)
|
case custom(transitioningDelegate: UIViewControllerTransitioningDelegate)
|
||||||
case customPush
|
case customPush(animated: Bool)
|
||||||
case safariPresent(animated: Bool, completion: (() -> Void)? = nil)
|
case safariPresent(animated: Bool, completion: (() -> Void)? = nil)
|
||||||
case alertController(animated: Bool, completion: (() -> Void)? = nil)
|
case alertController(animated: Bool, completion: (() -> Void)? = nil)
|
||||||
case activityViewControllerPresent(animated: Bool, completion: (() -> Void)? = nil)
|
case activityViewControllerPresent(animated: Bool, completion: (() -> Void)? = nil)
|
||||||
|
@ -158,7 +158,7 @@ extension SceneCoordinator {
|
||||||
case mastodonServerRules(viewModel: MastodonServerRulesViewModel)
|
case mastodonServerRules(viewModel: MastodonServerRulesViewModel)
|
||||||
case mastodonConfirmEmail(viewModel: MastodonConfirmEmailViewModel)
|
case mastodonConfirmEmail(viewModel: MastodonConfirmEmailViewModel)
|
||||||
case mastodonResendEmail(viewModel: MastodonResendEmailViewModel)
|
case mastodonResendEmail(viewModel: MastodonResendEmailViewModel)
|
||||||
case mastodonWebView(viewModel:WebViewModel)
|
case mastodonWebView(viewModel: WebViewModel)
|
||||||
|
|
||||||
// search
|
// search
|
||||||
case searchDetail(viewModel: SearchDetailViewModel)
|
case searchDetail(viewModel: SearchDetailViewModel)
|
||||||
|
@ -184,6 +184,8 @@ extension SceneCoordinator {
|
||||||
|
|
||||||
// report
|
// report
|
||||||
case report(viewModel: ReportViewModel)
|
case report(viewModel: ReportViewModel)
|
||||||
|
case reportServerRules(viewModel: ReportServerRulesViewModel)
|
||||||
|
case reportStatus(viewModel: ReportStatusViewModel)
|
||||||
case reportSupplementary(viewModel: ReportSupplementaryViewModel)
|
case reportSupplementary(viewModel: ReportSupplementaryViewModel)
|
||||||
case reportResult(viewModel: ReportResultViewModel)
|
case reportResult(viewModel: ReportResultViewModel)
|
||||||
|
|
||||||
|
@ -309,7 +311,7 @@ extension SceneCoordinator {
|
||||||
if scene.isOnboarding {
|
if scene.isOnboarding {
|
||||||
return OnboardingNavigationController(rootViewController: viewController)
|
return OnboardingNavigationController(rootViewController: viewController)
|
||||||
} else {
|
} else {
|
||||||
return UINavigationController(rootViewController: viewController)
|
return AdaptiveStatusBarStyleNavigationController(rootViewController: viewController)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
modalNavigationController.modalPresentationCapturesStatusBarAppearance = true
|
modalNavigationController.modalPresentationCapturesStatusBarAppearance = true
|
||||||
|
@ -339,10 +341,10 @@ extension SceneCoordinator {
|
||||||
viewController.transitioningDelegate = transitioningDelegate
|
viewController.transitioningDelegate = transitioningDelegate
|
||||||
(splitViewController ?? presentingViewController)?.present(viewController, animated: true, completion: nil)
|
(splitViewController ?? presentingViewController)?.present(viewController, animated: true, completion: nil)
|
||||||
|
|
||||||
case .customPush:
|
case .customPush(let animated):
|
||||||
// set delegate in view controller
|
// set delegate in view controller
|
||||||
assert(sender?.navigationController?.delegate != nil)
|
assert(sender?.navigationController?.delegate != nil)
|
||||||
sender?.navigationController?.pushViewController(viewController, animated: true)
|
sender?.navigationController?.pushViewController(viewController, animated: animated)
|
||||||
|
|
||||||
case .safariPresent(let animated, let completion):
|
case .safariPresent(let animated, let completion):
|
||||||
if UserDefaults.shared.preferredUsingDefaultBrowser, case let .safari(url) = scene {
|
if UserDefaults.shared.preferredUsingDefaultBrowser, case let .safari(url) = scene {
|
||||||
|
@ -368,10 +370,10 @@ extension SceneCoordinator {
|
||||||
splitViewController?.contentSplitViewController.currentSupplementaryTab = tab
|
splitViewController?.contentSplitViewController.currentSupplementaryTab = tab
|
||||||
|
|
||||||
splitViewController?.compactMainTabBarViewController.selectedIndex = tab.rawValue
|
splitViewController?.compactMainTabBarViewController.selectedIndex = tab.rawValue
|
||||||
splitViewController?.compactMainTabBarViewController.currentTab.value = tab
|
splitViewController?.compactMainTabBarViewController.currentTab = tab
|
||||||
|
|
||||||
tabBarController.selectedIndex = tab.rawValue
|
tabBarController.selectedIndex = tab.rawValue
|
||||||
tabBarController.currentTab.value = tab
|
tabBarController.currentTab = tab
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -447,6 +449,14 @@ private extension SceneCoordinator {
|
||||||
let _viewController = ReportViewController()
|
let _viewController = ReportViewController()
|
||||||
_viewController.viewModel = viewModel
|
_viewController.viewModel = viewModel
|
||||||
viewController = _viewController
|
viewController = _viewController
|
||||||
|
case .reportServerRules(let viewModel):
|
||||||
|
let _viewController = ReportServerRulesViewController()
|
||||||
|
_viewController.viewModel = viewModel
|
||||||
|
viewController = _viewController
|
||||||
|
case .reportStatus(let viewModel):
|
||||||
|
let _viewController = ReportStatusViewController()
|
||||||
|
_viewController.viewModel = viewModel
|
||||||
|
viewController = _viewController
|
||||||
case .reportSupplementary(let viewModel):
|
case .reportSupplementary(let viewModel):
|
||||||
let _viewController = ReportSupplementaryViewController()
|
let _viewController = ReportSupplementaryViewController()
|
||||||
_viewController.viewModel = viewModel
|
_viewController.viewModel = viewModel
|
||||||
|
|
|
@ -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 dependency,
|
||||||
weak pickServerCellDelegate
|
weak pickServerCellDelegate
|
||||||
] tableView, indexPath, item -> UITableViewCell? in
|
] tableView, indexPath, item -> UITableViewCell? in
|
||||||
guard let dependency = dependency else { return nil }
|
guard let _ = dependency else { return nil }
|
||||||
switch item {
|
switch item {
|
||||||
case .header:
|
case .header:
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: OnboardingHeadlineTableViewCell.self), for: indexPath) as! OnboardingHeadlineTableViewCell
|
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: OnboardingHeadlineTableViewCell.self), for: indexPath) as! OnboardingHeadlineTableViewCell
|
||||||
|
|
|
@ -69,10 +69,10 @@ extension SearchHistorySection {
|
||||||
let trendHeaderRegister = UICollectionView.SupplementaryRegistration<SearchHistorySectionHeaderCollectionReusableView>(elementKind: UICollectionView.elementKindSectionHeader) { [weak dataSource] supplementaryView, elementKind, indexPath in
|
let trendHeaderRegister = UICollectionView.SupplementaryRegistration<SearchHistorySectionHeaderCollectionReusableView>(elementKind: UICollectionView.elementKindSectionHeader) { [weak dataSource] supplementaryView, elementKind, indexPath in
|
||||||
supplementaryView.delegate = configuration.searchHistorySectionHeaderCollectionReusableViewDelegate
|
supplementaryView.delegate = configuration.searchHistorySectionHeaderCollectionReusableViewDelegate
|
||||||
|
|
||||||
guard let dataSource = dataSource else { return }
|
guard let _ = dataSource else { return }
|
||||||
let sections = dataSource.snapshot().sectionIdentifiers
|
// let sections = dataSource.snapshot().sectionIdentifiers
|
||||||
guard indexPath.section < sections.count else { return }
|
// guard indexPath.section < sections.count else { return }
|
||||||
let section = sections[indexPath.section]
|
// let section = sections[indexPath.section]
|
||||||
}
|
}
|
||||||
|
|
||||||
dataSource.supplementaryViewProvider = { (collectionView: UICollectionView, elementKind: String, indexPath: IndexPath) in
|
dataSource.supplementaryViewProvider = { (collectionView: UICollectionView, elementKind: String, indexPath: IndexPath) in
|
||||||
|
|
|
@ -21,26 +21,7 @@ extension SearchSection {
|
||||||
) -> UICollectionViewDiffableDataSource<SearchSection, SearchItem> {
|
) -> UICollectionViewDiffableDataSource<SearchSection, SearchItem> {
|
||||||
|
|
||||||
let trendCellRegister = UICollectionView.CellRegistration<TrendCollectionViewCell, Mastodon.Entity.Tag> { cell, indexPath, item in
|
let trendCellRegister = UICollectionView.CellRegistration<TrendCollectionViewCell, Mastodon.Entity.Tag> { cell, indexPath, item in
|
||||||
let primaryLabelText = "#" + item.name
|
|
||||||
let secondaryLabelText = L10n.Scene.Search.Recommend.HashTag.peopleTalking(item.talkingPeopleCount ?? 0)
|
|
||||||
|
|
||||||
cell.primaryLabel.text = primaryLabelText
|
|
||||||
cell.secondaryLabel.text = secondaryLabelText
|
|
||||||
|
|
||||||
cell.lineChartView.data = (item.history ?? [])
|
|
||||||
.sorted(by: { $0.day < $1.day }) // latest last
|
|
||||||
.map { entry in
|
|
||||||
guard let point = Int(entry.accounts) else {
|
|
||||||
return .zero
|
|
||||||
}
|
|
||||||
return CGFloat(point)
|
|
||||||
}
|
|
||||||
|
|
||||||
cell.isAccessibilityElement = true
|
|
||||||
cell.accessibilityLabel = [
|
|
||||||
primaryLabelText,
|
|
||||||
secondaryLabelText
|
|
||||||
].joined(separator: ", ")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let dataSource = UICollectionViewDiffableDataSource<SearchSection, SearchItem>(
|
let dataSource = UICollectionViewDiffableDataSource<SearchSection, SearchItem>(
|
||||||
|
|
|
@ -51,7 +51,7 @@ extension SettingsSection {
|
||||||
}
|
}
|
||||||
cell.delegate = settingsAppearanceTableViewCellDelegate
|
cell.delegate = settingsAppearanceTableViewCellDelegate
|
||||||
return cell
|
return cell
|
||||||
case .appearancePreference(let record, let appearanceType):
|
case .appearancePreference(let record, _):
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsToggleTableViewCell.self), for: indexPath) as! SettingsToggleTableViewCell
|
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsToggleTableViewCell.self), for: indexPath) as! SettingsToggleTableViewCell
|
||||||
cell.delegate = settingsToggleCellDelegate
|
cell.delegate = settingsToggleCellDelegate
|
||||||
managedObjectContext.performAndWait {
|
managedObjectContext.performAndWait {
|
||||||
|
|
|
@ -9,53 +9,6 @@ import Foundation
|
||||||
import CoreDataStack
|
import CoreDataStack
|
||||||
import MastodonSDK
|
import MastodonSDK
|
||||||
|
|
||||||
extension MastodonUser {
|
|
||||||
|
|
||||||
public var displayNameWithFallback: String {
|
|
||||||
return !displayName.isEmpty ? displayName : username
|
|
||||||
}
|
|
||||||
|
|
||||||
public var acctWithDomain: String {
|
|
||||||
if !acct.contains("@") {
|
|
||||||
// Safe concat due to username cannot contains "@"
|
|
||||||
return username + "@" + domain
|
|
||||||
} else {
|
|
||||||
return acct
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public var domainFromAcct: String {
|
|
||||||
if !acct.contains("@") {
|
|
||||||
return domain
|
|
||||||
} else {
|
|
||||||
let domain = acct.split(separator: "@").last
|
|
||||||
return String(domain!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MastodonUser {
|
|
||||||
|
|
||||||
public func headerImageURL() -> URL? {
|
|
||||||
return URL(string: header)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func headerImageURLWithFallback(domain: String) -> URL {
|
|
||||||
return URL(string: header) ?? URL(string: "https://\(domain)/headers/original/missing.png")!
|
|
||||||
}
|
|
||||||
|
|
||||||
public func avatarImageURL() -> URL? {
|
|
||||||
let string = UserDefaults.shared.preferredStaticAvatar ? avatarStatic ?? avatar : avatar
|
|
||||||
return URL(string: string)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func avatarImageURLWithFallback(domain: String) -> URL {
|
|
||||||
return avatarImageURL() ?? URL(string: "https://\(domain)/avatars/original/missing.png")!
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MastodonUser {
|
extension MastodonUser {
|
||||||
|
|
||||||
public var profileURL: URL {
|
public var profileURL: URL {
|
||||||
|
|
|
@ -7,24 +7,12 @@
|
||||||
|
|
||||||
import MastodonSDK
|
import MastodonSDK
|
||||||
|
|
||||||
extension Mastodon.Entity.Tag: Hashable {
|
//extension Mastodon.Entity.Tag: Hashable {
|
||||||
public func hash(into hasher: inout Hasher) {
|
// public func hash(into hasher: inout Hasher) {
|
||||||
hasher.combine(name)
|
// hasher.combine(name)
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
public static func == (lhs: Mastodon.Entity.Tag, rhs: Mastodon.Entity.Tag) -> Bool {
|
// public static func == (lhs: Mastodon.Entity.Tag, rhs: Mastodon.Entity.Tag) -> Bool {
|
||||||
return lhs.name == rhs.name
|
// return lhs.name == rhs.name
|
||||||
}
|
// }
|
||||||
}
|
//}
|
||||||
|
|
||||||
extension Mastodon.Entity.Tag {
|
|
||||||
|
|
||||||
/// the sum of recent 2 days
|
|
||||||
public var talkingPeopleCount: Int? {
|
|
||||||
return history?
|
|
||||||
.prefix(2)
|
|
||||||
.compactMap { Int($0.accounts) }
|
|
||||||
.reduce(0, +)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
//
|
//
|
||||||
// ThemeService+Appearance.swift
|
// ThemeService.swift
|
||||||
// Mastodon
|
// Mastodon
|
||||||
//
|
//
|
||||||
// Created by MainasuK Cirno on 2021-7-19.
|
// Created by MainasuK on 2022-4-13.
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import MastodonCommon
|
||||||
|
import MastodonUI
|
||||||
|
|
||||||
extension ThemeService {
|
extension ThemeService {
|
||||||
func set(themeName: ThemeName) {
|
func set(themeName: ThemeName) {
|
|
@ -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
|
// Generated using Sourcery 1.6.1 — https://github.com/krzysztofzablocki/Sourcery
|
||||||
// DO NOT EDIT
|
// DO NOT EDIT
|
||||||
|
|
||||||
|
// sourcery:inline:DiscoveryCommunityViewController.AutoGenerateTableViewDelegate
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// sourcery:inline:UserTimelineViewController.AutoGenerateTableViewDelegate
|
|
||||||
|
|
||||||
// Generated using Sourcery
|
// Generated using Sourcery
|
||||||
// DO NOT EDIT
|
// DO NOT EDIT
|
||||||
|
@ -33,3 +26,13 @@ func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith con
|
||||||
}
|
}
|
||||||
// sourcery:end
|
// sourcery:end
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -30,7 +30,7 @@
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.3.0</string>
|
<string>1.4.2</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
|
@ -43,7 +43,7 @@
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>109</string>
|
<string>127</string>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>LSApplicationQueriesSchemes</key>
|
<key>LSApplicationQueriesSchemes</key>
|
||||||
|
|
|
@ -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.
|
// 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
|
meta: Meta
|
||||||
) async {
|
) async {
|
||||||
switch meta {
|
switch meta {
|
||||||
|
// note:
|
||||||
|
// some server mark the normal url as "u-url" class. highlighted content is a URL
|
||||||
case .url(_, _, let url, _),
|
case .url(_, _, let url, _),
|
||||||
.mention(_, let url, _) where url.lowercased().hasPrefix("http"):
|
.mention(_, let url, _) where url.lowercased().hasPrefix("http"):
|
||||||
// note:
|
// fix non-ascii character URL link can not open issue
|
||||||
// some server mark the normal url as "u-url" class. highlighted content is a URL
|
guard let url = URL(string: url) ?? URL(string: url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? url) else {
|
||||||
guard let url = URL(string: url) else { return }
|
assertionFailure()
|
||||||
|
return
|
||||||
|
}
|
||||||
if let domain = provider.context.authenticationService.activeMastodonAuthenticationBox.value?.domain, url.host == domain,
|
if let domain = provider.context.authenticationService.activeMastodonAuthenticationBox.value?.domain, url.host == domain,
|
||||||
url.pathComponents.count >= 4,
|
url.pathComponents.count >= 4,
|
||||||
url.pathComponents[0] == "/",
|
url.pathComponents[0] == "/",
|
||||||
|
|
|
@ -122,12 +122,12 @@ extension DataSourceFacade {
|
||||||
let barButtonItem: UIBarButtonItem?
|
let barButtonItem: UIBarButtonItem?
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
// @MainActor
|
||||||
static func createProfileActionMenu(
|
// static func createProfileActionMenu(
|
||||||
dependency: NeedsDependency,
|
// dependency: NeedsDependency,
|
||||||
user: ManagedObjectRecord<MastodonUser>
|
// user: ManagedObjectRecord<MastodonUser>
|
||||||
) -> UIMenu {
|
// ) -> UIMenu {
|
||||||
var children: [UIMenuElement] = []
|
// var children: [UIMenuElement] = []
|
||||||
// let name = mastodonUser.displayNameWithFallback
|
// let name = mastodonUser.displayNameWithFallback
|
||||||
//
|
//
|
||||||
// if let shareUser = shareUser {
|
// if let shareUser = shareUser {
|
||||||
|
@ -339,9 +339,9 @@ extension DataSourceFacade {
|
||||||
// }
|
// }
|
||||||
// children.append(deleteAction)
|
// children.append(deleteAction)
|
||||||
// }
|
// }
|
||||||
|
//
|
||||||
return UIMenu(title: "", options: [], children: children)
|
// return UIMenu(title: "", options: [], children: children)
|
||||||
}
|
// }
|
||||||
|
|
||||||
static func createActivityViewController(
|
static func createActivityViewController(
|
||||||
dependency: NeedsDependency,
|
dependency: NeedsDependency,
|
||||||
|
|
|
@ -99,7 +99,7 @@ extension DataSourceFacade {
|
||||||
|
|
||||||
try await managedObjectContext.performChanges {
|
try await managedObjectContext.performChanges {
|
||||||
guard let authenticationBox = _authenticationBox else { return }
|
guard let authenticationBox = _authenticationBox else { return }
|
||||||
guard let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user else { return }
|
guard let _ = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user else { return }
|
||||||
let request = SearchHistory.sortedFetchRequest
|
let request = SearchHistory.sortedFetchRequest
|
||||||
request.predicate = SearchHistory.predicate(
|
request.predicate = SearchHistory.predicate(
|
||||||
domain: authenticationBox.domain,
|
domain: authenticationBox.domain,
|
||||||
|
|
|
@ -286,24 +286,8 @@ extension DataSourceFacade {
|
||||||
try await dependency.context.managedObjectContext.perform {
|
try await dependency.context.managedObjectContext.perform {
|
||||||
guard let _status = status.object(in: dependency.context.managedObjectContext) else { return }
|
guard let _status = status.object(in: dependency.context.managedObjectContext) else { return }
|
||||||
let status = _status.reblog ?? _status
|
let status = _status.reblog ?? _status
|
||||||
|
status.update(isSensitiveToggled: !status.isSensitiveToggled)
|
||||||
let allToggled = status.isContentSensitiveToggled && status.isMediaSensitiveToggled
|
|
||||||
|
|
||||||
status.update(isContentSensitiveToggled: !allToggled)
|
|
||||||
status.update(isMediaSensitiveToggled: !allToggled)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// static func responseToToggleMediaSensitiveAction(
|
|
||||||
// dependency: NeedsDependency,
|
|
||||||
// status: ManagedObjectRecord<Status>
|
|
||||||
// ) async throws {
|
|
||||||
// try await dependency.context.managedObjectContext.perform {
|
|
||||||
// guard let _status = status.object(in: dependency.context.managedObjectContext) else { return }
|
|
||||||
// let status = _status.reblog ?? _status
|
|
||||||
//
|
|
||||||
// status.update(isMediaSensitiveToggled: !status.isMediaSensitiveToggled)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -135,7 +135,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Med
|
||||||
let status = _status.reblog ?? _status
|
let status = _status.reblog ?? _status
|
||||||
return NotificationMediaTransitionContext(
|
return NotificationMediaTransitionContext(
|
||||||
status: .init(objectID: status.objectID),
|
status: .init(objectID: status.objectID),
|
||||||
needsToggleMediaSensitive: status.isMediaSensitiveToggled ? !status.sensitive : status.sensitive
|
needsToggleMediaSensitive: status.isSensitiveToggled ? !status.sensitive : status.sensitive
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -187,7 +187,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Med
|
||||||
let status = _status.reblog ?? _status
|
let status = _status.reblog ?? _status
|
||||||
return NotificationMediaTransitionContext(
|
return NotificationMediaTransitionContext(
|
||||||
status: .init(objectID: status.objectID),
|
status: .init(objectID: status.objectID),
|
||||||
needsToggleMediaSensitive: status.isMediaSensitiveToggled ? !status.sensitive : status.sensitive
|
needsToggleMediaSensitive: status.isMediaSensitive ? !status.isSensitiveToggled : false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -486,7 +486,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider {
|
||||||
provider: self,
|
provider: self,
|
||||||
user: user
|
user: user
|
||||||
)
|
)
|
||||||
case .notification(let notification):
|
case .notification:
|
||||||
assertionFailure("TODO")
|
assertionFailure("TODO")
|
||||||
default:
|
default:
|
||||||
assertionFailure("TODO")
|
assertionFailure("TODO")
|
||||||
|
|
|
@ -143,12 +143,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & MediaPrev
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let managedObjectContext = self.context.managedObjectContext
|
let needsToggleMediaSensitive = await !statusView.viewModel.isMediaReveal
|
||||||
let needsToggleMediaSensitive: Bool = try await managedObjectContext.perform {
|
|
||||||
guard let _status = status.object(in: managedObjectContext) else { return false }
|
|
||||||
let status = _status.reblog ?? _status
|
|
||||||
return status.isMediaSensitiveToggled ? !status.sensitive : status.sensitive
|
|
||||||
}
|
|
||||||
|
|
||||||
guard !needsToggleMediaSensitive else {
|
guard !needsToggleMediaSensitive else {
|
||||||
try await DataSourceFacade.responseToToggleSensitiveAction(
|
try await DataSourceFacade.responseToToggleSensitiveAction(
|
||||||
|
@ -499,7 +494,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider {
|
||||||
provider: self,
|
provider: self,
|
||||||
user: user
|
user: user
|
||||||
)
|
)
|
||||||
case .notification(let notification):
|
case .notification:
|
||||||
assertionFailure("TODO")
|
assertionFailure("TODO")
|
||||||
default:
|
default:
|
||||||
assertionFailure("TODO")
|
assertionFailure("TODO")
|
||||||
|
|
|
@ -115,7 +115,7 @@ extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvid
|
||||||
|
|
||||||
guard let provider = self as? (DataSourceProvider & MediaPreviewableViewController) else { return }
|
guard let provider = self as? (DataSourceProvider & MediaPreviewableViewController) else { return }
|
||||||
guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow,
|
guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow,
|
||||||
let cell = tableView.cellForRow(at: indexPathForSelectedRow) as? StatusTableViewCell
|
let cell = tableView.cellForRow(at: indexPathForSelectedRow) as? StatusViewContainerTableViewCell
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
guard let mediaView = cell.statusView.mediaGridContainerView.mediaViews.first else { return }
|
guard let mediaView = cell.statusView.mediaGridContainerView.mediaViews.first else { return }
|
||||||
|
|
|
@ -138,7 +138,7 @@ extension TableViewControllerNavigateableCore where Self: DataSourceProvider {
|
||||||
target: .status,
|
target: .status,
|
||||||
status: record
|
status: record
|
||||||
)
|
)
|
||||||
case .notification(let record):
|
case .notification:
|
||||||
assertionFailure()
|
assertionFailure()
|
||||||
default:
|
default:
|
||||||
assertionFailure()
|
assertionFailure()
|
||||||
|
|
|
@ -93,7 +93,7 @@ extension UITableViewDelegate where Self: DataSourceProvider & MediaPreviewableV
|
||||||
guard let image = mediaView.thumbnail(),
|
guard let image = mediaView.thumbnail(),
|
||||||
let assetURLString = mediaView.configuration?.assetURL,
|
let assetURLString = mediaView.configuration?.assetURL,
|
||||||
let assetURL = URL(string: assetURLString),
|
let assetURL = URL(string: assetURLString),
|
||||||
let resourceType = mediaView.configuration?.resourceType
|
let _ = mediaView.configuration?.resourceType
|
||||||
else {
|
else {
|
||||||
// not provide preview unless thumbnail ready
|
// not provide preview unless thumbnail ready
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -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.
|
// the presentingViewController may deinit.
|
||||||
// Hold it and check the window to prevent PanModel crash
|
// Hold it and check the window to prevent PanModel crash
|
||||||
guard let presentingViewController = presentingViewController else { return }
|
guard let _ = presentingViewController else { return }
|
||||||
guard self.view.window != nil else { return }
|
guard self.view.window != nil else { return }
|
||||||
|
|
||||||
self.hasLoaded = true
|
self.hasLoaded = true
|
||||||
|
|
|
@ -77,7 +77,7 @@ extension AutoCompleteViewModel.State {
|
||||||
|
|
||||||
override func didEnter(from previousState: GKState?) {
|
override func didEnter(from previousState: GKState?) {
|
||||||
super.didEnter(from: previousState)
|
super.didEnter(from: previousState)
|
||||||
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
guard let viewModel = viewModel, let _ = stateMachine else { return }
|
||||||
|
|
||||||
let searchText = viewModel.inputText.value
|
let searchText = viewModel.inputText.value
|
||||||
let searchType = AutoCompleteViewModel.SearchType(inputText: searchText) ?? .default
|
let searchType = AutoCompleteViewModel.SearchType(inputText: searchText) ?? .default
|
||||||
|
|
|
@ -10,6 +10,7 @@ import UIKit
|
||||||
import Combine
|
import Combine
|
||||||
import MastodonAsset
|
import MastodonAsset
|
||||||
import MastodonLocalization
|
import MastodonLocalization
|
||||||
|
import MastodonUI
|
||||||
|
|
||||||
protocol ComposeStatusPollOptionCollectionViewCellDelegate: AnyObject {
|
protocol ComposeStatusPollOptionCollectionViewCellDelegate: AnyObject {
|
||||||
func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textFieldDidBeginEditing textField: UITextField)
|
func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textFieldDidBeginEditing textField: UITextField)
|
||||||
|
|
|
@ -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 composeBarButtonItem: UIBarButtonItem = {
|
||||||
let barButtonItem = UIBarButtonItem()
|
let barButtonItem = UIBarButtonItem()
|
||||||
barButtonItem.image = UIImage(systemName: "square.and.pencil")?.withRenderingMode(.alwaysTemplate)
|
barButtonItem.image = Asset.ObjectsAndTools.squareAndPencil.image.withRenderingMode(.alwaysTemplate)
|
||||||
return barButtonItem
|
return barButtonItem
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
@ -84,7 +84,6 @@ extension HashtagTimelineViewController {
|
||||||
])
|
])
|
||||||
|
|
||||||
tableView.delegate = self
|
tableView.delegate = self
|
||||||
// tableView.prefetchDataSource = self
|
|
||||||
viewModel.setupDiffableDataSource(
|
viewModel.setupDiffableDataSource(
|
||||||
tableView: tableView,
|
tableView: tableView,
|
||||||
statusTableViewCellDelegate: self
|
statusTableViewCellDelegate: self
|
||||||
|
@ -158,27 +157,6 @@ extension HashtagTimelineViewController {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - TableViewCellHeightCacheableContainer
|
|
||||||
//extension HashtagTimelineViewController: TableViewCellHeightCacheableContainer {
|
|
||||||
// var cellFrameCache: NSCache<NSNumber, NSValue> {
|
|
||||||
// return viewModel.cellFrameCache
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
|
|
||||||
//// MARK: - UIScrollViewDelegate
|
|
||||||
//extension HashtagTimelineViewController {
|
|
||||||
// func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
|
||||||
// aspectScrollViewDidScroll(scrollView)
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
|
|
||||||
//extension HashtagTimelineViewController: LoadMoreConfigurableTableViewContainer {
|
|
||||||
// typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
|
|
||||||
// typealias LoadingState = HashtagTimelineViewModel.LoadOldestState.Loading
|
|
||||||
// var loadMoreConfigurableTableView: UITableView { return tableView }
|
|
||||||
// var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.loadOldestStateMachine }
|
|
||||||
//}
|
|
||||||
|
|
||||||
// MARK: - UITableViewDelegate
|
// MARK: - UITableViewDelegate
|
||||||
extension HashtagTimelineViewController: UITableViewDelegate, AutoGenerateTableViewDelegate {
|
extension HashtagTimelineViewController: UITableViewDelegate, AutoGenerateTableViewDelegate {
|
||||||
// sourcery:inline:HashtagTimelineViewController.AutoGenerateTableViewDelegate
|
// sourcery:inline:HashtagTimelineViewController.AutoGenerateTableViewDelegate
|
||||||
|
@ -206,82 +184,23 @@ extension HashtagTimelineViewController: UITableViewDelegate, AutoGenerateTableV
|
||||||
}
|
}
|
||||||
// sourcery:end
|
// sourcery:end
|
||||||
|
|
||||||
// func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
|
||||||
// return aspectTableView(tableView, estimatedHeightForRowAt: indexPath)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
|
||||||
// aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
|
||||||
// aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
|
||||||
// aspectTableView(tableView, didSelectRowAt: indexPath)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
|
||||||
// return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
|
||||||
// return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
|
||||||
// return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
|
||||||
// aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator)
|
|
||||||
// }
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - UITableViewDataSourcePrefetching
|
|
||||||
//extension HashtagTimelineViewController: UITableViewDataSourcePrefetching {
|
|
||||||
// func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
|
|
||||||
// aspectTableView(tableView, prefetchRowsAt: indexPaths)
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
|
|
||||||
// MARK: - StatusTableViewCellDelegate
|
// MARK: - StatusTableViewCellDelegate
|
||||||
extension HashtagTimelineViewController: StatusTableViewCellDelegate { }
|
extension HashtagTimelineViewController: StatusTableViewCellDelegate { }
|
||||||
|
|
||||||
// MARK: - AVPlayerViewControllerDelegate
|
extension HashtagTimelineViewController {
|
||||||
//extension HashtagTimelineViewController: AVPlayerViewControllerDelegate {
|
override var keyCommands: [UIKeyCommand]? {
|
||||||
//
|
return navigationKeyCommands + statusNavigationKeyCommands
|
||||||
// func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
|
}
|
||||||
// aspectPlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
|
}
|
||||||
// }
|
// MARK: - StatusTableViewControllerNavigateable
|
||||||
//
|
extension HashtagTimelineViewController: StatusTableViewControllerNavigateable {
|
||||||
// func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
|
@objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
|
||||||
// aspectPlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
|
navigateKeyCommandHandler(sender)
|
||||||
// }
|
}
|
||||||
//
|
|
||||||
//}
|
@objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
|
||||||
|
statusKeyCommandHandler(sender)
|
||||||
// MARK: - StatusTableViewCellDelegate
|
}
|
||||||
//extension HashtagTimelineViewController: StatusTableViewCellDelegate {
|
}
|
||||||
// weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self }
|
|
||||||
// func parent() -> UIViewController { return self }
|
|
||||||
//}
|
|
||||||
|
|
||||||
//extension HashtagTimelineViewController {
|
|
||||||
// override var keyCommands: [UIKeyCommand]? {
|
|
||||||
// return navigationKeyCommands + statusNavigationKeyCommands
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
//
|
|
||||||
//// MARK: - StatusTableViewControllerNavigateable
|
|
||||||
//extension HashtagTimelineViewController: StatusTableViewControllerNavigateable {
|
|
||||||
// @objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
|
|
||||||
// navigateKeyCommandHandler(sender)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// @objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
|
|
||||||
// statusKeyCommandHandler(sender)
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
|
|
|
@ -58,6 +58,10 @@ extension HomeTimelineViewController {
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.showWelcomeAction(action)
|
self.showWelcomeAction(action)
|
||||||
},
|
},
|
||||||
|
UIAction(title: "Register", image: UIImage(systemName: "list.bullet.rectangle.portrait.fill"), attributes: []) { [weak self] action in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.showRegisterAction(action)
|
||||||
|
},
|
||||||
UIAction(title: "Confirm Email", image: UIImage(systemName: "envelope"), attributes: []) { [weak self] action in
|
UIAction(title: "Confirm Email", image: UIImage(systemName: "envelope"), attributes: []) { [weak self] action in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.showConfirmEmail(action)
|
self.showConfirmEmail(action)
|
||||||
|
@ -182,7 +186,7 @@ extension HomeTimelineViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
func match(item: StatusItem) -> Bool {
|
func match(item: StatusItem) -> Bool {
|
||||||
let authenticationBox = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value
|
// let authenticationBox = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value
|
||||||
switch item {
|
switch item {
|
||||||
case .feed(let record):
|
case .feed(let record):
|
||||||
guard let feed = record.object(in: AppContext.shared.managedObjectContext) else { return false }
|
guard let feed = record.object(in: AppContext.shared.managedObjectContext) else { return false }
|
||||||
|
@ -294,6 +298,33 @@ extension HomeTimelineViewController {
|
||||||
@objc private func showWelcomeAction(_ sender: UIAction) {
|
@objc private func showWelcomeAction(_ sender: UIAction) {
|
||||||
coordinator.present(scene: .welcome, from: self, transition: .modal(animated: true, completion: nil))
|
coordinator.present(scene: .welcome, from: self, transition: .modal(animated: true, completion: nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func showRegisterAction(_ sender: UIAction) {
|
||||||
|
Task { @MainActor in
|
||||||
|
try await showRegisterController()
|
||||||
|
} // end Task
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func showRegisterController(domain: String = "mstdn.jp") async throws {
|
||||||
|
let viewController = try await MastodonRegisterViewController.create(
|
||||||
|
context: context,
|
||||||
|
coordinator: coordinator,
|
||||||
|
domain: "mstdn.jp"
|
||||||
|
)
|
||||||
|
let navigationController = UINavigationController(rootViewController: viewController)
|
||||||
|
navigationController.modalPresentationStyle = .fullScreen
|
||||||
|
present(navigationController, animated: true) {
|
||||||
|
viewController.navigationItem.leftBarButtonItem = UIBarButtonItem(
|
||||||
|
systemItem: .close,
|
||||||
|
primaryAction: UIAction(handler: { [weak viewController] _ in
|
||||||
|
guard let viewController = viewController else { return }
|
||||||
|
viewController.dismiss(animated: true)
|
||||||
|
}),
|
||||||
|
menu: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func showConfirmEmail(_ sender: UIAction) {
|
@objc private func showConfirmEmail(_ sender: UIAction) {
|
||||||
let mastodonConfirmEmailViewModel = MastodonConfirmEmailViewModel()
|
let mastodonConfirmEmailViewModel = MastodonConfirmEmailViewModel()
|
||||||
|
|
|
@ -17,6 +17,7 @@ import AlamofireImage
|
||||||
import StoreKit
|
import StoreKit
|
||||||
import MastodonAsset
|
import MastodonAsset
|
||||||
import MastodonLocalization
|
import MastodonLocalization
|
||||||
|
import MastodonUI
|
||||||
|
|
||||||
final class HomeTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
|
final class HomeTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
|
||||||
|
|
||||||
|
@ -50,19 +51,11 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency, Media
|
||||||
let settingBarButtonItem: UIBarButtonItem = {
|
let settingBarButtonItem: UIBarButtonItem = {
|
||||||
let barButtonItem = UIBarButtonItem()
|
let barButtonItem = UIBarButtonItem()
|
||||||
barButtonItem.tintColor = ThemeService.tintColor
|
barButtonItem.tintColor = ThemeService.tintColor
|
||||||
barButtonItem.image = UIImage(systemName: "gear")?.withRenderingMode(.alwaysTemplate)
|
barButtonItem.image = Asset.ObjectsAndTools.gear.image.withRenderingMode(.alwaysTemplate)
|
||||||
barButtonItem.accessibilityLabel = L10n.Common.Controls.Actions.settings
|
barButtonItem.accessibilityLabel = L10n.Common.Controls.Actions.settings
|
||||||
return barButtonItem
|
return barButtonItem
|
||||||
}()
|
}()
|
||||||
|
|
||||||
let composeBarButtonItem: UIBarButtonItem = {
|
|
||||||
let barButtonItem = UIBarButtonItem()
|
|
||||||
barButtonItem.tintColor = ThemeService.tintColor
|
|
||||||
barButtonItem.image = UIImage(systemName: "square.and.pencil")?.withRenderingMode(.alwaysTemplate)
|
|
||||||
barButtonItem.accessibilityLabel = L10n.Common.Controls.Actions.compose
|
|
||||||
return barButtonItem
|
|
||||||
}()
|
|
||||||
|
|
||||||
let tableView: UITableView = {
|
let tableView: UITableView = {
|
||||||
let tableView = ControlContainableTableView()
|
let tableView = ControlContainableTableView()
|
||||||
tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self))
|
tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self))
|
||||||
|
@ -108,14 +101,14 @@ extension HomeTimelineViewController {
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
// display debug menu
|
// display debug menu
|
||||||
self.navigationItem.leftBarButtonItem = {
|
self.navigationItem.rightBarButtonItem = {
|
||||||
let barButtonItem = UIBarButtonItem()
|
let barButtonItem = UIBarButtonItem()
|
||||||
barButtonItem.image = UIImage(systemName: "ellipsis.circle")
|
barButtonItem.image = UIImage(systemName: "ellipsis.circle")
|
||||||
barButtonItem.menu = self.debugMenu
|
barButtonItem.menu = self.debugMenu
|
||||||
return barButtonItem
|
return barButtonItem
|
||||||
}()
|
}()
|
||||||
#else
|
#else
|
||||||
self.navigationItem.leftBarButtonItem = displaySettingBarButtonItem ? self.settingBarButtonItem : nil
|
self.navigationItem.rightBarButtonItem = displaySettingBarButtonItem ? self.settingBarButtonItem : nil
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
@ -132,16 +125,6 @@ extension HomeTimelineViewController {
|
||||||
titleView.button.menu = self.debugMenu
|
titleView.button.menu = self.debugMenu
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
viewModel.$displayComposeBarButtonItem
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] displayComposeBarButtonItem in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.navigationItem.rightBarButtonItem = displayComposeBarButtonItem ? self.composeBarButtonItem : nil
|
|
||||||
}
|
|
||||||
.store(in: &disposeBag)
|
|
||||||
composeBarButtonItem.target = self
|
|
||||||
composeBarButtonItem.action = #selector(HomeTimelineViewController.composeBarButtonItemPressed(_:))
|
|
||||||
|
|
||||||
navigationItem.titleView = titleView
|
navigationItem.titleView = titleView
|
||||||
titleView.delegate = self
|
titleView.delegate = self
|
||||||
|
|
||||||
|
@ -291,7 +274,7 @@ extension HomeTimelineViewController {
|
||||||
tableView.deselectRow(with: transitionCoordinator, animated: animated)
|
tableView.deselectRow(with: transitionCoordinator, animated: animated)
|
||||||
|
|
||||||
// needs trigger manually after onboarding dismiss
|
// needs trigger manually after onboarding dismiss
|
||||||
setNeedsStatusBarAppearanceUpdate()
|
setNeedsStatusBarAppearanceUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidAppear(_ animated: Bool) {
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
@ -410,18 +393,7 @@ extension HomeTimelineViewController {
|
||||||
let settingsViewModel = SettingsViewModel(context: context, setting: setting)
|
let settingsViewModel = SettingsViewModel(context: context, setting: setting)
|
||||||
coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil))
|
coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
|
||||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
|
||||||
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
|
|
||||||
let composeViewModel = ComposeViewModel(
|
|
||||||
context: context,
|
|
||||||
composeKind: .post,
|
|
||||||
authenticationBox: authenticationBox
|
|
||||||
)
|
|
||||||
coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
|
@objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
|
||||||
guard viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self) else {
|
guard viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self) else {
|
||||||
sender.endRefreshing()
|
sender.endRefreshing()
|
||||||
|
|
|
@ -33,7 +33,6 @@ final class HomeTimelineViewModel: NSObject {
|
||||||
@Published var lastAutomaticFetchTimestamp: Date? = nil
|
@Published var lastAutomaticFetchTimestamp: Date? = nil
|
||||||
@Published var scrollPositionRecord: ScrollPositionRecord? = nil
|
@Published var scrollPositionRecord: ScrollPositionRecord? = nil
|
||||||
@Published var displaySettingBarButtonItem = true
|
@Published var displaySettingBarButtonItem = true
|
||||||
@Published var displayComposeBarButtonItem = true
|
|
||||||
|
|
||||||
weak var tableView: UITableView?
|
weak var tableView: UITableView?
|
||||||
weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?
|
weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?
|
||||||
|
|
|
@ -108,6 +108,8 @@ extension HomeTimelineNavigationBarTitleView {
|
||||||
logoButton.setImage(Asset.Asset.mastodonTextLogo.image.withRenderingMode(.alwaysTemplate), for: .normal)
|
logoButton.setImage(Asset.Asset.mastodonTextLogo.image.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||||
logoButton.contentMode = .center
|
logoButton.contentMode = .center
|
||||||
logoButton.isHidden = false
|
logoButton.isHidden = false
|
||||||
|
logoButton.accessibilityLabel = "Logo Button" // TODO :i18n
|
||||||
|
logoButton.accessibilityHint = "Tap to scroll to top and tap again to previous location"
|
||||||
case .newPostButton:
|
case .newPostButton:
|
||||||
configureButton(
|
configureButton(
|
||||||
title: L10n.Scene.HomeTimeline.NavigationBarState.newPosts,
|
title: L10n.Scene.HomeTimeline.NavigationBarState.newPosts,
|
||||||
|
@ -115,6 +117,7 @@ extension HomeTimelineNavigationBarTitleView {
|
||||||
backgroundColor: Asset.Colors.brandBlue.color
|
backgroundColor: Asset.Colors.brandBlue.color
|
||||||
)
|
)
|
||||||
button.isHidden = false
|
button.isHidden = false
|
||||||
|
button.accessibilityLabel = L10n.Scene.HomeTimeline.NavigationBarState.newPosts
|
||||||
case .offlineButton:
|
case .offlineButton:
|
||||||
configureButton(
|
configureButton(
|
||||||
title: L10n.Scene.HomeTimeline.NavigationBarState.offline,
|
title: L10n.Scene.HomeTimeline.NavigationBarState.offline,
|
||||||
|
@ -122,12 +125,14 @@ extension HomeTimelineNavigationBarTitleView {
|
||||||
backgroundColor: Asset.Colors.danger.color
|
backgroundColor: Asset.Colors.danger.color
|
||||||
)
|
)
|
||||||
button.isHidden = false
|
button.isHidden = false
|
||||||
|
button.accessibilityLabel = L10n.Scene.HomeTimeline.NavigationBarState.offline
|
||||||
case .publishingPostLabel:
|
case .publishingPostLabel:
|
||||||
label.font = .systemFont(ofSize: 17, weight: .semibold)
|
label.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
label.textColor = Asset.Colors.Label.primary.color
|
label.textColor = Asset.Colors.Label.primary.color
|
||||||
label.text = L10n.Scene.HomeTimeline.NavigationBarState.publishing
|
label.text = L10n.Scene.HomeTimeline.NavigationBarState.publishing
|
||||||
label.textAlignment = .center
|
label.textAlignment = .center
|
||||||
label.isHidden = false
|
label.isHidden = false
|
||||||
|
button.accessibilityLabel = L10n.Scene.HomeTimeline.NavigationBarState.publishing
|
||||||
case .publishedButton:
|
case .publishedButton:
|
||||||
blockingState = state
|
blockingState = state
|
||||||
configureButton(
|
configureButton(
|
||||||
|
@ -136,6 +141,7 @@ extension HomeTimelineNavigationBarTitleView {
|
||||||
backgroundColor: Asset.Colors.successGreen.color
|
backgroundColor: Asset.Colors.successGreen.color
|
||||||
)
|
)
|
||||||
button.isHidden = false
|
button.isHidden = false
|
||||||
|
button.accessibilityLabel = L10n.Scene.HomeTimeline.NavigationBarState.published
|
||||||
|
|
||||||
let presentDuration: TimeInterval = 0.33
|
let presentDuration: TimeInterval = 0.33
|
||||||
let scaleAnimator = UIViewPropertyAnimator(duration: presentDuration, timingParameters: UISpringTimingParameters())
|
let scaleAnimator = UIViewPropertyAnimator(duration: presentDuration, timingParameters: UISpringTimingParameters())
|
||||||
|
|
|
@ -39,6 +39,8 @@ final class NotificationTimelineViewController: UIViewController, NeedsDependenc
|
||||||
return tableView
|
return tableView
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
let cellFrameCache = NSCache<NSNumber, NSValue>()
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
}
|
}
|
||||||
|
@ -122,6 +124,16 @@ extension NotificationTimelineViewController {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - CellFrameCacheContainer
|
||||||
|
extension NotificationTimelineViewController: CellFrameCacheContainer {
|
||||||
|
func keyForCache(tableView: UITableView, indexPath: IndexPath) -> NSNumber? {
|
||||||
|
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
|
||||||
|
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return nil }
|
||||||
|
let key = NSNumber(value: item.hashValue)
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension NotificationTimelineViewController {
|
extension NotificationTimelineViewController {
|
||||||
|
|
||||||
@objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
|
@objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
|
||||||
|
@ -162,6 +174,13 @@ extension NotificationTimelineViewController: UITableViewDelegate, AutoGenerateT
|
||||||
|
|
||||||
// sourcery:end
|
// sourcery:end
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||||
|
guard let frame = retrieveCellFrame(tableView: tableView, indexPath: indexPath) else {
|
||||||
|
return 300
|
||||||
|
}
|
||||||
|
return ceil(frame.height)
|
||||||
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||||
guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else {
|
guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else {
|
||||||
return
|
return
|
||||||
|
@ -172,6 +191,10 @@ extension NotificationTimelineViewController: UITableViewDelegate, AutoGenerateT
|
||||||
await viewModel.loadMore(item: item)
|
await viewModel.loadMore(item: item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||||
|
cacheCellFrame(tableView: tableView, didEndDisplaying: cell, forRowAt: indexPath)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -229,6 +229,11 @@ extension MastodonConfirmEmailViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - PanPopableViewController
|
||||||
|
extension MastodonConfirmEmailViewController: PanPopableViewController {
|
||||||
|
var isPanPopable: Bool { false }
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - OnboardingViewControllerAppearance
|
// MARK: - OnboardingViewControllerAppearance
|
||||||
extension MastodonConfirmEmailViewController: OnboardingViewControllerAppearance { }
|
extension MastodonConfirmEmailViewController: OnboardingViewControllerAppearance { }
|
||||||
|
|
||||||
|
|
|
@ -12,6 +12,7 @@ import GameController
|
||||||
import AuthenticationServices
|
import AuthenticationServices
|
||||||
import MastodonAsset
|
import MastodonAsset
|
||||||
import MastodonLocalization
|
import MastodonLocalization
|
||||||
|
import MastodonUI
|
||||||
|
|
||||||
final class MastodonPickServerViewController: UIViewController, NeedsDependency {
|
final class MastodonPickServerViewController: UIViewController, NeedsDependency {
|
||||||
|
|
||||||
|
@ -91,7 +92,7 @@ extension MastodonPickServerViewController {
|
||||||
tableView.topAnchor.constraint(equalTo: view.topAnchor),
|
tableView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
tableView.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor),
|
||||||
])
|
])
|
||||||
|
|
||||||
navigationActionView.translatesAutoresizingMaskIntoConstraints = false
|
navigationActionView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
@ -106,10 +107,10 @@ extension MastodonPickServerViewController {
|
||||||
])
|
])
|
||||||
|
|
||||||
navigationActionView
|
navigationActionView
|
||||||
.observe(\.bounds, options: [.initial, .new]) { [weak self] navigationActionView, _ in
|
.observe(\.bounds, options: [.initial, .new]) { [weak self] _, _ in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
let inset = navigationActionView.frame.height
|
let inset = self.navigationActionView.frame.height
|
||||||
self.tableView.contentInset.bottom = inset
|
self.viewModel.additionalTableViewInsets.bottom = inset
|
||||||
}
|
}
|
||||||
.store(in: &observations)
|
.store(in: &observations)
|
||||||
|
|
||||||
|
@ -144,6 +145,14 @@ extension MastodonPickServerViewController {
|
||||||
pickServerServerSectionTableHeaderViewDelegate: self,
|
pickServerServerSectionTableHeaderViewDelegate: self,
|
||||||
pickServerCellDelegate: self
|
pickServerCellDelegate: self
|
||||||
)
|
)
|
||||||
|
|
||||||
|
KeyboardResponderService
|
||||||
|
.configure(
|
||||||
|
scrollView: tableView,
|
||||||
|
layoutNeedsUpdate: viewModel.viewDidAppear.eraseToAnyPublisher(),
|
||||||
|
additionalSafeAreaInsets: viewModel.$additionalTableViewInsets.eraseToAnyPublisher()
|
||||||
|
)
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
viewModel
|
viewModel
|
||||||
.selectedServer
|
.selectedServer
|
||||||
|
@ -238,6 +247,7 @@ extension MastodonPickServerViewController {
|
||||||
super.viewDidAppear(animated)
|
super.viewDidAppear(animated)
|
||||||
|
|
||||||
tableView.flashScrollIndicators()
|
tableView.flashScrollIndicators()
|
||||||
|
viewModel.viewDidAppear.send()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||||
|
@ -332,7 +342,10 @@ extension MastodonPickServerViewController {
|
||||||
) else {
|
) else {
|
||||||
throw APIService.APIError.explicit(.badResponse)
|
throw APIService.APIError.explicit(.badResponse)
|
||||||
}
|
}
|
||||||
return MastodonPickServerViewModel.SignUpResponseSecond(instance: response.instance, authenticateInfo: authenticateInfo)
|
return MastodonPickServerViewModel.SignUpResponseSecond(
|
||||||
|
instance: response.instance,
|
||||||
|
authenticateInfo: authenticateInfo
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.compactMap { [weak self] response -> AnyPublisher<MastodonPickServerViewModel.SignUpResponseThird, Error>? in
|
.compactMap { [weak self] response -> AnyPublisher<MastodonPickServerViewModel.SignUpResponseThird, Error>? in
|
||||||
guard let self = self else { return nil }
|
guard let self = self else { return nil }
|
||||||
|
@ -344,7 +357,13 @@ extension MastodonPickServerViewController {
|
||||||
clientSecret: authenticateInfo.clientSecret,
|
clientSecret: authenticateInfo.clientSecret,
|
||||||
redirectURI: authenticateInfo.redirectURI
|
redirectURI: authenticateInfo.redirectURI
|
||||||
)
|
)
|
||||||
.map { MastodonPickServerViewModel.SignUpResponseThird(instance: instance, authenticateInfo: authenticateInfo, applicationToken: $0) }
|
.map {
|
||||||
|
MastodonPickServerViewModel.SignUpResponseThird(
|
||||||
|
instance: instance,
|
||||||
|
authenticateInfo: authenticateInfo,
|
||||||
|
applicationToken: $0
|
||||||
|
)
|
||||||
|
}
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
.switchToLatest()
|
.switchToLatest()
|
||||||
|
@ -416,28 +435,6 @@ extension MastodonPickServerViewController: UITableViewDelegate {
|
||||||
viewModel.selectedServer.send(nil)
|
viewModel.selectedServer.send(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
|
||||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
|
||||||
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
|
||||||
|
|
||||||
switch item {
|
|
||||||
// case .categoryPicker:
|
|
||||||
// guard let cell = cell as? PickServerCategoriesCell else { return }
|
|
||||||
// guard let diffableDataSource = cell.diffableDataSource else { return }
|
|
||||||
// let snapshot = diffableDataSource.snapshot()
|
|
||||||
//
|
|
||||||
// let item = viewModel.selectCategoryItem.value
|
|
||||||
// guard let section = snapshot.indexOfSection(.main),
|
|
||||||
// let row = snapshot.indexOfItem(item) else { return }
|
|
||||||
// cell.collectionView.selectItem(at: IndexPath(item: row, section: section), animated: false, scrollPosition: .centeredHorizontally)
|
|
||||||
// case .search:
|
|
||||||
// guard let cell = cell as? PickServerSearchCell else { return }
|
|
||||||
// cell.searchTextField.text = viewModel.searchText.value
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||||
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
|
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
|
||||||
let snapshot = diffableDataSource.snapshot()
|
let snapshot = diffableDataSource.snapshot()
|
||||||
|
|
|
@ -45,6 +45,8 @@ class MastodonPickServerViewModel: NSObject {
|
||||||
let indexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([])
|
let indexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([])
|
||||||
let unindexedServers = CurrentValueSubject<[Mastodon.Entity.Server]?, Never>([]) // set nil when loading
|
let unindexedServers = CurrentValueSubject<[Mastodon.Entity.Server]?, Never>([]) // set nil when loading
|
||||||
let viewWillAppear = PassthroughSubject<Void, Never>()
|
let viewWillAppear = PassthroughSubject<Void, Never>()
|
||||||
|
let viewDidAppear = CurrentValueSubject<Void, Never>(Void())
|
||||||
|
@Published var additionalTableViewInsets: UIEdgeInsets = .zero
|
||||||
|
|
||||||
// output
|
// output
|
||||||
var diffableDataSource: UITableViewDiffableDataSource<PickServerSection, PickServerItem>?
|
var diffableDataSource: UITableViewDiffableDataSource<PickServerSection, PickServerItem>?
|
||||||
|
@ -114,8 +116,11 @@ extension MastodonPickServerViewModel {
|
||||||
if self.mode == .signUp {
|
if self.mode == .signUp {
|
||||||
indexedServers = indexedServers.filter { !$0.approvalRequired }
|
indexedServers = indexedServers.filter { !$0.approvalRequired }
|
||||||
}
|
}
|
||||||
|
// Note:
|
||||||
|
// sort by calculate last week users count
|
||||||
|
// and make medium size (~800) server to top
|
||||||
|
|
||||||
// group by language user preferred language first. Then sort by `totalUsers`
|
// group by language user preferred language first
|
||||||
var languageToServersMapping = OrderedDictionary<String, [Mastodon.Entity.Server]>()
|
var languageToServersMapping = OrderedDictionary<String, [Mastodon.Entity.Server]>()
|
||||||
for language in Locale.preferredLanguages {
|
for language in Locale.preferredLanguages {
|
||||||
let local = Locale(identifier: language)
|
let local = Locale(identifier: language)
|
||||||
|
@ -125,14 +130,22 @@ extension MastodonPickServerViewModel {
|
||||||
// append to dict
|
// append to dict
|
||||||
languageToServersMapping[languageCode] = indexedServers
|
languageToServersMapping[languageCode] = indexedServers
|
||||||
.filter { $0.language.lowercased() == languageCode.lowercased() }
|
.filter { $0.language.lowercased() == languageCode.lowercased() }
|
||||||
.sorted(by: { $0.totalUsers > $1.totalUsers })
|
.sorted(by: { lh, rh in
|
||||||
|
let lhValue = abs(log2(800.0) - log2(Double(lh.lastWeekUsers)))
|
||||||
|
let rhValue = abs(log2(800.0) - log2(Double(rh.lastWeekUsers)))
|
||||||
|
return lhValue < rhValue
|
||||||
|
})
|
||||||
}
|
}
|
||||||
// sort remains servers by `totalUsers`
|
// sort remains servers
|
||||||
let remainsServers = indexedServers
|
let remainsServers = indexedServers
|
||||||
.filter { server in
|
.filter { server in
|
||||||
return !languageToServersMapping.contains { _, servers in servers.contains(server) }
|
return !languageToServersMapping.contains { _, servers in servers.contains(server) }
|
||||||
}
|
}
|
||||||
.sorted(by: { $0.totalUsers > $1.totalUsers })
|
.sorted(by: { lh, rh in
|
||||||
|
let lhValue = abs(log2(800.0) - log2(Double(lh.lastWeekUsers)))
|
||||||
|
let rhValue = abs(log2(800.0) - log2(Double(rh.lastWeekUsers)))
|
||||||
|
return lhValue < rhValue
|
||||||
|
})
|
||||||
|
|
||||||
var _indexedServers: [Mastodon.Entity.Server] = []
|
var _indexedServers: [Mastodon.Entity.Server] = []
|
||||||
for key in languageToServersMapping.keys {
|
for key in languageToServersMapping.keys {
|
||||||
|
|
|
@ -185,12 +185,12 @@ extension PickServerServerSectionTableHeaderView {
|
||||||
|
|
||||||
override func accessibilityElementCount() -> Int {
|
override func accessibilityElementCount() -> Int {
|
||||||
guard let diffableDataSource = diffableDataSource else { return 0 }
|
guard let diffableDataSource = diffableDataSource else { return 0 }
|
||||||
return diffableDataSource.snapshot().itemIdentifiers.count
|
return diffableDataSource.snapshot().itemIdentifiers.count + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
override func accessibilityElement(at index: Int) -> Any? {
|
override func accessibilityElement(at index: Int) -> Any? {
|
||||||
guard let item = collectionView.cellForItem(at: IndexPath(item: index, section: 0)) else { return nil }
|
if let item = collectionView.cellForItem(at: IndexPath(item: index, section: 0)) { return item }
|
||||||
return item
|
return searchTextField
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -115,6 +115,7 @@ extension MastodonRegisterTextFieldTableViewCell {
|
||||||
label.font = MastodonRegisterTextFieldTableViewCell.textFieldLabelFont
|
label.font = MastodonRegisterTextFieldTableViewCell.textFieldLabelFont
|
||||||
label.textColor = Asset.Colors.Label.primary.color
|
label.textColor = Asset.Colors.Label.primary.color
|
||||||
label.text = text
|
label.text = text
|
||||||
|
label.lineBreakMode = .byTruncatingMiddle
|
||||||
|
|
||||||
label.translatesAutoresizingMaskIntoConstraints = false
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
containerView.addSubview(label)
|
containerView.addSubview(label)
|
||||||
|
@ -123,6 +124,7 @@ extension MastodonRegisterTextFieldTableViewCell {
|
||||||
label.leadingAnchor.constraint(equalTo: paddingView.trailingAnchor),
|
label.leadingAnchor.constraint(equalTo: paddingView.trailingAnchor),
|
||||||
containerView.trailingAnchor.constraint(equalTo: label.trailingAnchor, constant: 16),
|
containerView.trailingAnchor.constraint(equalTo: label.trailingAnchor, constant: 16),
|
||||||
label.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
|
label.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
|
||||||
|
label.widthAnchor.constraint(lessThanOrEqualToConstant: 180).priority(.required - 1),
|
||||||
])
|
])
|
||||||
return containerView
|
return containerView
|
||||||
}()
|
}()
|
||||||
|
|
|
@ -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 os.log
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import SwiftUI
|
||||||
|
import MastodonUI
|
||||||
import MastodonAsset
|
import MastodonAsset
|
||||||
import MastodonLocalization
|
import MastodonLocalization
|
||||||
|
|
||||||
|
@ -27,6 +29,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
|
||||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||||
|
|
||||||
var viewModel: MastodonRegisterViewModel!
|
var viewModel: MastodonRegisterViewModel!
|
||||||
|
private(set) lazy var mastodonRegisterView = MastodonRegisterView(viewModel: viewModel)
|
||||||
|
|
||||||
// picker
|
// picker
|
||||||
private(set) lazy var imagePicker: PHPickerViewController = {
|
private(set) lazy var imagePicker: PHPickerViewController = {
|
||||||
|
@ -51,22 +54,6 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
|
||||||
return documentPickerController
|
return documentPickerController
|
||||||
}()
|
}()
|
||||||
|
|
||||||
let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
|
|
||||||
|
|
||||||
let tableView: UITableView = {
|
|
||||||
let tableView = UITableView()
|
|
||||||
tableView.rowHeight = UITableView.automaticDimension
|
|
||||||
tableView.separatorStyle = .none
|
|
||||||
tableView.backgroundColor = .clear
|
|
||||||
tableView.keyboardDismissMode = .onDrag
|
|
||||||
if #available(iOS 15.0, *) {
|
|
||||||
tableView.sectionHeaderTopPadding = .leastNonzeroMagnitude
|
|
||||||
} else {
|
|
||||||
// Fallback on earlier versions
|
|
||||||
}
|
|
||||||
return tableView
|
|
||||||
}()
|
|
||||||
|
|
||||||
let navigationActionView: NavigationActionView = {
|
let navigationActionView: NavigationActionView = {
|
||||||
let navigationActionView = NavigationActionView()
|
let navigationActionView = NavigationActionView()
|
||||||
navigationActionView.backgroundColor = Asset.Scene.Onboarding.background.color
|
navigationActionView.backgroundColor = Asset.Scene.Onboarding.background.color
|
||||||
|
@ -87,17 +74,21 @@ extension MastodonRegisterViewController {
|
||||||
navigationItem.leftBarButtonItem = UIBarButtonItem()
|
navigationItem.leftBarButtonItem = UIBarButtonItem()
|
||||||
|
|
||||||
setupOnboardingAppearance()
|
setupOnboardingAppearance()
|
||||||
|
viewModel.backgroundColor = view.backgroundColor ?? .clear
|
||||||
defer {
|
defer {
|
||||||
setupNavigationBarBackgroundView()
|
setupNavigationBarBackgroundView()
|
||||||
}
|
}
|
||||||
|
|
||||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
let hostingViewController = UIHostingController(rootView: mastodonRegisterView)
|
||||||
view.addSubview(tableView)
|
hostingViewController.view.preservesSuperviewLayoutMargins = true
|
||||||
|
addChild(hostingViewController)
|
||||||
|
hostingViewController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(hostingViewController.view)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
tableView.topAnchor.constraint(equalTo: view.topAnchor),
|
hostingViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
hostingViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
hostingViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
hostingViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
])
|
])
|
||||||
|
|
||||||
navigationActionView.translatesAutoresizingMaskIntoConstraints = false
|
navigationActionView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
@ -115,7 +106,7 @@ extension MastodonRegisterViewController {
|
||||||
.observe(\.bounds, options: [.initial, .new]) { [weak self] navigationActionView, _ in
|
.observe(\.bounds, options: [.initial, .new]) { [weak self] navigationActionView, _ in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
let inset = navigationActionView.frame.height
|
let inset = navigationActionView.frame.height
|
||||||
self.tableView.contentInset.bottom = inset
|
self.viewModel.bottomPaddingHeight = inset
|
||||||
}
|
}
|
||||||
.store(in: &observations)
|
.store(in: &observations)
|
||||||
|
|
||||||
|
@ -129,19 +120,14 @@ extension MastodonRegisterViewController {
|
||||||
self.navigationActionView.nextButton.isEnabled = isAllValid
|
self.navigationActionView.nextButton.isEnabled = isAllValid
|
||||||
}
|
}
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
viewModel.setupDiffableDataSource(tableView: tableView)
|
|
||||||
|
|
||||||
// KeyboardResponderService
|
|
||||||
// .configure(
|
|
||||||
// scrollView: tableView,
|
|
||||||
// layoutNeedsUpdate: viewModel.viewDidAppear.eraseToAnyPublisher()
|
|
||||||
// )
|
|
||||||
// .store(in: &disposeBag)
|
|
||||||
|
|
||||||
// gesture
|
viewModel.endEditing
|
||||||
view.addGestureRecognizer(tapGestureRecognizer)
|
.receive(on: DispatchQueue.main)
|
||||||
tapGestureRecognizer.addTarget(self, action: #selector(tapGestureRecognizerHandler))
|
.sink { [weak self] _ in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.view.endEditing(true)
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
// // return
|
// // return
|
||||||
// if viewModel.approvalRequired {
|
// if viewModel.approvalRequired {
|
||||||
|
@ -149,80 +135,22 @@ extension MastodonRegisterViewController {
|
||||||
// } else {
|
// } else {
|
||||||
// passwordTextField.returnKeyType = .done
|
// passwordTextField.returnKeyType = .done
|
||||||
// }
|
// }
|
||||||
//
|
|
||||||
// viewModel.usernameValidateState
|
viewModel.$error
|
||||||
// .receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
// .sink { [weak self] validateState in
|
.sink { [weak self] error in
|
||||||
// guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
// self.setTextFieldValidAppearance(self.usernameTextField, validateState: validateState)
|
guard let error = error as? Mastodon.API.Error else { return }
|
||||||
// }
|
let alertController = UIAlertController(for: error, title: "Sign Up Failure", preferredStyle: .alert)
|
||||||
// .store(in: &disposeBag)
|
let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
|
||||||
// viewModel.usernameErrorPrompt
|
alertController.addAction(okAction)
|
||||||
// .receive(on: DispatchQueue.main)
|
self.coordinator.present(
|
||||||
// .sink { [weak self] prompt in
|
scene: .alertController(alertController: alertController),
|
||||||
// guard let self = self else { return }
|
from: nil,
|
||||||
// self.usernameErrorPromptLabel.attributedText = prompt
|
transition: .alertController(animated: true, completion: nil)
|
||||||
// }
|
)
|
||||||
// .store(in: &disposeBag)
|
}
|
||||||
// viewModel.displayNameValidateState
|
.store(in: &disposeBag)
|
||||||
// .receive(on: DispatchQueue.main)
|
|
||||||
// .sink { [weak self] validateState in
|
|
||||||
// guard let self = self else { return }
|
|
||||||
// self.setTextFieldValidAppearance(self.displayNameTextField, validateState: validateState)
|
|
||||||
// }
|
|
||||||
// .store(in: &disposeBag)
|
|
||||||
// viewModel.emailValidateState
|
|
||||||
// .receive(on: DispatchQueue.main)
|
|
||||||
// .sink { [weak self] validateState in
|
|
||||||
// guard let self = self else { return }
|
|
||||||
// self.setTextFieldValidAppearance(self.emailTextField, validateState: validateState)
|
|
||||||
// }
|
|
||||||
// .store(in: &disposeBag)
|
|
||||||
// viewModel.emailErrorPrompt
|
|
||||||
// .receive(on: DispatchQueue.main)
|
|
||||||
// .sink { [weak self] prompt in
|
|
||||||
// guard let self = self else { return }
|
|
||||||
// self.emailErrorPromptLabel.attributedText = prompt
|
|
||||||
// }
|
|
||||||
// .store(in: &disposeBag)
|
|
||||||
// viewModel.passwordValidateState
|
|
||||||
// .receive(on: DispatchQueue.main)
|
|
||||||
// .sink { [weak self] validateState in
|
|
||||||
// guard let self = self else { return }
|
|
||||||
// self.setTextFieldValidAppearance(self.passwordTextField, validateState: validateState)
|
|
||||||
// self.passwordCheckLabel.attributedText = MastodonRegisterViewModel.attributeStringForPassword(validateState: validateState)
|
|
||||||
// }
|
|
||||||
// .store(in: &disposeBag)
|
|
||||||
// viewModel.passwordErrorPrompt
|
|
||||||
// .receive(on: DispatchQueue.main)
|
|
||||||
// .sink { [weak self] prompt in
|
|
||||||
// guard let self = self else { return }
|
|
||||||
// self.passwordErrorPromptLabel.attributedText = prompt
|
|
||||||
// }
|
|
||||||
// .store(in: &disposeBag)
|
|
||||||
// viewModel.reasonErrorPrompt
|
|
||||||
// .receive(on: DispatchQueue.main)
|
|
||||||
// .sink { [weak self] prompt in
|
|
||||||
// guard let self = self else { return }
|
|
||||||
// self.reasonErrorPromptLabel.attributedText = prompt
|
|
||||||
// }
|
|
||||||
// .store(in: &disposeBag)
|
|
||||||
// viewModel.error
|
|
||||||
// .receive(on: DispatchQueue.main)
|
|
||||||
// .sink { [weak self] error in
|
|
||||||
// guard let self = self else { return }
|
|
||||||
// guard let error = error as? Mastodon.API.Error else { return }
|
|
||||||
// let alertController = UIAlertController(for: error, title: "Sign Up Failure", preferredStyle: .alert)
|
|
||||||
// let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
|
|
||||||
// alertController.addAction(okAction)
|
|
||||||
// self.coordinator.present(
|
|
||||||
// scene: .alertController(alertController: alertController),
|
|
||||||
// from: nil,
|
|
||||||
// transition: .alertController(animated: true, completion: nil)
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
// .store(in: &disposeBag)
|
|
||||||
//
|
|
||||||
|
|
||||||
viewModel.avatarMediaMenuActionPublisher
|
viewModel.avatarMediaMenuActionPublisher
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
|
@ -260,10 +188,6 @@ extension MastodonRegisterViewController {
|
||||||
|
|
||||||
extension MastodonRegisterViewController {
|
extension MastodonRegisterViewController {
|
||||||
|
|
||||||
@objc private func tapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
|
|
||||||
view.endEditing(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func backButtonPressed(_ sender: UIButton) {
|
@objc private func backButtonPressed(_ sender: UIButton) {
|
||||||
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
|
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
|
||||||
navigationController?.popViewController(animated: true)
|
navigationController?.popViewController(animated: true)
|
||||||
|
@ -403,65 +327,3 @@ extension MastodonRegisterViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension MastodonRegisterViewController: UITextFieldDelegate {
|
|
||||||
// func textFieldDidBeginEditing(_ textField: UITextField) {
|
|
||||||
// let text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
//
|
|
||||||
// switch textField {
|
|
||||||
// case usernameTextField:
|
|
||||||
// viewModel.username.value = text
|
|
||||||
// case displayNameTextField:
|
|
||||||
// viewModel.displayName.value = text
|
|
||||||
// case emailTextField:
|
|
||||||
// viewModel.email.value = text
|
|
||||||
// case passwordTextField:
|
|
||||||
// viewModel.password.value = text
|
|
||||||
// case reasonTextField:
|
|
||||||
// viewModel.reason.value = text
|
|
||||||
// default:
|
|
||||||
// break
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// func textFieldDidEndEditing(_ textField: UITextField) {
|
|
||||||
// let text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
//
|
|
||||||
// switch textField {
|
|
||||||
// case usernameTextField:
|
|
||||||
// viewModel.username.value = text
|
|
||||||
// case displayNameTextField:
|
|
||||||
// viewModel.displayName.value = text
|
|
||||||
// case emailTextField:
|
|
||||||
// viewModel.email.value = text
|
|
||||||
// case passwordTextField:
|
|
||||||
// viewModel.password.value = text
|
|
||||||
// case reasonTextField:
|
|
||||||
// viewModel.reason.value = text
|
|
||||||
// default:
|
|
||||||
// break
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
|
||||||
// switch textField {
|
|
||||||
// case usernameTextField:
|
|
||||||
// displayNameTextField.becomeFirstResponder()
|
|
||||||
// case displayNameTextField:
|
|
||||||
// emailTextField.becomeFirstResponder()
|
|
||||||
// case emailTextField:
|
|
||||||
// passwordTextField.becomeFirstResponder()
|
|
||||||
// case passwordTextField:
|
|
||||||
// if viewModel.approvalRequired {
|
|
||||||
// reasonTextField.becomeFirstResponder()
|
|
||||||
// } else {
|
|
||||||
// passwordTextField.resignFirstResponder()
|
|
||||||
// }
|
|
||||||
// case reasonTextField:
|
|
||||||
// reasonTextField.resignFirstResponder()
|
|
||||||
// default:
|
|
||||||
// break
|
|
||||||
// }
|
|
||||||
// return true
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
|
@ -143,7 +143,7 @@ extension MastodonRegisterViewModel {
|
||||||
snapshot.appendItems([.header(domain: domain)], toSection: .main)
|
snapshot.appendItems([.header(domain: domain)], toSection: .main)
|
||||||
snapshot.appendItems([.avatar, .name, .username, .email, .password, .hint], toSection: .main)
|
snapshot.appendItems([.avatar, .name, .username, .email, .password, .hint], toSection: .main)
|
||||||
if approvalRequired {
|
if approvalRequired {
|
||||||
snapshot.appendItems([.reason], toSection: .main)
|
snapshot.appendItems([.reason], toSection: .main)
|
||||||
}
|
}
|
||||||
diffableDataSource?.applySnapshot(snapshot, animated: false, completion: nil)
|
diffableDataSource?.applySnapshot(snapshot, animated: false, completion: nil)
|
||||||
}
|
}
|
||||||
|
@ -164,51 +164,6 @@ extension MastodonRegisterViewModel {
|
||||||
.store(in: &cell.disposeBag)
|
.store(in: &cell.disposeBag)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AvatarMediaMenuAction {
|
|
||||||
case photoLibrary
|
|
||||||
case camera
|
|
||||||
case browse
|
|
||||||
case delete
|
|
||||||
}
|
|
||||||
|
|
||||||
private func createAvatarMediaContextMenu() -> UIMenu {
|
|
||||||
var children: [UIMenuElement] = []
|
|
||||||
|
|
||||||
// Photo Library
|
|
||||||
let photoLibraryAction = UIAction(title: L10n.Scene.Compose.MediaSelection.photoLibrary, image: UIImage(systemName: "rectangle.on.rectangle"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.avatarMediaMenuActionPublisher.send(.photoLibrary)
|
|
||||||
}
|
|
||||||
children.append(photoLibraryAction)
|
|
||||||
|
|
||||||
// Camera
|
|
||||||
if UIImagePickerController.isSourceTypeAvailable(.camera) {
|
|
||||||
let cameraAction = UIAction(title: L10n.Scene.Compose.MediaSelection.camera, image: UIImage(systemName: "camera"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] _ in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.avatarMediaMenuActionPublisher.send(.camera)
|
|
||||||
})
|
|
||||||
children.append(cameraAction)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Browse
|
|
||||||
let browseAction = UIAction(title: L10n.Scene.Compose.MediaSelection.browse, image: UIImage(systemName: "ellipsis"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.avatarMediaMenuActionPublisher.send(.browse)
|
|
||||||
}
|
|
||||||
children.append(browseAction)
|
|
||||||
|
|
||||||
// Delete
|
|
||||||
if avatarImage != nil {
|
|
||||||
let deleteAction = UIAction(title: L10n.Scene.Register.Input.Avatar.delete, image: UIImage(systemName: "delete.left"), identifier: nil, discoverabilityTitle: nil, attributes: [.destructive], state: .off) { [weak self] _ in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.avatarMediaMenuActionPublisher.send(.delete)
|
|
||||||
}
|
|
||||||
children.append(deleteAction)
|
|
||||||
}
|
|
||||||
|
|
||||||
return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func configureTextFieldCell(
|
private func configureTextFieldCell(
|
||||||
cell: MastodonRegisterTextFieldTableViewCell,
|
cell: MastodonRegisterTextFieldTableViewCell,
|
||||||
validateState: Published<ValidateState>.Publisher
|
validateState: Published<ValidateState>.Publisher
|
||||||
|
|
|
@ -12,7 +12,7 @@ import UIKit
|
||||||
import MastodonAsset
|
import MastodonAsset
|
||||||
import MastodonLocalization
|
import MastodonLocalization
|
||||||
|
|
||||||
final class MastodonRegisterViewModel {
|
final class MastodonRegisterViewModel: ObservableObject {
|
||||||
var disposeBag = Set<AnyCancellable>()
|
var disposeBag = Set<AnyCancellable>()
|
||||||
|
|
||||||
// input
|
// input
|
||||||
|
@ -23,6 +23,7 @@ final class MastodonRegisterViewModel {
|
||||||
let applicationToken: Mastodon.Entity.Token
|
let applicationToken: Mastodon.Entity.Token
|
||||||
let viewDidAppear = CurrentValueSubject<Void, Never>(Void())
|
let viewDidAppear = CurrentValueSubject<Void, Never>(Void())
|
||||||
|
|
||||||
|
@Published var backgroundColor: UIColor = Asset.Scene.Onboarding.background.color
|
||||||
@Published var avatarImage: UIImage? = nil
|
@Published var avatarImage: UIImage? = nil
|
||||||
@Published var name = ""
|
@Published var name = ""
|
||||||
@Published var username = ""
|
@Published var username = ""
|
||||||
|
@ -30,10 +31,12 @@ final class MastodonRegisterViewModel {
|
||||||
@Published var password = ""
|
@Published var password = ""
|
||||||
@Published var reason = ""
|
@Published var reason = ""
|
||||||
|
|
||||||
let usernameErrorPrompt = CurrentValueSubject<NSAttributedString?, Never>(nil)
|
@Published var usernameErrorPrompt: String? = nil
|
||||||
let emailErrorPrompt = CurrentValueSubject<NSAttributedString?, Never>(nil)
|
@Published var emailErrorPrompt: String? = nil
|
||||||
let passwordErrorPrompt = CurrentValueSubject<NSAttributedString?, Never>(nil)
|
@Published var passwordErrorPrompt: String? = nil
|
||||||
let reasonErrorPrompt = CurrentValueSubject<NSAttributedString?, Never>(nil)
|
@Published var reasonErrorPrompt: String? = nil
|
||||||
|
|
||||||
|
@Published var bottomPaddingHeight: CGFloat = .zero
|
||||||
|
|
||||||
// output
|
// output
|
||||||
var diffableDataSource: UITableViewDiffableDataSource<RegisterSection, RegisterItem>?
|
var diffableDataSource: UITableViewDiffableDataSource<RegisterSection, RegisterItem>?
|
||||||
|
@ -51,6 +54,7 @@ final class MastodonRegisterViewModel {
|
||||||
@Published var error: Error? = nil
|
@Published var error: Error? = nil
|
||||||
|
|
||||||
let avatarMediaMenuActionPublisher = PassthroughSubject<AvatarMediaMenuAction, Never>()
|
let avatarMediaMenuActionPublisher = PassthroughSubject<AvatarMediaMenuAction, Never>()
|
||||||
|
let endEditing = PassthroughSubject<Void, Never>()
|
||||||
|
|
||||||
init(
|
init(
|
||||||
context: AppContext,
|
context: AppContext,
|
||||||
|
@ -97,45 +101,46 @@ final class MastodonRegisterViewModel {
|
||||||
.assign(to: \.usernameValidateState, on: self)
|
.assign(to: \.usernameValidateState, on: self)
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
// TODO: check username available
|
// check username available
|
||||||
// username
|
$username
|
||||||
// .filter { !$0.isEmpty }
|
.filter { !$0.isEmpty }
|
||||||
// .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
||||||
// .removeDuplicates()
|
.removeDuplicates()
|
||||||
// .compactMap { [weak self] text -> AnyPublisher<Result<Mastodon.Response.Content<Mastodon.Entity.Account>, Error>, Never>? in
|
.compactMap { [weak self] text -> AnyPublisher<Result<Mastodon.Response.Content<Mastodon.Entity.Account>, Error>, Never>? in
|
||||||
// guard let self = self else { return nil }
|
guard let self = self else { return nil }
|
||||||
// let query = Mastodon.API.Account.AccountLookupQuery(acct: text)
|
let query = Mastodon.API.Account.AccountLookupQuery(acct: text)
|
||||||
// return context.apiService.accountLookup(domain: domain, query: query, authorization: self.applicationAuthorization)
|
return context.apiService.accountLookup(domain: domain, query: query, authorization: self.applicationAuthorization)
|
||||||
// .map {
|
.map {
|
||||||
// response -> Result<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> in
|
response -> Result<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> in
|
||||||
// Result.success(response)
|
Result.success(response)
|
||||||
// }
|
}
|
||||||
// .catch { error in
|
.catch { error in
|
||||||
// Just(Result.failure(error))
|
Just(Result.failure(error))
|
||||||
// }
|
}
|
||||||
// .eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
// }
|
}
|
||||||
// .switchToLatest()
|
.switchToLatest()
|
||||||
// .sink { [weak self] result in
|
.receive(on: DispatchQueue.main)
|
||||||
// guard let self = self else { return }
|
.sink { [weak self] result in
|
||||||
// switch result {
|
guard let self = self else { return }
|
||||||
// case .success:
|
switch result {
|
||||||
// let text = L10n.Scene.Register.Error.Reason.taken(L10n.Scene.Register.Error.Item.username)
|
case .success:
|
||||||
// self.usernameErrorPrompt.value = MastodonRegisterViewModel.errorPromptAttributedString(for: text)
|
let text = L10n.Scene.Register.Error.Reason.taken(L10n.Scene.Register.Error.Item.username)
|
||||||
// self.usernameValidateState.value = .invalid
|
self.usernameErrorPrompt = text
|
||||||
// case .failure:
|
self.usernameValidateState = .invalid
|
||||||
// break
|
case .failure:
|
||||||
// }
|
break
|
||||||
// }
|
}
|
||||||
// .store(in: &disposeBag)
|
}
|
||||||
//
|
.store(in: &disposeBag)
|
||||||
// usernameValidateState
|
|
||||||
// .sink { [weak self] validateState in
|
$usernameValidateState
|
||||||
// if validateState == .valid {
|
.sink { [weak self] validateState in
|
||||||
// self?.usernameErrorPrompt.value = nil
|
if validateState == .valid {
|
||||||
// }
|
self?.usernameErrorPrompt = nil
|
||||||
// }
|
}
|
||||||
// .store(in: &disposeBag)
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
$email
|
$email
|
||||||
.map { email in
|
.map { email in
|
||||||
|
@ -163,27 +168,31 @@ final class MastodonRegisterViewModel {
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
}
|
}
|
||||||
|
|
||||||
// error
|
$error
|
||||||
// .sink { [weak self] error in
|
.sink { [weak self] error in
|
||||||
// guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
// let error = error as? Mastodon.API.Error
|
let error = error as? Mastodon.API.Error
|
||||||
// let mastodonError = error?.mastodonError
|
let mastodonError = error?.mastodonError
|
||||||
// if case let .generic(genericMastodonError) = mastodonError,
|
if case let .generic(genericMastodonError) = mastodonError,
|
||||||
// let details = genericMastodonError.details
|
let details = genericMastodonError.details
|
||||||
// {
|
{
|
||||||
// self.usernameErrorPrompt.value = details.usernameErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) }
|
self.usernameErrorPrompt = details.usernameErrorDescriptions.first
|
||||||
// self.emailErrorPrompt.value = details.emailErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) }
|
details.usernameErrorDescriptions.first.flatMap { _ in self.usernameValidateState = .invalid }
|
||||||
// self.passwordErrorPrompt.value = details.passwordErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) }
|
self.emailErrorPrompt = details.emailErrorDescriptions.first
|
||||||
// self.reasonErrorPrompt.value = details.reasonErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) }
|
details.emailErrorDescriptions.first.flatMap { _ in self.emailValidateState = .invalid }
|
||||||
// } else {
|
self.passwordErrorPrompt = details.passwordErrorDescriptions.first
|
||||||
// self.usernameErrorPrompt.value = nil
|
details.passwordErrorDescriptions.first.flatMap { _ in self.passwordValidateState = .invalid }
|
||||||
// self.emailErrorPrompt.value = nil
|
self.reasonErrorPrompt = details.reasonErrorDescriptions.first
|
||||||
// self.passwordErrorPrompt.value = nil
|
details.reasonErrorDescriptions.first.flatMap { _ in self.reasonValidateState = .invalid }
|
||||||
// self.reasonErrorPrompt.value = nil
|
} else {
|
||||||
// }
|
self.usernameErrorPrompt = nil
|
||||||
// }
|
self.emailErrorPrompt = nil
|
||||||
// .store(in: &disposeBag)
|
self.passwordErrorPrompt = nil
|
||||||
//
|
self.reasonErrorPrompt = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
let publisherOne = Publishers.CombineLatest4(
|
let publisherOne = Publishers.CombineLatest4(
|
||||||
$usernameValidateState,
|
$usernameValidateState,
|
||||||
$displayNameValidateState,
|
$displayNameValidateState,
|
||||||
|
@ -213,7 +222,7 @@ final class MastodonRegisterViewModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension MastodonRegisterViewModel {
|
extension MastodonRegisterViewModel {
|
||||||
enum ValidateState {
|
enum ValidateState: Hashable {
|
||||||
case empty
|
case empty
|
||||||
case invalid
|
case invalid
|
||||||
case valid
|
case valid
|
||||||
|
@ -271,3 +280,52 @@ extension MastodonRegisterViewModel {
|
||||||
return attributeString
|
return attributeString
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension MastodonRegisterViewModel {
|
||||||
|
|
||||||
|
enum AvatarMediaMenuAction {
|
||||||
|
case photoLibrary
|
||||||
|
case camera
|
||||||
|
case browse
|
||||||
|
case delete
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createAvatarMediaContextMenu() -> UIMenu {
|
||||||
|
var children: [UIMenuElement] = []
|
||||||
|
|
||||||
|
// Photo Library
|
||||||
|
let photoLibraryAction = UIAction(title: L10n.Scene.Compose.MediaSelection.photoLibrary, image: UIImage(systemName: "rectangle.on.rectangle"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.avatarMediaMenuActionPublisher.send(.photoLibrary)
|
||||||
|
}
|
||||||
|
children.append(photoLibraryAction)
|
||||||
|
|
||||||
|
// Camera
|
||||||
|
if UIImagePickerController.isSourceTypeAvailable(.camera) {
|
||||||
|
let cameraAction = UIAction(title: L10n.Scene.Compose.MediaSelection.camera, image: UIImage(systemName: "camera"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] _ in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.avatarMediaMenuActionPublisher.send(.camera)
|
||||||
|
})
|
||||||
|
children.append(cameraAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browse
|
||||||
|
let browseAction = UIAction(title: L10n.Scene.Compose.MediaSelection.browse, image: UIImage(systemName: "ellipsis"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.avatarMediaMenuActionPublisher.send(.browse)
|
||||||
|
}
|
||||||
|
children.append(browseAction)
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
if avatarImage != nil {
|
||||||
|
let deleteAction = UIAction(title: L10n.Scene.Register.Input.Avatar.delete, image: UIImage(systemName: "delete.left"), identifier: nil, discoverabilityTitle: nil, attributes: [.destructive], state: .off) { [weak self] _ in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.avatarMediaMenuActionPublisher.send(.delete)
|
||||||
|
}
|
||||||
|
children.append(deleteAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -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 {
|
extension ProfileHeaderViewModel {
|
||||||
|
|
||||||
static func normalize(note: String?) -> String? {
|
static func normalize(note: String?) -> String? {
|
||||||
guard let note = note?.trimmingCharacters(in: .whitespacesAndNewlines),!note.isEmpty else {
|
let _note = note?.replacingOccurrences(of: "<br>|<br />", with: "\u{2028}", options: .regularExpression, range: nil)
|
||||||
|
.replacingOccurrences(of: "</p>", with: "</p>\u{2029}", range: nil)
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard let note = _note, !note.isEmpty else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,8 +13,9 @@ import MetaTextKit
|
||||||
import MastodonAsset
|
import MastodonAsset
|
||||||
import MastodonLocalization
|
import MastodonLocalization
|
||||||
import MastodonUI
|
import MastodonUI
|
||||||
import Tabman
|
|
||||||
import CoreDataStack
|
import CoreDataStack
|
||||||
|
import Tabman
|
||||||
|
import Pageboy
|
||||||
|
|
||||||
protocol ProfileViewModelEditable {
|
protocol ProfileViewModelEditable {
|
||||||
func isEdited() -> Bool
|
func isEdited() -> Bool
|
||||||
|
@ -42,19 +43,34 @@ final class ProfileViewController: UIViewController, NeedsDependency, MediaPrevi
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private(set) lazy var settingBarButtonItem: UIBarButtonItem = {
|
private(set) lazy var settingBarButtonItem: UIBarButtonItem = {
|
||||||
let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "gear"), style: .plain, target: self, action: #selector(ProfileViewController.settingBarButtonItemPressed(_:)))
|
let barButtonItem = UIBarButtonItem(
|
||||||
|
image: Asset.ObjectsAndTools.gear.image.withRenderingMode(.alwaysTemplate),
|
||||||
|
style: .plain,
|
||||||
|
target: self,
|
||||||
|
action: #selector(ProfileViewController.settingBarButtonItemPressed(_:))
|
||||||
|
)
|
||||||
barButtonItem.tintColor = .white
|
barButtonItem.tintColor = .white
|
||||||
return barButtonItem
|
return barButtonItem
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private(set) lazy var shareBarButtonItem: UIBarButtonItem = {
|
private(set) lazy var shareBarButtonItem: UIBarButtonItem = {
|
||||||
let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "square.and.arrow.up"), style: .plain, target: self, action: #selector(ProfileViewController.shareBarButtonItemPressed(_:)))
|
let barButtonItem = UIBarButtonItem(
|
||||||
|
image: Asset.Arrow.squareAndArrowUp.image.withRenderingMode(.alwaysTemplate),
|
||||||
|
style: .plain,
|
||||||
|
target: self,
|
||||||
|
action: #selector(ProfileViewController.shareBarButtonItemPressed(_:))
|
||||||
|
)
|
||||||
barButtonItem.tintColor = .white
|
barButtonItem.tintColor = .white
|
||||||
return barButtonItem
|
return barButtonItem
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private(set) lazy var favoriteBarButtonItem: UIBarButtonItem = {
|
private(set) lazy var favoriteBarButtonItem: UIBarButtonItem = {
|
||||||
let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "star"), style: .plain, target: self, action: #selector(ProfileViewController.favoriteBarButtonItemPressed(_:)))
|
let barButtonItem = UIBarButtonItem(
|
||||||
|
image: Asset.ObjectsAndTools.star.image.withRenderingMode(.alwaysTemplate),
|
||||||
|
style: .plain,
|
||||||
|
target: self,
|
||||||
|
action: #selector(ProfileViewController.favoriteBarButtonItemPressed(_:))
|
||||||
|
)
|
||||||
barButtonItem.tintColor = .white
|
barButtonItem.tintColor = .white
|
||||||
return barButtonItem
|
return barButtonItem
|
||||||
}()
|
}()
|
||||||
|
@ -402,6 +418,7 @@ extension ProfileViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ProfileViewController {
|
extension ProfileViewController {
|
||||||
|
|
||||||
private func updateBarButtonInsets() {
|
private func updateBarButtonInsets() {
|
||||||
let margin: CGFloat = {
|
let margin: CGFloat = {
|
||||||
switch traitCollection.userInterfaceIdiom {
|
switch traitCollection.userInterfaceIdiom {
|
||||||
|
@ -618,7 +635,7 @@ extension ProfileViewController {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let name = user.displayNameWithFallback
|
let name = user.displayNameWithFallback
|
||||||
let record = ManagedObjectRecord<MastodonUser>(objectID: user.objectID)
|
let _ = ManagedObjectRecord<MastodonUser>(objectID: user.objectID)
|
||||||
let menu = MastodonMenu.setupMenu(
|
let menu = MastodonMenu.setupMenu(
|
||||||
actions: [
|
actions: [
|
||||||
.muteUser(.init(name: name, isMuting: self.viewModel.isMuting.value)),
|
.muteUser(.init(name: name, isMuting: self.viewModel.isMuting.value)),
|
||||||
|
@ -633,7 +650,7 @@ extension ProfileViewController {
|
||||||
.sink { [weak self] completion in
|
.sink { [weak self] completion in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
switch completion {
|
switch completion {
|
||||||
case .failure(let error):
|
case .failure:
|
||||||
self.moreMenuBarButtonItem.menu = nil
|
self.moreMenuBarButtonItem.menu = nil
|
||||||
case .finished:
|
case .finished:
|
||||||
break
|
break
|
||||||
|
@ -937,6 +954,7 @@ extension ProfileViewController: ProfileHeaderViewDelegate {
|
||||||
viewModel.isUpdating.value = true
|
viewModel.isUpdating.value = true
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
|
// TODO: handle error
|
||||||
_ = try await viewModel.updateProfileInfo(
|
_ = try await viewModel.updateProfileInfo(
|
||||||
headerProfileInfo: profileHeaderViewModel.editProfileInfo,
|
headerProfileInfo: profileHeaderViewModel.editProfileInfo,
|
||||||
aboutProfileInfo: profileAboutViewModel.editProfileInfo
|
aboutProfileInfo: profileAboutViewModel.editProfileInfo
|
||||||
|
@ -1138,25 +1156,28 @@ extension ProfileViewController: ScrollViewContainer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//extension ProfileViewController {
|
extension ProfileViewController {
|
||||||
//
|
|
||||||
// override var keyCommands: [UIKeyCommand]? {
|
override var keyCommands: [UIKeyCommand]? {
|
||||||
// if !viewModel.isEditing.value {
|
if !viewModel.isEditing.value {
|
||||||
// return segmentedControlNavigateKeyCommands
|
return pageboyNavigateKeyCommands
|
||||||
// }
|
}
|
||||||
//
|
|
||||||
// return nil
|
return nil
|
||||||
// }
|
}
|
||||||
//
|
|
||||||
//}
|
}
|
||||||
|
|
||||||
|
// MARK: - PageboyNavigateable
|
||||||
|
extension ProfileViewController: PageboyNavigateable {
|
||||||
|
|
||||||
|
var navigateablePageViewController: PageboyViewController {
|
||||||
|
return profileSegmentedViewController.pagingViewController
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func pageboyNavigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
|
||||||
|
pageboyNavigateKeyCommandHandler(sender)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - SegmentedControlNavigateable
|
|
||||||
//extension ProfileViewController: SegmentedControlNavigateable {
|
|
||||||
// var navigateableSegmentedControl: UISegmentedControl {
|
|
||||||
// profileHeaderViewController.pageSegmentedControl
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// @objc func segmentedControlNavigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
|
|
||||||
// segmentedControlNavigateKeyCommandHandler(sender)
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
|
|
|
@ -13,10 +13,11 @@ import MastodonSDK
|
||||||
import MastodonMeta
|
import MastodonMeta
|
||||||
import MastodonAsset
|
import MastodonAsset
|
||||||
import MastodonLocalization
|
import MastodonLocalization
|
||||||
|
import MastodonUI
|
||||||
|
|
||||||
// please override this base class
|
// please override this base class
|
||||||
class ProfileViewModel: NSObject {
|
class ProfileViewModel: NSObject {
|
||||||
|
|
||||||
let logger = Logger(subsystem: "ProfileViewModel", category: "ViewModel")
|
let logger = Logger(subsystem: "ProfileViewModel", category: "ViewModel")
|
||||||
|
|
||||||
typealias UserID = String
|
typealias UserID = String
|
||||||
|
@ -372,101 +373,6 @@ extension ProfileViewModel {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ProfileViewModel {
|
|
||||||
|
|
||||||
enum RelationshipAction: Int, CaseIterable {
|
|
||||||
case none // set hide from UI
|
|
||||||
case follow
|
|
||||||
case request
|
|
||||||
case pending
|
|
||||||
case following
|
|
||||||
case muting
|
|
||||||
case blocked
|
|
||||||
case blocking
|
|
||||||
case suspended
|
|
||||||
case edit
|
|
||||||
case editing
|
|
||||||
case updating
|
|
||||||
|
|
||||||
var option: RelationshipActionOptionSet {
|
|
||||||
return RelationshipActionOptionSet(rawValue: 1 << rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// construct option set on the enum for safe iterator
|
|
||||||
struct RelationshipActionOptionSet: OptionSet {
|
|
||||||
let rawValue: Int
|
|
||||||
|
|
||||||
static let none = RelationshipAction.none.option
|
|
||||||
static let follow = RelationshipAction.follow.option
|
|
||||||
static let request = RelationshipAction.request.option
|
|
||||||
static let pending = RelationshipAction.pending.option
|
|
||||||
static let following = RelationshipAction.following.option
|
|
||||||
static let muting = RelationshipAction.muting.option
|
|
||||||
static let blocked = RelationshipAction.blocked.option
|
|
||||||
static let blocking = RelationshipAction.blocking.option
|
|
||||||
static let suspended = RelationshipAction.suspended.option
|
|
||||||
static let edit = RelationshipAction.edit.option
|
|
||||||
static let editing = RelationshipAction.editing.option
|
|
||||||
static let updating = RelationshipAction.updating.option
|
|
||||||
|
|
||||||
static let editOptions: RelationshipActionOptionSet = [.edit, .editing, .updating]
|
|
||||||
|
|
||||||
func highPriorityAction(except: RelationshipActionOptionSet) -> RelationshipAction? {
|
|
||||||
let set = subtracting(except)
|
|
||||||
for action in RelationshipAction.allCases.reversed() where set.contains(action.option) {
|
|
||||||
return action
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var title: String {
|
|
||||||
guard let highPriorityAction = self.highPriorityAction(except: []) else {
|
|
||||||
assertionFailure()
|
|
||||||
return " "
|
|
||||||
}
|
|
||||||
switch highPriorityAction {
|
|
||||||
case .none: return " "
|
|
||||||
case .follow: return L10n.Common.Controls.Friendship.follow
|
|
||||||
case .request: return L10n.Common.Controls.Friendship.request
|
|
||||||
case .pending: return L10n.Common.Controls.Friendship.pending
|
|
||||||
case .following: return L10n.Common.Controls.Friendship.following
|
|
||||||
case .muting: return L10n.Common.Controls.Friendship.muted
|
|
||||||
case .blocked: return L10n.Common.Controls.Friendship.follow // blocked by user
|
|
||||||
case .blocking: return L10n.Common.Controls.Friendship.blocked
|
|
||||||
case .suspended: return L10n.Common.Controls.Friendship.follow
|
|
||||||
case .edit: return L10n.Common.Controls.Friendship.editInfo
|
|
||||||
case .editing: return L10n.Common.Controls.Actions.done
|
|
||||||
case .updating: return " "
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, deprecated, message: "")
|
|
||||||
var backgroundColor: UIColor {
|
|
||||||
guard let highPriorityAction = self.highPriorityAction(except: []) else {
|
|
||||||
assertionFailure()
|
|
||||||
return Asset.Colors.brandBlue.color
|
|
||||||
}
|
|
||||||
switch highPriorityAction {
|
|
||||||
case .none: return Asset.Colors.brandBlue.color
|
|
||||||
case .follow: return Asset.Colors.brandBlue.color
|
|
||||||
case .request: return Asset.Colors.brandBlue.color
|
|
||||||
case .pending: return Asset.Colors.brandBlue.color
|
|
||||||
case .following: return Asset.Colors.brandBlue.color
|
|
||||||
case .muting: return Asset.Colors.alertYellow.color
|
|
||||||
case .blocked: return Asset.Colors.brandBlue.color
|
|
||||||
case .blocking: return Asset.Colors.danger.color
|
|
||||||
case .suspended: return Asset.Colors.brandBlue.color
|
|
||||||
case .edit: return Asset.Colors.brandBlue.color
|
|
||||||
case .editing: return Asset.Colors.brandBlue.color
|
|
||||||
case .updating: return Asset.Colors.brandBlue.color
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ProfileViewModel {
|
extension ProfileViewModel {
|
||||||
func updateProfileInfo(
|
func updateProfileInfo(
|
||||||
headerProfileInfo: ProfileHeaderViewModel.ProfileInfo,
|
headerProfileInfo: ProfileHeaderViewModel.ProfileInfo,
|
||||||
|
|
|
@ -44,7 +44,7 @@ final class ProfilePagingViewModel: NSObject {
|
||||||
let barItems: [TMBarItemable] = {
|
let barItems: [TMBarItemable] = {
|
||||||
let items = [
|
let items = [
|
||||||
TMBarItem(title: L10n.Scene.Profile.SegmentedControl.posts),
|
TMBarItem(title: L10n.Scene.Profile.SegmentedControl.posts),
|
||||||
TMBarItem(title: L10n.Scene.Profile.SegmentedControl.postsAndReplies), // TODO: i18n
|
TMBarItem(title: L10n.Scene.Profile.SegmentedControl.postsAndReplies),
|
||||||
TMBarItem(title: L10n.Scene.Profile.SegmentedControl.media),
|
TMBarItem(title: L10n.Scene.Profile.SegmentedControl.media),
|
||||||
TMBarItem(title: L10n.Scene.Profile.SegmentedControl.about),
|
TMBarItem(title: L10n.Scene.Profile.SegmentedControl.about),
|
||||||
]
|
]
|
||||||
|
|
|
@ -36,7 +36,7 @@ final class UserTimelineViewController: UIViewController, NeedsDependency, Media
|
||||||
let cellFrameCache = NSCache<NSNumber, NSValue>()
|
let cellFrameCache = NSCache<NSNumber, NSValue>()
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -195,6 +195,12 @@ extension UserTimelineViewModel.State {
|
||||||
|
|
||||||
// trigger data source update. otherwise, spinner always display
|
// trigger data source update. otherwise, spinner always display
|
||||||
viewModel.isSuspended.value = viewModel.isSuspended.value
|
viewModel.isSuspended.value = viewModel.isSuspended.value
|
||||||
|
|
||||||
|
// remove bottom loader
|
||||||
|
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||||
|
var snapshot = diffableDataSource.snapshot()
|
||||||
|
snapshot.deleteItems([.bottomLoader])
|
||||||
|
diffableDataSource.apply(snapshot)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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