Merge pull request #110 from tootsuite/feature/suggestion

Feature/suggestion
This commit is contained in:
sxiaojian88 2021-04-23 04:49:06 -07:00 committed by GitHub
commit bcdb55f9f0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1266 additions and 100 deletions

View File

@ -51,7 +51,9 @@
"preview": "Preview",
"share": "Share",
"share_user": "Share %s",
"open_in_safari": "Open in Safari"
"open_in_safari": "Open in Safari",
"find_people": "Find people to follow",
"manually_search": "Manually search instead"
},
"status": {
"user_reblogged": "%s reblogged",
@ -231,6 +233,10 @@
"Publishing": "Publishing post..."
}
},
"suggestion_account": {
"title": "Find People to Follow",
"follow_explain": "When you follow someone, youll see their posts in your home feed."
},
"public_timeline": {
"title": "Public"
},
@ -330,7 +336,7 @@
},
"favorite": {
"title": "Your Favorites"
},
},
"notification": {
"title": {
"Everything": "Everything",
@ -342,6 +348,7 @@
"reblog": "rebloged your post",
"poll": "Your poll has ended",
"mention": "mentioned you"
},
},
"thread": {
"back_title": "Post",

View File

@ -74,6 +74,9 @@
2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8E25C8228A004A627A /* UIButton.swift */; };
2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */; };
2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */; };
2D4AD89C263165B500613EFC /* SuggestionAccountCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4AD89B263165B500613EFC /* SuggestionAccountCollectionViewCell.swift */; };
2D4AD8A226316CD200613EFC /* SelectedAccountSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4AD8A126316CD200613EFC /* SelectedAccountSection.swift */; };
2D4AD8A826316D3500613EFC /* SelectedAccountItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4AD8A726316D3500613EFC /* SelectedAccountItem.swift */; };
2D571B2F26004EC000540450 /* NavigationBarProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */; };
2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D59819A25E4A581000FB903 /* MastodonConfirmEmailViewController.swift */; };
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5981A025E4A593000FB903 /* MastodonConfirmEmailViewModel.swift */; };
@ -120,6 +123,9 @@
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */; };
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; };
2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; };
2DAC9E38262FC2320062E1A6 /* SuggestionAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */; };
2DAC9E3E262FC2400062E1A6 /* SuggestionAccountViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */; };
2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAC9E45262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift */; };
2DB72C8C262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */; };
2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */; };
2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */; };
@ -488,6 +494,9 @@
2D42FF8E25C8228A004A627A /* UIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIButton.swift; sourceTree = "<group>"; };
2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+State.swift"; sourceTree = "<group>"; };
2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSLayoutConstraint.swift; sourceTree = "<group>"; };
2D4AD89B263165B500613EFC /* SuggestionAccountCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountCollectionViewCell.swift; sourceTree = "<group>"; };
2D4AD8A126316CD200613EFC /* SelectedAccountSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedAccountSection.swift; sourceTree = "<group>"; };
2D4AD8A726316D3500613EFC /* SelectedAccountItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedAccountItem.swift; sourceTree = "<group>"; };
2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarProgressView.swift; sourceTree = "<group>"; };
2D59819A25E4A581000FB903 /* MastodonConfirmEmailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonConfirmEmailViewController.swift; sourceTree = "<group>"; };
2D5981A025E4A593000FB903 /* MastodonConfirmEmailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonConfirmEmailViewModel.swift; sourceTree = "<group>"; };
@ -532,6 +541,9 @@
2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = "<group>"; };
2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; };
2DA7D05625CA693F00804E11 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; };
2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountViewController.swift; sourceTree = "<group>"; };
2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountViewModel.swift; sourceTree = "<group>"; };
2DAC9E45262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountTableViewCell.swift; sourceTree = "<group>"; };
2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Notification+Type.swift"; sourceTree = "<group>"; };
2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendCollectionHeader.swift; sourceTree = "<group>"; };
2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendHashTagSection.swift; sourceTree = "<group>"; };
@ -1018,6 +1030,14 @@
path = Button;
sourceTree = "<group>";
};
2D4AD89A2631659400613EFC /* CollectionViewCell */ = {
isa = PBXGroup;
children = (
2D4AD89B263165B500613EFC /* SuggestionAccountCollectionViewCell.swift */,
);
path = CollectionViewCell;
sourceTree = "<group>";
};
2D59819925E4A55C000FB903 /* ConfirmEmail */ = {
isa = PBXGroup;
children = (
@ -1116,6 +1136,7 @@
DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */,
2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */,
2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */,
2D4AD8A126316CD200613EFC /* SelectedAccountSection.swift */,
2D35237926256D920031AF25 /* NotificationSection.swift */,
2D198648261C0B8500F0B013 /* SearchResultSection.swift */,
DB66729525F9F91600D60309 /* ComposeStatusSection.swift */,
@ -1170,6 +1191,7 @@
children = (
2D7631B225C159F700929FB9 /* Item.swift */,
2D198642261BF09500F0B013 /* SearchResultItem.swift */,
2D4AD8A726316D3500613EFC /* SelectedAccountItem.swift */,
2D7867182625B77500211898 /* NotificationItem.swift */,
DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */,
DB1E347725F519300079D7DF /* PickServerItem.swift */,
@ -1188,6 +1210,25 @@
path = Decoration;
sourceTree = "<group>";
};
2DAC9E36262FC20B0062E1A6 /* SuggestionAccount */ = {
isa = PBXGroup;
children = (
2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */,
2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */,
2D4AD89A2631659400613EFC /* CollectionViewCell */,
2DAC9E43262FC9DE0062E1A6 /* TableViewCell */,
);
path = SuggestionAccount;
sourceTree = "<group>";
};
2DAC9E43262FC9DE0062E1A6 /* TableViewCell */ = {
isa = PBXGroup;
children = (
2DAC9E45262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift */,
);
path = TableViewCell;
sourceTree = "<group>";
};
2DE0FAC62615F5D200CDF649 /* View */ = {
isa = PBXGroup;
children = (
@ -1675,6 +1716,7 @@
2D38F1D325CD463600561493 /* HomeTimeline */,
2D76316325C14BAC00929FB9 /* PublicTimeline */,
0F2021F5261325ED000C64BF /* HashtagTimeline */,
2DAC9E36262FC20B0062E1A6 /* SuggestionAccount */,
DB9D6BEE25E4F5370051B173 /* Search */,
5B90C455262599800002E742 /* Settings */,
DB9D6BFD25E4F57B0051B173 /* Notification */,
@ -2391,6 +2433,7 @@
DBCBED1726132DB500B49291 /* UserTimelineViewModel+Diffable.swift in Sources */,
DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */,
2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */,
2DAC9E3E262FC2400062E1A6 /* SuggestionAccountViewModel.swift in Sources */,
DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */,
DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */,
2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */,
@ -2407,6 +2450,7 @@
DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */,
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */,
DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */,
2D4AD8A826316D3500613EFC /* SelectedAccountItem.swift in Sources */,
DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */,
DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */,
2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */,
@ -2431,6 +2475,7 @@
DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */,
DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */,
2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */,
2DAC9E38262FC2320062E1A6 /* SuggestionAccountViewController.swift in Sources */,
DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */,
DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */,
2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */,
@ -2509,6 +2554,7 @@
2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */,
DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */,
DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */,
2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */,
DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */,
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */,
2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */,
@ -2532,6 +2578,7 @@
DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */,
2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */,
DB084B5725CBC56C00F898ED /* Status.swift in Sources */,
2D4AD89C263165B500613EFC /* SuggestionAccountCollectionViewCell.swift in Sources */,
DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */,
DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */,
DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */,
@ -2597,6 +2644,7 @@
DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */,
DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */,
0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */,
2D4AD8A226316CD200613EFC /* SelectedAccountSection.swift in Sources */,
DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */,
DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */,
DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */,

View File

@ -13,6 +13,7 @@ final public class SceneCoordinator {
private weak var scene: UIScene!
private weak var sceneDelegate: SceneDelegate!
private weak var appContext: AppContext!
private weak var tabBarController: MainTabBarController!
let id = UUID().uuidString
@ -61,6 +62,8 @@ extension SceneCoordinator {
case profile(viewModel: ProfileViewModel)
case favorite(viewModel: FavoriteViewModel)
// suggestion account
case suggestionAccount(viewModel: SuggestionAccountViewModel)
// misc
case safari(url: URL)
case alertController(alertController: UIAlertController)
@ -93,6 +96,7 @@ extension SceneCoordinator {
func setup() {
let viewController = MainTabBarController(context: appContext, coordinator: self)
sceneDelegate.window?.rootViewController = viewController
tabBarController = viewController
}
func setupOnboardingIfNeeds(animated: Bool) {
@ -187,6 +191,9 @@ extension SceneCoordinator {
return viewController
}
func switchToTabBar(tab: MainTabBarController.Tab) {
tabBarController.selectedIndex = tab.rawValue
}
}
private extension SceneCoordinator {
@ -246,6 +253,10 @@ private extension SceneCoordinator {
let _viewController = FavoriteViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .suggestionAccount(let viewModel):
let _viewController = SuggestionAccountViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .safari(let url):
guard let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https" else {

View File

@ -0,0 +1,38 @@
//
// SelectedAccountItem.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/22.
//
import CoreData
import Foundation
enum SelectedAccountItem {
case accountObjectID(accountObjectID: NSManagedObjectID)
case placeHolder(uuid: UUID)
}
extension SelectedAccountItem: Equatable {
static func == (lhs: SelectedAccountItem, rhs: SelectedAccountItem) -> Bool {
switch (lhs, rhs) {
case (.accountObjectID(let idLeft), .accountObjectID(let idRight)):
return idLeft == idRight
case (.placeHolder(let uuidLeft), .placeHolder(let uuidRight)):
return uuidLeft == uuidRight
default:
return false
}
}
}
extension SelectedAccountItem: Hashable {
func hash(into hasher: inout Hasher) {
switch self {
case .accountObjectID(let id):
hasher.combine(id)
case .placeHolder(let id):
hasher.combine(id.uuidString)
}
}
}

View File

@ -29,4 +29,21 @@ extension RecommendAccountSection {
return cell
}
}
static func tableViewDiffableDataSource(
for tableView: UITableView,
managedObjectContext: NSManagedObjectContext,
viewModel: SuggestionAccountViewModel,
delegate: SuggestionAccountTableViewCellDelegate
) -> UITableViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID> {
UITableViewDiffableDataSource(tableView: tableView) { [weak viewModel, weak delegate] (tableView, indexPath, objectID) -> UITableViewCell? in
guard let viewModel = viewModel else { return nil }
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SuggestionAccountTableViewCell.self)) as! SuggestionAccountTableViewCell
let user = managedObjectContext.object(with: objectID) as! MastodonUser
let isSelected = viewModel.selectedAccounts.value.contains(objectID)
cell.delegate = delegate
cell.config(with: user, isSelected: isSelected)
return cell
}
}
}

View File

@ -0,0 +1,35 @@
//
// SelectedAccountSection.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/22.
//
import CoreData
import CoreDataStack
import Foundation
import MastodonSDK
import UIKit
enum SelectedAccountSection: Equatable, Hashable {
case main
}
extension SelectedAccountSection {
static func collectionViewDiffableDataSource(
for collectionView: UICollectionView,
managedObjectContext: NSManagedObjectContext
) -> UICollectionViewDiffableDataSource<SelectedAccountSection, SelectedAccountItem> {
UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SuggestionAccountCollectionViewCell.self), for: indexPath) as! SuggestionAccountCollectionViewCell
switch item {
case .accountObjectID(let objectID):
let user = managedObjectContext.object(with: objectID) as! MastodonUser
cell.config(with: user)
case .placeHolder:
cell.configAsPlaceHolder()
}
return cell
}
}
}

View File

@ -69,6 +69,7 @@ internal enum Asset {
internal static let highlight = ColorAsset(name: "Colors/Label/highlight")
internal static let primary = ColorAsset(name: "Colors/Label/primary")
internal static let secondary = ColorAsset(name: "Colors/Label/secondary")
internal static let tertiary = ColorAsset(name: "Colors/Label/tertiary")
}
internal enum Notification {
internal static let favourite = ColorAsset(name: "Colors/Notification/favourite")

View File

@ -72,6 +72,10 @@ internal enum L10n {
internal static let done = L10n.tr("Localizable", "Common.Controls.Actions.Done")
/// Edit
internal static let edit = L10n.tr("Localizable", "Common.Controls.Actions.Edit")
/// Find people to follow
internal static let findPeople = L10n.tr("Localizable", "Common.Controls.Actions.FindPeople")
/// Manually search instead
internal static let manuallySearch = L10n.tr("Localizable", "Common.Controls.Actions.ManuallySearch")
/// OK
internal static let ok = L10n.tr("Localizable", "Common.Controls.Actions.Ok")
/// Open in Safari
@ -679,6 +683,12 @@ internal enum L10n {
}
}
}
internal enum SuggestionAccount {
/// When you follow someone, youll see their posts in your home feed.
internal static let followExplain = L10n.tr("Localizable", "Scene.SuggestionAccount.FollowExplain")
/// Find People to Follow
internal static let title = L10n.tr("Localizable", "Scene.SuggestionAccount.Title")
}
internal enum Thread {
/// Post
internal static let backTitle = L10n.tr("Localizable", "Scene.Thread.BackTitle")

View File

@ -44,7 +44,8 @@ extension UserProviderFacade {
return context.apiService.toggleFollow(
for: mastodonUser,
activeMastodonAuthenticationBox: activeMastodonAuthenticationBox
activeMastodonAuthenticationBox: activeMastodonAuthenticationBox,
needFeedback: true
)
}
.switchToLatest()

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "0.300",
"blue" : "67",
"green" : "60",
"red" : "60"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -20,6 +20,8 @@ Please check your internet connection.";
"Common.Controls.Actions.Discard" = "Discard";
"Common.Controls.Actions.Done" = "Done";
"Common.Controls.Actions.Edit" = "Edit";
"Common.Controls.Actions.FindPeople" = "Find people to follow";
"Common.Controls.Actions.ManuallySearch" = "Manually search instead";
"Common.Controls.Actions.Ok" = "OK";
"Common.Controls.Actions.OpenInSafari" = "Open in Safari";
"Common.Controls.Actions.Preview" = "Preview";
@ -221,6 +223,8 @@ any server.";
"Scene.Settings.Section.Spicyzone.Signout" = "Sign Out";
"Scene.Settings.Section.Spicyzone.Title" = "The spicy zone";
"Scene.Settings.Title" = "Settings";
"Scene.SuggestionAccount.FollowExplain" = "When you follow someone, youll see their posts in your home feed.";
"Scene.SuggestionAccount.Title" = "Find People to Follow";
"Scene.Thread.BackTitle" = "Post";
"Scene.Thread.Favorite.Multiple" = "%@ favorites";
"Scene.Thread.Favorite.Single" = "%@ favorite";
@ -228,4 +232,4 @@ any server.";
"Scene.Thread.Reblog.Single" = "%@ reblog";
"Scene.Thread.Title" = "Post from %@";
"Scene.Welcome.Slogan" = "Social networking
back in your hands.";
back in your hands.";

View File

@ -84,7 +84,7 @@ final class HashtagTimelineViewModel: NSObject {
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
return
}
let query = Mastodon.API.Search.Query(q: hashtag, type: .hashtags)
let query = Mastodon.API.V2.Search.Query(q: hashtag, type: .hashtags)
context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
.sink { _ in

View File

@ -25,6 +25,14 @@ extension HomeTimelineViewController {
guard let self = self else { return }
self.showWelcomeAction(action)
},
UIAction(title: "Show Or Remove EmptyView", image: UIImage(systemName: "clear"), attributes: []) { [weak self] action in
guard let self = self else { return }
if self.emptyView.superview != nil {
self.emptyView.removeFromSuperview()
} else {
self.showEmptyView()
}
},
UIAction(title: "Show Public Timeline", image: UIImage(systemName: "list.dash"), attributes: []) { [weak self] action in
guard let self = self else { return }
self.showPublicTimelineAction(action)

View File

@ -23,6 +23,15 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency {
var disposeBag = Set<AnyCancellable>()
private(set) lazy var viewModel = HomeTimelineViewModel(context: context)
lazy var emptyView: UIStackView = {
let emptyView = UIStackView()
emptyView.axis = .vertical
emptyView.distribution = .fill
emptyView.layoutMargins = UIEdgeInsets(top: 0, left: 20, bottom: 54, right: 20)
emptyView.isLayoutMarginsRelativeArrangement = true
return emptyView
}()
let titleView = HomeTimelineNavigationBarTitleView()
let settingBarButtonItem: UIBarButtonItem = {
@ -142,7 +151,7 @@ extension HomeTimelineViewController {
UIView.animate(withDuration: 0.5) { [weak self] in
guard let self = self else { return }
self.refreshControl.endRefreshing()
}
} completion: { _ in }
}
}
.store(in: &disposeBag)
@ -173,6 +182,17 @@ extension HomeTimelineViewController {
self.publishProgressView.setProgress(progress, animated: true)
}
.store(in: &disposeBag)
viewModel.timelineIsEmpty
.receive(on: DispatchQueue.main)
.sink { [weak self] isEmpty in
if isEmpty {
self?.showEmptyView()
} else {
self?.emptyView.removeFromSuperview()
}
}
.store(in: &disposeBag)
}
override func viewWillAppear(_ animated: Bool) {
@ -182,6 +202,10 @@ extension HomeTimelineViewController {
// needs trigger manually after onboarding dismiss
setNeedsStatusBarAppearanceUpdate()
if (viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty {
viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self)
}
}
override func viewDidAppear(_ animated: Bool) {
@ -217,6 +241,58 @@ extension HomeTimelineViewController {
}
extension HomeTimelineViewController {
func showEmptyView() {
if emptyView.superview != nil {
return
}
view.addSubview(emptyView)
emptyView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
emptyView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor),
emptyView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor),
emptyView.bottomAnchor.constraint(equalTo: view.readableContentGuide.bottomAnchor)
])
if emptyView.arrangedSubviews.count > 0 {
return
}
let findPeopleButton: PrimaryActionButton = {
let button = PrimaryActionButton()
button.setTitle(L10n.Common.Controls.Actions.findPeople, for: .normal)
button.addTarget(self, action: #selector(HomeTimelineViewController.findPeopleButtonPressed(_:)), for: .touchUpInside)
return button
}()
NSLayoutConstraint.activate([
findPeopleButton.heightAnchor.constraint(equalToConstant: 46)
])
let manuallySearchButton: HighlightDimmableButton = {
let button = HighlightDimmableButton()
button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold))
button.setTitle(L10n.Common.Controls.Actions.manuallySearch, for: .normal)
button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal)
button.addTarget(self, action: #selector(HomeTimelineViewController.manuallySearchButtonPressed(_:)), for: .touchUpInside)
return button
}()
emptyView.addArrangedSubview(findPeopleButton)
emptyView.setCustomSpacing(17, after: findPeopleButton)
emptyView.addArrangedSubview(manuallySearchButton)
}
}
extension HomeTimelineViewController {
@objc private func findPeopleButtonPressed(_ sender: PrimaryActionButton) {
let viewModel = SuggestionAccountViewModel(context: context)
viewModel.delegate = self.viewModel
coordinator.present(scene: .suggestionAccount(viewModel: viewModel), from: self, transition: .modal(animated: true, completion: nil))
}
@objc private func manuallySearchButtonPressed(_ sender: UIButton) {
coordinator.switchToTabBar(tab: .search)
}
@objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)

View File

@ -107,6 +107,7 @@ extension HomeTimelineViewModel.LoadLatestState {
viewModel.homeTimelineNavigationBarTitleViewModel.newPostsIncoming()
}
}
viewModel.timelineIsEmpty.value = latestStatusIDs.isEmpty && statuses.isEmpty
}
.store(in: &viewModel.disposeBag)
}

View File

@ -34,6 +34,8 @@ final class HomeTimelineViewModel: NSObject {
weak var tableView: UITableView?
weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?
let timelineIsEmpty = CurrentValueSubject<Bool, Never>(false)
let homeTimelineNeedRefresh = PassthroughSubject<Void, Never>()
// output
// top loader
private(set) lazy var loadLatestStateMachine: GKStateMachine = {
@ -122,6 +124,12 @@ final class HomeTimelineViewModel: NSObject {
}
.store(in: &disposeBag)
homeTimelineNeedRefresh
.sink { [weak self] _ in
self?.loadLatestStateMachine.enter(LoadLatestState.Loading.self)
}
.store(in: &disposeBag)
}
deinit {
@ -129,3 +137,5 @@ final class HomeTimelineViewModel: NSObject {
}
}
extension HomeTimelineViewModel: SuggestionAccountViewModelDelegate { }

View File

@ -9,7 +9,7 @@ import UIKit
final class ProfileRelationshipActionButton: RoundedEdgesButton {
let actvityIndicatorView: UIActivityIndicatorView = {
let activityIndicatorView: UIActivityIndicatorView = {
let activityIndicatorView = UIActivityIndicatorView(style: .medium)
activityIndicatorView.color = .white
return activityIndicatorView
@ -31,15 +31,15 @@ extension ProfileRelationshipActionButton {
private func _init() {
titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold)
actvityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
addSubview(actvityIndicatorView)
activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
addSubview(activityIndicatorView)
NSLayoutConstraint.activate([
actvityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor),
actvityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor),
activityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor),
activityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor),
])
actvityIndicatorView.hidesWhenStopped = true
actvityIndicatorView.stopAnimating()
activityIndicatorView.hidesWhenStopped = true
activityIndicatorView.stopAnimating()
}
}
@ -52,13 +52,13 @@ extension ProfileRelationshipActionButton {
setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .highlighted)
setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .disabled)
actvityIndicatorView.stopAnimating()
activityIndicatorView.stopAnimating()
if let option = actionOptionSet.highPriorityAction(except: .editOptions), option == .blocked || option == .suspended {
isEnabled = false
} else if actionOptionSet.contains(.updating) {
isEnabled = false
actvityIndicatorView.startAnimating()
activityIndicatorView.startAnimating()
} else {
isEnabled = true
}

View File

@ -98,10 +98,7 @@ extension SearchRecommendAccountsCollectionViewCell {
headerImageView.layer.borderColor = Asset.Colors.Border.searchCard.color.cgColor
applyShadow(color: Asset.Colors.Shadow.searchCard.color, alpha: 0.1, x: 0, y: 3, blur: 12, spread: 0)
}
override open func layoutSubviews() {
super.layoutSubviews()
followButton.layer.cornerRadius = followButton.frame.height/2
}
private func configure() {
headerImageView.backgroundColor = Asset.Colors.brandBlue.color
layer.cornerRadius = 10

View File

@ -83,7 +83,6 @@ extension SearchRecommendTagsCollectionViewCell {
let containerStackView = UIStackView()
containerStackView.axis = .vertical
containerStackView.distribution = .fill
containerStackView.spacing = 6
containerStackView.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 0, right: 16)
containerStackView.isLayoutMarginsRelativeArrangement = true
containerStackView.translatesAutoresizingMaskIntoConstraints = false
@ -113,6 +112,7 @@ extension SearchRecommendTagsCollectionViewCell {
peopleLabel.translatesAutoresizingMaskIntoConstraints = false
peopleLabel.setContentHuggingPriority(.defaultLow - 1, for: .vertical)
containerStackView.addArrangedSubview(peopleLabel)
containerStackView.setCustomSpacing(SearchViewController.hashtagPeopleTalkingLabelTop, after: horizontalStackView)
}
func config(with tag: Mastodon.Entity.Tag) {

View File

@ -26,7 +26,7 @@ extension SearchViewController {
hashtagCollectionView.translatesAutoresizingMaskIntoConstraints = false
stackView.addArrangedSubview(hashtagCollectionView)
NSLayoutConstraint.activate([
hashtagCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 130)
hashtagCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: CGFloat(SearchViewController.hashtagCardHeight))
])
}
@ -43,7 +43,7 @@ extension SearchViewController {
accountsCollectionView.translatesAutoresizingMaskIntoConstraints = false
stackView.addArrangedSubview(accountsCollectionView)
NSLayoutConstraint.activate([
accountsCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 202)
accountsCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: CGFloat(SearchViewController.accountCardHeight))
])
}
@ -91,9 +91,9 @@ extension SearchViewController: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
if collectionView == hashtagCollectionView {
return CGSize(width: 228, height: 130)
return CGSize(width: 228, height: SearchViewController.hashtagCardHeight)
} else {
return CGSize(width: 257, height: 202)
return CGSize(width: 257, height: SearchViewController.accountCardHeight)
}
}
}
@ -101,5 +101,11 @@ extension SearchViewController: UICollectionViewDelegateFlowLayout {
extension SearchViewController {
@objc func hashtagSeeAllButtonPressed(_ sender: UIButton) {}
@objc func accountSeeAllButtonPressed(_ sender: UIButton) {}
@objc func accountSeeAllButtonPressed(_ sender: UIButton) {
if self.viewModel.recommendAccounts.isEmpty {
return
}
let viewModel = SuggestionAccountViewModel(context: context, accounts: self.viewModel.recommendAccounts)
coordinator.present(scene: .suggestionAccount(viewModel: viewModel), from: self, transition: .modal(animated: true, completion: nil))
}
}

View File

@ -11,6 +11,26 @@ import MastodonSDK
import UIKit
final class SearchViewController: UIViewController, NeedsDependency {
public static var hashtagCardHeight: CGFloat {
get {
if UIScreen.main.bounds.size.height > 736 {
return 186
}
return 130
}
}
public static var hashtagPeopleTalkingLabelTop: CGFloat {
get {
if UIScreen.main.bounds.size.height > 736 {
return 18
}
return 6
}
}
public static let accountCardHeight = 202
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
@ -49,9 +69,6 @@ final class SearchViewController: UIViewController, NeedsDependency {
let stackView = UIStackView()
stackView.axis = .vertical
stackView.distribution = .fill
stackView.spacing = 0
stackView.layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 68, right: 0)
stackView.isLayoutMarginsRelativeArrangement = true
return stackView
}()
@ -130,6 +147,8 @@ extension SearchViewController {
setupSearchingTableView()
setupDataSource()
setupSearchHeader()
view.bringSubviewToFront(searchBar)
view.bringSubviewToFront(statusBar)
}
func setupSearchBar() {
@ -148,23 +167,27 @@ extension SearchViewController {
statusBar.topAnchor.constraint(equalTo: view.topAnchor),
statusBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
statusBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
statusBar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 3),
statusBar.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 3),
])
}
func setupScrollView() {
scrollView.translatesAutoresizingMaskIntoConstraints = false
stackView.translatesAutoresizingMaskIntoConstraints = false
// scrollView
view.addSubview(scrollView)
NSLayoutConstraint.activate([
scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: searchBar.bottomAnchor),
scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
scrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor),
scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: searchBar.frame.height),
scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor),
view.trailingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.trailingAnchor),
scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor),
scrollView.frameLayoutGuide.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor),
])
stackView.translatesAutoresizingMaskIntoConstraints = false
// stackview
scrollView.addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
@ -217,11 +240,11 @@ extension SearchViewController: UISearchBarDelegate {
func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
switch selectedScope {
case 0:
viewModel.searchScope.value = Mastodon.API.Search.SearchType.default
viewModel.searchScope.value = Mastodon.API.V2.Search.SearchType.default
case 1:
viewModel.searchScope.value = Mastodon.API.Search.SearchType.accounts
viewModel.searchScope.value = Mastodon.API.V2.Search.SearchType.accounts
case 2:
viewModel.searchScope.value = Mastodon.API.Search.SearchType.hashtags
viewModel.searchScope.value = Mastodon.API.V2.Search.SearchType.hashtags
default:
break
}

View File

@ -53,14 +53,14 @@ extension SearchViewModel.LoadOldestState {
}
var offset = 0
switch viewModel.searchScope.value {
case Mastodon.API.Search.SearchType.accounts:
case Mastodon.API.V2.Search.SearchType.accounts:
offset = oldSearchResult.accounts.count
case Mastodon.API.Search.SearchType.hashtags:
case Mastodon.API.V2.Search.SearchType.hashtags:
offset = oldSearchResult.hashtags.count
default:
return
}
let query = Mastodon.API.Search.Query(q: viewModel.searchText.value,
let query = Mastodon.API.V2.Search.Query(q: viewModel.searchText.value,
type: viewModel.searchScope.value,
accountID: nil,
maxID: nil,
@ -82,7 +82,7 @@ extension SearchViewModel.LoadOldestState {
}
} receiveValue: { result in
switch viewModel.searchScope.value {
case Mastodon.API.Search.SearchType.accounts:
case Mastodon.API.V2.Search.SearchType.accounts:
if result.value.accounts.isEmpty {
stateMachine.enter(NoMore.self)
} else {
@ -93,7 +93,7 @@ extension SearchViewModel.LoadOldestState {
viewModel.searchResult.value = Mastodon.Entity.SearchResult(accounts: newAccounts, statuses: oldSearchResult.statuses, hashtags: oldSearchResult.hashtags)
stateMachine.enter(Idle.self)
}
case Mastodon.API.Search.SearchType.hashtags:
case Mastodon.API.V2.Search.SearchType.hashtags:
if result.value.hashtags.isEmpty {
stateMachine.enter(NoMore.self)
} else {

View File

@ -26,7 +26,7 @@ final class SearchViewModel: NSObject {
// output
let searchText = CurrentValueSubject<String, Never>("")
let searchScope = CurrentValueSubject<Mastodon.API.Search.SearchType, Never>(Mastodon.API.Search.SearchType.default)
let searchScope = CurrentValueSubject<Mastodon.API.V2.Search.SearchType, Never>(Mastodon.API.V2.Search.SearchType.default)
let isSearching = CurrentValueSubject<Bool, Never>(false)
@ -34,6 +34,7 @@ final class SearchViewModel: NSObject {
var recommendHashTags = [Mastodon.Entity.Tag]()
var recommendAccounts = [NSManagedObjectID]()
var recommendAccountsFallback = PassthroughSubject<Void, Never>()
var hashtagDiffableDataSource: UICollectionViewDiffableDataSource<RecommendHashTagSection, Mastodon.Entity.Tag>?
var accountDiffableDataSource: UICollectionViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID>?
@ -86,16 +87,16 @@ final class SearchViewModel: NSObject {
}
.flatMap { (text, scope) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> in
let query = Mastodon.API.Search.Query(q: text,
type: scope,
accountID: nil,
maxID: nil,
minID: nil,
excludeUnreviewed: nil,
resolve: nil,
limit: nil,
offset: nil,
following: nil)
let query = Mastodon.API.V2.Search.Query(q: text,
type: scope,
accountID: nil,
maxID: nil,
minID: nil,
excludeUnreviewed: nil,
resolve: nil,
limit: nil,
offset: nil,
following: nil)
return context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
}
.sink { _ in
@ -130,8 +131,8 @@ final class SearchViewModel: NSObject {
snapshot.appendSections([.mixed])
searchHistories.forEach { searchHistory in
let containsAccount = scope == Mastodon.API.Search.SearchType.accounts || scope == Mastodon.API.Search.SearchType.default
let containsHashTag = scope == Mastodon.API.Search.SearchType.hashtags || scope == Mastodon.API.Search.SearchType.default
let containsAccount = scope == Mastodon.API.V2.Search.SearchType.accounts || scope == Mastodon.API.V2.Search.SearchType.default
let containsHashTag = scope == Mastodon.API.V2.Search.SearchType.hashtags || scope == Mastodon.API.V2.Search.SearchType.default
if let mastodonUser = searchHistory.account, containsAccount {
let item = SearchResultItem.accountObjectID(accountObjectID: mastodonUser.objectID)
snapshot.appendItems([item], toSection: .mixed)
@ -142,7 +143,6 @@ final class SearchViewModel: NSObject {
}
}
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
}
.store(in: &disposeBag)
@ -161,21 +161,31 @@ final class SearchViewModel: NSObject {
}
.store(in: &disposeBag)
requestRecommendAccounts()
.receive(on: DispatchQueue.main)
requestRecommendAccountsV2()
.sink { [weak self] _ in
guard let self = self else { return }
if !self.recommendAccounts.isEmpty {
guard let dataSource = self.accountDiffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<RecommendAccountSection, NSManagedObjectID>()
snapshot.appendSections([.main])
snapshot.appendItems(self.recommendAccounts, toSection: .main)
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
self.applyDataSource()
}
} receiveValue: { _ in
}
.store(in: &disposeBag)
recommendAccountsFallback
.sink { [weak self] _ in
guard let self = self else { return }
self.requestRecommendAccounts()
.sink { [weak self] _ in
guard let self = self else { return }
if !self.recommendAccounts.isEmpty {
self.applyDataSource()
}
} receiveValue: { _ in
}
.store(in: &self.disposeBag)
}
.store(in: &disposeBag)
searchResult
.receive(on: DispatchQueue.main)
.sink { [weak self] searchResult in
@ -186,7 +196,7 @@ final class SearchViewModel: NSObject {
snapshot.appendSections([.account])
let items = accounts.compactMap { SearchResultItem.account(account: $0) }
snapshot.appendItems(items, toSection: .account)
if self.searchScope.value == Mastodon.API.Search.SearchType.accounts, !items.isEmpty {
if self.searchScope.value == Mastodon.API.V2.Search.SearchType.accounts, !items.isEmpty {
snapshot.appendItems([.bottomLoader], toSection: .account)
}
}
@ -194,7 +204,7 @@ final class SearchViewModel: NSObject {
snapshot.appendSections([.hashtag])
let items = tags.compactMap { SearchResultItem.hashtag(tag: $0) }
snapshot.appendItems(items, toSection: .hashtag)
if self.searchScope.value == Mastodon.API.Search.SearchType.hashtags, !items.isEmpty {
if self.searchScope.value == Mastodon.API.V2.Search.SearchType.hashtags, !items.isEmpty {
snapshot.appendItems([.bottomLoader], toSection: .hashtag)
}
}
@ -227,13 +237,43 @@ final class SearchViewModel: NSObject {
}
}
func requestRecommendAccountsV2() -> Future<Void, Error> {
Future { promise in
guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing)))
return
}
self.context.apiService.suggestionAccountV2(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
.sink { [weak self] completion in
switch completion {
case .failure(let error):
if let apiError = error as? Mastodon.API.Error {
if apiError.httpResponseStatus == .notFound {
self?.recommendAccountsFallback.send()
}
}
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
promise(.failure(error))
case .finished:
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request success", (#file as NSString).lastPathComponent, #line, #function)
promise(.success(()))
}
} receiveValue: { [weak self] accounts in
guard let self = self else { return }
let ids = accounts.value.compactMap({$0.account.id})
self.receiveAccounts(ids: ids)
}
.store(in: &self.disposeBag)
}
}
func requestRecommendAccounts() -> Future<Void, Error> {
Future { promise in
guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing)))
return
}
self.context.apiService.recommendAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
self.context.apiService.suggestionAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
.sink { completion in
switch completion {
case .failure(let error):
@ -246,27 +286,47 @@ final class SearchViewModel: NSObject {
} receiveValue: { [weak self] accounts in
guard let self = self else { return }
let ids = accounts.value.compactMap({$0.id})
let userFetchRequest = MastodonUser.sortedFetchRequest
userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids)
let mastodonUsers: [MastodonUser]? = {
let userFetchRequest = MastodonUser.sortedFetchRequest
userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids)
userFetchRequest.returnsObjectsAsFaults = false
do {
return try self.context.managedObjectContext.fetch(userFetchRequest)
} catch {
assertionFailure(error.localizedDescription)
return nil
}
}()
if let users = mastodonUsers {
self.recommendAccounts = users.map(\.objectID)
}
self.receiveAccounts(ids: ids)
}
.store(in: &self.disposeBag)
}
}
func applyDataSource() {
DispatchQueue.main.async {
guard let dataSource = self.accountDiffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<RecommendAccountSection, NSManagedObjectID>()
snapshot.appendSections([.main])
snapshot.appendItems(self.recommendAccounts, toSection: .main)
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
}
}
func receiveAccounts(ids: [String]) {
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
return
}
let userFetchRequest = MastodonUser.sortedFetchRequest
userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids)
let mastodonUsers: [MastodonUser]? = {
let userFetchRequest = MastodonUser.sortedFetchRequest
userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids)
userFetchRequest.returnsObjectsAsFaults = false
do {
return try self.context.managedObjectContext.fetch(userFetchRequest)
} catch {
assertionFailure(error.localizedDescription)
return nil
}
}()
if let users = mastodonUsers {
let sortedUsers = users.sorted { (user1, user2) -> Bool in
(ids.firstIndex(of: user1.id) ?? 0) < (ids.firstIndex(of: user2.id) ?? 0)
}
recommendAccounts = sortedUsers.map(\.objectID)
}
}
func accountCollectionViewItemDidSelected(mastodonUser: MastodonUser, from: UIViewController) {
let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser)
DispatchQueue.main.async {

View File

@ -0,0 +1,61 @@
//
// SuggestionAccountCollectionViewCell.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/22.
//
import CoreDataStack
import Foundation
import UIKit
class SuggestionAccountCollectionViewCell: UICollectionViewCell {
let imageView: UIImageView = {
let imageView = UIImageView()
imageView.tintColor = Asset.Colors.Label.tertiary.color
imageView.layer.cornerRadius = 4
imageView.clipsToBounds = true
imageView.image = UIImage.placeholder(color: .systemFill)
return imageView
}()
func configAsPlaceHolder() {
imageView.tintColor = Asset.Colors.Label.tertiary.color
imageView.image = UIImage.placeholder(color: .systemFill)
}
func config(with mastodonUser: MastodonUser) {
imageView.af.setImage(
withURL: URL(string: mastodonUser.avatar)!,
placeholderImage: UIImage.placeholder(color: .systemFill),
imageTransition: .crossDissolve(0.2)
)
}
override func prepareForReuse() {
super.prepareForReuse()
}
override init(frame: CGRect) {
super.init(frame: .zero)
configure()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
configure()
}
}
extension SuggestionAccountCollectionViewCell {
private func configure() {
contentView.addSubview(imageView)
imageView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
])
}
}

View File

@ -0,0 +1,213 @@
//
// SuggestionAccountViewController.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/21.
//
import Combine
import CoreData
import CoreDataStack
import Foundation
import OSLog
import UIKit
class SuggestionAccountViewController: UIViewController, NeedsDependency {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var disposeBag = Set<AnyCancellable>()
var viewModel: SuggestionAccountViewModel!
let tableView: UITableView = {
let tableView = ControlContainableTableView()
tableView.register(SuggestionAccountTableViewCell.self, forCellReuseIdentifier: String(describing: SuggestionAccountTableViewCell.self))
tableView.rowHeight = UITableView.automaticDimension
tableView.tableFooterView = UIView()
tableView.separatorStyle = .singleLine
tableView.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
return tableView
}()
lazy var tableHeader: UIView = {
let view = UIView()
view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
view.frame = CGRect(origin: .zero, size: CGSize(width: tableView.frame.width, height: 156))
return view
}()
let followExplainLabel: UILabel = {
let label = UILabel()
label.text = L10n.Scene.SuggestionAccount.followExplain
label.textColor = Asset.Colors.Label.primary.color
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular))
label.numberOfLines = 0
return label
}()
let selectedCollectionView: UICollectionView = {
let flowLayout = UICollectionViewFlowLayout()
flowLayout.scrollDirection = .horizontal
let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout)
view.register(SuggestionAccountCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SuggestionAccountCollectionViewCell.self))
view.backgroundColor = .clear
view.showsHorizontalScrollIndicator = false
view.showsVerticalScrollIndicator = false
view.layer.masksToBounds = false
return view
}()
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", (#file as NSString).lastPathComponent, #line, #function)
}
}
extension SuggestionAccountViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = Asset.Colors.Background.systemBackground.color
title = L10n.Scene.SuggestionAccount.title
navigationItem.rightBarButtonItem
= UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.done,
target: self,
action: #selector(SuggestionAccountViewController.doneButtonDidClick(_:)))
tableView.delegate = self
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),
])
viewModel.diffableDataSource = RecommendAccountSection.tableViewDiffableDataSource(
for: tableView,
managedObjectContext: context.managedObjectContext,
viewModel: viewModel,
delegate: self
)
viewModel.collectionDiffableDataSource = SelectedAccountSection.collectionViewDiffableDataSource(for: selectedCollectionView, managedObjectContext: context.managedObjectContext)
viewModel.accounts
.receive(on: DispatchQueue.main)
.sink { [weak self] accounts in
guard let self = self else { return }
self.setupHeader(accounts: accounts)
}
.store(in: &disposeBag)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
tableView.deselectRow(with: transitionCoordinator, animated: animated)
viewModel.checkAccountsFollowState()
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
let avatarImageViewHeight: Double = 56
let avatarImageViewCount = Int(floor((Double(view.frame.width) - 20) / (avatarImageViewHeight + 15)))
viewModel.headerPlaceholderCount.value = avatarImageViewCount
}
func setupHeader(accounts: [NSManagedObjectID]) {
if accounts.isEmpty {
return
}
followExplainLabel.translatesAutoresizingMaskIntoConstraints = false
tableHeader.addSubview(followExplainLabel)
NSLayoutConstraint.activate([
followExplainLabel.topAnchor.constraint(equalTo: tableHeader.topAnchor, constant: 20),
followExplainLabel.leadingAnchor.constraint(equalTo: tableHeader.leadingAnchor, constant: 20),
tableHeader.trailingAnchor.constraint(equalTo: followExplainLabel.trailingAnchor, constant: 20),
])
selectedCollectionView.translatesAutoresizingMaskIntoConstraints = false
tableHeader.addSubview(selectedCollectionView)
NSLayoutConstraint.activate([
selectedCollectionView.frameLayoutGuide.topAnchor.constraint(equalTo: followExplainLabel.topAnchor, constant: 20),
selectedCollectionView.frameLayoutGuide.leadingAnchor.constraint(equalTo: tableHeader.leadingAnchor, constant: 20),
selectedCollectionView.frameLayoutGuide.trailingAnchor.constraint(equalTo: tableHeader.trailingAnchor),
selectedCollectionView.frameLayoutGuide.bottomAnchor.constraint(equalTo: tableHeader.bottomAnchor),
])
selectedCollectionView.delegate = self
tableView.tableHeaderView = tableHeader
}
}
extension SuggestionAccountViewController: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
15
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
CGSize(width: 56, height: 56)
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let diffableDataSource = viewModel.collectionDiffableDataSource else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
switch item {
case .accountObjectID(let accountObjectID):
let mastodonUser = context.managedObjectContext.object(with: accountObjectID) as! MastodonUser
let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser)
DispatchQueue.main.async {
self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show)
}
default:
break
}
}
}
extension SuggestionAccountViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let objectID = diffableDataSource.itemIdentifier(for: indexPath) else { return }
let mastodonUser = context.managedObjectContext.object(with: objectID) as! MastodonUser
let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser)
DispatchQueue.main.async {
self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show)
}
}
}
extension SuggestionAccountViewController: SuggestionAccountTableViewCellDelegate {
func accountButtonPressed(objectID: NSManagedObjectID, cell: SuggestionAccountTableViewCell) {
let selected = !viewModel.selectedAccounts.value.contains(objectID)
cell.startAnimating()
viewModel.followAction(objectID: objectID)?
.sink(receiveCompletion: { [weak self] completion in
guard let self = self else { return }
cell.stopAnimating()
switch completion {
case .failure(let error):
os_log("%{public}s[%{public}ld], %{public}s: follow failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
case .finished:
var selectedAccounts = self.viewModel.selectedAccounts.value
if selected {
selectedAccounts.append(objectID)
} else {
selectedAccounts.removeAll { $0 == objectID }
}
cell.button.isSelected = selected
self.viewModel.selectedAccounts.value = selectedAccounts
}
}, receiveValue: { _ in
})
.store(in: &disposeBag)
}
}
extension SuggestionAccountViewController {
@objc func doneButtonDidClick(_ sender: UIButton) {
dismiss(animated: true, completion: nil)
if viewModel.selectedAccounts.value.count > 0 {
viewModel.delegate?.homeTimelineNeedRefresh.send()
}
}
}

View File

@ -0,0 +1,222 @@
//
// SuggestionAccountViewModel.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/21.
//
import Combine
import CoreData
import CoreDataStack
import GameplayKit
import MastodonSDK
import os.log
import UIKit
protocol SuggestionAccountViewModelDelegate: AnyObject {
var homeTimelineNeedRefresh: PassthroughSubject<Void, Never> { get }
}
final class SuggestionAccountViewModel: NSObject {
var disposeBag = Set<AnyCancellable>()
// input
let context: AppContext
let currentMastodonUser = CurrentValueSubject<MastodonUser?, Never>(nil)
weak var delegate: SuggestionAccountViewModelDelegate?
// output
let accounts = CurrentValueSubject<[NSManagedObjectID], Never>([])
var selectedAccounts = CurrentValueSubject<[NSManagedObjectID], Never>([])
var headerPlaceholderCount = CurrentValueSubject<Int?, Never>(nil)
var suggestionAccountsFallback = PassthroughSubject<Void, Never>()
var viewWillAppear = PassthroughSubject<Void, Never>()
var diffableDataSource: UITableViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID>? {
didSet(value) {
if !accounts.value.isEmpty {
applyTableViewDataSource(accounts: accounts.value)
}
}
}
var collectionDiffableDataSource: UICollectionViewDiffableDataSource<SelectedAccountSection, SelectedAccountItem>?
init(context: AppContext, accounts: [NSManagedObjectID]? = nil) {
self.context = context
super.init()
Publishers.CombineLatest(self.accounts,self.selectedAccounts)
.sink { [weak self] accounts,selectedAccounts in
self?.applyTableViewDataSource(accounts: accounts)
self?.applySelectedCollectionViewDataSource(accounts: selectedAccounts)
}
.store(in: &disposeBag)
Publishers.CombineLatest(self.selectedAccounts,self.headerPlaceholderCount)
.sink { [weak self] selectedAccount,count in
self?.applySelectedCollectionViewDataSource(accounts: selectedAccount)
}
.store(in: &disposeBag)
viewWillAppear
.sink { [weak self] _ in
self?.checkAccountsFollowState()
}
.store(in: &disposeBag)
if let accounts = accounts {
self.accounts.value = accounts
}
context.authenticationService.activeMastodonAuthentication
.sink { [weak self] activeMastodonAuthentication in
guard let self = self else { return }
guard let activeMastodonAuthentication = activeMastodonAuthentication else {
self.currentMastodonUser.value = nil
return
}
self.currentMastodonUser.value = activeMastodonAuthentication.user
}
.store(in: &disposeBag)
if accounts == nil || (accounts ?? []).isEmpty {
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
context.apiService.suggestionAccountV2(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
.sink { [weak self] completion in
switch completion {
case .failure(let error):
if let apiError = error as? Mastodon.API.Error {
if apiError.httpResponseStatus == .notFound {
self?.suggestionAccountsFallback.send()
}
}
os_log("%{public}s[%{public}ld], %{public}s: fetch recommendAccountV2 failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
case .finished:
// handle isFetchingLatestTimeline in fetch controller delegate
break
}
} receiveValue: { [weak self] response in
let ids = response.value.map(\.account.id)
self?.receiveAccounts(ids: ids)
}
.store(in: &disposeBag)
suggestionAccountsFallback
.sink(receiveValue: { [weak self] _ in
self?.requestSuggestionAccount()
})
.store(in: &disposeBag)
}
}
func requestSuggestionAccount() {
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
context.apiService.suggestionAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
.sink { completion in
switch completion {
case .failure(let error):
os_log("%{public}s[%{public}ld], %{public}s: fetch recommendAccount failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
case .finished:
// handle isFetchingLatestTimeline in fetch controller delegate
break
}
} receiveValue: { [weak self] response in
let ids = response.value.map(\.id)
self?.receiveAccounts(ids: ids)
}
.store(in: &disposeBag)
}
func applyTableViewDataSource(accounts: [NSManagedObjectID]) {
guard let dataSource = diffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<RecommendAccountSection, NSManagedObjectID>()
snapshot.appendSections([.main])
snapshot.appendItems(accounts, toSection: .main)
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
}
func applySelectedCollectionViewDataSource(accounts: [NSManagedObjectID]) {
guard let count = headerPlaceholderCount.value else { return }
guard let dataSource = collectionDiffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<SelectedAccountSection, SelectedAccountItem>()
snapshot.appendSections([.main])
let placeholderCount = count - accounts.count
let accountItems = accounts.map { SelectedAccountItem.accountObjectID(accountObjectID: $0) }
snapshot.appendItems(accountItems, toSection: .main)
if placeholderCount > 0 {
for _ in 0 ..< placeholderCount {
snapshot.appendItems([SelectedAccountItem.placeHolder(uuid: UUID())], toSection: .main)
}
}
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
}
func receiveAccounts(ids: [String]) {
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
return
}
let userFetchRequest = MastodonUser.sortedFetchRequest
userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids)
let mastodonUsers: [MastodonUser]? = {
let userFetchRequest = MastodonUser.sortedFetchRequest
userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids)
userFetchRequest.returnsObjectsAsFaults = false
do {
return try self.context.managedObjectContext.fetch(userFetchRequest)
} catch {
assertionFailure(error.localizedDescription)
return nil
}
}()
if let users = mastodonUsers {
let sortedUsers = users.sorted { (user1, user2) -> Bool in
(ids.firstIndex(of: user1.id) ?? 0) < (ids.firstIndex(of: user2.id) ?? 0)
}
accounts.value = sortedUsers.map(\.objectID)
}
}
func followAction(objectID: NSManagedObjectID) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error>? {
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return nil }
let mastodonUser = context.managedObjectContext.object(with: objectID) as! MastodonUser
return context.apiService.toggleFollow(
for: mastodonUser,
activeMastodonAuthenticationBox: activeMastodonAuthenticationBox,
needFeedback: false
)
}
func checkAccountsFollowState() {
guard let currentMastodonUser = currentMastodonUser.value else {
return
}
let users: [MastodonUser] = accounts.value.compactMap {
guard let user = context.managedObjectContext.object(with: $0) as? MastodonUser else {
return nil
}
let isBlock = user.blockingBy.flatMap { $0.contains(currentMastodonUser) } ?? false
let isDomainBlock = user.domainBlockingBy.flatMap { $0.contains(currentMastodonUser) } ?? false
if isBlock || isDomainBlock {
return nil
} else {
return user
}
}
accounts.value = users.map(\.objectID)
let followingUsers = users.filter { user -> Bool in
let isFollowing = user.followingBy.flatMap { $0.contains(currentMastodonUser) } ?? false
let isPending = user.followRequestedBy.flatMap { $0.contains(currentMastodonUser) } ?? false
return isFollowing || isPending
}.map(\.objectID)
selectedAccounts.value = followingUsers
}
}

View File

@ -0,0 +1,191 @@
//
// SuggestionAccountTableViewCell.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/21.
//
import Combine
import CoreData
import CoreDataStack
import Foundation
import MastodonSDK
import UIKit
protocol SuggestionAccountTableViewCellDelegate: AnyObject {
func accountButtonPressed(objectID: NSManagedObjectID, cell: SuggestionAccountTableViewCell)
}
final class SuggestionAccountTableViewCell: UITableViewCell {
var disposeBag = Set<AnyCancellable>()
weak var delegate: SuggestionAccountTableViewCellDelegate?
let _imageView: UIImageView = {
let imageView = UIImageView()
imageView.tintColor = Asset.Colors.Label.primary.color
imageView.layer.cornerRadius = 4
imageView.clipsToBounds = true
return imageView
}()
let titleLabel: UILabel = {
let label = UILabel()
label.textColor = Asset.Colors.brandBlue.color
label.font = .systemFont(ofSize: 17, weight: .semibold)
label.lineBreakMode = .byTruncatingTail
return label
}()
let subTitleLabel: UILabel = {
let label = UILabel()
label.textColor = Asset.Colors.Label.secondary.color
label.font = .preferredFont(forTextStyle: .body)
return label
}()
let buttonContainer: UIView = {
let view = UIView()
view.backgroundColor = .clear
return view
}()
let button: HighlightDimmableButton = {
let button = HighlightDimmableButton(type: .custom)
if let plusImage = UIImage(systemName: "plus.circle", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .regular))?.withRenderingMode(.alwaysTemplate) {
button.setImage(plusImage, for: .normal)
}
if let minusImage = UIImage(systemName: "minus.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .regular))?.withRenderingMode(.alwaysTemplate) {
button.setImage(minusImage, for: .selected)
}
return button
}()
let activityIndicatorView: UIActivityIndicatorView = {
let activityIndicatorView = UIActivityIndicatorView(style: .medium)
activityIndicatorView.hidesWhenStopped = true
return activityIndicatorView
}()
override func prepareForReuse() {
super.prepareForReuse()
_imageView.af.cancelImageRequest()
_imageView.image = nil
disposeBag.removeAll()
}
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
configure()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
configure()
}
}
extension SuggestionAccountTableViewCell {
private func configure() {
backgroundColor = .clear
let containerStackView = UIStackView()
containerStackView.axis = .horizontal
containerStackView.distribution = .fill
containerStackView.spacing = 12
containerStackView.layoutMargins = UIEdgeInsets(top: 12, left: 21, bottom: 12, right: 12)
containerStackView.isLayoutMarginsRelativeArrangement = true
containerStackView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(containerStackView)
NSLayoutConstraint.activate([
containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor),
containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
])
_imageView.translatesAutoresizingMaskIntoConstraints = false
containerStackView.addArrangedSubview(_imageView)
NSLayoutConstraint.activate([
_imageView.widthAnchor.constraint(equalToConstant: 42).priority(.required - 1),
_imageView.heightAnchor.constraint(equalToConstant: 42).priority(.required - 1),
])
let textStackView = UIStackView()
textStackView.axis = .vertical
textStackView.distribution = .fill
textStackView.alignment = .leading
textStackView.translatesAutoresizingMaskIntoConstraints = false
titleLabel.translatesAutoresizingMaskIntoConstraints = false
textStackView.addArrangedSubview(titleLabel)
subTitleLabel.translatesAutoresizingMaskIntoConstraints = false
textStackView.addArrangedSubview(subTitleLabel)
subTitleLabel.setContentHuggingPriority(.defaultLow - 1, for: .vertical)
containerStackView.addArrangedSubview(textStackView)
textStackView.setContentHuggingPriority(.defaultLow - 1, for: .horizontal)
buttonContainer.translatesAutoresizingMaskIntoConstraints = false
containerStackView.addArrangedSubview(buttonContainer)
NSLayoutConstraint.activate([
buttonContainer.widthAnchor.constraint(equalToConstant: 24).priority(.required - 1),
buttonContainer.heightAnchor.constraint(equalToConstant: 42).priority(.required - 1),
])
buttonContainer.setContentHuggingPriority(.required - 1, for: .horizontal)
activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
button.translatesAutoresizingMaskIntoConstraints = false
activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
buttonContainer.addSubview(button)
buttonContainer.addSubview(activityIndicatorView)
NSLayoutConstraint.activate([
buttonContainer.centerXAnchor.constraint(equalTo: activityIndicatorView.centerXAnchor),
buttonContainer.centerYAnchor.constraint(equalTo: activityIndicatorView.centerYAnchor),
buttonContainer.centerXAnchor.constraint(equalTo: button.centerXAnchor),
buttonContainer.centerYAnchor.constraint(equalTo: button.centerYAnchor),
])
}
func config(with account: MastodonUser, isSelected: Bool) {
if let url = account.avatarImageURL() {
_imageView.af.setImage(
withURL: url,
placeholderImage: UIImage.placeholder(color: .systemFill),
imageTransition: .crossDissolve(0.2)
)
}
titleLabel.text = account.displayName.isEmpty ? account.username : account.displayName
subTitleLabel.text = account.acct
button.isSelected = isSelected
button.publisher(for: .touchUpInside)
.sink { [weak self] _ in
guard let self = self else { return }
self.delegate?.accountButtonPressed(objectID: account.objectID, cell: self)
}
.store(in: &disposeBag)
button.publisher(for: \.isSelected)
.sink { [weak self] isSelected in
if isSelected {
self?.button.tintColor = Asset.Colors.danger.color
} else {
self?.button.tintColor = Asset.Colors.Label.secondary.color
}
}
.store(in: &disposeBag)
activityIndicatorView.publisher(for: \.isHidden)
.receive(on: DispatchQueue.main)
.sink { [weak self] isHidden in
self?.button.isHidden = !isHidden
}
.store(in: &disposeBag)
}
func startAnimating() {
activityIndicatorView.isHidden = false
activityIndicatorView.startAnimating()
}
func stopAnimating() {
activityIndicatorView.stopAnimating()
activityIndicatorView.isHidden = true
}
}

View File

@ -24,10 +24,15 @@ extension APIService {
/// - Returns: publisher for `Relationship`
func toggleFollow(
for mastodonUser: MastodonUser,
activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox,
needFeedback: Bool
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light)
let notificationFeedbackGenerator = UINotificationFeedbackGenerator()
var impactFeedbackGenerator: UIImpactFeedbackGenerator?
var notificationFeedbackGenerator: UINotificationFeedbackGenerator?
if needFeedback {
impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light)
notificationFeedbackGenerator = UINotificationFeedbackGenerator()
}
return followUpdateLocal(
mastodonUserObjectID: mastodonUser.objectID,
@ -35,9 +40,9 @@ extension APIService {
)
.receive(on: DispatchQueue.main)
.handleEvents { _ in
impactFeedbackGenerator.prepare()
impactFeedbackGenerator?.prepare()
} receiveOutput: { _ in
impactFeedbackGenerator.impactOccurred()
impactFeedbackGenerator?.impactOccurred()
} receiveCompletion: { completion in
switch completion {
case .failure(let error):
@ -74,13 +79,13 @@ extension APIService {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Friendship] rollback finish", ((#file as NSString).lastPathComponent), #line, #function)
} receiveValue: { _ in
// do nothing
notificationFeedbackGenerator.prepare()
notificationFeedbackGenerator.notificationOccurred(.error)
notificationFeedbackGenerator?.prepare()
notificationFeedbackGenerator?.notificationOccurred(.error)
}
.store(in: &self.disposeBag)
case .finished:
notificationFeedbackGenerator.notificationOccurred(.success)
notificationFeedbackGenerator?.notificationOccurred(.success)
os_log("%{public}s[%{public}ld], %{public}s: [Friendship] remote friendship update success", ((#file as NSString).lastPathComponent), #line, #function)
}
})

View File

@ -13,7 +13,7 @@ import CoreDataStack
import OSLog
extension APIService {
func recommendAccount(
func suggestionAccount(
domain: String,
query: Mastodon.API.Suggestions.Query?,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
@ -44,6 +44,38 @@ extension APIService {
.eraseToAnyPublisher()
}
func suggestionAccountV2(
domain: String,
query: Mastodon.API.Suggestions.Query?,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.V2.SuggestionAccount]>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
return Mastodon.API.V2.Suggestions.get(session: session, domain: domain, query: query, authorization: authorization)
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.V2.SuggestionAccount]>, Error> in
let log = OSLog.api
return self.backgroundManagedObjectContext.performChanges {
response.value.forEach { suggestionAccount in
let user = suggestionAccount.account
let (mastodonUser,isCreated) = APIService.CoreData.createOrMergeMastodonUser(into: self.backgroundManagedObjectContext, for: nil, in: domain, entity: user, userCache: nil, networkDate: Date(), log: log)
let flag = isCreated ? "+" : "-"
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: fetch mastodon user [%s](%s)%s", (#file as NSString).lastPathComponent, #line, #function, flag, mastodonUser.id, mastodonUser.username)
}
}
.setFailureType(to: Error.self)
.tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.V2.SuggestionAccount]> in
switch result {
case .success:
return response
case .failure(let error):
throw error
}
}
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
func recommendTrends(
domain: String,
query: Mastodon.API.Trends.Query?

View File

@ -13,11 +13,11 @@ extension APIService {
func search(
domain: String,
query: Mastodon.API.Search.Query,
query: Mastodon.API.V2.Search.Query,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
return Mastodon.API.Search.search(session: session, domain: domain, query: query, authorization: authorization)
return Mastodon.API.V2.Search.search(session: session, domain: domain, query: query, authorization: authorization)
}
}

View File

@ -8,7 +8,7 @@
import Combine
import Foundation
extension Mastodon.API.Search {
extension Mastodon.API.V2.Search {
static func searchURL(domain: String) -> URL {
Mastodon.API.endpointV2URL(domain: domain).appendingPathComponent("search")
}
@ -32,7 +32,7 @@ extension Mastodon.API.Search {
public static func search(
session: URLSession,
domain: String,
query: Mastodon.API.Search.Query,
query: Mastodon.API.V2.Search.Query,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> {
let request = Mastodon.API.get(
@ -49,7 +49,7 @@ extension Mastodon.API.Search {
}
}
extension Mastodon.API.Search {
extension Mastodon.API.V2.Search {
public struct Query: Codable, GetQuery {
public init(q: String,
@ -105,7 +105,7 @@ extension Mastodon.API.Search {
}
}
public extension Mastodon.API.Search {
public extension Mastodon.API.V2.Search {
enum SearchType: String, Codable {
case accounts
case hashtags

View File

@ -0,0 +1,41 @@
//
// Mastodon+API+V2+Suggestions.swift
//
//
// Created by sxiaojian on 2021/4/20.
//
import Combine
import Foundation
extension Mastodon.API.V2.Suggestions {
static func suggestionsURL(domain: String) -> URL {
Mastodon.API.endpointV2URL(domain: domain).appendingPathComponent("suggestions")
}
/// Follow suggestions, No document for now
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - query: query
/// - authorization: User token.
/// - Returns: `AnyPublisher` contains `AccountsSuggestion` nested in the response
public static func get(
session: URLSession,
domain: String,
query: Mastodon.API.Suggestions.Query?,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.V2.SuggestionAccount]>, Error> {
let request = Mastodon.API.get(
url: suggestionsURL(domain: domain),
query: query,
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: [Mastodon.Entity.V2.SuggestionAccount].self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
}

View File

@ -99,6 +99,7 @@ extension Mastodon.API {
}
extension Mastodon.API {
public enum V2 { }
public enum Account { }
public enum App { }
public enum CustomEmojis { }
@ -111,13 +112,17 @@ extension Mastodon.API {
public enum Reblog { }
public enum Statuses { }
public enum Timeline { }
public enum Search { }
public enum Trends { }
public enum Suggestions { }
public enum Notifications { }
public enum Subscriptions { }
}
extension Mastodon.API.V2 {
public enum Search { }
public enum Suggestions { }
}
extension Mastodon.API {
static func get(

View File

@ -0,0 +1,23 @@
//
// Mastodon+Entity+Suggestion.swift
//
//
// Created by sxiaojian on 2021/4/20.
//
import Foundation
extension Mastodon.Entity.V2 {
public struct SuggestionAccount: Codable {
public let source: String
public let account: Mastodon.Entity.Account
enum CodingKeys: String, CodingKey {
case source
case account
}
}
}