Compare commits

...

5 Commits

10 changed files with 586 additions and 44 deletions

View File

@ -17,6 +17,7 @@
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */; }; 04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */; };
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* ImageCache.swift */; }; 04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* ImageCache.swift */; };
04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04ED00B021481ED800567C53 /* SteppedProgressView.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 */; }; D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */; };
D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */; }; D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */; };
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = D60CFFDA24A290BA00D00083 /* SwiftSoup */; }; D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = D60CFFDA24A290BA00D00083 /* SwiftSoup */; };
@ -155,6 +156,7 @@
D65B4B6629773AE600DABDFB /* DeleteStatusService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B6529773AE600DABDFB /* DeleteStatusService.swift */; }; D65B4B6629773AE600DABDFB /* DeleteStatusService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B6529773AE600DABDFB /* DeleteStatusService.swift */; };
D65B4B682977769E00DABDFB /* StatusActionAccountListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B672977769E00DABDFB /* StatusActionAccountListViewController.swift */; }; D65B4B682977769E00DABDFB /* StatusActionAccountListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B672977769E00DABDFB /* StatusActionAccountListViewController.swift */; };
D65B4B6A297777D900DABDFB /* StatusNotFoundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B69297777D900DABDFB /* StatusNotFoundView.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 */; }; D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */; };
D65F613423AEAB6600F3CFD3 /* OnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65F613323AEAB6600F3CFD3 /* OnboardingTests.swift */; }; D65F613423AEAB6600F3CFD3 /* OnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65F613323AEAB6600F3CFD3 /* OnboardingTests.swift */; };
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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; }; 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; }; D65F613023AE99E000F3CFD3 /* Ambassador.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Ambassador.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@ -966,6 +970,7 @@
D641C780213DD7C4004B4513 /* Screens */ = { D641C780213DD7C4004B4513 /* Screens */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D65B4B89297879DE00DABDFB /* Account Follows */,
D6A3BC822321F69400FD64D5 /* Account List */, D6A3BC822321F69400FD64D5 /* Account List */,
D6B053A023BD2BED00A066FA /* Asset Picker */, D6B053A023BD2BED00A066FA /* Asset Picker */,
0411610522B457290030A9B7 /* Attachment Gallery */, 0411610522B457290030A9B7 /* Attachment Gallery */,
@ -1207,6 +1212,15 @@
path = Report; path = Report;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D65B4B89297879DE00DABDFB /* Account Follows */ = {
isa = PBXGroup;
children = (
D65B4B8A297879E900DABDFB /* AccountFollowsViewController.swift */,
D601FA5A29787AB100A8E8B5 /* AccountFollowsListViewController.swift */,
);
path = "Account Follows";
sourceTree = "<group>";
};
D663626021360A9600C9CBA2 /* Preferences */ = { D663626021360A9600C9CBA2 /* Preferences */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -2024,6 +2038,7 @@
D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */, D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */,
D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */, D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */,
D6F6A557291F4F1600F496A8 /* MuteAccountView.swift in Sources */, D6F6A557291F4F1600F496A8 /* MuteAccountView.swift in Sources */,
D65B4B8B297879E900DABDFB /* AccountFollowsViewController.swift in Sources */,
D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */, D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */,
D65B4B6429771EFF00DABDFB /* ConversationViewController.swift in Sources */, D65B4B6429771EFF00DABDFB /* ConversationViewController.swift in Sources */,
D68E6F59253C9969001A1B4C /* MultiSourceEmojiLabel.swift in Sources */, D68E6F59253C9969001A1B4C /* MultiSourceEmojiLabel.swift in Sources */,
@ -2060,6 +2075,7 @@
D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */, D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */,
D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */, D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */,
D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */, D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */,
D601FA5B29787AB100A8E8B5 /* AccountFollowsListViewController.swift in Sources */,
D6BEA24B291C6A2B002F4D01 /* AlertWithData.swift in Sources */, D6BEA24B291C6A2B002F4D01 /* AlertWithData.swift in Sources */,
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */, D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */,
D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */, D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */,

View File

@ -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) { func addAll(notifications: [Pachyderm.Notification], completion: (() -> Void)? = nil) {
backgroundContext.perform { backgroundContext.perform {
let statuses = notifications.compactMap { $0.status } let statuses = notifications.compactMap { $0.status }

View File

@ -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
}
}

View File

@ -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 }
}
}

View File

@ -111,7 +111,9 @@ class ConversationViewController: UIViewController {
super.viewWillAppear(animated) super.viewWillAppear(animated)
Task { Task {
await loadMainStatus() if case .unloaded = state {
await loadMainStatus()
}
} }
} }

View File

@ -11,6 +11,8 @@ import Pachyderm
class StatusActionAccountListCollectionViewController: UIViewController, CollectionViewController { class StatusActionAccountListCollectionViewController: UIViewController, CollectionViewController {
private let statusID: String
private let actionType: StatusActionAccountListViewController.ActionType
private let mastodonController: MastodonController private let mastodonController: MastodonController
/// If `true`, a warning will be shown below the account list describing that the total favs/reblogs may be innacurate. /// 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 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. 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. - 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 self.mastodonController = mastodonController
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
@ -38,6 +45,18 @@ class StatusActionAccountListCollectionViewController: UIViewController, Collect
} }
override func loadView() { 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 let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in
switch dataSource.sectionIdentifier(for: sectionIndex)! { switch dataSource.sectionIdentifier(for: sectionIndex)! {
case .status: case .status:
@ -51,7 +70,7 @@ class StatusActionAccountListCollectionViewController: UIViewController, Collect
} }
return NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) return NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
case .accounts: case .accounts:
return NSCollectionLayoutSection.list(using: .init(appearance: .grouped), layoutEnvironment: environment) return NSCollectionLayoutSection.list(using: accountsConfig, layoutEnvironment: environment)
} }
} }
view = UICollectionView(frame: .zero, collectionViewLayout: layout) view = UICollectionView(frame: .zero, collectionViewLayout: layout)
@ -70,12 +89,17 @@ class StatusActionAccountListCollectionViewController: UIViewController, Collect
cell.delegate = self cell.delegate = self
cell.updateUI(accountID: item) 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 let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
switch itemIdentifier { switch itemIdentifier {
case .status(let id, let state): case .status(let id, let state):
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state)) return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state))
case .account(let id): case .account(let id):
return collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: 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 let sectionHeaderCell = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionFooter) { (headerView, collectionView, indexPath) in
@ -93,6 +117,12 @@ class StatusActionAccountListCollectionViewController: UIViewController, Collect
super.viewWillAppear(animated) super.viewWillAppear(animated)
clearSelectionOnAppear(animated: animated) clearSelectionOnAppear(animated: animated)
if case .unloaded = state {
Task {
await loadAccounts()
}
}
} }
func addStatus(_ status: StatusMO, state: CollapseState) { func addStatus(_ status: StatusMO, state: CollapseState) {
@ -104,12 +134,115 @@ class StatusActionAccountListCollectionViewController: UIViewController, Collect
dataSource.apply(snapshot, animatingDifferences: false) 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() var snapshot = dataSource.snapshot()
snapshot.appendItems(accountIDs.map { .account($0) }, toSection: .accounts) snapshot.appendItems(accountIDs.map { .account($0) }, toSection: .accounts)
dataSource.apply(snapshot, animatingDifferences: animated) 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 { extension StatusActionAccountListCollectionViewController {
@ -120,6 +253,16 @@ extension StatusActionAccountListCollectionViewController {
enum Item: Hashable { enum Item: Hashable {
case status(String, CollapseState) case status(String, CollapseState)
case account(String) case account(String)
case loadingIndicator
var hideSeparators: Bool {
switch self {
case .loadingIndicator:
return true
default:
return false
}
}
static func ==(lhs: Item, rhs: Item) -> Bool { static func ==(lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) { switch (lhs, rhs) {
@ -127,6 +270,8 @@ extension StatusActionAccountListCollectionViewController {
return a == b return a == b
case (.account(let a), .account(let b)): case (.account(let a), .account(let b)):
return a == b return a == b
case (.loadingIndicator, .loadingIndicator):
return true
default: default:
return false return false
} }
@ -140,12 +285,23 @@ extension StatusActionAccountListCollectionViewController {
case .account(let id): case .account(let id):
hasher.combine(1) hasher.combine(1)
hasher.combine(id) hasher.combine(id)
case .loadingIndicator:
hasher.combine(2)
} }
} }
} }
} }
extension StatusActionAccountListCollectionViewController: UICollectionViewDelegate { 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) { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
switch dataSource.itemIdentifier(for: indexPath) { switch dataSource.itemIdentifier(for: indexPath) {
case nil: case nil:
@ -154,6 +310,8 @@ extension StatusActionAccountListCollectionViewController: UICollectionViewDeleg
selected(status: id, state: state.copy()) selected(status: id, state: state.copy())
case .account(let id): case .account(let id):
selected(account: id) selected(account: id)
case .loadingIndicator:
return
} }
} }
@ -171,6 +329,8 @@ extension StatusActionAccountListCollectionViewController: UICollectionViewDeleg
} actionProvider: { _ in } actionProvider: { _ in
UIMenu(children: self.actionsForProfile(accountID: id, source: .view(cell))) 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) let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID)
activity.displaysAuxiliaryScene = true activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all) provider.registerObject(activity, visibility: .all)
case .loadingIndicator:
return []
} }
return [UIDragItem(itemProvider: provider)] return [UIDragItem(itemProvider: provider)]
} }

View File

@ -95,8 +95,10 @@ class StatusActionAccountListViewController: UIViewController {
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
Task { if case .unloaded = state {
await loadStatus() Task {
await loadStatus()
}
} }
} }
@ -143,45 +145,13 @@ class StatusActionAccountListViewController: UIViewController {
} }
private func statusLoaded(_ status: StatusMO) async { 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.addStatus(status, state: statusState)
vc.showInacurateCountWarning = showInacurateCountWarning vc.showInacurateCountWarning = showInacurateCountWarning
state = .displaying(vc)
if let accountIDs { if let accountIDs {
vc.addAccounts(accountIDs, animated: false) vc.setAccounts(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)
} }
state = .displaying(vc)
} }
private func showStatusNotFound() { private func showStatusNotFound() {

View File

@ -58,6 +58,7 @@ class CachedImageView: UIImageView {
} }
try Task.checkCancellation() try Task.checkCancellation()
self.image = transformedImage self.image = transformedImage
self.isGrayscale = Preferences.shared.grayscaleImages
} }
} }

View File

@ -35,6 +35,7 @@ class ProfileHeaderView: UIView {
@IBOutlet weak var relationshipLabel: UILabel! @IBOutlet weak var relationshipLabel: UILabel!
@IBOutlet weak var noteTextView: StatusContentTextView! @IBOutlet weak var noteTextView: StatusContentTextView!
@IBOutlet weak var fieldsView: ProfileFieldsView! @IBOutlet weak var fieldsView: ProfileFieldsView!
@IBOutlet weak var followCountButton: UIButton!
private(set) var pagesSegmentedControl: ScrollingSegmentedControl<ProfileViewController.Page>! private(set) var pagesSegmentedControl: ScrollingSegmentedControl<ProfileViewController.Page>!
var accountID: String! var accountID: String!
@ -148,6 +149,26 @@ class ProfileHeaderView: UIView {
fieldsView.delegate = delegate fieldsView.delegate = delegate
fieldsView.updateUI(account: account) 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 = [ accessibilityElements = [
displayNameLabel!, displayNameLabel!,
usernameLabel!, 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 // MARK: Interaction
@objc func avatarPressed() { @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 { extension ProfileHeaderView {

View File

@ -61,7 +61,7 @@
<action selector="followPressed" destination="iN0-l3-epB" eventType="touchUpInside" id="OM3-lq-Z14"/> <action selector="followPressed" destination="iN0-l3-epB" eventType="touchUpInside" id="OM3-lq-Z14"/>
</connections> </connections>
</button> </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"/> <rect key="frame" x="16" y="266" width="398" height="596"/>
<subviews> <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"> <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"/> <nil key="highlightedColor"/>
</label> </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"> <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> <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"/> <color key="textColor" systemColor="labelColor"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/> <fontDescription key="fontDescription" type="system" pointSize="17"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/> <textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView> </textView>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="vKC-m1-Sbs" customClass="ProfileFieldsView" customModule="Tusker" customModuleProvider="target"> <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"/> <color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints> <constraints>
<constraint firstAttribute="height" constant="128" placeholder="YES" id="xbR-M6-H0I"/> <constraint firstAttribute="height" constant="128" placeholder="YES" id="xbR-M6-H0I"/>
</constraints> </constraints>
</view> </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> </subviews>
<constraints> <constraints>
<constraint firstItem="vKC-m1-Sbs" firstAttribute="width" secondItem="u4P-3i-gEq" secondAttribute="width" id="0dI-ax-7eI"/> <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="displayNameLabel" destination="vcl-Gl-kXl" id="64n-a9-my0"/>
<outlet property="fieldsView" destination="vKC-m1-Sbs" id="FeE-jh-lYH"/> <outlet property="fieldsView" destination="vKC-m1-Sbs" id="FeE-jh-lYH"/>
<outlet property="followButton" destination="cr8-p9-xkc" id="E1n-gh-mCl"/> <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="headerImageView" destination="dgG-dR-lSv" id="HXT-v4-2iX"/>
<outlet property="lockImageView" destination="KNY-GD-beC" id="9EJ-iM-Eos"/> <outlet property="lockImageView" destination="KNY-GD-beC" id="9EJ-iM-Eos"/>
<outlet property="moreButton" destination="vFa-g3-xIP" id="dEX-1a-PHF"/> <outlet property="moreButton" destination="vFa-g3-xIP" id="dEX-1a-PHF"/>