Merge pull request #1155 from mastodon/ios-190-user-suggestions

Make "Suggestions" use Entities (IOS-190)
This commit is contained in:
Nathan Mattes 2023-11-16 10:36:21 +01:00 committed by GitHub
commit 6e149cd505
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 129 additions and 169 deletions

View File

@ -562,18 +562,29 @@ private extension SceneCoordinator {
//MARK: - Loading
public extension SceneCoordinator {
@MainActor
func showLoading() {
guard let rootViewController else { return }
showLoading(on: rootViewController)
}
MBProgressHUD.showAdded(to: rootViewController.view, animated: true)
@MainActor
func showLoading(on viewController: UIViewController?) {
guard let viewController else { return }
MBProgressHUD.showAdded(to: viewController.view, animated: true)
}
@MainActor
func hideLoading() {
guard let rootViewController else { return }
hideLoading(on: rootViewController)
}
MBProgressHUD.hide(for: rootViewController.view, animated: true)
@MainActor
func hideLoading(on viewController: UIViewController?) {
guard let viewController else { return }
MBProgressHUD.hide(for: viewController.view, animated: true)
}
}

View File

@ -203,7 +203,7 @@ extension MastodonPickServerViewModel {
func chooseRandomServer() -> Mastodon.Entity.Server? {
let language = Locale.autoupdatingCurrent.languageCode?.lowercased() ?? "en"
let language = Locale.autoupdatingCurrent.language.languageCode?.identifier.lowercased() ?? "en"
let servers = indexedServers.value
guard servers.isNotEmpty else { return nil }

View File

@ -6,8 +6,8 @@
//
import Foundation
import CoreDataStack
import MastodonSDK
enum RecommendAccountItem: Hashable {
case account(ManagedObjectRecord<MastodonUser>)
case account(Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?)
}

View File

@ -34,21 +34,11 @@ extension RecommendAccountSection {
UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SuggestionAccountTableViewCell.self)) as! SuggestionAccountTableViewCell
switch item {
case .account(let record):
cell.delegate = configuration.suggestionAccountTableViewCellDelegate
context.managedObjectContext.performAndWait {
guard let user = record.object(in: context.managedObjectContext) else { return }
cell.configure(viewModel:
SuggestionAccountTableViewCell.ViewModel(
user: user,
followedUsers: configuration.authContext.mastodonAuthenticationBox.inMemoryCache.followingUserIds,
blockedUsers: configuration.authContext.mastodonAuthenticationBox.inMemoryCache.blockedUserIds,
followRequestedUsers: configuration.authContext.mastodonAuthenticationBox.inMemoryCache.followRequestedUserIDs)
)
}
case .account(let account, let relationship):
cell.delegate = configuration.suggestionAccountTableViewCellDelegate
cell.configure(account: account, relationship: relationship)
}
return cell
}
}
}

View File

@ -38,7 +38,6 @@ class SuggestionAccountViewController: UIViewController, NeedsDependency {
setupNavigationBarAppearance()
defer { setupNavigationBarBackgroundView() }
title = L10n.Scene.SuggestionAccount.title
navigationItem.rightBarButtonItem = UIBarButtonItem(
barButtonSystemItem: UIBarButtonItem.SystemItem.done,
@ -72,6 +71,8 @@ class SuggestionAccountViewController: UIViewController, NeedsDependency {
navigationItem.largeTitleDisplayMode = .automatic
tableView.deselectRow(with: transitionCoordinator, animated: animated)
viewModel.updateSuggestions()
}
//MARK: - Actions
@ -85,18 +86,15 @@ class SuggestionAccountViewController: UIViewController, NeedsDependency {
// MARK: - UITableViewDelegate
extension SuggestionAccountViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let tableViewDiffableDataSource = viewModel.tableViewDiffableDataSource else { return }
guard let item = tableViewDiffableDataSource.itemIdentifier(for: indexPath) else { return }
switch item {
case .account(let record):
guard let account = record.object(in: context.managedObjectContext) else { return }
let cachedProfileViewModel = CachedProfileViewModel(context: context, authContext: viewModel.authContext, mastodonUser: account)
_ = coordinator.present(
scene: .profile(viewModel: cachedProfileViewModel),
from: self,
transition: .show
)
case .account(let account, _):
Task { await DataSourceFacade.coordinateToProfileScene(provider: self, account: account) }
}
tableView.deselectRow(at: indexPath, animated: true)
}
func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
@ -104,7 +102,7 @@ extension SuggestionAccountViewController: UITableViewDelegate {
return nil
}
footerView.followAllButton.isEnabled = viewModel.userFetchedResultsController.records.isNotEmpty
footerView.followAllButton.isEnabled = viewModel.accounts.isNotEmpty
footerView.delegate = self
return footerView
@ -125,8 +123,9 @@ extension SuggestionAccountViewController: SuggestionAccountTableViewCellDelegat
extension SuggestionAccountViewController: SuggestionAccountTableViewFooterDelegate {
func followAll(_ footerView: SuggestionAccountTableViewFooter) {
viewModel.followAllSuggestedAccounts(self) {
viewModel.followAllSuggestedAccounts(self, presentedOn: self.navigationController) {
DispatchQueue.main.async {
self.coordinator.hideLoading(on: self.navigationController)
self.dismiss(animated: true)
}
}

View File

@ -6,9 +6,6 @@
//
import Combine
import CoreData
import CoreDataStack
import GameplayKit
import MastodonSDK
import MastodonCore
import UIKit
@ -25,7 +22,8 @@ final class SuggestionAccountViewModel: NSObject {
// input
let context: AppContext
let authContext: AuthContext
let userFetchedResultsController: UserFetchedResultsController
@Published var accounts: [Mastodon.Entity.V2.SuggestionAccount]
var relationships: [Mastodon.Entity.Relationship]
var viewWillAppear = PassthroughSubject<Void, Never>()
@ -38,51 +36,42 @@ final class SuggestionAccountViewModel: NSObject {
) {
self.context = context
self.authContext = authContext
self.userFetchedResultsController = UserFetchedResultsController(
managedObjectContext: context.managedObjectContext,
domain: nil,
additionalPredicate: nil
)
super.init()
userFetchedResultsController.domain = authContext.mastodonAuthenticationBox.domain
// fetch recommended users
accounts = []
relationships = []
super.init()
updateSuggestions()
}
func updateSuggestions() {
Task {
var userIDs: [MastodonUser.ID] = []
var suggestedAccounts: [Mastodon.Entity.V2.SuggestionAccount] = []
do {
let response = try await context.apiService.suggestionAccountV2(
query: .init(limit: 5),
authenticationBox: authContext.mastodonAuthenticationBox
)
userIDs = response.value.map { $0.account.id }
} catch let error as Mastodon.API.Error where error.httpResponseStatus == .notFound {
let response = try await context.apiService.suggestionAccount(
query: nil,
suggestedAccounts = response.value
guard suggestedAccounts.isNotEmpty else { return }
let accounts = suggestedAccounts.compactMap { $0.account }
let relationships = try await context.apiService.relationship(
forAccounts: accounts,
authenticationBox: authContext.mastodonAuthenticationBox
)
userIDs = response.value.map { $0.id }
).value
self.relationships = relationships
self.accounts = suggestedAccounts
} catch {
self.relationships = []
self.accounts = []
}
guard userIDs.isNotEmpty else { return }
userFetchedResultsController.userIDs = userIDs
}
// fetch relationship
userFetchedResultsController.$records
.removeDuplicates()
.sink { [weak self] records in
guard let _ = self else { return }
Task {
_ = try await context.apiService.relationship(
records: records,
authenticationBox: authContext.mastodonAuthenticationBox
)
}
}
.store(in: &disposeBag)
}
func setupDiffableDataSource(
@ -98,15 +87,22 @@ final class SuggestionAccountViewModel: NSObject {
)
)
userFetchedResultsController.$records
$accounts
.receive(on: DispatchQueue.main)
.sink { [weak self] records in
guard let self = self else { return }
guard let tableViewDiffableDataSource = self.tableViewDiffableDataSource else { return }
.sink { [weak self] suggestedAccounts in
guard let self, let tableViewDiffableDataSource = self.tableViewDiffableDataSource else { return }
let accounts = suggestedAccounts.compactMap { $0.account }
let accountsWithRelationship: [(account: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?)] = accounts.compactMap { account in
guard let relationship = self.relationships.first(where: {$0.id == account.id }) else { return (account: account, relationship: nil)}
return (account: account, relationship: relationship)
}
var snapshot = NSDiffableDataSourceSnapshot<RecommendAccountSection, RecommendAccountItem>()
snapshot.appendSections([.main])
let items: [RecommendAccountItem] = records.map { RecommendAccountItem.account($0) }
let items: [RecommendAccountItem] = accountsWithRelationship.map { RecommendAccountItem.account($0.account, relationship: $0.relationship) }
snapshot.appendItems(items, toSection: .main)
tableViewDiffableDataSource.applySnapshotUsingReloadData(snapshot)
@ -114,19 +110,18 @@ final class SuggestionAccountViewModel: NSObject {
.store(in: &disposeBag)
}
func followAllSuggestedAccounts(_ dependency: NeedsDependency & AuthContextProvider, completion: (() -> Void)? = nil) {
func followAllSuggestedAccounts(_ dependency: NeedsDependency & AuthContextProvider, presentedOn: UIViewController?, completion: (() -> Void)? = nil) {
let userRecords = userFetchedResultsController.records.compactMap {
$0.object(in: dependency.context.managedObjectContext)?.asRecord
}
let tmpAccounts = accounts.compactMap { $0.account }
Task {
await dependency.coordinator.showLoading(on: presentedOn)
await withTaskGroup(of: Void.self, body: { taskGroup in
for user in userRecords {
for account in tmpAccounts {
taskGroup.addTask {
try? await DataSourceFacade.responseToUserViewButtonAction(
dependency: dependency,
user: user,
user: account,
buttonState: .follow
)
}

View File

@ -85,28 +85,17 @@ final class SuggestionAccountTableViewCell: UITableViewCell {
disposeBag.removeAll()
}
func configure(viewModel: SuggestionAccountTableViewCell.ViewModel) {
userView.configure(user: viewModel.user, delegate: delegate)
if viewModel.blockedUsers.contains(viewModel.user.id) {
self.userView.setButtonState(.blocked)
} else if viewModel.followedUsers.contains(viewModel.user.id) {
self.userView.setButtonState(.unfollow)
} else if viewModel.followRequestedUsers.contains(viewModel.user.id) {
self.userView.setButtonState(.pending)
} else if viewModel.user.locked {
self.userView.setButtonState(.request)
} else {
self.userView.setButtonState(.follow)
}
func configure(account: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?) {
userView.configure(with: account, relationship: relationship, delegate: delegate)
userView.updateButtonState(with: relationship, isMe: false)
let metaContent: MetaContent = {
do {
let mastodonContent = MastodonContent(content: viewModel.user.note ?? "", emojis: viewModel.user.emojis.asDictionary)
let mastodonContent = MastodonContent(content: account.note, emojis: account.emojis?.asDictionary ?? [:])
return try MastodonMetaContent.convert(document: mastodonContent)
} catch {
assertionFailure()
return PlaintextMetaContent(string: viewModel.user.note ?? "")
return PlaintextMetaContent(string: account.note)
}
}()

View File

@ -65,7 +65,7 @@ extension ThreadViewModel.LoadThreadState {
authenticationBox: viewModel.authContext.mastodonAuthenticationBox
)
await enter(state: NoMore.self)
enter(state: NoMore.self)
// assert(!Thread.isMainThread)
// await Task.sleep(1_000_000_000) // 1s delay to prevent UI render issue
@ -88,7 +88,7 @@ extension ThreadViewModel.LoadThreadState {
}
)
} catch {
await enter(state: Fail.self)
enter(state: Fail.self)
}
} // end Task
}

View File

@ -107,6 +107,7 @@ let package = Package(
name: "MastodonSDK",
dependencies: [
.product(name: "NIOHTTP1", package: "swift-nio"),
"MastodonCommon"
]
),
.target(

View File

@ -94,18 +94,6 @@ public final class CoreDataStack {
container.viewContext.automaticallyMergesChangesFromParent = true
callback()
#if DEBUG
do {
let storeURL = URL.storeURL(for: AppName.groupID, databaseName: "shared")
let data = try Data(contentsOf: storeURL)
let formatter = ByteCountFormatter()
formatter.allowedUnits = [.useMB]
formatter.countStyle = .file
let size = formatter.string(fromByteCount: Int64(data.count))
} catch {
}
#endif
})
}

View File

@ -9,33 +9,6 @@ import Foundation
import MastodonSDK
import MastodonMeta
extension Mastodon.Entity.Account: Hashable {
public func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
public static func == (lhs: Mastodon.Entity.Account, rhs: Mastodon.Entity.Account) -> Bool {
return lhs.id == rhs.id
}
}
extension Mastodon.Entity.Account {
public func avatarImageURL() -> URL? {
let string = UserDefaults.shared.preferredStaticAvatar ? avatarStatic ?? avatar : avatar
return URL(string: string)
}
public func avatarImageURLWithFallback(domain: String) -> URL {
return avatarImageURL() ?? URL(string: "https://\(domain)/avatars/original/missing.png")!
}
}
extension Mastodon.Entity.Account {
public var displayNameWithFallback: String {
return !displayName.isEmpty ? displayName : username
}
}
extension Mastodon.Entity.Account {
public var emojiMeta: MastodonContent.Emojis {
let isAnimated = !UserDefaults.shared.preferredStaticEmoji

View File

@ -1,19 +0,0 @@
//
// Mastodon+Entity+Field.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-5-25.
//
import Foundation
import MastodonSDK
extension Mastodon.Entity.Field: Equatable {
public static func == (lhs: Mastodon.Entity.Field, rhs: Mastodon.Entity.Field) -> Bool {
return lhs.name == rhs.name &&
lhs.value == rhs.value &&
lhs.verifiedAt == rhs.verifiedAt
}
}

View File

@ -50,7 +50,7 @@ extension APIService {
domain: domain,
authorization: authorization).singleOutput()
let responseHistory = try await Mastodon.API.Statuses.editHistory(
_ = try await Mastodon.API.Statuses.editHistory(
forStatusID: statusID,
session: session,
domain: domain,

View File

@ -238,7 +238,7 @@ extension NotificationService {
}
private func authenticationBox(for pushNotification: MastodonPushNotification) async throws -> MastodonAuthenticationBox? {
guard let authenticationService = self.authenticationService else { return nil }
guard self.authenticationService != nil else { return nil }
let results = AuthenticationServiceProvider.shared.authentications.filter { $0.userAccessToken == pushNotification.accessToken }
guard let authentication = results.first else { return nil }

View File

@ -6,6 +6,7 @@
//
import Foundation
import MastodonCommon
extension Mastodon.Entity {
@ -18,7 +19,7 @@ extension Mastodon.Entity {
/// # Reference
/// [Document](https://docs.joinmastodon.org/entities/account/)
public final class Account: Codable, Sendable {
public typealias ID = String
// Base
@ -84,6 +85,24 @@ extension Mastodon.Entity {
}
}
//MARK: - Hashable
extension Mastodon.Entity.Account: Hashable {
public func hash(into hasher: inout Hasher) {
// The URL seems to be the only thing that doesn't change across instances.
hasher.combine(url)
}
}
//MARK: - Equatable
extension Mastodon.Entity.Account: Equatable {
public static func == (lhs: Mastodon.Entity.Account, rhs: Mastodon.Entity.Account) -> Bool {
// The URL seems to be the only thing that doesn't change across instances.
return lhs.url == rhs.url
}
}
//MARK: - Convenience
extension Mastodon.Entity.Account {
public func acctWithDomainIfMissing(_ localDomain: String) -> String {
guard acct.contains("@") else {
@ -102,4 +121,18 @@ extension Mastodon.Entity.Account {
return components.host
}
public func avatarImageURL() -> URL? {
let string = UserDefaults.shared.preferredStaticAvatar ? avatarStatic ?? avatar : avatar
return URL(string: string)
}
public func avatarImageURLWithFallback(domain: String) -> URL {
return avatarImageURL() ?? URL(string: "https://\(domain)/avatars/original/missing.png")!
}
public var displayNameWithFallback: String {
return !displayName.isEmpty ? displayName : username
}
}

View File

@ -16,7 +16,7 @@ extension Mastodon.Entity {
/// 2021/1/28
/// # Reference
/// [Document](https://docs.joinmastodon.org/entities/emoji/)
public struct Emoji: Codable, Sendable {
public struct Emoji: Codable, Sendable, Hashable {
public let shortcode: String
public let url: String
public let staticURL: String

View File

@ -16,7 +16,7 @@ extension Mastodon.Entity {
/// 2021/1/28
/// # Reference
/// [Document](https://docs.joinmastodon.org/entities/field/)
public struct Field: Codable, Sendable {
public struct Field: Codable, Sendable, Hashable {
public let name: String
public let value: String

View File

@ -16,7 +16,7 @@ extension Mastodon.Entity {
/// 2021/2/3
/// # Reference
/// [Document](https://docs.joinmastodon.org/entities/source/)
public struct Source: Codable, Sendable {
public struct Source: Codable, Sendable, Hashable {
// Base
public let note: String
@ -40,7 +40,7 @@ extension Mastodon.Entity {
}
extension Mastodon.Entity.Source {
public enum Privacy: RawRepresentable, Codable, Sendable {
public enum Privacy: RawRepresentable, Codable, Sendable, Hashable {
case `public`
case unlisted
case `private`

View File

@ -9,7 +9,7 @@ import Foundation
extension Mastodon.Entity.V2 {
public struct SuggestionAccount: Codable, Sendable {
public struct SuggestionAccount: Codable, Sendable, Hashable {
public let source: String
public let account: Mastodon.Entity.Account

View File

@ -31,7 +31,7 @@ extension ComposeContentViewModel: UITextViewDelegate {
switch textView {
case contentMetaText?.textView:
// update model
guard let metaText = self.contentMetaText else {
guard self.contentMetaText != nil else {
assertionFailure()
return
}

View File

@ -211,7 +211,7 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
let recentLanguages = context.settingService.currentSetting.value?.recentLanguages ?? []
self.recentLanguages = recentLanguages
self.language = recentLanguages.first ?? Locale.current.languageCode ?? "en"
self.language = recentLanguages.first ?? Locale.current.language.languageCode?.identifier ?? "en"
super.init()
// end init
@ -490,7 +490,7 @@ extension ComposeContentViewModel {
.flatMap { settings in
if let settings {
return settings.publisher(for: \.recentLanguages, options: .initial).eraseToAnyPublisher()
} else if let code = Locale.current.languageCode {
} else if let code = Locale.current.language.languageCode?.identifier {
return Just([code]).eraseToAnyPublisher()
}
return Just([]).eraseToAnyPublisher()

View File

@ -32,7 +32,7 @@ extension ComposeContentToolbarView {
@Published var isAttachmentButtonEnabled = false
@Published var isPollButtonEnabled = false
@Published var language = Locale.current.languageCode ?? "en"
@Published var language = Locale.current.language.languageCode?.identifier ?? "en"
@Published var recentLanguages: [String] = []
@Published public var maxTextInputLimit = 500

View File

@ -14,7 +14,7 @@ struct LanguagePicker: View {
let locales = Locale.availableIdentifiers.map(Locale.init(identifier:))
var languages: [String: Language] = [:]
for locale in locales {
if let code = locale.languageCode,
if let code = locale.language.languageCode?.identifier,
let endonym = locale.localizedString(forLanguageCode: code),
let exonym = Locale.current.localizedString(forLanguageCode: code) {
// dont overwrite the base language