Merge branch 'develop' into l10n_develop

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

View File

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

View File

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

View File

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

View File

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

6
Gemfile Normal file
View File

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

109
Gemfile.lock Normal file
View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -90,7 +90,10 @@ extension ProfileHeaderViewModel {
extension ProfileHeaderViewModel {
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
}

View File

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

View File

@ -13,10 +13,11 @@ import MastodonSDK
import MastodonMeta
import MastodonAsset
import MastodonLocalization
import MastodonUI
// please override this base class
class ProfileViewModel: NSObject {
let logger = Logger(subsystem: "ProfileViewModel", category: "ViewModel")
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 {
func updateProfileInfo(
headerProfileInfo: ProfileHeaderViewModel.ProfileInfo,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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