Merge pull request #1044 from mastodon/ios-157-popular-on-mastodon

Better UI/UX for suggestions for new users (IOS-157)
This commit is contained in:
Nathan Mattes 2023-05-25 15:42:11 +02:00 committed by GitHub
commit ddf0afcc6d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 601 additions and 946 deletions

View File

@ -464,8 +464,8 @@
}
},
"suggestion_account": {
"title": "Find People to Follow",
"follow_explain": "When you follow someone, youll see their posts in your home feed."
"title": "Popular on Mastodon",
"follow_all": "Follow all"
},
"compose": {
"title": {

View File

@ -75,9 +75,6 @@
2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1F625CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift */; };
2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F20725CD491300561493 /* DisposeBagCollectable.swift */; };
2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.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 */; };
@ -149,6 +146,8 @@
D886FBD329DF710F00272017 /* WelcomeSeparatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D886FBD229DF710F00272017 /* WelcomeSeparatorView.swift */; };
D8916DC029211BE500124085 /* ContentSizedTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8916DBF29211BE500124085 /* ContentSizedTableView.swift */; };
D8A6AB6C291C5136003AB663 /* MastodonLoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A6AB6B291C5136003AB663 /* MastodonLoginViewController.swift */; };
D8BE30B32A179E26006B8270 /* SuggestionAccountTableViewFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8BE30B22A179E26006B8270 /* SuggestionAccountTableViewFooter.swift */; };
D8BEBCB62A1B7FFD0004F475 /* SuggestionAccountTableViewCell+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8BEBCB52A1B7FFD0004F475 /* SuggestionAccountTableViewCell+ViewModel.swift */; };
D8E5C346296DAB84007E76A7 /* DataSourceFacade+Status+History.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E5C345296DAB84007E76A7 /* DataSourceFacade+Status+History.swift */; };
D8E5C349296DB8A3007E76A7 /* StatusEditHistoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E5C348296DB8A3007E76A7 /* StatusEditHistoryViewController.swift */; };
D8F0372C29D232730027DE2E /* HashtagIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F0372B29D232730027DE2E /* HashtagIntentHandler.swift */; };
@ -416,7 +415,6 @@
DBB45B5927B39FE4002DC5A7 /* MediaPreviewVideoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB45B5827B39FE4002DC5A7 /* MediaPreviewVideoViewModel.swift */; };
DBB45B5B27B3A109002DC5A7 /* MediaPreviewTransitionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB45B5A27B3A109002DC5A7 /* MediaPreviewTransitionViewController.swift */; };
DBB45B6027B50A4F002DC5A7 /* RecommendAccountItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB45B5F27B50A4F002DC5A7 /* RecommendAccountItem.swift */; };
DBB45B6227B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB45B6127B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift */; };
DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */; };
DBB525302611EBF3002F1F29 /* ProfilePagingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */; };
DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525352611ECEB002F1F29 /* UserTimelineViewController.swift */; };
@ -441,7 +439,6 @@
DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B2F261440A50045B23D /* UITabBarController.swift */; };
DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */; };
DBD376B2269302A4007FEC24 /* UITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD376B1269302A4007FEC24 /* UITableViewCell.swift */; };
DBD5B1F627BCD3D200BD6B38 /* SuggestionAccountTableViewCell+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD5B1F527BCD3D200BD6B38 /* SuggestionAccountTableViewCell+ViewModel.swift */; };
DBD5B1F827BCFD9D00BD6B38 /* DataSourceProvider+TableViewControllerNavigateable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD5B1F727BCFD9D00BD6B38 /* DataSourceProvider+TableViewControllerNavigateable.swift */; };
DBD5B1FA27BD013700BD6B38 /* DataSourceProvider+StatusTableViewControllerNavigateable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD5B1F927BD013700BD6B38 /* DataSourceProvider+StatusTableViewControllerNavigateable.swift */; };
DBDFF1902805543100557A48 /* DiscoveryPostsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDFF18F2805543100557A48 /* DiscoveryPostsViewController.swift */; };
@ -694,9 +691,6 @@
2D38F1F625CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewModel+LoadOldestState.swift"; sourceTree = "<group>"; };
2D38F20725CD491300561493 /* DisposeBagCollectable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisposeBagCollectable.swift; sourceTree = "<group>"; };
2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITapGestureRecognizer.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>"; };
@ -801,6 +795,8 @@
D8A6FE6429325F5900666A47 /* StringsConvertor */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = StringsConvertor; sourceTree = "<group>"; };
D8A6FE6529325F5900666A47 /* ios-infoPlist.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "ios-infoPlist.json"; sourceTree = "<group>"; };
D8A6FE6629325F5900666A47 /* Localizable.stringsdict */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; path = Localizable.stringsdict; sourceTree = "<group>"; };
D8BE30B22A179E26006B8270 /* SuggestionAccountTableViewFooter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountTableViewFooter.swift; sourceTree = "<group>"; };
D8BEBCB52A1B7FFD0004F475 /* SuggestionAccountTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionAccountTableViewCell+ViewModel.swift"; sourceTree = "<group>"; };
D8E5C345296DAB84007E76A7 /* DataSourceFacade+Status+History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Status+History.swift"; sourceTree = "<group>"; };
D8E5C348296DB8A3007E76A7 /* StatusEditHistoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditHistoryViewController.swift; sourceTree = "<group>"; };
D8F0372B29D232730027DE2E /* HashtagIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagIntentHandler.swift; sourceTree = "<group>"; };
@ -1135,7 +1131,6 @@
DBB45B5827B39FE4002DC5A7 /* MediaPreviewVideoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewVideoViewModel.swift; sourceTree = "<group>"; };
DBB45B5A27B3A109002DC5A7 /* MediaPreviewTransitionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewTransitionViewController.swift; sourceTree = "<group>"; };
DBB45B5F27B50A4F002DC5A7 /* RecommendAccountItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendAccountItem.swift; sourceTree = "<group>"; };
DBB45B6127B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionAccountViewModel+Diffable.swift"; sourceTree = "<group>"; };
DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewController.swift; sourceTree = "<group>"; };
DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewModel.swift; sourceTree = "<group>"; };
DBB525352611ECEB002F1F29 /* UserTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTimelineViewController.swift; sourceTree = "<group>"; };
@ -1168,7 +1163,6 @@
DBCC3B2F261440A50045B23D /* UITabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITabBarController.swift; sourceTree = "<group>"; };
DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedProfileViewModel.swift; sourceTree = "<group>"; };
DBD376B1269302A4007FEC24 /* UITableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITableViewCell.swift; sourceTree = "<group>"; };
DBD5B1F527BCD3D200BD6B38 /* SuggestionAccountTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionAccountTableViewCell+ViewModel.swift"; sourceTree = "<group>"; };
DBD5B1F727BCFD9D00BD6B38 /* DataSourceProvider+TableViewControllerNavigateable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceProvider+TableViewControllerNavigateable.swift"; sourceTree = "<group>"; };
DBD5B1F927BD013700BD6B38 /* DataSourceProvider+StatusTableViewControllerNavigateable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceProvider+StatusTableViewControllerNavigateable.swift"; sourceTree = "<group>"; };
DBDFF18F2805543100557A48 /* DiscoveryPostsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryPostsViewController.swift; sourceTree = "<group>"; };
@ -1576,14 +1570,6 @@
path = Button;
sourceTree = "<group>";
};
2D4AD89A2631659400613EFC /* CollectionViewCell */ = {
isa = PBXGroup;
children = (
2D4AD89B263165B500613EFC /* SuggestionAccountCollectionViewCell.swift */,
);
path = CollectionViewCell;
sourceTree = "<group>";
};
2D59819925E4A55C000FB903 /* ConfirmEmail */ = {
isa = PBXGroup;
children = (
@ -1625,10 +1611,8 @@
isa = PBXGroup;
children = (
DB4F097826A039B400D62E92 /* Onboarding */,
DB0617FB27855B740030EE79 /* Account */,
DB0617F827855B170030EE79 /* User */,
DB0617F927855B460030EE79 /* Profile */,
DB0FCB892796BE1E006C02E2 /* RecommandAccount */,
DB4F097926A039C400D62E92 /* Status */,
DB65C63527A2AF52008BAC2E /* Report */,
DB0617F727855B010030EE79 /* Notification */,
@ -1683,22 +1667,23 @@
2DAC9E36262FC20B0062E1A6 /* SuggestionAccount */ = {
isa = PBXGroup;
children = (
2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */,
DBB45B5F27B50A4F002DC5A7 /* RecommendAccountItem.swift */,
2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */,
2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */,
DBB45B6127B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift */,
2D4AD89A2631659400613EFC /* CollectionViewCell */,
2DAC9E43262FC9DE0062E1A6 /* TableViewCell */,
2DAC9E43262FC9DE0062E1A6 /* TableView-Components */,
);
path = SuggestionAccount;
sourceTree = "<group>";
};
2DAC9E43262FC9DE0062E1A6 /* TableViewCell */ = {
2DAC9E43262FC9DE0062E1A6 /* TableView-Components */ = {
isa = PBXGroup;
children = (
2DAC9E45262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift */,
DBD5B1F527BCD3D200BD6B38 /* SuggestionAccountTableViewCell+ViewModel.swift */,
D8BEBCB52A1B7FFD0004F475 /* SuggestionAccountTableViewCell+ViewModel.swift */,
D8BE30B22A179E26006B8270 /* SuggestionAccountTableViewFooter.swift */,
);
path = TableViewCell;
path = "TableView-Components";
sourceTree = "<group>";
};
2DE0FAC62615F5D200CDF649 /* View */ = {
@ -1907,15 +1892,6 @@
path = Settings;
sourceTree = "<group>";
};
DB0617FB27855B740030EE79 /* Account */ = {
isa = PBXGroup;
children = (
2D4AD8A126316CD200613EFC /* SelectedAccountSection.swift */,
2D4AD8A726316D3500613EFC /* SelectedAccountItem.swift */,
);
path = Account;
sourceTree = "<group>";
};
DB0618082785B2790030EE79 /* Cell */ = {
isa = PBXGroup;
children = (
@ -1943,15 +1919,6 @@
path = View;
sourceTree = "<group>";
};
DB0FCB892796BE1E006C02E2 /* RecommandAccount */ = {
isa = PBXGroup;
children = (
2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */,
DBB45B5F27B50A4F002DC5A7 /* RecommendAccountItem.swift */,
);
path = RecommandAccount;
sourceTree = "<group>";
};
DB1D187125EF5BBD003F1F23 /* TableView */ = {
isa = PBXGroup;
children = (
@ -3563,6 +3530,7 @@
DBE3CDCF261C42ED00430CC6 /* TimelineHeaderView.swift in Sources */,
62FD27D32893707B00B205C5 /* BookmarkViewController+DataSourceProvider.swift in Sources */,
DB1D843426579931000346B3 /* TableViewControllerNavigateable.swift in Sources */,
D8BE30B32A179E26006B8270 /* SuggestionAccountTableViewFooter.swift in Sources */,
0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */,
DB65C63727A2AF6C008BAC2E /* ReportItem.swift in Sources */,
DB5B54B22833C24B00DEF8B2 /* RebloggedByViewController+DataSourceProvider.swift in Sources */,
@ -3618,7 +3586,6 @@
DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */,
DB6180F226391CF40018D199 /* MediaPreviewImageViewModel.swift in Sources */,
62FD27D12893707600B205C5 /* BookmarkViewController.swift in Sources */,
DBD5B1F627BCD3D200BD6B38 /* SuggestionAccountTableViewCell+ViewModel.swift in Sources */,
DB63F767279A5EB300455B82 /* NotificationTimelineViewModel.swift in Sources */,
2D607AD826242FC500B70763 /* NotificationViewModel.swift in Sources */,
DB5B54AB2833C12A00DEF8B2 /* RebloggedByViewController.swift in Sources */,
@ -3632,7 +3599,6 @@
DBDFF1952805561700557A48 /* DiscoveryPostsViewModel+Diffable.swift in Sources */,
DB03A795272A981400EE37C5 /* ContentSplitViewController.swift in Sources */,
DBDFF19C28055BD600557A48 /* DiscoveryViewModel.swift in Sources */,
DBB45B6227B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift in Sources */,
DBB3BA2A26A81C020004F2D4 /* FLAnimatedImageView.swift in Sources */,
DB3E6FF32806D97400B035AE /* DiscoveryNewsViewModel+State.swift in Sources */,
DB6746ED278F45F0008A6B94 /* AutoGenerateProtocolRelayDelegate.swift in Sources */,
@ -3689,7 +3655,6 @@
DB63F7542799491600455B82 /* DataSourceFacade+SearchHistory.swift in Sources */,
DB7A9F912818EAF10016AF98 /* MastodonRegisterView.swift in Sources */,
DBF1572F27046F1A00EC00B7 /* SecondaryPlaceholderViewController.swift in Sources */,
2D4AD8A826316D3500613EFC /* SelectedAccountItem.swift in Sources */,
DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */,
2AB12E4629362F27006BC925 /* DataSourceFacade+Translate.swift in Sources */,
DBE3CE01261D623D00430CC6 /* FavoriteViewModel+State.swift in Sources */,
@ -3856,7 +3821,6 @@
DB938F0326240EA300E5B6C1 /* CachedThreadViewModel.swift in Sources */,
DBA5A53526F0A36A00CACBAA /* AddAccountTableViewCell.swift in Sources */,
2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */,
2D4AD89C263165B500613EFC /* SuggestionAccountCollectionViewCell.swift in Sources */,
DB98EB6927B21A7C0082E365 /* ReportResultActionTableViewCell.swift in Sources */,
DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */,
DB697DDF278F524F004EF2F7 /* DataSourceFacade+Profile.swift in Sources */,
@ -3888,6 +3852,7 @@
DB6B74FC272FF55800C70B6E /* UserSection.swift in Sources */,
DB0FCB862796BDA1006C02E2 /* SearchSection.swift in Sources */,
DB1D61CF26F1B33600DA8662 /* WelcomeViewModel.swift in Sources */,
D8BEBCB62A1B7FFD0004F475 /* SuggestionAccountTableViewCell+ViewModel.swift in Sources */,
DBD376B2269302A4007FEC24 /* UITableViewCell.swift in Sources */,
DB4F0966269ED52200D62E92 /* SearchResultViewModel.swift in Sources */,
DB6180FA26391F2E0018D199 /* MediaPreviewViewModel.swift in Sources */,
@ -3927,7 +3892,6 @@
DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */,
DBDFF19A28055A1400557A48 /* DiscoveryViewController.swift in Sources */,
DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */,
2D4AD8A226316CD200613EFC /* SelectedAccountSection.swift in Sources */,
DB3EA8F1281B9EF600598866 /* DiscoveryCommunityViewModel+Diffable.swift in Sources */,
85BC11B32932414900E191CD /* AltTextViewController.swift in Sources */,
DB63F775279A997D00455B82 /* NotificationTableViewCell+ViewModel.swift in Sources */,

View File

@ -1,15 +0,0 @@
//
// SelectedAccountItem.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/22.
//
import CoreData
import Foundation
import CoreDataStack
enum SelectedAccountItem: Hashable {
case account(ManagedObjectRecord<MastodonUser>)
case placeHolder(uuid: UUID)
}

View File

@ -1,37 +0,0 @@
//
// SelectedAccountSection.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/22.
//
import UIKit
import CoreData
import CoreDataStack
import MastodonCore
import MastodonSDK
enum SelectedAccountSection: Equatable, Hashable {
case main
}
extension SelectedAccountSection {
static func collectionViewDiffableDataSource(
collectionView: UICollectionView,
context: AppContext
) -> 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 .account(let record):
context.managedObjectContext.performAndWait {
guard let user = record.object(in: context.managedObjectContext) else { return }
cell.config(with: user)
}
case .placeHolder:
cell.configAsPlaceHolder()
}
return cell
}
}
}

View File

@ -1,161 +0,0 @@
//
// RecommendAccountSection.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/1.
//
import CoreData
import CoreDataStack
import Foundation
import MastodonSDK
import UIKit
import MetaTextKit
import MastodonMeta
import Combine
import MastodonCore
enum RecommendAccountSection: Equatable, Hashable {
case main
}
//extension RecommendAccountSection {
// static func collectionViewDiffableDataSource(
// for collectionView: UICollectionView,
// dependency: NeedsDependency,
// delegate: SearchRecommendAccountsCollectionViewCellDelegate,
// managedObjectContext: NSManagedObjectContext
// ) -> UICollectionViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID> {
// UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak delegate] collectionView, indexPath, objectID -> UICollectionViewCell? in
// let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SearchRecommendAccountsCollectionViewCell.self), for: indexPath) as! SearchRecommendAccountsCollectionViewCell
// managedObjectContext.performAndWait {
// let user = managedObjectContext.object(with: objectID) as! MastodonUser
// configure(cell: cell, user: user, dependency: dependency)
// }
// cell.delegate = delegate
// return cell
// }
// }
//
// static func configure(
// cell: SearchRecommendAccountsCollectionViewCell,
// user: MastodonUser,
// dependency: NeedsDependency
// ) {
// configureContent(cell: cell, user: user)
//
// if let currentMastodonUser = dependency.context.authenticationService.activeMastodonAuthentication.value?.user {
// configureFollowButton(with: user, currentMastodonUser: currentMastodonUser, followButton: cell.followButton)
// }
//
// Publishers.CombineLatest(
// ManagedObjectObserver.observe(object: user).eraseToAnyPublisher().mapError { $0 as Error },
// dependency.context.authenticationService.activeMastodonAuthentication.setFailureType(to: Error.self)
// )
// .receive(on: DispatchQueue.main)
// .sink { _ in
// // do nothing
// } receiveValue: { [weak cell] change, authentication in
// guard let cell = cell else { return }
// guard case .update(let object) = change.changeType,
// let user = object as? MastodonUser else { return }
// guard let currentMastodonUser = authentication?.user else { return }
//
// configureFollowButton(with: user, currentMastodonUser: currentMastodonUser, followButton: cell.followButton)
// }
// .store(in: &cell.disposeBag)
//
// }
//
// static func configureContent(
// cell: SearchRecommendAccountsCollectionViewCell,
// user: MastodonUser
// ) {
// do {
// let mastodonContent = MastodonContent(content: user.displayNameWithFallback, emojis: user.emojis.asDictionary)
// let metaContent = try MastodonMetaContent.convert(document: mastodonContent)
// cell.displayNameLabel.configure(content: metaContent)
// } catch {
// let metaContent = PlaintextMetaContent(string: user.displayNameWithFallback)
// cell.displayNameLabel.configure(content: metaContent)
// }
// cell.acctLabel.text = "@" + user.acct
// cell.avatarImageView.af.setImage(
// withURL: user.avatarImageURLWithFallback(domain: user.domain),
// placeholderImage: UIImage.placeholder(color: .systemFill),
// imageTransition: .crossDissolve(0.2)
// )
// cell.headerImageView.af.setImage(
// withURL: URL(string: user.header)!,
// placeholderImage: UIImage.placeholder(color: .systemFill),
// imageTransition: .crossDissolve(0.2)
// )
// }
//
// static func configureFollowButton(
// with mastodonUser: MastodonUser,
// currentMastodonUser: MastodonUser,
// followButton: HighlightDimmableButton
// ) {
// let relationshipActionSet = relationShipActionSet(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser)
// followButton.setTitle(relationshipActionSet.title, for: .normal)
// }
//
// static func relationShipActionSet(
// mastodonUser: MastodonUser,
// currentMastodonUser: MastodonUser
// ) -> ProfileViewModel.RelationshipActionOptionSet {
// var relationshipActionSet = ProfileViewModel.RelationshipActionOptionSet([.follow])
// let isFollowing = mastodonUser.followingBy.flatMap { $0.contains(currentMastodonUser) } ?? false
// if isFollowing {
// relationshipActionSet.insert(.following)
// }
//
// let isPending = mastodonUser.followRequestedBy.flatMap { $0.contains(currentMastodonUser) } ?? false
// if isPending {
// relationshipActionSet.insert(.pending)
// }
//
// let isBlocking = mastodonUser.blockingBy.flatMap { $0.contains(currentMastodonUser) } ?? false
// if isBlocking {
// relationshipActionSet.insert(.blocking)
// }
//
// let isBlockedBy = currentMastodonUser.blockingBy.flatMap { $0.contains(mastodonUser) } ?? false
// if isBlockedBy {
// relationshipActionSet.insert(.blocked)
// }
// return relationshipActionSet
// }
//
//}
//
extension RecommendAccountSection {
struct Configuration {
let authContext: AuthContext
weak var suggestionAccountTableViewCellDelegate: SuggestionAccountTableViewCellDelegate?
}
static func tableViewDiffableDataSource(
tableView: UITableView,
context: AppContext,
configuration: Configuration
) -> UITableViewDiffableDataSource<RecommendAccountSection, RecommendAccountItem> {
UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SuggestionAccountTableViewCell.self)) as! SuggestionAccountTableViewCell
switch item {
case .account(let record):
context.managedObjectContext.performAndWait {
guard let user = record.object(in: context.managedObjectContext) else { return }
cell.configure(user: user)
}
cell.viewModel.userIdentifier = configuration.authContext.mastodonAuthenticationBox
cell.delegate = configuration.suggestionAccountTableViewCellDelegate
}
return cell
}
}
}

View File

@ -32,7 +32,12 @@ extension SearchHistorySection {
guard let user = item.object(in: context.managedObjectContext) else { return }
cell.configure(
me: authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user,
viewModel: .init(value: user, followedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$followingUserIds.eraseToAnyPublisher(), blockedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$blockedUserIds.eraseToAnyPublisher()),
viewModel: SearchHistoryUserCollectionViewCell.ViewModel(
value: user,
followedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$followingUserIds.eraseToAnyPublisher(),
blockedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$blockedUserIds.eraseToAnyPublisher(),
followRequestedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$followRequestedUserIDs.eraseToAnyPublisher()
),
delegate: configuration.searchHistorySectionHeaderCollectionReusableViewDelegate
)
}

View File

@ -53,7 +53,10 @@ extension SearchResultSection {
authContext: authContext,
tableView: tableView,
cell: cell,
viewModel: .init(value: .user(user), followedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$followingUserIds.eraseToAnyPublisher(), blockedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$blockedUserIds.eraseToAnyPublisher()),
viewModel: UserTableViewCell.ViewModel(value: .user(user),
followedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$followingUserIds.eraseToAnyPublisher(),
blockedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$blockedUserIds.eraseToAnyPublisher(),
followRequestedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$followRequestedUserIDs.eraseToAnyPublisher()),
configuration: configuration
)
}

View File

@ -48,7 +48,11 @@ extension UserSection {
authContext: authContext,
tableView: tableView,
cell: cell,
viewModel: .init(value: .user(user), followedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$followingUserIds.eraseToAnyPublisher(), blockedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$blockedUserIds.eraseToAnyPublisher()),
viewModel: UserTableViewCell.ViewModel(value: .user(user),
followedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$followingUserIds.eraseToAnyPublisher(),
blockedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$blockedUserIds.eraseToAnyPublisher(),
followRequestedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$followRequestedUserIDs.eraseToAnyPublisher()
),
configuration: configuration
)
}

View File

@ -32,7 +32,7 @@ extension DataSourceFacade {
static func responseToUserFollowRequestAction(
dependency: NeedsDependency & AuthContextProvider,
notification: ManagedObjectRecord<Notification>,
query: Mastodon.API.Account.FollowReqeustQuery
query: Mastodon.API.Account.FollowRequestQuery
) async throws {
let selectionFeedbackGenerator = await UISelectionFeedbackGenerator()
await selectionFeedbackGenerator.selectionChanged()

View File

@ -22,6 +22,17 @@ extension DataSourceFacade {
if let userObject = user.object(in: dependency.context.managedObjectContext) {
dependency.authContext.mastodonAuthenticationBox.inMemoryCache.followingUserIds.append(userObject.id)
}
case .request:
try await DataSourceFacade.responseToUserFollowAction(
dependency: dependency,
user: user
)
if let userObject = user.object(in: dependency.context.managedObjectContext) {
dependency.authContext.mastodonAuthenticationBox.inMemoryCache.followRequestedUserIDs.append(userObject.id)
}
case .unfollow:
try await DataSourceFacade.responseToUserFollowAction(
dependency: dependency,
@ -39,6 +50,16 @@ extension DataSourceFacade {
if let userObject = user.object(in: dependency.context.managedObjectContext) {
dependency.authContext.mastodonAuthenticationBox.inMemoryCache.blockedUserIds.append(userObject.id)
}
case .pending:
try await DataSourceFacade.responseToUserFollowAction(
dependency: dependency,
user: user
)
if let userObject = user.object(in: dependency.context.managedObjectContext) {
dependency.authContext.mastodonAuthenticationBox.inMemoryCache.followRequestedUserIDs.removeAll(where: { $0 == userObject.id })
}
case .none, .loading:
break //no-op
}

View File

@ -240,6 +240,19 @@ extension HomeTimelineViewController {
.sink { [weak self] isEmpty in
if isEmpty {
self?.showEmptyView()
let userDoesntFollowPeople: Bool
if let managedObjectContext = self?.context.managedObjectContext,
let me = self?.authContext.mastodonAuthenticationBox.authenticationRecord.object(in: managedObjectContext)?.user {
userDoesntFollowPeople = me.followersCount == 0
} else {
userDoesntFollowPeople = true
}
if (self?.viewModel.presentedSuggestions == false) && userDoesntFollowPeople {
self?.findPeopleButtonPressed(self)
self?.viewModel.presentedSuggestions = true
}
} else {
self?.emptyView.removeFromSuperview()
}
@ -285,8 +298,6 @@ extension HomeTimelineViewController {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
viewModel.viewDidAppear.send()
if let timestamp = viewModel.lastAutomaticFetchTimestamp {
let now = Date()
if now.timeIntervalSince(timestamp) > 60 {
@ -360,6 +371,7 @@ extension HomeTimelineViewController {
bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
topPaddingView.heightAnchor.constraint(equalTo: bottomPaddingView.heightAnchor, multiplier: 0.8),
manuallySearchButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 20),
])
let buttonContainerStackView = UIStackView()
@ -374,9 +386,10 @@ extension HomeTimelineViewController {
}
}
//MARK: - Actions
extension HomeTimelineViewController {
@objc private func findPeopleButtonPressed(_ sender: PrimaryActionButton) {
@objc private func findPeopleButtonPressed(_ sender: Any?) {
let suggestionAccountViewModel = SuggestionAccountViewModel(context: context, authContext: viewModel.authContext)
suggestionAccountViewModel.delegate = viewModel
_ = coordinator.present(
@ -387,13 +400,11 @@ extension HomeTimelineViewController {
}
@objc private func manuallySearchButtonPressed(_ sender: UIButton) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
let searchDetailViewModel = SearchDetailViewModel(authContext: viewModel.authContext)
_ = coordinator.present(scene: .searchDetail(viewModel: searchDetailViewModel), from: self, transition: .modal(animated: true, completion: nil))
}
@objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
guard let setting = context.settingService.currentSetting.value else { return }
let settingsViewModel = SettingsViewModel(context: context, authContext: viewModel.authContext, setting: setting)
_ = coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil))

View File

@ -30,7 +30,8 @@ final class HomeTimelineViewModel: NSObject {
let fetchedResultsController: FeedFetchedResultsController
let homeTimelineNavigationBarTitleViewModel: HomeTimelineNavigationBarTitleViewModel
let listBatchFetchViewModel = ListBatchFetchViewModel()
let viewDidAppear = PassthroughSubject<Void, Never>()
var presentedSuggestions = false
@Published var lastAutomaticFetchTimestamp: Date? = nil
@Published var scrollPositionRecord: ScrollPositionRecord? = nil

View File

@ -301,7 +301,7 @@ extension MastodonLoginViewController: MastodonLoginViewModelDelegate {
dataSource?.apply(snapshot, animatingDifferences: false)
OperationQueue.main.addOperation {
DispatchQueue.main.async {
let numberOfResults = viewModel.filteredServers.count
self.contentView.updateCorners(numberOfResults: numberOfResults)
}

View File

@ -65,7 +65,7 @@ extension PickServerSection {
}()
if let proxiedThumbnail = server.proxiedThumbnail, let thumbnailUrl = URL(string: proxiedThumbnail) {
cell.thumbnailImageView.af.setImage(withURL: thumbnailUrl, completion: { _ in
OperationQueue.main.addOperation {
DispatchQueue.main.async {
cell.thumbnailImageView.isHidden = false
}
})

View File

@ -280,8 +280,6 @@ extension ProfileHeaderViewController {
}
func updateHeaderScrollProgress(_ progress: CGFloat, throttle: CGFloat) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress)
// set title view offset
let nameTextFieldInWindow = profileHeaderView.nameTextField.superview!.convert(profileHeaderView.nameTextField.frame, to: nil)
let nameTextFieldTopToNavigationBarBottomOffset = containerSafeAreaInset.top - nameTextFieldInWindow.origin.y

View File

@ -289,6 +289,12 @@ extension ProfileViewController {
bindPager()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.navigationBar.prefersLargeTitles = false
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
@ -374,7 +380,7 @@ extension ProfileViewController {
profileHeaderViewController.profileHeaderView.viewModel.$name
.receive(on: DispatchQueue.main)
.sink { [weak self] name in
guard let self = self else { return }
guard let self = self, self.isModal == false else { return }
self.navigationItem.title = name
}
.store(in: &disposeBag)
@ -422,7 +428,7 @@ extension ProfileViewController {
}
} receiveValue: { [weak self] menu in
guard let self = self else { return }
OperationQueue.main.addOperation {
DispatchQueue.main.async {
self.moreMenuBarButtonItem.menu = menu
}
}

View File

@ -16,10 +16,12 @@ extension SearchHistoryUserCollectionViewCell {
let followedUsers: AnyPublisher<[String], Never>
let blockedUsers: AnyPublisher<[String], Never>
init(value: MastodonUser, followedUsers: AnyPublisher<[String], Never>, blockedUsers: AnyPublisher<[String], Never>) {
let followRequestedUsers: AnyPublisher<[String], Never>
init(value: MastodonUser, followedUsers: AnyPublisher<[String], Never>, blockedUsers: AnyPublisher<[String], Never>, followRequestedUsers: AnyPublisher<[String], Never>) {
self.value = value
self.followedUsers = followedUsers
self.followRequestedUsers = followRequestedUsers
self.blockedUsers = blockedUsers
}
}
@ -45,25 +47,26 @@ extension SearchHistoryUserCollectionViewCell {
userView.setButtonState(.loading)
}
Publishers.CombineLatest(
Publishers.CombineLatest3(
viewModel.followedUsers,
viewModel.followRequestedUsers,
viewModel.blockedUsers
)
.receive(on: DispatchQueue.main)
.sink { [weak self] followed, blocked in
if blocked.contains(where: { $0 == user.id }) {
.sink { [weak self] followed, requested, blocked in
if blocked.contains(user.id) {
self?.userView.setButtonState(.blocked)
} else if followed.contains(where: { $0 == user.id }) {
} else if followed.contains(user.id) {
self?.userView.setButtonState(.unfollow)
} else {
} else if requested.contains(user.id) {
self?.userView.setButtonState(.pending)
} else if user.locked {
self?.userView.setButtonState(.request)
} else if user != me {
self?.userView.setButtonState(.follow)
}
self?.setNeedsLayout()
self?.setNeedsUpdateConstraints()
self?.layoutIfNeeded()
}
.store(in: &_disposeBag)
}
}

View File

@ -33,7 +33,6 @@ extension StatusTableViewCell {
if statusView.frame == .zero {
// set status view width
statusView.frame.size.width = tableView.frame.width - containerViewHorizontalMargin
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): did layout for new cell")
}
switch viewModel.value {

View File

@ -15,8 +15,6 @@ import MastodonUI
final class StatusTableViewCell: UITableViewCell {
static let marginForRegularHorizontalSizeClass: CGFloat = 64
let logger = Logger(subsystem: "StatusTableViewCell", category: "View")
weak var delegate: StatusTableViewCellDelegate?
var disposeBag = Set<AnyCancellable>()
@ -44,11 +42,6 @@ final class StatusTableViewCell: UITableViewCell {
super.init(coder: coder)
_init()
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension StatusTableViewCell {

View File

@ -16,10 +16,12 @@ extension UserTableViewCell {
let followedUsers: AnyPublisher<[String], Never>
let blockedUsers: AnyPublisher<[String], Never>
let followRequestedUsers: AnyPublisher<[String], Never>
init(value: Value, followedUsers: AnyPublisher<[String], Never>, blockedUsers: AnyPublisher<[String], Never>) {
init(value: Value, followedUsers: AnyPublisher<[String], Never>, blockedUsers: AnyPublisher<[String], Never>, followRequestedUsers: AnyPublisher<[String], Never>) {
self.value = value
self.followedUsers = followedUsers
self.followRequestedUsers = followRequestedUsers
self.blockedUsers = blockedUsers
}
@ -52,20 +54,24 @@ extension UserTableViewCell {
userView.setButtonState(.loading)
}
Publishers.CombineLatest(
Publishers.CombineLatest3(
viewModel.followedUsers,
viewModel.followRequestedUsers,
viewModel.blockedUsers
)
.receive(on: DispatchQueue.main)
.sink { [weak self] followed, blocked in
.sink { [weak self] followed, requested, blocked in
if blocked.contains(user.id) {
self?.userView.setButtonState(.blocked)
} else if followed.contains(user.id) {
self?.userView.setButtonState(.unfollow)
} else if requested.contains(user.id) {
self?.userView.setButtonState(.pending)
} else if user.locked {
self?.userView.setButtonState(.request)
} else if user != me {
self?.userView.setButtonState(.follow)
}
}
.store(in: &disposeBag)

View File

@ -1,58 +0,0 @@
//
// SuggestionAccountCollectionViewCell.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/22.
//
import CoreDataStack
import Foundation
import UIKit
import MastodonAsset
import MastodonLocalization
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
imageView.pinToParent()
}
}

View File

@ -0,0 +1,54 @@
//
// RecommendAccountSection.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/1.
//
import CoreData
import CoreDataStack
import Foundation
import MastodonSDK
import UIKit
import MetaTextKit
import MastodonMeta
import Combine
import MastodonCore
enum RecommendAccountSection: Equatable, Hashable {
case main
}
extension RecommendAccountSection {
struct Configuration {
let authContext: AuthContext
weak var suggestionAccountTableViewCellDelegate: SuggestionAccountTableViewCellDelegate?
}
static func tableViewDiffableDataSource(
tableView: UITableView,
context: AppContext,
configuration: Configuration
) -> UITableViewDiffableDataSource<RecommendAccountSection, RecommendAccountItem> {
UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SuggestionAccountTableViewCell.self)) as! SuggestionAccountTableViewCell
switch item {
case .account(let record):
cell.delegate = configuration.suggestionAccountTableViewCellDelegate
context.managedObjectContext.performAndWait {
guard let user = record.object(in: context.managedObjectContext) else { return }
cell.configure(viewModel:
SuggestionAccountTableViewCell.ViewModel(
user: user,
followedUsers: configuration.authContext.mastodonAuthenticationBox.inMemoryCache.followingUserIds,
blockedUsers: configuration.authContext.mastodonAuthenticationBox.inMemoryCache.blockedUserIds,
followRequestedUsers: configuration.authContext.mastodonAuthenticationBox.inMemoryCache.followRequestedUserIDs)
)
}
}
return cell
}
}
}

View File

@ -9,7 +9,6 @@ import Combine
import CoreData
import CoreDataStack
import Foundation
import OSLog
import UIKit
import MastodonAsset
import MastodonCore
@ -17,71 +16,28 @@ import MastodonUI
import MastodonLocalization
class SuggestionAccountViewController: UIViewController, NeedsDependency {
static let collectionViewHeight: CGFloat = 24 + 64 + 24
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var disposeBag = Set<AnyCancellable>()
var viewModel: SuggestionAccountViewModel!
private static func createCollectionViewLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(64), heightDimension: .absolute(64))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: 24, leading: 0, bottom: 24, trailing: 0)
section.orthogonalScrollingBehavior = .continuous
section.contentInsetsReference = .readableContent
section.interGroupSpacing = 16
return UICollectionViewCompositionalLayout(section: section)
}
let collectionView: UICollectionView = {
let collectionViewLayout = SuggestionAccountViewController.createCollectionViewLayout()
let view = ControlContainableCollectionView(
frame: .zero,
collectionViewLayout: collectionViewLayout
)
view.register(SuggestionAccountCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SuggestionAccountCollectionViewCell.self))
view.backgroundColor = .clear
view.showsHorizontalScrollIndicator = false
view.showsVerticalScrollIndicator = false
view.layer.masksToBounds = false
return view
}()
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)
let tableView = UITableView(frame: .zero, style: .insetGrouped)
tableView.register(SuggestionAccountTableViewCell.self, forCellReuseIdentifier: SuggestionAccountTableViewCell.reuseIdentifier)
// we're lazy, that's why we don't put the Footer in tableViewFooter
tableView.register(SuggestionAccountTableViewFooter.self, forHeaderFooterViewReuseIdentifier: SuggestionAccountTableViewFooter.reuseIdentifier)
tableView.contentInset.top = 16
return tableView
}()
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()
setupBackgroundColor(theme: ThemeService.shared.currentTheme.value)
ThemeService.shared.currentTheme
.receive(on: DispatchQueue.main)
.sink { [weak self] theme in
guard let self = self else { return }
self.setupBackgroundColor(theme: theme)
}
.store(in: &disposeBag)
setupNavigationBarAppearance()
defer { setupNavigationBarBackgroundView() }
title = L10n.Scene.SuggestionAccount.title
navigationItem.rightBarButtonItem = UIBarButtonItem(
@ -89,66 +45,40 @@ extension SuggestionAccountViewController {
target: self,
action: #selector(SuggestionAccountViewController.doneButtonDidClick(_:))
)
collectionView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(collectionView)
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
collectionView.heightAnchor.constraint(equalToConstant: SuggestionAccountViewController.collectionViewHeight),
])
defer { view.bringSubviewToFront(collectionView) }
tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: collectionView.bottomAnchor),
tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
collectionView.delegate = self
viewModel.setupDiffableDataSource(
collectionView: collectionView
)
tableView.delegate = self
viewModel.setupDiffableDataSource(
tableView: tableView,
suggestionAccountTableViewCellDelegate: self
)
view.backgroundColor = .secondarySystemBackground
tableView.backgroundColor = .secondarySystemBackground
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.navigationBar.prefersLargeTitles = true
navigationItem.largeTitleDisplayMode = .automatic
tableView.deselectRow(with: transitionCoordinator, animated: animated)
}
private func setupBackgroundColor(theme: Theme) {
view.backgroundColor = theme.systemBackgroundColor
collectionView.backgroundColor = theme.systemGroupedBackgroundColor
}
}
//MARK: - Actions
// MARK: - UICollectionViewDelegateFlowLayout
extension SuggestionAccountViewController: UICollectionViewDelegate {
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
// }
@objc func doneButtonDidClick(_ sender: UIButton) {
viewModel.delegate?.homeTimelineNeedRefresh.send()
dismiss(animated: true, completion: nil)
}
}
@ -168,6 +98,15 @@ extension SuggestionAccountViewController: UITableViewDelegate {
)
}
}
func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
guard let footerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: SuggestionAccountTableViewFooter.reuseIdentifier) as? SuggestionAccountTableViewFooter else {
return nil
}
footerView.delegate = self
return footerView
}
}
// MARK: - AuthContextProvider
@ -175,39 +114,21 @@ extension SuggestionAccountViewController: AuthContextProvider {
var authContext: AuthContext { viewModel.authContext }
}
// MARK: - UserTableViewCellDelegate
extension SuggestionAccountViewController: UserTableViewCellDelegate {}
// MARK: - SuggestionAccountTableViewCellDelegate
extension SuggestionAccountViewController: SuggestionAccountTableViewCellDelegate {
func suggestionAccountTableViewCell(
_ cell: SuggestionAccountTableViewCell,
friendshipDidPressed button: UIButton
) {
guard let tableViewDiffableDataSource = viewModel.tableViewDiffableDataSource else { return }
guard let indexPath = tableView.indexPath(for: cell) else { return }
guard let item = tableViewDiffableDataSource.itemIdentifier(for: indexPath) else { return }
switch item {
case .account(let user):
Task { @MainActor in
cell.startAnimating()
do {
try await DataSourceFacade.responseToUserFollowAction(
dependency: self,
user: user
)
} catch {
// do noting
}
cell.stopAnimating()
} // end Task
extension SuggestionAccountViewController: SuggestionAccountTableViewCellDelegate { }
extension SuggestionAccountViewController: SuggestionAccountTableViewFooterDelegate {
func followAll(_ footerView: SuggestionAccountTableViewFooter) {
viewModel.followAllSuggestedAccounts(self) {
DispatchQueue.main.async {
self.dismiss(animated: true)
}
}
}
}
extension SuggestionAccountViewController {
@objc func doneButtonDidClick(_ sender: UIButton) {
dismiss(animated: true, completion: nil)
// if viewModel.selectedAccounts.value.count > 0 {
// viewModel.delegate?.homeTimelineNeedRefresh.send()
// }
}
}
extension SuggestionAccountViewController: OnboardingViewControllerAppearance { }

View File

@ -1,74 +0,0 @@
//
// SuggestionAccountViewModel+Diffable.swift
// Mastodon
//
// Created by MainasuK on 2022-2-10.
//
import UIKit
extension SuggestionAccountViewModel {
func setupDiffableDataSource(
tableView: UITableView,
suggestionAccountTableViewCellDelegate: SuggestionAccountTableViewCellDelegate
) {
tableViewDiffableDataSource = RecommendAccountSection.tableViewDiffableDataSource(
tableView: tableView,
context: context,
configuration: RecommendAccountSection.Configuration(
authContext: authContext,
suggestionAccountTableViewCellDelegate: suggestionAccountTableViewCellDelegate
)
)
userFetchedResultsController.$records
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] records in
guard let self = self else { return }
guard let tableViewDiffableDataSource = self.tableViewDiffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<RecommendAccountSection, RecommendAccountItem>()
snapshot.appendSections([.main])
let items: [RecommendAccountItem] = records.map { RecommendAccountItem.account($0) }
snapshot.appendItems(items, toSection: .main)
tableViewDiffableDataSource.applySnapshotUsingReloadData(snapshot, completion: nil)
}
.store(in: &disposeBag)
}
func setupDiffableDataSource(
collectionView: UICollectionView
) {
collectionViewDiffableDataSource = SelectedAccountSection.collectionViewDiffableDataSource(
collectionView: collectionView,
context: context
)
selectedUserFetchedResultsController.$records
.receive(on: DispatchQueue.main)
.sink { [weak self] records in
guard let self = self else { return }
guard let collectionViewDiffableDataSource = self.collectionViewDiffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<SelectedAccountSection, SelectedAccountItem>()
snapshot.appendSections([.main])
var items: [SelectedAccountItem] = records.map { SelectedAccountItem.account($0) }
if items.count < 10 {
let count = 10 - items.count
let placeholderItems: [SelectedAccountItem] = (0..<count).map { _ in
SelectedAccountItem.placeHolder(uuid: UUID())
}
items.append(contentsOf: placeholderItems)
}
snapshot.appendItems(items, toSection: .main)
collectionViewDiffableDataSource.applySnapshotUsingReloadData(snapshot, completion: nil)
}
.store(in: &disposeBag)
}
}

View File

@ -11,7 +11,6 @@ import CoreDataStack
import GameplayKit
import MastodonSDK
import MastodonCore
import os.log
import UIKit
protocol SuggestionAccountViewModelDelegate: AnyObject {
@ -27,12 +26,10 @@ final class SuggestionAccountViewModel: NSObject {
let context: AppContext
let authContext: AuthContext
let userFetchedResultsController: UserFetchedResultsController
let selectedUserFetchedResultsController: UserFetchedResultsController
var viewWillAppear = PassthroughSubject<Void, Never>()
// output
var collectionViewDiffableDataSource: UICollectionViewDiffableDataSource<SelectedAccountSection, SelectedAccountItem>?
var tableViewDiffableDataSource: UITableViewDiffableDataSource<RecommendAccountSection, RecommendAccountItem>?
init(
@ -46,26 +43,16 @@ final class SuggestionAccountViewModel: NSObject {
domain: nil,
additionalPredicate: nil
)
self.selectedUserFetchedResultsController = UserFetchedResultsController(
managedObjectContext: context.managedObjectContext,
domain: nil,
additionalPredicate: nil
)
super.init()
userFetchedResultsController.domain = authContext.mastodonAuthenticationBox.domain
selectedUserFetchedResultsController.domain = authContext.mastodonAuthenticationBox.domain
selectedUserFetchedResultsController.additionalPredicate = NSCompoundPredicate(orPredicateWithSubpredicates: [
MastodonUser.predicate(followingBy: authContext.mastodonAuthenticationBox.userID),
MastodonUser.predicate(followRequestedBy: authContext.mastodonAuthenticationBox.userID)
])
// fetch recomment users
// fetch recommended users
Task {
var userIDs: [MastodonUser.ID] = []
do {
let response = try await context.apiService.suggestionAccountV2(
query: nil,
query: .init(limit: 5),
authenticationBox: authContext.mastodonAuthenticationBox
)
userIDs = response.value.map { $0.account.id }
@ -76,12 +63,11 @@ final class SuggestionAccountViewModel: NSObject {
)
userIDs = response.value.map { $0.id }
} catch {
os_log("%{public}s[%{public}ld], %{public}s: fetch recommendAccountV2 failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
}
guard !userIDs.isEmpty else { return }
userFetchedResultsController.userIDs = userIDs
selectedUserFetchedResultsController.userIDs = userIDs
}
// fetch relationship
@ -99,4 +85,56 @@ final class SuggestionAccountViewModel: NSObject {
.store(in: &disposeBag)
}
func setupDiffableDataSource(
tableView: UITableView,
suggestionAccountTableViewCellDelegate: SuggestionAccountTableViewCellDelegate
) {
tableViewDiffableDataSource = RecommendAccountSection.tableViewDiffableDataSource(
tableView: tableView,
context: context,
configuration: RecommendAccountSection.Configuration(
authContext: authContext,
suggestionAccountTableViewCellDelegate: suggestionAccountTableViewCellDelegate
)
)
userFetchedResultsController.$records
.receive(on: DispatchQueue.main)
.sink { [weak self] records in
guard let self = self else { return }
guard let tableViewDiffableDataSource = self.tableViewDiffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<RecommendAccountSection, RecommendAccountItem>()
snapshot.appendSections([.main])
let items: [RecommendAccountItem] = records.map { RecommendAccountItem.account($0) }
snapshot.appendItems(items, toSection: .main)
tableViewDiffableDataSource.applySnapshotUsingReloadData(snapshot)
}
.store(in: &disposeBag)
}
func followAllSuggestedAccounts(_ dependency: NeedsDependency & AuthContextProvider, completion: (() -> Void)? = nil) {
let userRecords = userFetchedResultsController.records.compactMap {
$0.object(in: dependency.context.managedObjectContext)?.asRecord
}
Task {
await withTaskGroup(of: Void.self, body: { taskGroup in
for user in userRecords {
taskGroup.addTask {
try? await DataSourceFacade.responseToUserViewButtonAction(
dependency: dependency,
user: user,
buttonState: .follow
)
}
}
})
delegate?.homeTimelineNeedRefresh.send()
completion?()
}
}
}

View File

@ -0,0 +1,22 @@
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
import Combine
import MastodonUI
import CoreDataStack
extension SuggestionAccountTableViewCell {
final class ViewModel {
let user: MastodonUser
let followedUsers: [String]
let blockedUsers: [String]
let followRequestedUsers: [String]
init(user: MastodonUser, followedUsers: [String], blockedUsers: [String], followRequestedUsers: [String]) {
self.user = user
self.followedUsers = followedUsers
self.followRequestedUsers = followRequestedUsers
self.blockedUsers = blockedUsers
}
}
}

View File

@ -0,0 +1,115 @@
//
// SuggestionAccountTableViewCell.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/21.
//
import Combine
import CoreData
import CoreDataStack
import Foundation
import MastodonSDK
import UIKit
import MetaTextKit
import MastodonMeta
import MastodonAsset
import MastodonLocalization
import MastodonUI
import MastodonCore
protocol SuggestionAccountTableViewCellDelegate: AnyObject, UserViewDelegate {}
final class SuggestionAccountTableViewCell: UITableViewCell {
static let reuseIdentifier = "SuggestionAccountTableViewCell"
var disposeBag = Set<AnyCancellable>()
weak var delegate: SuggestionAccountTableViewCellDelegate?
let userView: UserView
let bioMetaLabel: MetaLabel
private let contentStackView: UIStackView
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
userView = UserView()
userView.translatesAutoresizingMaskIntoConstraints = false
bioMetaLabel = MetaLabel()
bioMetaLabel.translatesAutoresizingMaskIntoConstraints = false
bioMetaLabel.numberOfLines = 0
bioMetaLabel.textAttributes = [
.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)),
.foregroundColor: UIColor.label
]
bioMetaLabel.linkAttributes = [
.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)),
.foregroundColor: Asset.Colors.brand.color
]
bioMetaLabel.isUserInteractionEnabled = false
contentStackView = UIStackView(arrangedSubviews: [userView, bioMetaLabel])
contentStackView.translatesAutoresizingMaskIntoConstraints = false
contentStackView.alignment = .leading
contentStackView.axis = .vertical
super.init(style: style, reuseIdentifier: reuseIdentifier)
contentView.addSubview(contentStackView)
backgroundColor = .systemBackground
setupConstraints()
}
required init?(coder: NSCoder) { fatalError("We don't support ancient technology like Storyboards") }
private func setupConstraints() {
let constraints = [
contentStackView.topAnchor.constraint(equalTo: contentView.topAnchor),
contentStackView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
contentView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: contentStackView.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: contentStackView.bottomAnchor, constant: 16),
userView.widthAnchor.constraint(equalTo: contentStackView.widthAnchor),
bioMetaLabel.widthAnchor.constraint(equalTo: contentStackView.widthAnchor),
]
NSLayoutConstraint.activate(constraints)
}
override func prepareForReuse() {
super.prepareForReuse()
disposeBag.removeAll()
}
func configure(viewModel: SuggestionAccountTableViewCell.ViewModel) {
userView.configure(user: viewModel.user, delegate: delegate)
if viewModel.blockedUsers.contains(viewModel.user.id) {
self.userView.setButtonState(.blocked)
} else if viewModel.followedUsers.contains(viewModel.user.id) {
self.userView.setButtonState(.unfollow)
} else if viewModel.followRequestedUsers.contains(viewModel.user.id) {
self.userView.setButtonState(.pending)
} else if viewModel.user.locked {
self.userView.setButtonState(.request)
} else {
self.userView.setButtonState(.follow)
}
let metaContent: MetaContent = {
do {
let mastodonContent = MastodonContent(content: viewModel.user.note ?? "", emojis: viewModel.user.emojis.asDictionary)
return try MastodonMetaContent.convert(document: mastodonContent)
} catch {
assertionFailure()
return PlaintextMetaContent(string: viewModel.user.note ?? "")
}
}()
bioMetaLabel.configure(content: metaContent)
}
}

View File

@ -0,0 +1,62 @@
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
import UIKit
import MastodonUI
import MastodonAsset
import MastodonLocalization
protocol SuggestionAccountTableViewFooterDelegate: AnyObject {
func followAll(_ footerView: SuggestionAccountTableViewFooter)
}
class SuggestionAccountTableViewFooter: UITableViewHeaderFooterView {
static let reuseIdentifier = "SuggestionAccountTableViewFooter"
weak var delegate: SuggestionAccountTableViewFooterDelegate?
let followAllButton: FollowButton
override init(reuseIdentifier: String?) {
//TODO: Check if we can use UIButton.configuration here instead?
followAllButton = FollowButton()
followAllButton.translatesAutoresizingMaskIntoConstraints = false
followAllButton.setTitle(L10n.Scene.SuggestionAccount.followAll, for: .normal)
followAllButton.setBackgroundColor(Asset.Colors.Button.userFollow.color, for: .normal)
followAllButton.setTitleColor(.white, for: .normal)
followAllButton.contentEdgeInsets = .init(horizontal: 20, vertical: 12)
followAllButton.cornerRadius = 10
followAllButton.titleLabel?.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .boldSystemFont(ofSize: 15))
followAllButton.setContentCompressionResistancePriority(.required, for: .horizontal)
followAllButton.setContentHuggingPriority(.required, for: .horizontal)
super.init(reuseIdentifier: reuseIdentifier)
contentView.addSubview(followAllButton)
setupConstraints()
followAllButton.addTarget(self, action: #selector(SuggestionAccountTableViewFooter.followAll(_:)), for: .touchUpInside)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
private func setupConstraints() {
let constraints = [
followAllButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16),
followAllButton.leadingAnchor.constraint(greaterThanOrEqualTo: contentView.leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: followAllButton.trailingAnchor),
contentView.safeAreaLayoutGuide.bottomAnchor.constraint(equalTo: followAllButton.bottomAnchor, constant: 16),
followAllButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 96),
followAllButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 36),
]
NSLayoutConstraint.activate(constraints)
}
//MARK: - Actions
@objc func followAll(_ sender: UIButton) {
delegate?.followAll(self)
}
}

View File

@ -1,141 +0,0 @@
//
// SuggestionAccountTableViewCell+ViewModel.swift
// Mastodon
//
// Created by MainasuK on 2022-2-16.
//
import UIKit
import Combine
import CoreDataStack
import MastodonAsset
import MastodonCore
import MastodonUI
import MastodonMeta
import Meta
extension SuggestionAccountTableViewCell {
class ViewModel {
var disposeBag = Set<AnyCancellable>()
@Published public var userIdentifier: UserIdentifier? // me
@Published var avatarImageURL: URL?
@Published public var authorName: MetaContent?
@Published public var authorUsername: String?
@Published var isFollowing = false
@Published var isPending = false
func prepareForReuse() {
isFollowing = false
isPending = false
}
}
}
extension SuggestionAccountTableViewCell.ViewModel {
func bind(cell: SuggestionAccountTableViewCell) {
// avatar
$avatarImageURL.removeDuplicates()
.sink { url in
let configuration = AvatarImageView.Configuration(url: url)
cell.avatarButton.avatarImageView.configure(configuration: configuration)
cell.avatarButton.avatarImageView.configure(cornerConfiguration: .init(corner: .fixed(radius: 12)))
}
.store(in: &disposeBag)
// name
$authorName
.sink { metaContent in
let metaContent = metaContent ?? PlaintextMetaContent(string: " ")
cell.titleLabel.configure(content: metaContent)
}
.store(in: &disposeBag)
// username
$authorUsername
.map { text -> String in
guard let text = text else { return "" }
return "@\(text)"
}
.sink { username in
cell.subTitleLabel.text = username
}
.store(in: &disposeBag)
// button
Publishers.CombineLatest(
$isFollowing,
$isPending
)
.sink { isFollowing, isPending in
let isFollowState = isFollowing || isPending
let imageName = isFollowState ? "minus.circle.fill" : "plus.circle"
let image = UIImage(systemName: imageName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .regular))
cell.button.setImage(image, for: .normal)
cell.button.tintColor = isFollowState ? Asset.Colors.danger.color : Asset.Colors.Label.secondary.color
}
.store(in: &disposeBag)
}
}
extension SuggestionAccountTableViewCell {
func configure(user: MastodonUser) {
// author avatar
Publishers.CombineLatest(
user.publisher(for: \.avatar),
UserDefaults.shared.publisher(for: \.preferredStaticAvatar)
)
.map { _ in user.avatarImageURL() }
.assign(to: \.avatarImageURL, on: viewModel)
.store(in: &disposeBag)
// author name
Publishers.CombineLatest(
user.publisher(for: \.displayName),
user.publisher(for: \.emojis)
)
.map { _, emojis in
do {
let content = MastodonContent(content: user.displayNameWithFallback, emojis: emojis.asDictionary)
let metaContent = try MastodonMetaContent.convert(document: content)
return metaContent
} catch {
assertionFailure(error.localizedDescription)
return PlaintextMetaContent(string: user.displayNameWithFallback)
}
}
.assign(to: \.authorName, on: viewModel)
.store(in: &disposeBag)
// author username
user.publisher(for: \.acct)
.map { $0 as String? }
.assign(to: \.authorUsername, on: viewModel)
.store(in: &disposeBag)
// isFollowing
Publishers.CombineLatest(
viewModel.$userIdentifier,
user.publisher(for: \.followingBy)
)
.map { userIdentifier, followingBy in
guard let userIdentifier = userIdentifier else { return false }
return followingBy.contains(where: {
$0.id == userIdentifier.userID && $0.domain == userIdentifier.domain
})
}
.assign(to: \.isFollowing, on: viewModel)
.store(in: &disposeBag)
// isPending
Publishers.CombineLatest(
viewModel.$userIdentifier,
user.publisher(for: \.followRequestedBy)
)
.map { userIdentifier, followRequestedBy in
guard let userIdentifier = userIdentifier else { return false }
return followRequestedBy.contains(where: {
$0.id == userIdentifier.userID && $0.domain == userIdentifier.domain
})
}
.assign(to: \.isPending, on: viewModel)
.store(in: &disposeBag)
}
}

View File

@ -1,169 +0,0 @@
//
// SuggestionAccountTableViewCell.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/21.
//
import os.log
import Combine
import CoreData
import CoreDataStack
import Foundation
import MastodonSDK
import UIKit
import MetaTextKit
import MastodonMeta
import MastodonAsset
import MastodonLocalization
import MastodonUI
protocol SuggestionAccountTableViewCellDelegate: AnyObject {
func suggestionAccountTableViewCell(_ cell: SuggestionAccountTableViewCell, friendshipDidPressed button: UIButton)
}
final class SuggestionAccountTableViewCell: UITableViewCell {
let logger = Logger(subsystem: "SuggestionAccountTableViewCell", category: "View")
var disposeBag = Set<AnyCancellable>()
weak var delegate: SuggestionAccountTableViewCellDelegate?
public private(set) lazy var viewModel: ViewModel = {
let viewModel = ViewModel()
viewModel.bind(cell: self)
return viewModel
}()
let avatarButton = AvatarButton()
let titleLabel = MetaLabel(style: .statusName)
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)
let image = UIImage(systemName: "plus.circle", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .regular))
button.setImage(image, for: .normal)
return button
}()
let activityIndicatorView: UIActivityIndicatorView = {
let activityIndicatorView = UIActivityIndicatorView(style: .medium)
activityIndicatorView.hidesWhenStopped = true
return activityIndicatorView
}()
override func prepareForReuse() {
super.prepareForReuse()
disposeBag.removeAll()
avatarButton.avatarImageView.prepareForReuse()
viewModel.prepareForReuse()
}
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() {
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)
containerStackView.pinToParent()
avatarButton.translatesAutoresizingMaskIntoConstraints = false
containerStackView.addArrangedSubview(avatarButton)
NSLayoutConstraint.activate([
avatarButton.widthAnchor.constraint(equalToConstant: 42).priority(.required - 1),
avatarButton.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),
])
button.addTarget(self, action: #selector(SuggestionAccountTableViewCell.buttonDidPressed(_:)), for: .touchUpInside)
}
}
extension SuggestionAccountTableViewCell {
@objc private func buttonDidPressed(_ sender: UIButton) {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
delegate?.suggestionAccountTableViewCell(self, friendshipDidPressed: sender)
}
}
extension SuggestionAccountTableViewCell {
func startAnimating() {
activityIndicatorView.isHidden = false
activityIndicatorView.startAnimating()
button.isHidden = true
}
func stopAnimating() {
activityIndicatorView.stopAnimating()
activityIndicatorView.isHidden = true
button.isHidden = false
}
}

View File

@ -52,6 +52,7 @@ extension MastodonAuthenticationBox {
public class MastodonAccountInMemoryCache {
@Published public var followingUserIds: [String] = []
@Published public var blockedUserIds: [String] = []
@Published public var followRequestedUserIDs: [String] = []
static var sharedCaches = [String: MastodonAccountInMemoryCache]()

View File

@ -83,11 +83,6 @@ final public class FeedFetchedResultsController: NSObject {
}
.store(in: &disposeBag)
}
deinit {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
}
}
// MARK: - NSFetchedResultsControllerDelegate
@ -96,7 +91,6 @@ extension FeedFetchedResultsController: NSFetchedResultsControllerDelegate {
_ controller: NSFetchedResultsController<NSFetchRequestResult>,
didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference
) {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
let snapshot = snapshot as NSDiffableDataSourceSnapshot<String, NSManagedObjectID>
self._objectIDs.send(snapshot.itemIdentifiers)
}

View File

@ -14,7 +14,7 @@ import MastodonSDK
extension APIService {
#if DEBUG
private static let clientName = "Skimming"
private static let clientName = "Mastodon for iOS (Development)"
#else
private static let clientName = "Mastodon for iOS"
#endif

View File

@ -16,7 +16,7 @@ extension APIService {
public func followRequest(
userID: Mastodon.Entity.Account.ID,
query: Mastodon.API.Account.FollowReqeustQuery,
query: Mastodon.API.Account.FollowRequestQuery,
authenticationBox: MastodonAuthenticationBox
) async throws -> Mastodon.Response.Content<Mastodon.Entity.Relationship> {
let response = try await Mastodon.API.Account.followRequest(
@ -51,4 +51,17 @@ extension APIService {
return response
}
public func pendingFollowRequest(
userID: Mastodon.Entity.Account.ID,
authenticationBox: MastodonAuthenticationBox
) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Account]> {
let response = try await Mastodon.API.Account.pendingFollowRequest(
session: session,
domain: authenticationBox.domain,
userID: userID,
authorization: authenticationBox.userAuthorization
).singleOutput()
return response
}
}

View File

@ -45,7 +45,12 @@ public final class AuthenticationService: NSObject {
let blockedIds = try await apiService.getBlocked(
authenticationBox: authBox
).value.map { $0.id }
let followRequestIds = try await apiService.pendingFollowRequest(userID: authBox.userID,
authenticationBox: authBox)
.value.map { $0.id }
authBox.inMemoryCache.followRequestedUserIDs = followRequestIds
authBox.inMemoryCache.followingUserIds = followingIds
authBox.inMemoryCache.blockedUserIds = blockedIds
}

View File

@ -1484,10 +1484,10 @@ public enum L10n {
}
}
public enum SuggestionAccount {
/// When you follow someone, youll see their posts in your home feed.
public static let followExplain = L10n.tr("Localizable", "Scene.SuggestionAccount.FollowExplain", fallback: "When you follow someone, youll see their posts in your home feed.")
/// Find People to Follow
public static let title = L10n.tr("Localizable", "Scene.SuggestionAccount.Title", fallback: "Find People to Follow")
/// Follow All
public static let followAll = L10n.tr("Localizable", "Scene.SuggestionAccount.FollowAll", fallback: "Follow All")
/// Popular on Mastodon
public static let title = L10n.tr("Localizable", "Scene.SuggestionAccount.Title", fallback: "Popular on Mastodon")
}
public enum Thread {
/// Post

View File

@ -517,8 +517,8 @@ uploaded to Mastodon.";
"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.SuggestionAccount.Title" = "Popular on Mastodon";
"Scene.SuggestionAccount.FollowAll" = "Follow All";
"Scene.Thread.BackTitle" = "Post";
"Scene.Thread.Title" = "Post from %@";
"Scene.Welcome.Education.A11Y.WhatIsMastodon.Title" = "What is Mastodon?";
@ -552,4 +552,4 @@ uploaded to Mastodon.";
"Widget.MultipleFollowers.ConfigurationDescription" = "Show number of followers for multiple accounts.";
"Widget.MultipleFollowers.ConfigurationDisplayName" = "Multiple followers";
"Widget.MultipleFollowers.MockUser.AccountName" = "another@follower.social";
"Widget.MultipleFollowers.MockUser.DisplayName" = "Another follower";
"Widget.MultipleFollowers.MockUser.DisplayName" = "Another follower";

View File

@ -517,8 +517,8 @@ uploaded to Mastodon.";
"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.SuggestionAccount.Title" = "Popular on Mastodon";
"Scene.SuggestionAccount.FollowAll" = "Follow All";
"Scene.Thread.BackTitle" = "Post";
"Scene.Thread.Title" = "Post from %@";
"Scene.Welcome.Education.A11Y.WhatIsMastodon.Title" = "What is Mastodon?";
@ -552,4 +552,4 @@ uploaded to Mastodon.";
"Widget.MultipleFollowers.ConfigurationDescription" = "Show number of followers for multiple accounts.";
"Widget.MultipleFollowers.ConfigurationDisplayName" = "Multiple followers";
"Widget.MultipleFollowers.MockUser.AccountName" = "another@follower.social";
"Widget.MultipleFollowers.MockUser.DisplayName" = "Another follower";
"Widget.MultipleFollowers.MockUser.DisplayName" = "Another follower";

View File

@ -10,6 +10,11 @@ import Combine
// MARK: - Account credentials
extension Mastodon.API.Account {
static func pendingFollowRequestEndpointURL(domain: String) -> URL {
return Mastodon.API.endpointURL(domain: domain)
.appendingPathComponent("follow_requests")
}
static func acceptFollowRequestEndpointURL(domain: String, userID: Mastodon.Entity.Account.ID) -> URL {
return Mastodon.API.endpointURL(domain: domain)
@ -25,7 +30,7 @@ extension Mastodon.API.Account {
.appendingPathComponent("reject")
}
/// Accept Follow
/// Pending Follow Requests
///
///
/// - Since: 0.0.0
@ -37,6 +42,38 @@ extension Mastodon.API.Account {
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - userID: ID of the account in the database
/// - authorization: User token
/// - Returns: `AnyPublisher` contains `[Account]` nested in the response
public static func pendingFollowRequest(
session: URLSession,
domain: String,
userID: Mastodon.Entity.Account.ID,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Account]>, Error> {
let request = Mastodon.API.get(
url: pendingFollowRequestEndpointURL(domain: domain),
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: [Mastodon.Entity.Account].self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
/// Accept Follow
///
///
/// - Since: 0.0.0
/// - Version: 3.3.0
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/accounts/follow_requests/#allow)
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - userID: ID of the account in the database
/// - authorization: User token
/// - Returns: `AnyPublisher` contains `Relationship` nested in the response
public static func acceptFollowRequest(
session: URLSession,
@ -63,7 +100,7 @@ extension Mastodon.API.Account {
/// - Since: 0.0.0
/// - Version: 3.3.0
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/accounts/follow_requests/)
/// [Document](https://docs.joinmastodon.org/methods/accounts/follow_requests/#reject)
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
@ -92,7 +129,7 @@ extension Mastodon.API.Account {
extension Mastodon.API.Account {
public enum FollowReqeustQuery {
public enum FollowRequestQuery {
case accept
case reject
}
@ -101,7 +138,7 @@ extension Mastodon.API.Account {
session: URLSession,
domain: String,
userID: Mastodon.Entity.Account.ID,
query: FollowReqeustQuery,
query: FollowRequestQuery,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
switch query {

View File

@ -0,0 +1,31 @@
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
import UIKit
import MastodonAsset
public final class FollowButton: RoundedEdgesButton {
public init() {
super.init(frame: .zero)
configureAppearance()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func configureAppearance() {
setTitleColor(Asset.Colors.Label.primaryReverse.color, for: .normal)
setTitleColor(Asset.Colors.Label.primaryReverse.color.withAlphaComponent(0.5), for: .highlighted)
switch traitCollection.userInterfaceStyle {
case .dark:
setBackgroundImage(.placeholder(color: Asset.Scene.Profile.RelationshipButton.backgroundDark.color), for: .normal)
setBackgroundImage(.placeholder(color: Asset.Scene.Profile.RelationshipButton.backgroundHighlightedDark.color), for: .highlighted)
setBackgroundImage(.placeholder(color: Asset.Scene.Profile.RelationshipButton.backgroundHighlightedDark.color), for: .disabled)
default:
setBackgroundImage(.placeholder(color: Asset.Scene.Profile.RelationshipButton.backgroundLight.color), for: .normal)
setBackgroundImage(.placeholder(color: Asset.Scene.Profile.RelationshipButton.backgroundHighlightedLight.color), for: .highlighted)
setBackgroundImage(.placeholder(color: Asset.Scene.Profile.RelationshipButton.backgroundHighlightedLight.color), for: .disabled)
}
}
}

View File

@ -20,7 +20,7 @@ public protocol UserViewDelegate: AnyObject {
public final class UserView: UIView {
public enum ButtonState {
case none, loading, follow, unfollow, blocked
case none, loading, follow, request, pending, unfollow, blocked
}
private var currentButtonState: ButtonState = .none
@ -99,7 +99,8 @@ public final class UserView: UIView {
label.textColor = .secondaryLabel
return label
}()
private let followButtonWrapper = UIView()
private let followButton: FollowButton = {
let button = FollowButton()
button.cornerRadius = 10
@ -149,10 +150,7 @@ extension UserView {
avatarButton.translatesAutoresizingMaskIntoConstraints = false
containerStackView.addArrangedSubview(avatarButton)
NSLayoutConstraint.activate([
avatarButton.widthAnchor.constraint(equalToConstant: 28).priority(.required - 1),
avatarButton.heightAnchor.constraint(equalToConstant: 28).priority(.required - 1),
])
avatarButton.setContentHuggingPriority(.defaultLow, for: .vertical)
avatarButton.setContentHuggingPriority(.defaultLow, for: .horizontal)
@ -162,7 +160,19 @@ extension UserView {
containerStackView.addArrangedSubview(labelStackView)
// follow button
containerStackView.addArrangedSubview(followButton)
followButtonWrapper.translatesAutoresizingMaskIntoConstraints = false
followButtonWrapper.addSubview(followButton)
containerStackView.addArrangedSubview(followButtonWrapper)
NSLayoutConstraint.activate([
followButton.topAnchor.constraint(lessThanOrEqualTo: avatarButton.topAnchor),
followButton.leadingAnchor.constraint(equalTo: followButtonWrapper.leadingAnchor),
followButtonWrapper.trailingAnchor.constraint(equalTo: followButton.trailingAnchor),
followButtonWrapper.bottomAnchor.constraint(greaterThanOrEqualTo: followButton.bottomAnchor),
followButtonWrapper.heightAnchor.constraint(equalTo: containerStackView.heightAnchor),
])
let nameStackView = UIStackView()
nameStackView.axis = .horizontal
@ -181,7 +191,14 @@ extension UserView {
authorUsernameLabel.setContentCompressionResistancePriority(.defaultHigh - 1, for: .horizontal)
labelStackView.addArrangedSubview(nameStackView)
NSLayoutConstraint.activate([
avatarButton.heightAnchor.constraint(lessThanOrEqualToConstant: 56),
avatarButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 28),
avatarButton.heightAnchor.constraint(equalTo: avatarButton.widthAnchor),
avatarButton.heightAnchor.constraint(equalTo: labelStackView.heightAnchor),
])
let verifiedSpacerView = UIView()
let verifiedStackTrailingSpacerView = UIView()
@ -216,33 +233,6 @@ extension UserView {
}
private final class FollowButton: RoundedEdgesButton {
init() {
super.init(frame: .zero)
configureAppearance()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func configureAppearance() {
setTitleColor(Asset.Colors.Label.primaryReverse.color, for: .normal)
setTitleColor(Asset.Colors.Label.primaryReverse.color.withAlphaComponent(0.5), for: .highlighted)
switch traitCollection.userInterfaceStyle {
case .dark:
setBackgroundImage(.placeholder(color: Asset.Scene.Profile.RelationshipButton.backgroundDark.color), for: .normal)
setBackgroundImage(.placeholder(color: Asset.Scene.Profile.RelationshipButton.backgroundHighlightedDark.color), for: .highlighted)
setBackgroundImage(.placeholder(color: Asset.Scene.Profile.RelationshipButton.backgroundHighlightedDark.color), for: .disabled)
default:
setBackgroundImage(.placeholder(color: Asset.Scene.Profile.RelationshipButton.backgroundLight.color), for: .normal)
setBackgroundImage(.placeholder(color: Asset.Scene.Profile.RelationshipButton.backgroundHighlightedLight.color), for: .highlighted)
setBackgroundImage(.placeholder(color: Asset.Scene.Profile.RelationshipButton.backgroundHighlightedLight.color), for: .disabled)
}
}
}
public extension UserView {
private func prepareButtonStateLayout(for state: ButtonState) {
switch state {
@ -269,6 +259,7 @@ public extension UserView {
prepareButtonStateLayout(for: state)
switch state {
case .loading:
followButton.isHidden = false
followButton.setTitle(nil, for: .normal)
@ -279,7 +270,19 @@ public extension UserView {
followButton.setTitle(L10n.Common.Controls.Friendship.follow, for: .normal)
followButton.setBackgroundColor(Asset.Colors.Button.userFollow.color, for: .normal)
followButton.setTitleColor(.white, for: .normal)
case .request:
followButton.isHidden = false
followButton.setTitle(L10n.Common.Controls.Friendship.request, for: .normal)
followButton.setBackgroundColor(Asset.Colors.Button.userFollow.color, for: .normal)
followButton.setTitleColor(.white, for: .normal)
case .pending:
followButton.isHidden = false
followButton.setTitle(L10n.Common.Controls.Friendship.pending, for: .normal)
followButton.setTitleColor(Asset.Colors.Button.userFollowingTitle.color, for: .normal)
followButton.setBackgroundColor(Asset.Colors.Button.userFollowing.color, for: .normal)
case .unfollow:
followButton.isHidden = false
followButton.setTitle(L10n.Common.Controls.Friendship.following, for: .normal)