Compare commits
5 Commits
10803408cd
...
bf739b9f41
Author | SHA1 | Date |
---|---|---|
Shadowfacts | bf739b9f41 | |
Shadowfacts | 4211806b5f | |
Shadowfacts | 88aada8d35 | |
Shadowfacts | 5623cedab3 | |
Shadowfacts | ccfc8331fb |
|
@ -17,6 +17,7 @@
|
|||
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */; };
|
||||
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* ImageCache.swift */; };
|
||||
04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04ED00B021481ED800567C53 /* SteppedProgressView.swift */; };
|
||||
D601FA5B29787AB100A8E8B5 /* AccountFollowsListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA5A29787AB100A8E8B5 /* AccountFollowsListViewController.swift */; };
|
||||
D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */; };
|
||||
D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */; };
|
||||
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = D60CFFDA24A290BA00D00083 /* SwiftSoup */; };
|
||||
|
@ -155,6 +156,7 @@
|
|||
D65B4B6629773AE600DABDFB /* DeleteStatusService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B6529773AE600DABDFB /* DeleteStatusService.swift */; };
|
||||
D65B4B682977769E00DABDFB /* StatusActionAccountListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B672977769E00DABDFB /* StatusActionAccountListViewController.swift */; };
|
||||
D65B4B6A297777D900DABDFB /* StatusNotFoundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B69297777D900DABDFB /* StatusNotFoundView.swift */; };
|
||||
D65B4B8B297879E900DABDFB /* AccountFollowsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B8A297879E900DABDFB /* AccountFollowsViewController.swift */; };
|
||||
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */; };
|
||||
D65F613423AEAB6600F3CFD3 /* OnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65F613323AEAB6600F3CFD3 /* OnboardingTests.swift */; };
|
||||
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */; };
|
||||
|
@ -413,6 +415,7 @@
|
|||
04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabBarViewController.swift; sourceTree = "<group>"; };
|
||||
04DACE8D212CC7CC009840C4 /* ImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = "<group>"; };
|
||||
04ED00B021481ED800567C53 /* SteppedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteppedProgressView.swift; sourceTree = "<group>"; };
|
||||
D601FA5A29787AB100A8E8B5 /* AccountFollowsListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFollowsListViewController.swift; sourceTree = "<group>"; };
|
||||
D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagSearchResultsViewController.swift; sourceTree = "<group>"; };
|
||||
D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendHistoryView.swift; sourceTree = "<group>"; };
|
||||
D60D2B8123844C71001B87A3 /* BaseStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseStatusTableViewCell.swift; sourceTree = "<group>"; };
|
||||
|
@ -547,6 +550,7 @@
|
|||
D65B4B6529773AE600DABDFB /* DeleteStatusService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteStatusService.swift; sourceTree = "<group>"; };
|
||||
D65B4B672977769E00DABDFB /* StatusActionAccountListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActionAccountListViewController.swift; sourceTree = "<group>"; };
|
||||
D65B4B69297777D900DABDFB /* StatusNotFoundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusNotFoundView.swift; sourceTree = "<group>"; };
|
||||
D65B4B8A297879E900DABDFB /* AccountFollowsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFollowsViewController.swift; sourceTree = "<group>"; };
|
||||
D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundableViewController.swift; sourceTree = "<group>"; };
|
||||
D65F612D23AE990C00F3CFD3 /* Embassy.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Embassy.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
D65F613023AE99E000F3CFD3 /* Ambassador.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Ambassador.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
|
@ -966,6 +970,7 @@
|
|||
D641C780213DD7C4004B4513 /* Screens */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D65B4B89297879DE00DABDFB /* Account Follows */,
|
||||
D6A3BC822321F69400FD64D5 /* Account List */,
|
||||
D6B053A023BD2BED00A066FA /* Asset Picker */,
|
||||
0411610522B457290030A9B7 /* Attachment Gallery */,
|
||||
|
@ -1207,6 +1212,15 @@
|
|||
path = Report;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D65B4B89297879DE00DABDFB /* Account Follows */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D65B4B8A297879E900DABDFB /* AccountFollowsViewController.swift */,
|
||||
D601FA5A29787AB100A8E8B5 /* AccountFollowsListViewController.swift */,
|
||||
);
|
||||
path = "Account Follows";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D663626021360A9600C9CBA2 /* Preferences */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -2024,6 +2038,7 @@
|
|||
D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */,
|
||||
D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */,
|
||||
D6F6A557291F4F1600F496A8 /* MuteAccountView.swift in Sources */,
|
||||
D65B4B8B297879E900DABDFB /* AccountFollowsViewController.swift in Sources */,
|
||||
D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */,
|
||||
D65B4B6429771EFF00DABDFB /* ConversationViewController.swift in Sources */,
|
||||
D68E6F59253C9969001A1B4C /* MultiSourceEmojiLabel.swift in Sources */,
|
||||
|
@ -2060,6 +2075,7 @@
|
|||
D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */,
|
||||
D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */,
|
||||
D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */,
|
||||
D601FA5B29787AB100A8E8B5 /* AccountFollowsListViewController.swift in Sources */,
|
||||
D6BEA24B291C6A2B002F4D01 /* AlertWithData.swift in Sources */,
|
||||
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */,
|
||||
D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */,
|
||||
|
|
|
@ -349,6 +349,14 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
|||
}
|
||||
}
|
||||
|
||||
func addAll(accounts: [Account], in context: NSManagedObjectContext? = nil) async {
|
||||
await withCheckedContinuation { continuation in
|
||||
addAll(accounts: accounts, in: context) {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addAll(notifications: [Pachyderm.Notification], completion: (() -> Void)? = nil) {
|
||||
backgroundContext.perform {
|
||||
let statuses = notifications.compactMap { $0.status }
|
||||
|
|
|
@ -0,0 +1,277 @@
|
|||
//
|
||||
// AccountFollowsListViewController.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 1/18/23.
|
||||
// Copyright © 2023 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
class AccountFollowsListViewController: UIViewController, CollectionViewController {
|
||||
|
||||
let accountID: String
|
||||
let mastodonController: MastodonController
|
||||
let mode: AccountFollowsViewController.Mode
|
||||
|
||||
var collectionView: UICollectionView! {
|
||||
view as? UICollectionView
|
||||
}
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||
|
||||
private var state: State = .unloaded
|
||||
private var newer: RequestRange?
|
||||
private var older: RequestRange?
|
||||
|
||||
init(accountID: String, mastodonController: MastodonController, mode: AccountFollowsViewController.Mode) {
|
||||
self.accountID = accountID
|
||||
self.mastodonController = mastodonController
|
||||
self.mode = mode
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
title = mode.title
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func loadView() {
|
||||
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
|
||||
config.itemSeparatorHandler = { [unowned self] indexPath, sectionConfig in
|
||||
guard let item = self.dataSource.itemIdentifier(for: indexPath) else {
|
||||
return sectionConfig
|
||||
}
|
||||
var config = sectionConfig
|
||||
if item.hideSeparators {
|
||||
config.topSeparatorVisibility = .hidden
|
||||
config.bottomSeparatorVisibility = .hidden
|
||||
}
|
||||
return config
|
||||
}
|
||||
let layout = UICollectionViewCompositionalLayout.list(using: config)
|
||||
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
collectionView.delegate = self
|
||||
collectionView.dragDelegate = self
|
||||
collectionView.allowsFocus = true
|
||||
dataSource = createDataSource()
|
||||
}
|
||||
|
||||
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
||||
let accountCell = UICollectionView.CellRegistration<AccountCollectionViewCell, String> { [unowned self] cell, indexPath, item in
|
||||
cell.delegate = self
|
||||
cell.updateUI(accountID: item)
|
||||
}
|
||||
let loadingCell = UICollectionView.CellRegistration<LoadingCollectionViewCell, Void> { cell, indexPath, item in
|
||||
cell.indicator.startAnimating()
|
||||
}
|
||||
return UICollectionViewDiffableDataSource(collectionView: collectionView, cellProvider: { collectionView, indexPath, itemIdentifier in
|
||||
switch itemIdentifier {
|
||||
case .account(let id):
|
||||
return collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: id)
|
||||
case .loadingIndicator:
|
||||
return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
clearSelectionOnAppear(animated: animated)
|
||||
|
||||
if case .unloaded = state {
|
||||
Task {
|
||||
await loadInitial()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func request(for range: RequestRange) -> Request<[Account]> {
|
||||
switch mode {
|
||||
case .following:
|
||||
return Account.getFollowing(accountID, range: range)
|
||||
case .followers:
|
||||
return Account.getFollowers(accountID, range: range)
|
||||
}
|
||||
}
|
||||
|
||||
private func apply(snapshot: NSDiffableDataSourceSnapshot<Section, Item>) async {
|
||||
await Task { @MainActor in
|
||||
self.dataSource.apply(snapshot)
|
||||
}.value
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadInitial() async {
|
||||
guard case .unloaded = state else {
|
||||
return
|
||||
}
|
||||
|
||||
self.state = .loadingInitial
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections([.accounts])
|
||||
snapshot.appendItems([.loadingIndicator])
|
||||
await apply(snapshot: snapshot)
|
||||
|
||||
do {
|
||||
let (accounts, pagination) = try await mastodonController.run(request(for: .default))
|
||||
await mastodonController.persistentContainer.addAll(accounts: accounts)
|
||||
|
||||
guard case .loadingInitial = self.state else {
|
||||
return
|
||||
}
|
||||
self.state = .loaded
|
||||
self.newer = pagination?.newer
|
||||
self.older = pagination?.older
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections([.accounts])
|
||||
snapshot.appendItems(accounts.map { .account($0.id) })
|
||||
await apply(snapshot: snapshot)
|
||||
|
||||
} catch {
|
||||
self.state = .unloaded
|
||||
|
||||
let config = ToastConfiguration(from: error, with: "Error Loading Accounts", in: self) { [unowned self] toast in
|
||||
toast.dismissToast(animated: true)
|
||||
await self.loadInitial()
|
||||
}
|
||||
self.showToast(configuration: config, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadOlder() async {
|
||||
guard case .loaded = state,
|
||||
let older else {
|
||||
return
|
||||
}
|
||||
|
||||
self.state = .loadingOlder
|
||||
var snapshot = dataSource.snapshot()
|
||||
snapshot.appendItems([.loadingIndicator])
|
||||
await apply(snapshot: snapshot)
|
||||
|
||||
do {
|
||||
let (accounts, pagination) = try await mastodonController.run(request(for: older))
|
||||
await mastodonController.persistentContainer.addAll(accounts: accounts)
|
||||
|
||||
guard case .loadingOlder = self.state else {
|
||||
return
|
||||
}
|
||||
self.state = .loaded
|
||||
self.older = pagination?.older
|
||||
|
||||
var snapshot = dataSource.snapshot()
|
||||
snapshot.deleteItems([.loadingIndicator])
|
||||
snapshot.appendItems(accounts.map { .account($0.id) })
|
||||
await apply(snapshot: snapshot)
|
||||
|
||||
} catch {
|
||||
self.state = .loaded
|
||||
|
||||
let config = ToastConfiguration(from: error, with: "Error Loading More", in: self) { [unowned self] toast in
|
||||
toast.dismissToast(animated: true)
|
||||
await self.loadOlder()
|
||||
}
|
||||
self.showToast(configuration: config, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AccountFollowsListViewController {
|
||||
enum State {
|
||||
case unloaded
|
||||
case loadingInitial
|
||||
case loaded
|
||||
case loadingOlder
|
||||
}
|
||||
}
|
||||
|
||||
extension AccountFollowsListViewController {
|
||||
enum Section {
|
||||
case accounts
|
||||
}
|
||||
enum Item: Hashable {
|
||||
case account(String)
|
||||
case loadingIndicator
|
||||
|
||||
var hideSeparators: Bool {
|
||||
switch self {
|
||||
case .account(_):
|
||||
return false
|
||||
case .loadingIndicator:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AccountFollowsListViewController: UICollectionViewDelegate {
|
||||
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
|
||||
if indexPath.section == collectionView.numberOfSections - 1,
|
||||
indexPath.row == collectionView.numberOfItems(inSection: indexPath.section) - 1 {
|
||||
Task {
|
||||
await self.loadOlder()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
guard case .account(let id) = dataSource.itemIdentifier(for: indexPath) else {
|
||||
return
|
||||
}
|
||||
selected(account: id)
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
guard case .account(let id) = dataSource.itemIdentifier(for: indexPath),
|
||||
let cell = collectionView.cellForItem(at: indexPath) else {
|
||||
return nil
|
||||
}
|
||||
return UIContextMenuConfiguration {
|
||||
ProfileViewController(accountID: id, mastodonController: self.mastodonController)
|
||||
} actionProvider: { _ in
|
||||
UIMenu(children: self.actionsForProfile(accountID: id, source: .view(cell)))
|
||||
}
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self)
|
||||
}
|
||||
}
|
||||
|
||||
extension AccountFollowsListViewController: UICollectionViewDragDelegate {
|
||||
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
|
||||
guard case .account(let id) = dataSource.itemIdentifier(for: indexPath),
|
||||
let currentAccountID = mastodonController.accountInfo?.id,
|
||||
let account = mastodonController.persistentContainer.account(for: id) else {
|
||||
return []
|
||||
}
|
||||
let provider = NSItemProvider(object: account.url as NSURL)
|
||||
let activity = UserActivityManager.showProfileActivity(id: id, accountID: currentAccountID)
|
||||
activity.displaysAuxiliaryScene = true
|
||||
provider.registerObject(activity, visibility: .all)
|
||||
return [UIDragItem(itemProvider: provider)]
|
||||
}
|
||||
}
|
||||
|
||||
extension AccountFollowsListViewController: TuskerNavigationDelegate {
|
||||
var apiController: MastodonController! { mastodonController }
|
||||
}
|
||||
|
||||
extension AccountFollowsListViewController: MenuActionProvider {
|
||||
}
|
||||
|
||||
extension AccountFollowsListViewController: ToastableViewController {
|
||||
}
|
||||
|
||||
extension AccountFollowsListViewController: StatusBarTappableViewController {
|
||||
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
|
||||
collectionView.scrollToTop()
|
||||
return .stop
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
//
|
||||
// AccountFollowsViewController.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 1/18/23.
|
||||
// Copyright © 2023 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class AccountFollowsViewController: SegmentedPageViewController<AccountFollowsViewController.Mode> {
|
||||
|
||||
let accountID: String
|
||||
let mastodonController: MastodonController
|
||||
|
||||
init(accountID: String, mastodonController: MastodonController) {
|
||||
self.accountID = accountID
|
||||
self.mastodonController = mastodonController
|
||||
|
||||
super.init(pages: [.following, .followers]) { mode in
|
||||
AccountFollowsListViewController(accountID: accountID, mastodonController: mastodonController, mode: mode)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension AccountFollowsViewController {
|
||||
enum Mode: SegmentedPageViewControllerPage {
|
||||
case following, followers
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .following:
|
||||
return "Following"
|
||||
case .followers:
|
||||
return "Followers"
|
||||
}
|
||||
}
|
||||
|
||||
var segmentedControlTitle: String { title }
|
||||
}
|
||||
}
|
|
@ -111,7 +111,9 @@ class ConversationViewController: UIViewController {
|
|||
super.viewWillAppear(animated)
|
||||
|
||||
Task {
|
||||
await loadMainStatus()
|
||||
if case .unloaded = state {
|
||||
await loadMainStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -11,6 +11,8 @@ import Pachyderm
|
|||
|
||||
class StatusActionAccountListCollectionViewController: UIViewController, CollectionViewController {
|
||||
|
||||
private let statusID: String
|
||||
private let actionType: StatusActionAccountListViewController.ActionType
|
||||
private let mastodonController: MastodonController
|
||||
|
||||
/// If `true`, a warning will be shown below the account list describing that the total favs/reblogs may be innacurate.
|
||||
|
@ -21,12 +23,17 @@ class StatusActionAccountListCollectionViewController: UIViewController, Collect
|
|||
}
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||
|
||||
private var state: State = .unloaded
|
||||
private var older: RequestRange?
|
||||
|
||||
/**
|
||||
Creates a new view controller showing the accounts that performed the given action on the given status.
|
||||
|
||||
- Parameter mastodonController The `MastodonController` instance this view controller uses.
|
||||
*/
|
||||
init(mastodonController: MastodonController) {
|
||||
init(statusID: String, actionType: StatusActionAccountListViewController.ActionType, mastodonController: MastodonController) {
|
||||
self.statusID = statusID
|
||||
self.actionType = actionType
|
||||
self.mastodonController = mastodonController
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
@ -38,6 +45,18 @@ class StatusActionAccountListCollectionViewController: UIViewController, Collect
|
|||
}
|
||||
|
||||
override func loadView() {
|
||||
var accountsConfig = UICollectionLayoutListConfiguration(appearance: .grouped)
|
||||
accountsConfig.itemSeparatorHandler = { [unowned self] indexPath, sectionConfig in
|
||||
guard let item = self.dataSource.itemIdentifier(for: indexPath) else {
|
||||
return sectionConfig
|
||||
}
|
||||
var config = sectionConfig
|
||||
if item.hideSeparators {
|
||||
config.topSeparatorVisibility = .hidden
|
||||
config.bottomSeparatorVisibility = .hidden
|
||||
}
|
||||
return config
|
||||
}
|
||||
let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in
|
||||
switch dataSource.sectionIdentifier(for: sectionIndex)! {
|
||||
case .status:
|
||||
|
@ -51,7 +70,7 @@ class StatusActionAccountListCollectionViewController: UIViewController, Collect
|
|||
}
|
||||
return NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
|
||||
case .accounts:
|
||||
return NSCollectionLayoutSection.list(using: .init(appearance: .grouped), layoutEnvironment: environment)
|
||||
return NSCollectionLayoutSection.list(using: accountsConfig, layoutEnvironment: environment)
|
||||
}
|
||||
}
|
||||
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
|
@ -70,12 +89,17 @@ class StatusActionAccountListCollectionViewController: UIViewController, Collect
|
|||
cell.delegate = self
|
||||
cell.updateUI(accountID: item)
|
||||
}
|
||||
let loadingCell = UICollectionView.CellRegistration<LoadingCollectionViewCell, Void> { cell, indexPath, item in
|
||||
cell.indicator.startAnimating()
|
||||
}
|
||||
let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
|
||||
switch itemIdentifier {
|
||||
case .status(let id, let state):
|
||||
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state))
|
||||
case .account(let id):
|
||||
return collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: id)
|
||||
case .loadingIndicator:
|
||||
return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ())
|
||||
}
|
||||
}
|
||||
let sectionHeaderCell = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionFooter) { (headerView, collectionView, indexPath) in
|
||||
|
@ -93,6 +117,12 @@ class StatusActionAccountListCollectionViewController: UIViewController, Collect
|
|||
super.viewWillAppear(animated)
|
||||
|
||||
clearSelectionOnAppear(animated: animated)
|
||||
|
||||
if case .unloaded = state {
|
||||
Task {
|
||||
await loadAccounts()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addStatus(_ status: StatusMO, state: CollapseState) {
|
||||
|
@ -104,12 +134,115 @@ class StatusActionAccountListCollectionViewController: UIViewController, Collect
|
|||
dataSource.apply(snapshot, animatingDifferences: false)
|
||||
}
|
||||
|
||||
func addAccounts(_ accountIDs: [String], animated: Bool) {
|
||||
func setAccounts(_ accountIDs: [String], animated: Bool) {
|
||||
guard case .unloaded = state else {
|
||||
return
|
||||
}
|
||||
var snapshot = dataSource.snapshot()
|
||||
snapshot.appendItems(accountIDs.map { .account($0) }, toSection: .accounts)
|
||||
dataSource.apply(snapshot, animatingDifferences: animated)
|
||||
self.state = .loaded
|
||||
}
|
||||
|
||||
private func request(for range: RequestRange) -> Request<[Account]> {
|
||||
switch actionType {
|
||||
case .favorite:
|
||||
return Status.getFavourites(statusID, range: range)
|
||||
case .reblog:
|
||||
return Status.getReblogs(statusID, range: range)
|
||||
}
|
||||
}
|
||||
|
||||
func apply(snapshot: NSDiffableDataSourceSnapshot<Section, Item>) async {
|
||||
await Task { @MainActor in
|
||||
self.dataSource.apply(snapshot)
|
||||
}.value
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadAccounts() async {
|
||||
guard case .unloaded = state else {
|
||||
return
|
||||
}
|
||||
self.state = .loadingInitial
|
||||
|
||||
var snapshot = dataSource.snapshot()
|
||||
snapshot.appendItems([.loadingIndicator], toSection: .accounts)
|
||||
await apply(snapshot: snapshot)
|
||||
|
||||
do {
|
||||
let (accounts, pagination) = try await mastodonController.run(request(for: .default))
|
||||
await mastodonController.persistentContainer.addAll(accounts: accounts)
|
||||
|
||||
guard case .loadingInitial = self.state else {
|
||||
return
|
||||
}
|
||||
self.state = .loaded
|
||||
self.older = pagination?.older
|
||||
|
||||
var snapshot = dataSource.snapshot()
|
||||
snapshot.deleteItems([.loadingIndicator])
|
||||
snapshot.appendItems(accounts.map { .account($0.id) }, toSection: .accounts)
|
||||
await apply(snapshot: snapshot)
|
||||
|
||||
} catch {
|
||||
self.state = .unloaded
|
||||
|
||||
let config = ToastConfiguration(from: error, with: "Error Loading Accounts", in: self) { toast in
|
||||
toast.dismissToast(animated: true)
|
||||
await self.loadAccounts()
|
||||
}
|
||||
self.showToast(configuration: config, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadOlder() async {
|
||||
guard case .loaded = state,
|
||||
let older else {
|
||||
return
|
||||
}
|
||||
self.state = .loadingOlder
|
||||
|
||||
var snapshot = self.dataSource.snapshot()
|
||||
snapshot.appendItems([.loadingIndicator], toSection: .accounts)
|
||||
await apply(snapshot: snapshot)
|
||||
|
||||
do {
|
||||
try! await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC)
|
||||
let (accounts, pagination) = try await mastodonController.run(request(for: older))
|
||||
await mastodonController.persistentContainer.addAll(accounts: accounts)
|
||||
|
||||
guard case .loadingOlder = self.state else {
|
||||
return
|
||||
}
|
||||
self.state = .loaded
|
||||
self.older = pagination?.older
|
||||
|
||||
var snapshot = dataSource.snapshot()
|
||||
snapshot.deleteItems([.loadingIndicator])
|
||||
snapshot.appendItems(accounts.map { .account($0.id) }, toSection: .accounts)
|
||||
await apply(snapshot: snapshot)
|
||||
|
||||
} catch {
|
||||
self.state = .loaded
|
||||
|
||||
let config = ToastConfiguration(from: error, with: "Error Loading More", in: self) { [unowned self] toast in
|
||||
toast.dismissToast(animated: true)
|
||||
await self.loadOlder()
|
||||
}
|
||||
self.showToast(configuration: config, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension StatusActionAccountListCollectionViewController {
|
||||
enum State {
|
||||
case unloaded
|
||||
case loadingInitial
|
||||
case loaded
|
||||
case loadingOlder
|
||||
}
|
||||
}
|
||||
|
||||
extension StatusActionAccountListCollectionViewController {
|
||||
|
@ -120,6 +253,16 @@ extension StatusActionAccountListCollectionViewController {
|
|||
enum Item: Hashable {
|
||||
case status(String, CollapseState)
|
||||
case account(String)
|
||||
case loadingIndicator
|
||||
|
||||
var hideSeparators: Bool {
|
||||
switch self {
|
||||
case .loadingIndicator:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func ==(lhs: Item, rhs: Item) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
|
@ -127,6 +270,8 @@ extension StatusActionAccountListCollectionViewController {
|
|||
return a == b
|
||||
case (.account(let a), .account(let b)):
|
||||
return a == b
|
||||
case (.loadingIndicator, .loadingIndicator):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
@ -140,12 +285,23 @@ extension StatusActionAccountListCollectionViewController {
|
|||
case .account(let id):
|
||||
hasher.combine(1)
|
||||
hasher.combine(id)
|
||||
case .loadingIndicator:
|
||||
hasher.combine(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension StatusActionAccountListCollectionViewController: UICollectionViewDelegate {
|
||||
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
|
||||
if indexPath.section == collectionView.numberOfSections - 1,
|
||||
indexPath.row == collectionView.numberOfItems(inSection: indexPath.section) - 1 {
|
||||
Task {
|
||||
await self.loadOlder()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
switch dataSource.itemIdentifier(for: indexPath) {
|
||||
case nil:
|
||||
|
@ -154,6 +310,8 @@ extension StatusActionAccountListCollectionViewController: UICollectionViewDeleg
|
|||
selected(status: id, state: state.copy())
|
||||
case .account(let id):
|
||||
selected(account: id)
|
||||
case .loadingIndicator:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -171,6 +329,8 @@ extension StatusActionAccountListCollectionViewController: UICollectionViewDeleg
|
|||
} actionProvider: { _ in
|
||||
UIMenu(children: self.actionsForProfile(accountID: id, source: .view(cell)))
|
||||
}
|
||||
case .loadingIndicator:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -203,6 +363,8 @@ extension StatusActionAccountListCollectionViewController: UICollectionViewDragD
|
|||
let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID)
|
||||
activity.displaysAuxiliaryScene = true
|
||||
provider.registerObject(activity, visibility: .all)
|
||||
case .loadingIndicator:
|
||||
return []
|
||||
}
|
||||
return [UIDragItem(itemProvider: provider)]
|
||||
}
|
||||
|
|
|
@ -95,8 +95,10 @@ class StatusActionAccountListViewController: UIViewController {
|
|||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
Task {
|
||||
await loadStatus()
|
||||
if case .unloaded = state {
|
||||
Task {
|
||||
await loadStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -143,45 +145,13 @@ class StatusActionAccountListViewController: UIViewController {
|
|||
}
|
||||
|
||||
private func statusLoaded(_ status: StatusMO) async {
|
||||
let vc = StatusActionAccountListCollectionViewController(mastodonController: mastodonController)
|
||||
let vc = StatusActionAccountListCollectionViewController(statusID: statusID, actionType: actionType, mastodonController: mastodonController)
|
||||
vc.addStatus(status, state: statusState)
|
||||
vc.showInacurateCountWarning = showInacurateCountWarning
|
||||
state = .displaying(vc)
|
||||
|
||||
if let accountIDs {
|
||||
vc.addAccounts(accountIDs, animated: false)
|
||||
} else {
|
||||
await loadAccounts(list: vc)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadAccounts(list: StatusActionAccountListCollectionViewController) async {
|
||||
let request: Request<[Account]>
|
||||
switch actionType {
|
||||
case .favorite:
|
||||
request = Status.getFavourites(statusID)
|
||||
case .reblog:
|
||||
request = Status.getReblogs(statusID)
|
||||
}
|
||||
do {
|
||||
// TODO: pagination
|
||||
let (accounts, _) = try await mastodonController.run(request)
|
||||
|
||||
await withCheckedContinuation { continuation in
|
||||
mastodonController.persistentContainer.addAll(accounts: accounts) {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
list.addAccounts(accounts.map(\.id), animated: true)
|
||||
|
||||
} catch {
|
||||
let config = ToastConfiguration(from: error, with: "Error Loading Accounts", in: self) { toast in
|
||||
toast.dismissToast(animated: true)
|
||||
await self.loadAccounts(list: list)
|
||||
}
|
||||
self.showToast(configuration: config, animated: true)
|
||||
vc.setAccounts(accountIDs, animated: false)
|
||||
}
|
||||
state = .displaying(vc)
|
||||
}
|
||||
|
||||
private func showStatusNotFound() {
|
||||
|
|
|
@ -58,6 +58,7 @@ class CachedImageView: UIImageView {
|
|||
}
|
||||
try Task.checkCancellation()
|
||||
self.image = transformedImage
|
||||
self.isGrayscale = Preferences.shared.grayscaleImages
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -35,6 +35,7 @@ class ProfileHeaderView: UIView {
|
|||
@IBOutlet weak var relationshipLabel: UILabel!
|
||||
@IBOutlet weak var noteTextView: StatusContentTextView!
|
||||
@IBOutlet weak var fieldsView: ProfileFieldsView!
|
||||
@IBOutlet weak var followCountButton: UIButton!
|
||||
private(set) var pagesSegmentedControl: ScrollingSegmentedControl<ProfileViewController.Page>!
|
||||
|
||||
var accountID: String!
|
||||
|
@ -148,6 +149,26 @@ class ProfileHeaderView: UIView {
|
|||
fieldsView.delegate = delegate
|
||||
fieldsView.updateUI(account: account)
|
||||
|
||||
let (followingAbbr, followingSpelledOut) = formatBigNumber(account.followingCount)
|
||||
let (followersAbbr, followersSpelledOut) = formatBigNumber(account.followersCount)
|
||||
let followCountTitle = NSMutableAttributedString()
|
||||
followCountTitle.append(NSAttributedString(string: followingAbbr, attributes: [
|
||||
.font: UIFont.preferredFont(forTextStyle: .body).withTraits(.traitBold)!,
|
||||
.foregroundColor: UIColor.label,
|
||||
]))
|
||||
followCountTitle.append(NSAttributedString(string: " Following, ", attributes: [
|
||||
.foregroundColor: UIColor.secondaryLabel,
|
||||
]))
|
||||
followCountTitle.append(NSAttributedString(string: followersAbbr, attributes: [
|
||||
.font: UIFont.preferredFont(forTextStyle: .body).withTraits(.traitBold)!,
|
||||
.foregroundColor: UIColor.label,
|
||||
]))
|
||||
followCountTitle.append(NSAttributedString(string: " Follower\(account.followersCount == 1 ? "" : "s")", attributes: [
|
||||
.foregroundColor: UIColor.secondaryLabel,
|
||||
]))
|
||||
followCountButton.setAttributedTitle(followCountTitle, for: .normal)
|
||||
followCountButton.accessibilityLabel = "\(followingSpelledOut) following, \(followersSpelledOut) followers"
|
||||
|
||||
accessibilityElements = [
|
||||
displayNameLabel!,
|
||||
usernameLabel!,
|
||||
|
@ -260,6 +281,22 @@ class ProfileHeaderView: UIView {
|
|||
}
|
||||
}
|
||||
|
||||
private func formatBigNumber(_ value: Int) -> (String, String) {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.maximumFractionDigits = 1
|
||||
|
||||
for (threshold, abbr, spelledOut) in [(1_000_000, "m", "million"), (1_000, "k", "thousand")] {
|
||||
if value >= threshold {
|
||||
let frac = Double(value) / Double(threshold)
|
||||
let s = formatter.string(from: frac as NSNumber)!
|
||||
return ("\(s)\(abbr)", "\(s) \(spelledOut)")
|
||||
}
|
||||
}
|
||||
|
||||
let s = formatter.string(from: value as NSNumber)!
|
||||
return (s, s)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
|
||||
@objc func avatarPressed() {
|
||||
|
@ -313,6 +350,10 @@ class ProfileHeaderView: UIView {
|
|||
}
|
||||
}
|
||||
|
||||
@IBAction func followCountButtonPressed(_ sender: Any) {
|
||||
guard let accountID else { return }
|
||||
delegate?.show(AccountFollowsViewController(accountID: accountID, mastodonController: mastodonController))
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileHeaderView {
|
||||
|
|
|
@ -61,7 +61,7 @@
|
|||
<action selector="followPressed" destination="iN0-l3-epB" eventType="touchUpInside" id="OM3-lq-Z14"/>
|
||||
</connections>
|
||||
</button>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="top" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="u4P-3i-gEq">
|
||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="top" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="u4P-3i-gEq">
|
||||
<rect key="frame" x="16" y="266" width="398" height="596"/>
|
||||
<subviews>
|
||||
<label hidden="YES" opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Follows you" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="UF8-nI-KVj">
|
||||
|
@ -71,19 +71,37 @@
|
|||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" horizontalHuggingPriority="249" verticalHuggingPriority="251" horizontalCompressionResistancePriority="749" scrollEnabled="NO" delaysContentTouches="NO" editable="NO" textAlignment="natural" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="1O8-2P-Gbf" customClass="StatusContentTextView" customModule="Tusker" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="382" height="460"/>
|
||||
<rect key="frame" x="0.0" y="0.0" width="382" height="259.5"/>
|
||||
<string key="text">Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda.</string>
|
||||
<color key="textColor" systemColor="labelColor"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
|
||||
</textView>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="vKC-m1-Sbs" customClass="ProfileFieldsView" customModule="Tusker" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="468" width="398" height="128"/>
|
||||
<rect key="frame" x="0.0" y="263.5" width="398" height="128"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="128" placeholder="YES" id="xbR-M6-H0I"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="ood-3e-sSu" userLabel="Spacer">
|
||||
<rect key="frame" x="0.0" y="395.5" width="240" height="8"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="8" id="5ri-vD-wXe"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="5w9-LA-8kc">
|
||||
<rect key="frame" x="0.0" y="407.5" width="219" height="188.5"/>
|
||||
<state key="normal" title="Button"/>
|
||||
<buttonConfiguration key="configuration" style="plain" title="123 Following, 1.2k Followers">
|
||||
<fontDescription key="titleFontDescription" style="UICTFontTextStyleBody"/>
|
||||
<directionalEdgeInsets key="contentInsets" top="0.0" leading="0.0" bottom="0.0" trailing="0.0"/>
|
||||
</buttonConfiguration>
|
||||
<connections>
|
||||
<action selector="followCountButtonPressed:" destination="iN0-l3-epB" eventType="touchUpInside" id="QOO-zK-pfu"/>
|
||||
</connections>
|
||||
</button>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="vKC-m1-Sbs" firstAttribute="width" secondItem="u4P-3i-gEq" secondAttribute="width" id="0dI-ax-7eI"/>
|
||||
|
@ -149,6 +167,7 @@
|
|||
<outlet property="displayNameLabel" destination="vcl-Gl-kXl" id="64n-a9-my0"/>
|
||||
<outlet property="fieldsView" destination="vKC-m1-Sbs" id="FeE-jh-lYH"/>
|
||||
<outlet property="followButton" destination="cr8-p9-xkc" id="E1n-gh-mCl"/>
|
||||
<outlet property="followCountButton" destination="5w9-LA-8kc" id="umN-5g-q8N"/>
|
||||
<outlet property="headerImageView" destination="dgG-dR-lSv" id="HXT-v4-2iX"/>
|
||||
<outlet property="lockImageView" destination="KNY-GD-beC" id="9EJ-iM-Eos"/>
|
||||
<outlet property="moreButton" destination="vFa-g3-xIP" id="dEX-1a-PHF"/>
|
||||
|
|
Loading…
Reference in New Issue