mirror of
https://github.com/metabolist/metatext.git
synced 2024-11-25 01:31:02 +00:00
Follow requests
This commit is contained in:
parent
195f2d6a29
commit
1fabcb41cc
21 changed files with 243 additions and 63 deletions
|
@ -524,7 +524,7 @@ public extension ContentDatabase {
|
||||||
accountIds.firstIndex(of: $0.record.id) ?? 0
|
accountIds.firstIndex(of: $0.record.id) ?? 0
|
||||||
< accountIds.firstIndex(of: $1.record.id) ?? 0
|
< accountIds.firstIndex(of: $1.record.id) ?? 0
|
||||||
}
|
}
|
||||||
.map { CollectionItem.account(.init(info: $0)) }
|
.map { CollectionItem.account(.init(info: $0), .withoutNote) }
|
||||||
|
|
||||||
if let limit = limit, accounts.count >= limit {
|
if let limit = limit, accounts.count >= limit {
|
||||||
accounts.append(.moreResults(.init(scope: .accounts)))
|
accounts.append(.moreResults(.init(scope: .accounts)))
|
||||||
|
|
|
@ -5,7 +5,7 @@ import Mastodon
|
||||||
public enum CollectionItem: Hashable {
|
public enum CollectionItem: Hashable {
|
||||||
case status(Status, StatusConfiguration)
|
case status(Status, StatusConfiguration)
|
||||||
case loadMore(LoadMore)
|
case loadMore(LoadMore)
|
||||||
case account(Account)
|
case account(Account, AccountConfiguration)
|
||||||
case notification(MastodonNotification, StatusConfiguration?)
|
case notification(MastodonNotification, StatusConfiguration?)
|
||||||
case conversation(Conversation)
|
case conversation(Conversation)
|
||||||
case tag(Tag)
|
case tag(Tag)
|
||||||
|
@ -38,13 +38,19 @@ public extension CollectionItem {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AccountConfiguration: Hashable {
|
||||||
|
case withNote
|
||||||
|
case withoutNote
|
||||||
|
case followRequest
|
||||||
|
}
|
||||||
|
|
||||||
var itemId: Id? {
|
var itemId: Id? {
|
||||||
switch self {
|
switch self {
|
||||||
case let .status(status, _):
|
case let .status(status, _):
|
||||||
return status.id
|
return status.id
|
||||||
case .loadMore:
|
case .loadMore:
|
||||||
return nil
|
return nil
|
||||||
case let .account(account):
|
case let .account(account, _):
|
||||||
return account.id
|
return account.id
|
||||||
case let .notification(notification, _):
|
case let .notification(notification, _):
|
||||||
return notification.id
|
return notification.id
|
||||||
|
|
|
@ -37,6 +37,7 @@ public extension Identity {
|
||||||
public let header: URL
|
public let header: URL
|
||||||
public let headerStatic: URL
|
public let headerStatic: URL
|
||||||
public let emojis: [Emoji]
|
public let emojis: [Emoji]
|
||||||
|
public let followRequestCount: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Preferences: Codable, Hashable {
|
struct Preferences: Codable, Hashable {
|
||||||
|
|
|
@ -38,6 +38,7 @@ extension IdentityDatabase {
|
||||||
t.column("header", .text).notNull()
|
t.column("header", .text).notNull()
|
||||||
t.column("headerStatic", .text).notNull()
|
t.column("headerStatic", .text).notNull()
|
||||||
t.column("emojis", .blob).notNull()
|
t.column("emojis", .blob).notNull()
|
||||||
|
t.column("followRequestCount", .integer).notNull()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -92,7 +92,8 @@ public extension IdentityDatabase {
|
||||||
avatarStatic: account.avatarStatic,
|
avatarStatic: account.avatarStatic,
|
||||||
header: account.header,
|
header: account.header,
|
||||||
headerStatic: account.headerStatic,
|
headerStatic: account.headerStatic,
|
||||||
emojis: account.emojis)
|
emojis: account.emojis,
|
||||||
|
followRequestCount: account.source?.followRequestsCount ?? 0)
|
||||||
.save)
|
.save)
|
||||||
.ignoreOutput()
|
.ignoreOutput()
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
|
|
|
@ -40,8 +40,8 @@ extension CollectionItem {
|
||||||
identityContext: identityContext,
|
identityContext: identityContext,
|
||||||
status: status,
|
status: status,
|
||||||
configuration: configuration)
|
configuration: configuration)
|
||||||
case let .account(account):
|
case let .account(account, configuration):
|
||||||
return AccountView.estimatedHeight(width: width, account: account)
|
return AccountView.estimatedHeight(width: width, account: account, configuration: configuration)
|
||||||
case .loadMore:
|
case .loadMore:
|
||||||
return LoadMoreView.estimatedHeight
|
return LoadMoreView.estimatedHeight
|
||||||
case let .notification(notification, configuration):
|
case let .notification(notification, configuration):
|
||||||
|
|
|
@ -70,6 +70,7 @@
|
||||||
"emoji.system-group.flags" = "Flags";
|
"emoji.system-group.flags" = "Flags";
|
||||||
"error" = "Error";
|
"error" = "Error";
|
||||||
"favorites" = "Favorites";
|
"favorites" = "Favorites";
|
||||||
|
"follow-requests" = "Follow Requests";
|
||||||
"registration.review-terms-of-use-and-privacy-policy-%@" = "Please review %@'s Terms of Use and Privacy Policy to continue";
|
"registration.review-terms-of-use-and-privacy-policy-%@" = "Please review %@'s Terms of Use and Privacy Policy to continue";
|
||||||
"registration.username" = "Username";
|
"registration.username" = "Username";
|
||||||
"registration.email" = "Email";
|
"registration.email" = "Email";
|
||||||
|
|
|
@ -3,12 +3,6 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public final class Account: Codable, Identifiable {
|
public final class Account: Codable, Identifiable {
|
||||||
public struct Field: Codable, Hashable {
|
|
||||||
public let name: String
|
|
||||||
public let value: HTML
|
|
||||||
public let verifiedAt: Date?
|
|
||||||
}
|
|
||||||
|
|
||||||
public let id: Id
|
public let id: Id
|
||||||
public let username: String
|
public let username: String
|
||||||
public let acct: String
|
public let acct: String
|
||||||
|
@ -29,6 +23,7 @@ public final class Account: Codable, Identifiable {
|
||||||
@DecodableDefault.False public private(set) var bot: Bool
|
@DecodableDefault.False public private(set) var bot: Bool
|
||||||
@DecodableDefault.False public private(set) var discoverable: Bool
|
@DecodableDefault.False public private(set) var discoverable: Bool
|
||||||
public var moved: Account?
|
public var moved: Account?
|
||||||
|
public var source: Source?
|
||||||
|
|
||||||
public init(id: Id,
|
public init(id: Id,
|
||||||
username: String,
|
username: String,
|
||||||
|
@ -75,6 +70,21 @@ public final class Account: Codable, Identifiable {
|
||||||
|
|
||||||
public extension Account {
|
public extension Account {
|
||||||
typealias Id = String
|
typealias Id = String
|
||||||
|
|
||||||
|
struct Field: Codable, Hashable {
|
||||||
|
public let name: String
|
||||||
|
public let value: HTML
|
||||||
|
public let verifiedAt: Date?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Source: Codable, Hashable {
|
||||||
|
public let note: String?
|
||||||
|
public let fields: [Field]
|
||||||
|
public let privacy: Status.Visibility?
|
||||||
|
public let sensitive: Bool?
|
||||||
|
public let language: String?
|
||||||
|
public let followRequestsCount: Int?
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Account: Hashable {
|
extension Account: Hashable {
|
||||||
|
|
|
@ -11,6 +11,7 @@ public enum AccountsEndpoint {
|
||||||
case blocks
|
case blocks
|
||||||
case accountsFollowers(id: Account.Id)
|
case accountsFollowers(id: Account.Id)
|
||||||
case accountsFollowing(id: Account.Id)
|
case accountsFollowing(id: Account.Id)
|
||||||
|
case followRequests
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AccountsEndpoint: Endpoint {
|
extension AccountsEndpoint: Endpoint {
|
||||||
|
@ -20,7 +21,7 @@ extension AccountsEndpoint: Endpoint {
|
||||||
switch self {
|
switch self {
|
||||||
case .rebloggedBy, .favouritedBy:
|
case .rebloggedBy, .favouritedBy:
|
||||||
return defaultContext + ["statuses"]
|
return defaultContext + ["statuses"]
|
||||||
case .mutes, .blocks:
|
case .mutes, .blocks, .followRequests:
|
||||||
return defaultContext
|
return defaultContext
|
||||||
case .accountsFollowers, .accountsFollowing:
|
case .accountsFollowers, .accountsFollowing:
|
||||||
return defaultContext + ["accounts"]
|
return defaultContext + ["accounts"]
|
||||||
|
@ -41,6 +42,8 @@ extension AccountsEndpoint: Endpoint {
|
||||||
return [id, "followers"]
|
return [id, "followers"]
|
||||||
case let .accountsFollowing(id):
|
case let .accountsFollowing(id):
|
||||||
return [id, "following"]
|
return [id, "following"]
|
||||||
|
case .followRequests:
|
||||||
|
return ["follow_requests"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,13 +14,20 @@ public enum RelationshipEndpoint {
|
||||||
case accountsPin(id: Account.Id)
|
case accountsPin(id: Account.Id)
|
||||||
case accountsUnpin(id: Account.Id)
|
case accountsUnpin(id: Account.Id)
|
||||||
case note(String, id: Account.Id)
|
case note(String, id: Account.Id)
|
||||||
|
case acceptFollowRequest(id: Account.Id)
|
||||||
|
case rejectFollowRequest(id: Account.Id)
|
||||||
}
|
}
|
||||||
|
|
||||||
extension RelationshipEndpoint: Endpoint {
|
extension RelationshipEndpoint: Endpoint {
|
||||||
public typealias ResultType = Relationship
|
public typealias ResultType = Relationship
|
||||||
|
|
||||||
public var context: [String] {
|
public var context: [String] {
|
||||||
defaultContext + ["accounts"]
|
switch self {
|
||||||
|
case .acceptFollowRequest, .rejectFollowRequest:
|
||||||
|
return defaultContext + ["follow_requests"]
|
||||||
|
default:
|
||||||
|
return defaultContext + ["accounts"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public var pathComponentsInContext: [String] {
|
public var pathComponentsInContext: [String] {
|
||||||
|
@ -43,6 +50,10 @@ extension RelationshipEndpoint: Endpoint {
|
||||||
return [id, "unpin"]
|
return [id, "unpin"]
|
||||||
case let .note(_, id):
|
case let .note(_, id):
|
||||||
return [id, "note"]
|
return [id, "note"]
|
||||||
|
case let .acceptFollowRequest(id):
|
||||||
|
return [id, "authorize"]
|
||||||
|
case let .rejectFollowRequest(id):
|
||||||
|
return [id, "reject"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
// Copyright © 2021 Metabolist. All rights reserved.
|
||||||
|
|
||||||
|
import MastodonAPI
|
||||||
|
|
||||||
|
extension AccountsEndpoint {
|
||||||
|
var configuration: CollectionItem.AccountConfiguration {
|
||||||
|
switch self {
|
||||||
|
case .followRequests:
|
||||||
|
return .followRequest
|
||||||
|
default:
|
||||||
|
return .withNote
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -32,7 +32,7 @@ public struct AccountListService {
|
||||||
|
|
||||||
return $0 + $1.filter { !presentIds.contains($0.id) }
|
return $0 + $1.filter { !presentIds.contains($0.id) }
|
||||||
}
|
}
|
||||||
.map { [.init(items: $0.map(CollectionItem.account))] }
|
.map { [.init(items: $0.map { CollectionItem.account($0, endpoint.configuration) })] }
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
nextPageMaxId = nextPageMaxIdSubject.eraseToAnyPublisher()
|
nextPageMaxId = nextPageMaxIdSubject.eraseToAnyPublisher()
|
||||||
navigationService = NavigationService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
|
navigationService = NavigationService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
|
||||||
|
|
|
@ -92,6 +92,14 @@ public extension AccountService {
|
||||||
relationshipAction(.note(note, id: account.id))
|
relationshipAction(.note(note, id: account.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func acceptFollowRequest() -> AnyPublisher<Never, Error> {
|
||||||
|
relationshipAction(.acceptFollowRequest(id: account.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejectFollowRequest() -> AnyPublisher<Never, Error> {
|
||||||
|
relationshipAction(.rejectFollowRequest(id: account.id))
|
||||||
|
}
|
||||||
|
|
||||||
func report(_ elements: ReportElements) -> AnyPublisher<Never, Error> {
|
func report(_ elements: ReportElements) -> AnyPublisher<Never, Error> {
|
||||||
mastodonAPIClient.request(ReportEndpoint.create(elements)).ignoreOutput().eraseToAnyPublisher()
|
mastodonAPIClient.request(ReportEndpoint.create(elements)).ignoreOutput().eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
|
@ -242,11 +242,12 @@ public extension IdentityService {
|
||||||
TimelineService(timeline: timeline, mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
|
TimelineService(timeline: timeline, mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
|
||||||
}
|
}
|
||||||
|
|
||||||
func service(accountList: AccountsEndpoint) -> AccountListService {
|
func service(accountList: AccountsEndpoint, titleComponents: [String]? = nil) -> AccountListService {
|
||||||
AccountListService(
|
AccountListService(
|
||||||
endpoint: accountList,
|
endpoint: accountList,
|
||||||
mastodonAPIClient: mastodonAPIClient,
|
mastodonAPIClient: mastodonAPIClient,
|
||||||
contentDatabase: contentDatabase)
|
contentDatabase: contentDatabase,
|
||||||
|
titleComponents: titleComponents)
|
||||||
}
|
}
|
||||||
|
|
||||||
func exploreService() -> ExploreService {
|
func exploreService() -> ExploreService {
|
||||||
|
|
|
@ -40,8 +40,9 @@ final class MainNavigationViewController: UITabBarController {
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
viewModel.timelineNavigations
|
viewModel.timelineNavigations.map { _ in }
|
||||||
.sink { [weak self] _ in self?.selectedIndex = 0 }
|
.merge(with: viewModel.followRequestNavigations.map { _ in })
|
||||||
|
.sink { [weak self] in self?.selectedIndex = 0 }
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -82,6 +82,15 @@ final class TimelinesViewController: UIPageViewController {
|
||||||
self.show(vc, sender: self)
|
self.show(vc, sender: self)
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
viewModel.followRequestNavigations.sink { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
let vc = TableViewController(viewModel: $0, rootViewModel: self.rootViewModel)
|
||||||
|
|
||||||
|
self.show(vc, sender: self)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,9 +5,10 @@ import Foundation
|
||||||
import Mastodon
|
import Mastodon
|
||||||
import ServiceLayer
|
import ServiceLayer
|
||||||
|
|
||||||
public struct AccountViewModel: CollectionItemViewModel {
|
public final class AccountViewModel: CollectionItemViewModel, ObservableObject {
|
||||||
public let events: AnyPublisher<AnyPublisher<CollectionItemEvent, Error>, Never>
|
public let events: AnyPublisher<AnyPublisher<CollectionItemEvent, Error>, Never>
|
||||||
public let identityContext: IdentityContext
|
public let identityContext: IdentityContext
|
||||||
|
public internal(set) var configuration = CollectionItem.AccountConfiguration.withNote
|
||||||
|
|
||||||
private let accountService: AccountService
|
private let accountService: AccountService
|
||||||
private let eventsSubject = PassthroughSubject<AnyPublisher<CollectionItemEvent, Error>, Never>()
|
private let eventsSubject = PassthroughSubject<AnyPublisher<CollectionItemEvent, Error>, Never>()
|
||||||
|
@ -138,6 +139,30 @@ public extension AccountViewModel {
|
||||||
ignorableOutputEvent(accountService.set(note: note))
|
ignorableOutputEvent(accountService.set(note: note))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func acceptFollowRequest() {
|
||||||
|
ignorableOutputEvent(
|
||||||
|
accountService.acceptFollowRequest()
|
||||||
|
.collect()
|
||||||
|
.flatMap { [weak self] _ -> AnyPublisher<Never, Error> in
|
||||||
|
guard let self = self else { return Empty().eraseToAnyPublisher() }
|
||||||
|
|
||||||
|
return self.identityContext.service.verifyCredentials()
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher())
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejectFollowRequest() {
|
||||||
|
ignorableOutputEvent(
|
||||||
|
accountService.rejectFollowRequest()
|
||||||
|
.collect()
|
||||||
|
.flatMap { [weak self] _ -> AnyPublisher<Never, Error> in
|
||||||
|
guard let self = self else { return Empty().eraseToAnyPublisher() }
|
||||||
|
|
||||||
|
return self.identityContext.service.verifyCredentials()
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher())
|
||||||
|
}
|
||||||
|
|
||||||
func domainBlock() {
|
func domainBlock() {
|
||||||
ignorableOutputEvent(accountService.domainBlock())
|
ignorableOutputEvent(accountService.domainBlock())
|
||||||
}
|
}
|
||||||
|
|
|
@ -138,7 +138,7 @@ extension CollectionItemsViewModel: CollectionViewModel {
|
||||||
case let .loadMore(loadMore):
|
case let .loadMore(loadMore):
|
||||||
lastSelectedLoadMore = loadMore
|
lastSelectedLoadMore = loadMore
|
||||||
(viewModel(indexPath: indexPath) as? LoadMoreViewModel)?.loadMore()
|
(viewModel(indexPath: indexPath) as? LoadMoreViewModel)?.loadMore()
|
||||||
case let .account(account):
|
case let .account(account, _):
|
||||||
eventsSubject.send(
|
eventsSubject.send(
|
||||||
.navigation(.profile(collectionService
|
.navigation(.profile(collectionService
|
||||||
.navigationService
|
.navigationService
|
||||||
|
@ -225,16 +225,19 @@ extension CollectionItemsViewModel: CollectionViewModel {
|
||||||
cache(viewModel: viewModel, forItem: item)
|
cache(viewModel: viewModel, forItem: item)
|
||||||
|
|
||||||
return viewModel
|
return viewModel
|
||||||
case let .account(account):
|
case let .account(account, configuration):
|
||||||
if let cachedViewModel = cachedViewModel {
|
let viewModel: AccountViewModel
|
||||||
return cachedViewModel
|
|
||||||
}
|
|
||||||
|
|
||||||
let viewModel = AccountViewModel(
|
if let cachedViewModel = cachedViewModel as? AccountViewModel {
|
||||||
|
viewModel = cachedViewModel
|
||||||
|
} else {
|
||||||
|
viewModel = AccountViewModel(
|
||||||
accountService: collectionService.navigationService.accountService(account: account),
|
accountService: collectionService.navigationService.accountService(account: account),
|
||||||
identityContext: identityContext)
|
identityContext: identityContext)
|
||||||
|
|
||||||
cache(viewModel: viewModel, forItem: item)
|
cache(viewModel: viewModel, forItem: item)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.configuration = configuration
|
||||||
|
|
||||||
return viewModel
|
return viewModel
|
||||||
case let .notification(notification, statusConfiguration):
|
case let .notification(notification, statusConfiguration):
|
||||||
|
|
|
@ -8,6 +8,7 @@ import ServiceLayer
|
||||||
public final class NavigationViewModel: ObservableObject {
|
public final class NavigationViewModel: ObservableObject {
|
||||||
public let identityContext: IdentityContext
|
public let identityContext: IdentityContext
|
||||||
public let timelineNavigations: AnyPublisher<Timeline, Never>
|
public let timelineNavigations: AnyPublisher<Timeline, Never>
|
||||||
|
public let followRequestNavigations: AnyPublisher<CollectionViewModel, Never>
|
||||||
|
|
||||||
@Published public private(set) var recentIdentities = [Identity]()
|
@Published public private(set) var recentIdentities = [Identity]()
|
||||||
@Published public var presentingSecondaryNavigation = false
|
@Published public var presentingSecondaryNavigation = false
|
||||||
|
@ -38,11 +39,13 @@ public final class NavigationViewModel: ObservableObject {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private let timelineNavigationsSubject = PassthroughSubject<Timeline, Never>()
|
private let timelineNavigationsSubject = PassthroughSubject<Timeline, Never>()
|
||||||
|
private let followRequestNavigationsSubject = PassthroughSubject<CollectionViewModel, Never>()
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
public init(identityContext: IdentityContext) {
|
public init(identityContext: IdentityContext) {
|
||||||
self.identityContext = identityContext
|
self.identityContext = identityContext
|
||||||
timelineNavigations = timelineNavigationsSubject.eraseToAnyPublisher()
|
timelineNavigations = timelineNavigationsSubject.eraseToAnyPublisher()
|
||||||
|
followRequestNavigations = followRequestNavigationsSubject.eraseToAnyPublisher()
|
||||||
|
|
||||||
identityContext.$identity
|
identityContext.$identity
|
||||||
.sink { [weak self] _ in self?.objectWillChange.send() }
|
.sink { [weak self] _ in self?.objectWillChange.send() }
|
||||||
|
@ -121,6 +124,17 @@ public extension NavigationViewModel {
|
||||||
timelineNavigationsSubject.send(timeline)
|
timelineNavigationsSubject.send(timeline)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func navigateToFollowerRequests() {
|
||||||
|
let followRequestsViewModel = CollectionItemsViewModel(
|
||||||
|
collectionService: identityContext.service.service(
|
||||||
|
accountList: .followRequests,
|
||||||
|
titleComponents: ["follow-requests"]),
|
||||||
|
identityContext: identityContext)
|
||||||
|
|
||||||
|
presentingSecondaryNavigation = false
|
||||||
|
followRequestNavigationsSubject.send(followRequestsViewModel)
|
||||||
|
}
|
||||||
|
|
||||||
func viewModel(timeline: Timeline) -> CollectionItemsViewModel {
|
func viewModel(timeline: Timeline) -> CollectionItemsViewModel {
|
||||||
CollectionItemsViewModel(
|
CollectionItemsViewModel(
|
||||||
collectionService: identityContext.service.service(timeline: timeline),
|
collectionService: identityContext.service.service(timeline: timeline),
|
||||||
|
|
|
@ -3,12 +3,15 @@
|
||||||
import Kingfisher
|
import Kingfisher
|
||||||
import Mastodon
|
import Mastodon
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import ViewModels
|
||||||
|
|
||||||
final class AccountView: UIView {
|
final class AccountView: UIView {
|
||||||
let avatarImageView = AnimatedImageView()
|
let avatarImageView = AnimatedImageView()
|
||||||
let displayNameLabel = UILabel()
|
let displayNameLabel = UILabel()
|
||||||
let accountLabel = UILabel()
|
let accountLabel = UILabel()
|
||||||
let noteTextView = TouchFallthroughTextView()
|
let noteTextView = TouchFallthroughTextView()
|
||||||
|
let acceptFollowRequestButton = UIButton()
|
||||||
|
let rejectFollowRequestButton = UIButton()
|
||||||
|
|
||||||
private var accountConfiguration: AccountContentConfiguration
|
private var accountConfiguration: AccountContentConfiguration
|
||||||
|
|
||||||
|
@ -28,12 +31,21 @@ final class AccountView: UIView {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AccountView {
|
extension AccountView {
|
||||||
static func estimatedHeight(width: CGFloat, account: Account) -> CGFloat {
|
static func estimatedHeight(width: CGFloat,
|
||||||
.defaultSpacing * 2
|
account: Account,
|
||||||
+ .compactSpacing * 2
|
configuration: CollectionItem.AccountConfiguration) -> CGFloat {
|
||||||
|
var height = CGFloat.defaultSpacing * 2
|
||||||
|
+ .compactSpacing
|
||||||
+ account.displayName.height(width: width, font: .preferredFont(forTextStyle: .headline))
|
+ account.displayName.height(width: width, font: .preferredFont(forTextStyle: .headline))
|
||||||
+ account.acct.height(width: width, font: .preferredFont(forTextStyle: .subheadline))
|
+ account.acct.height(width: width, font: .preferredFont(forTextStyle: .subheadline))
|
||||||
+ account.note.attributed.string.height(width: width, font: .preferredFont(forTextStyle: .callout))
|
|
||||||
|
if configuration == .withNote {
|
||||||
|
height += .compactSpacing + account.note.attributed.string.height(
|
||||||
|
width: width,
|
||||||
|
font: .preferredFont(forTextStyle: .callout))
|
||||||
|
}
|
||||||
|
|
||||||
|
return max(height, .avatarDimension + .defaultSpacing * 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -71,17 +83,24 @@ private extension AccountView {
|
||||||
func initialSetup() {
|
func initialSetup() {
|
||||||
let stackView = UIStackView()
|
let stackView = UIStackView()
|
||||||
|
|
||||||
addSubview(avatarImageView)
|
|
||||||
addSubview(stackView)
|
addSubview(stackView)
|
||||||
avatarImageView.translatesAutoresizingMaskIntoConstraints = false
|
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
stackView.spacing = .defaultSpacing
|
||||||
|
stackView.alignment = .top
|
||||||
|
|
||||||
|
stackView.addArrangedSubview(avatarImageView)
|
||||||
avatarImageView.layer.cornerRadius = .avatarDimension / 2
|
avatarImageView.layer.cornerRadius = .avatarDimension / 2
|
||||||
avatarImageView.clipsToBounds = true
|
avatarImageView.clipsToBounds = true
|
||||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
stackView.axis = .vertical
|
let verticalStackView = UIStackView()
|
||||||
stackView.spacing = .compactSpacing
|
|
||||||
stackView.addArrangedSubview(displayNameLabel)
|
stackView.addArrangedSubview(verticalStackView)
|
||||||
stackView.addArrangedSubview(accountLabel)
|
verticalStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
stackView.addArrangedSubview(noteTextView)
|
verticalStackView.axis = .vertical
|
||||||
|
verticalStackView.spacing = .compactSpacing
|
||||||
|
verticalStackView.addArrangedSubview(displayNameLabel)
|
||||||
|
verticalStackView.addArrangedSubview(accountLabel)
|
||||||
|
verticalStackView.addArrangedSubview(noteTextView)
|
||||||
displayNameLabel.numberOfLines = 0
|
displayNameLabel.numberOfLines = 0
|
||||||
displayNameLabel.font = .preferredFont(forTextStyle: .headline)
|
displayNameLabel.font = .preferredFont(forTextStyle: .headline)
|
||||||
displayNameLabel.adjustsFontForContentSizeCategory = true
|
displayNameLabel.adjustsFontForContentSizeCategory = true
|
||||||
|
@ -92,36 +111,63 @@ private extension AccountView {
|
||||||
noteTextView.backgroundColor = .clear
|
noteTextView.backgroundColor = .clear
|
||||||
noteTextView.delegate = self
|
noteTextView.delegate = self
|
||||||
|
|
||||||
|
let largeTitlePointSize = UIFont.preferredFont(forTextStyle: .largeTitle).pointSize
|
||||||
|
|
||||||
|
stackView.addArrangedSubview(acceptFollowRequestButton)
|
||||||
|
acceptFollowRequestButton.setImage(
|
||||||
|
UIImage(systemName: "checkmark.circle",
|
||||||
|
withConfiguration: UIImage.SymbolConfiguration(pointSize: largeTitlePointSize)),
|
||||||
|
for: .normal)
|
||||||
|
acceptFollowRequestButton.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
acceptFollowRequestButton.addAction(
|
||||||
|
UIAction { [weak self] _ in self?.accountConfiguration.viewModel.acceptFollowRequest() },
|
||||||
|
for: .touchUpInside)
|
||||||
|
|
||||||
|
stackView.addArrangedSubview(rejectFollowRequestButton)
|
||||||
|
rejectFollowRequestButton.setImage(
|
||||||
|
UIImage(systemName: "xmark.circle",
|
||||||
|
withConfiguration: UIImage.SymbolConfiguration(pointSize: largeTitlePointSize)),
|
||||||
|
for: .normal)
|
||||||
|
rejectFollowRequestButton.tintColor = .systemRed
|
||||||
|
rejectFollowRequestButton.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
rejectFollowRequestButton.addAction(
|
||||||
|
UIAction { [weak self] _ in self?.accountConfiguration.viewModel.rejectFollowRequest() },
|
||||||
|
for: .touchUpInside)
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
avatarImageView.widthAnchor.constraint(equalToConstant: .avatarDimension),
|
avatarImageView.widthAnchor.constraint(equalToConstant: .avatarDimension),
|
||||||
avatarImageView.heightAnchor.constraint(equalToConstant: .avatarDimension),
|
avatarImageView.heightAnchor.constraint(equalToConstant: .avatarDimension),
|
||||||
avatarImageView.topAnchor.constraint(equalTo: readableContentGuide.topAnchor),
|
acceptFollowRequestButton.widthAnchor.constraint(greaterThanOrEqualToConstant: .avatarDimension),
|
||||||
avatarImageView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
|
acceptFollowRequestButton.heightAnchor.constraint(greaterThanOrEqualToConstant: .avatarDimension),
|
||||||
avatarImageView.bottomAnchor.constraint(lessThanOrEqualTo: readableContentGuide.bottomAnchor),
|
rejectFollowRequestButton.widthAnchor.constraint(greaterThanOrEqualToConstant: .avatarDimension),
|
||||||
stackView.leadingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: .defaultSpacing),
|
rejectFollowRequestButton.heightAnchor.constraint(greaterThanOrEqualToConstant: .avatarDimension),
|
||||||
|
stackView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
|
||||||
stackView.topAnchor.constraint(equalTo: readableContentGuide.topAnchor),
|
stackView.topAnchor.constraint(equalTo: readableContentGuide.topAnchor),
|
||||||
stackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),
|
stackView.bottomAnchor.constraint(equalTo: readableContentGuide.bottomAnchor),
|
||||||
stackView.bottomAnchor.constraint(equalTo: readableContentGuide.bottomAnchor)
|
stackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor)
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyAccountConfiguration() {
|
func applyAccountConfiguration() {
|
||||||
avatarImageView.kf.setImage(with: accountConfiguration.viewModel.avatarURL(profile: false))
|
let viewModel = accountConfiguration.viewModel
|
||||||
|
|
||||||
if accountConfiguration.viewModel.displayName.isEmpty {
|
avatarImageView.kf.setImage(with: viewModel.avatarURL(profile: false))
|
||||||
|
|
||||||
|
if viewModel.displayName.isEmpty {
|
||||||
displayNameLabel.isHidden = true
|
displayNameLabel.isHidden = true
|
||||||
} else {
|
} else {
|
||||||
let mutableDisplayName = NSMutableAttributedString(string: accountConfiguration.viewModel.displayName)
|
let mutableDisplayName = NSMutableAttributedString(string: viewModel.displayName)
|
||||||
|
|
||||||
mutableDisplayName.insert(emojis: accountConfiguration.viewModel.emojis, view: displayNameLabel)
|
mutableDisplayName.insert(emojis: viewModel.emojis, view: displayNameLabel)
|
||||||
mutableDisplayName.resizeAttachments(toLineHeight: displayNameLabel.font.lineHeight)
|
mutableDisplayName.resizeAttachments(toLineHeight: displayNameLabel.font.lineHeight)
|
||||||
displayNameLabel.attributedText = mutableDisplayName
|
displayNameLabel.attributedText = mutableDisplayName
|
||||||
}
|
}
|
||||||
|
|
||||||
accountLabel.text = accountConfiguration.viewModel.accountName
|
accountLabel.text = viewModel.accountName
|
||||||
|
|
||||||
|
if viewModel.configuration == .withNote {
|
||||||
let noteFont = UIFont.preferredFont(forTextStyle: .callout)
|
let noteFont = UIFont.preferredFont(forTextStyle: .callout)
|
||||||
let mutableNote = NSMutableAttributedString(attributedString: accountConfiguration.viewModel.note)
|
let mutableNote = NSMutableAttributedString(attributedString: viewModel.note)
|
||||||
let noteRange = NSRange(location: 0, length: mutableNote.length)
|
let noteRange = NSRange(location: 0, length: mutableNote.length)
|
||||||
|
|
||||||
mutableNote.removeAttribute(.font, range: noteRange)
|
mutableNote.removeAttribute(.font, range: noteRange)
|
||||||
|
@ -129,9 +175,18 @@ private extension AccountView {
|
||||||
[.font: noteFont as Any,
|
[.font: noteFont as Any,
|
||||||
.foregroundColor: UIColor.label],
|
.foregroundColor: UIColor.label],
|
||||||
range: noteRange)
|
range: noteRange)
|
||||||
mutableNote.insert(emojis: accountConfiguration.viewModel.emojis, view: noteTextView)
|
mutableNote.insert(emojis: viewModel.emojis, view: noteTextView)
|
||||||
mutableNote.resizeAttachments(toLineHeight: noteFont.lineHeight)
|
mutableNote.resizeAttachments(toLineHeight: noteFont.lineHeight)
|
||||||
|
|
||||||
noteTextView.attributedText = mutableNote
|
noteTextView.attributedText = mutableNote
|
||||||
|
noteTextView.isHidden = false
|
||||||
|
} else {
|
||||||
|
noteTextView.isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
let isFollowRequest = viewModel.configuration == .followRequest
|
||||||
|
|
||||||
|
acceptFollowRequestButton.isHidden = !isFollowRequest
|
||||||
|
rejectFollowRequestButton.isHidden = !isFollowRequest
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -68,6 +68,22 @@ struct SecondaryNavigationView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let followRequestCount = viewModel.identityContext.identity.account?.followRequestCount,
|
||||||
|
followRequestCount > 0 {
|
||||||
|
Button {
|
||||||
|
viewModel.navigateToFollowerRequests()
|
||||||
|
} label: {
|
||||||
|
Label {
|
||||||
|
HStack {
|
||||||
|
Text("follow-requests").foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
Text(verbatim: String(followRequestCount))
|
||||||
|
}
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "person.badge.plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Section {
|
Section {
|
||||||
NavigationLink(
|
NavigationLink(
|
||||||
|
|
Loading…
Reference in a new issue