Remove usage of Status (IOS-176)

This commit is contained in:
Marcus Kida 2023-11-22 12:32:04 +01:00
parent ace671af15
commit b010b6112e
No known key found for this signature in database
GPG Key ID: 19FF64E08013CA40
97 changed files with 1048 additions and 1455 deletions

View File

@ -7,10 +7,10 @@
import CoreData
import Foundation
import CoreDataStack
import MastodonSDK
enum NotificationItem: Hashable {
case feed(record: ManagedObjectRecord<Feed>)
case feedLoader(record: ManagedObjectRecord<Feed>)
case feed(record: MastodonFeed)
case feedLoader(record: MastodonFeed)
case bottomLoader
}

View File

@ -41,18 +41,15 @@ extension NotificationSection {
return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in
switch item {
case .feed(let record):
case .feed(let feed):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationTableViewCell.self), for: indexPath) as! NotificationTableViewCell
context.managedObjectContext.performAndWait {
guard let feed = record.object(in: context.managedObjectContext) else { return }
configure(
context: context,
tableView: tableView,
cell: cell,
viewModel: NotificationTableViewCell.ViewModel(value: .feed(feed)),
configuration: configuration
)
}
configure(
context: context,
tableView: tableView,
cell: cell,
viewModel: NotificationTableViewCell.ViewModel(value: .feed(feed)),
configuration: configuration
)
return cell
case .feedLoader:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell

View File

@ -7,10 +7,11 @@
import Foundation
import CoreDataStack
import MastodonSDK
enum ReportItem: Hashable {
case header(context: HeaderContext)
case status(record: ManagedObjectRecord<Status>)
case status(record: MastodonStatus)
case comment(context: CommentContext)
case result(record: ManagedObjectRecord<MastodonUser>)
case bottomLoader

View File

@ -45,18 +45,15 @@ extension ReportSection {
cell.primaryLabel.text = headerContext.primaryLabelText
cell.secondaryLabel.text = headerContext.secondaryLabelText
return cell
case .status(let record):
case .status(let status):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ReportStatusTableViewCell.self), for: indexPath) as! ReportStatusTableViewCell
context.managedObjectContext.performAndWait {
guard let status = record.object(in: context.managedObjectContext) else { return }
configure(
context: context,
tableView: tableView,
cell: cell,
viewModel: .init(value: status),
configuration: configuration
)
}
configure(
context: context,
tableView: tableView,
cell: cell,
viewModel: .init(value: status),
configuration: configuration
)
return cell
case .comment(let commentContext):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ReportCommentTableViewCell.self), for: indexPath) as! ReportCommentTableViewCell

View File

@ -8,11 +8,12 @@
import Foundation
import CoreDataStack
import MastodonUI
import MastodonSDK
enum StatusItem: Hashable {
case feed(record: ManagedObjectRecord<Feed>)
case feedLoader(record: ManagedObjectRecord<Feed>)
case status(record: ManagedObjectRecord<Status>)
case feed(record: MastodonFeed)
case feedLoader(record: MastodonFeed)
case status(record: MastodonStatus)
case thread(Thread)
case topLoader
case bottomLoader
@ -24,7 +25,7 @@ extension StatusItem {
case reply(context: Context)
case leaf(context: Context)
public var record: ManagedObjectRecord<Status> {
public var record: MastodonStatus {
switch self {
case .root(let threadContext),
.reply(let threadContext),
@ -37,12 +38,12 @@ extension StatusItem {
extension StatusItem.Thread {
class Context: Hashable {
let status: ManagedObjectRecord<Status>
let status: MastodonStatus
var displayUpperConversationLink: Bool
var displayBottomConversationLink: Bool
init(
status: ManagedObjectRecord<Status>,
status: MastodonStatus,
displayUpperConversationLink: Bool = false,
displayBottomConversationLink: Bool = false
) {

View File

@ -44,42 +44,33 @@ extension StatusSection {
return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in
switch item {
case .feed(let record):
case .feed(let feed):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell
context.managedObjectContext.performAndWait {
guard let feed = record.object(in: context.managedObjectContext) else { return }
configure(
context: context,
tableView: tableView,
cell: cell,
viewModel: StatusTableViewCell.ViewModel(value: .feed(feed)),
configuration: configuration
)
}
configure(
context: context,
tableView: tableView,
cell: cell,
viewModel: StatusTableViewCell.ViewModel(value: .feed(feed)),
configuration: configuration
)
return cell
case .feedLoader(let record):
case .feedLoader(let feed):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell
context.managedObjectContext.performAndWait {
guard let feed = record.object(in: context.managedObjectContext) else { return }
configure(
cell: cell,
feed: feed,
configuration: configuration
)
}
configure(
cell: cell,
feed: feed,
configuration: configuration
)
return cell
case .status(let record):
case .status(let status):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell
context.managedObjectContext.performAndWait {
guard let status = record.object(in: context.managedObjectContext) else { return }
configure(
context: context,
tableView: tableView,
cell: cell,
viewModel: StatusTableViewCell.ViewModel(value: .status(status)),
configuration: configuration
)
}
configure(
context: context,
tableView: tableView,
cell: cell,
viewModel: StatusTableViewCell.ViewModel(value: .status(status)),
configuration: configuration
)
return cell
case .thread(let thread):
let cell = dequeueConfiguredReusableCell(
@ -124,30 +115,24 @@ extension StatusSection {
switch configuration.thread {
case .root(let threadContext):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusThreadRootTableViewCell.self), for: indexPath) as! StatusThreadRootTableViewCell
managedObjectContext.performAndWait {
guard let status = threadContext.status.object(in: managedObjectContext) else { return }
StatusSection.configure(
context: context,
tableView: tableView,
cell: cell,
viewModel: StatusThreadRootTableViewCell.ViewModel(value: .status(status)),
configuration: configuration.configuration
)
}
StatusSection.configure(
context: context,
tableView: tableView,
cell: cell,
viewModel: StatusThreadRootTableViewCell.ViewModel(value: .status(threadContext.status)),
configuration: configuration.configuration
)
return cell
case .reply(let threadContext),
.leaf(let threadContext):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell
managedObjectContext.performAndWait {
guard let status = threadContext.status.object(in: managedObjectContext) else { return }
StatusSection.configure(
context: context,
tableView: tableView,
cell: cell,
viewModel: StatusTableViewCell.ViewModel(value: .status(status)),
configuration: configuration.configuration
)
}
StatusSection.configure(
context: context,
tableView: tableView,
cell: cell,
viewModel: StatusTableViewCell.ViewModel(value: .status(threadContext.status)),
configuration: configuration.configuration
)
return cell
}
}
@ -319,7 +304,7 @@ extension StatusSection {
static func configure(
cell: TimelineMiddleLoaderTableViewCell,
feed: Feed,
feed: MastodonFeed,
configuration: Configuration
) {
cell.configure(

View File

@ -9,11 +9,12 @@ import UIKit
import CoreData
import CoreDataStack
import MastodonCore
import MastodonSDK
extension DataSourceFacade {
public static func responseToStatusBookmarkAction(
provider: UIViewController & NeedsDependency & AuthContextProvider,
status: ManagedObjectRecord<Status>
status: MastodonStatus
) async throws {
let selectionFeedbackGenerator = await UISelectionFeedbackGenerator()
await selectionFeedbackGenerator.selectionChanged()

View File

@ -7,19 +7,19 @@
import UIKit
import CoreData
import CoreDataStack
import MastodonSDK
import MastodonCore
extension DataSourceFacade {
public static func responseToStatusFavoriteAction(
provider: DataSourceProvider & AuthContextProvider,
status: ManagedObjectRecord<Status>
status: MastodonStatus
) async throws {
let selectionFeedbackGenerator = await UISelectionFeedbackGenerator()
await selectionFeedbackGenerator.selectionChanged()
_ = try await provider.context.apiService.favorite(
record: status,
status: status,
authenticationBox: provider.authContext.mastodonAuthenticationBox
)
}

View File

@ -46,15 +46,14 @@ extension DataSourceFacade {
extension DataSourceFacade {
static func responseToUserFollowRequestAction(
dependency: NeedsDependency & AuthContextProvider,
notification: ManagedObjectRecord<Notification>,
notification: MastodonNotification,
query: Mastodon.API.Account.FollowRequestQuery
) async throws {
let selectionFeedbackGenerator = await UISelectionFeedbackGenerator()
await selectionFeedbackGenerator.selectionChanged()
let managedObjectContext = dependency.context.managedObjectContext
let _userID: MastodonUser.ID? = try await managedObjectContext.perform {
guard let notification = notification.object(in: managedObjectContext) else { return nil }
return notification.account.id
}
@ -63,23 +62,17 @@ extension DataSourceFacade {
throw APIService.APIError.implicit(.badRequest)
}
let state: MastodonFollowRequestState = try await managedObjectContext.perform {
guard let notification = notification.object(in: managedObjectContext) else { return .init(state: .none) }
return notification.followRequestState
}
let state: MastodonFollowRequestState = notification.followRequestState
guard state.state == .none else {
return
}
try? await managedObjectContext.performChanges {
guard let notification = notification.object(in: managedObjectContext) else { return }
switch query {
case .accept:
notification.transientFollowRequestState = .init(state: .isAccepting)
case .reject:
notification.transientFollowRequestState = .init(state: .isRejecting)
}
switch query {
case .accept:
notification.transientFollowRequestState = .init(state: .isAccepting)
case .reject:
notification.transientFollowRequestState = .init(state: .isRejecting)
}
do {
@ -90,22 +83,12 @@ extension DataSourceFacade {
)
} catch {
// reset state when failure
try? await managedObjectContext.performChanges {
guard let notification = notification.object(in: managedObjectContext) else { return }
notification.transientFollowRequestState = .init(state: .none)
}
notification.transientFollowRequestState = .init(state: .none)
if let error = error as? Mastodon.API.Error {
switch error.httpResponseStatus {
case .notFound:
let backgroundManagedObjectContext = dependency.context.backgroundManagedObjectContext
try await backgroundManagedObjectContext.performChanges {
guard let notification = notification.object(in: backgroundManagedObjectContext) else { return }
for feed in notification.feeds {
backgroundManagedObjectContext.delete(feed)
}
backgroundManagedObjectContext.delete(notification)
}
break
default:
let alertController = await UIAlertController(for: error, title: nil, preferredStyle: .alert)
let okAction = await UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default)
@ -121,32 +104,14 @@ extension DataSourceFacade {
return
}
try? await managedObjectContext.performChanges {
guard let notification = notification.object(in: managedObjectContext) else { return }
switch query {
case .accept:
notification.transientFollowRequestState = .init(state: .isAccept)
case .reject:
// do nothing due to will delete notification
break
}
switch query {
case .accept:
notification.transientFollowRequestState = .init(state: .isAccept)
notification.followRequestState = .init(state: .isAccept)
case .reject:
break
}
let backgroundManagedObjectContext = dependency.context.backgroundManagedObjectContext
try? await backgroundManagedObjectContext.performChanges {
guard let notification = notification.object(in: backgroundManagedObjectContext) else { return }
switch query {
case .accept:
notification.followRequestState = .init(state: .isAccept)
case .reject:
// delete notification
for feed in notification.feeds {
backgroundManagedObjectContext.delete(feed)
}
backgroundManagedObjectContext.delete(notification)
}
}
} // end func
}
}
extension DataSourceFacade {

View File

@ -9,6 +9,7 @@ import UIKit
import CoreDataStack
import MastodonUI
import MastodonLocalization
import MastodonSDK
extension DataSourceFacade {
@ -61,15 +62,12 @@ extension DataSourceFacade {
@MainActor
static func coordinateToMediaPreviewScene(
dependency: NeedsDependency & MediaPreviewableViewController,
status: ManagedObjectRecord<Status>,
status: MastodonStatus,
previewContext: AttachmentPreviewContext
) async throws {
let managedObjectContext = dependency.context.managedObjectContext
let attachments: [MastodonAttachment] = try await managedObjectContext.perform {
guard let _status = status.object(in: managedObjectContext) else { return [] }
let status = _status.reblog ?? _status
return status.attachments
}
let status = status.reblog ?? status
let attachments = status.entity.mastodonAttachments
let thumbnails = await previewContext.thumbnails()

View File

@ -9,16 +9,17 @@ import Foundation
import CoreDataStack
import MetaTextKit
import MastodonCore
import MastodonSDK
extension DataSourceFacade {
static func responseToMetaTextAction(
provider: DataSourceProvider & AuthContextProvider,
target: StatusTarget,
status: ManagedObjectRecord<Status>,
status: MastodonStatus,
meta: Meta
) async throws {
let _redirectRecord = await DataSourceFacade.status(
let _redirectRecord = DataSourceFacade.status(
managedObjectContext: provider.context.managedObjectContext,
status: status,
target: target
@ -35,7 +36,7 @@ extension DataSourceFacade {
static func responseToMetaTextAction(
provider: DataSourceProvider & AuthContextProvider,
status: ManagedObjectRecord<Status>,
status: MastodonStatus,
meta: Meta
) async {
switch meta {
@ -55,7 +56,7 @@ extension DataSourceFacade {
url: url
)
case .hashtag(_, let hashtag, _):
let hashtagTimelineViewModel = HashtagTimelineViewModel(context: provider.context, authContext: provider.authContext, hashtag: hashtag)
let hashtagTimelineViewModel = await HashtagTimelineViewModel(context: provider.context, authContext: provider.authContext, hashtag: hashtag)
_ = await provider.coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), from: provider, transition: .show)
case .mention(_, let mention, let userInfo):
await coordinateToProfileScene(

View File

@ -9,41 +9,14 @@ import Foundation
import CoreData
import CoreDataStack
import MastodonUI
import MastodonSDK
extension DataSourceFacade {
static func status(
managedObjectContext: NSManagedObjectContext,
status: ManagedObjectRecord<Status>,
status: MastodonStatus,
target: StatusTarget
) async -> ManagedObjectRecord<Status>? {
return try? await managedObjectContext.perform {
guard let object = status.object(in: managedObjectContext) else { return nil }
return DataSourceFacade.status(status: object, target: target)
.flatMap { ManagedObjectRecord<Status>(objectID: $0.objectID) }
}
}
}
extension DataSourceFacade {
static func author(
managedObjectContext: NSManagedObjectContext,
status: ManagedObjectRecord<Status>,
target: StatusTarget
) async -> ManagedObjectRecord<MastodonUser>? {
return try? await managedObjectContext.perform {
guard let object = status.object(in: managedObjectContext) else { return nil }
return DataSourceFacade.status(status: object, target: target)
.flatMap { $0.author }
.flatMap { ManagedObjectRecord<MastodonUser>(objectID: $0.objectID) }
}
}
}
extension DataSourceFacade {
static func status(
status: Status,
target: StatusTarget
) -> Status? {
) -> MastodonStatus? {
switch target {
case .status:
return status.reblog ?? status
@ -52,3 +25,19 @@ extension DataSourceFacade {
}
}
}
extension DataSourceFacade {
static func author(
managedObjectContext: NSManagedObjectContext,
status: MastodonStatus,
target: StatusTarget
) async -> ManagedObjectRecord<MastodonUser>? {
return try? await managedObjectContext.perform {
return DataSourceFacade.status(managedObjectContext: managedObjectContext, status: status, target: target)
.flatMap { $0.entity.account }
.flatMap {
MastodonUser.findOrFetch(in: managedObjectContext, matching: MastodonUser.predicate(domain: $0.domain ?? "", id: $0.id))?.asRecord
}
}
}
}

View File

@ -15,7 +15,7 @@ extension DataSourceFacade {
static func coordinateToProfileScene(
provider: DataSourceProvider & AuthContextProvider,
target: StatusTarget,
status: ManagedObjectRecord<Status>
status: MastodonStatus
) async {
let _redirectRecord = await DataSourceFacade.author(
managedObjectContext: provider.context.managedObjectContext,
@ -83,9 +83,10 @@ extension DataSourceFacade {
extension DataSourceFacade {
@MainActor
static func coordinateToProfileScene(
provider: DataSourceProvider & AuthContextProvider,
status: ManagedObjectRecord<Status>,
status: MastodonStatus,
mention: String, // username,
userInfo: [AnyHashable: Any]?
) async {
@ -100,11 +101,11 @@ extension DataSourceFacade {
let managedObjectContext = provider.context.managedObjectContext
let mentions = try? await managedObjectContext.perform {
return status.object(in: managedObjectContext)?.mentions ?? []
return status.entity.mentions ?? []
}
guard let mention = mentions?.first(where: { $0.url == href }) else {
_ = await provider.coordinator.present(
_ = provider.coordinator.present(
scene: .safari(url: url),
from: provider,
transition: .safariPresent(animated: true, completion: nil)
@ -131,7 +132,7 @@ extension DataSourceFacade {
}
}()
_ = await provider.coordinator.present(
_ = provider.coordinator.present(
scene: .profile(viewModel: profileViewModel),
from: provider,
transition: .show

View File

@ -6,20 +6,20 @@
//
import UIKit
import CoreDataStack
import MastodonCore
import MastodonUI
import MastodonSDK
extension DataSourceFacade {
static func responseToStatusReblogAction(
provider: DataSourceProvider & AuthContextProvider,
status: ManagedObjectRecord<Status>
status: MastodonStatus
) async throws {
let selectionFeedbackGenerator = await UISelectionFeedbackGenerator()
await selectionFeedbackGenerator.selectionChanged()
_ = try await provider.context.apiService.reblog(
record: status,
status: status,
authenticationBox: provider.authContext.mastodonAuthenticationBox
)
} // end func

View File

@ -14,13 +14,14 @@ import MastodonUI
import MastodonLocalization
import LinkPresentation
import UniformTypeIdentifiers
import MastodonSDK
// Delete
extension DataSourceFacade {
static func responseToDeleteStatus(
dependency: NeedsDependency & AuthContextProvider,
status: ManagedObjectRecord<Status>
status: MastodonStatus
) async throws {
_ = try await dependency.context.apiService.deleteStatus(
status: status,
@ -36,7 +37,7 @@ extension DataSourceFacade {
@MainActor
public static func responseToStatusShareAction(
provider: DataSourceProvider,
status: ManagedObjectRecord<Status>,
status: MastodonStatus,
button: UIButton
) async throws {
let activityViewController = try await createActivityViewController(
@ -56,22 +57,22 @@ extension DataSourceFacade {
private static func createActivityViewController(
dependency: NeedsDependency,
status: ManagedObjectRecord<Status>
status: MastodonStatus
) async throws -> UIActivityViewController {
var activityItems: [Any] = try await dependency.context.managedObjectContext.perform {
guard let status = status.object(in: dependency.context.managedObjectContext),
let url = URL(string: status.url ?? status.uri)
var activityItems: [Any] = {
guard let url = URL(string: status.entity.url ?? status.entity.uri)
else { return [] }
return [
URLActivityItemWithMetadata(url: url) { metadata in
metadata.title = "\(status.author.displayName) (@\(status.author.acctWithDomain))"
metadata.title = "\(status.entity.account.displayName) (@\(status.entity.account.acctWithDomain))"
metadata.iconProvider = ImageProvider(
url: status.author.avatarImageURLWithFallback(domain: status.author.domain),
url: status.entity.account.avatarImageURLWithFallback(domain: status.entity.account.domain ?? ""),
filter: ScaledToSizeFilter(size: CGSize.authorAvatarButtonSize)
).itemProvider
}
] as [Any]
}
}()
var applicationActivities: [UIActivity] = [
SafariActivity(sceneCoordinator: dependency.coordinator), // open URL
]
@ -94,20 +95,12 @@ extension DataSourceFacade {
@MainActor
static func responseToActionToolbar(
provider: DataSourceProvider & AuthContextProvider,
status: ManagedObjectRecord<Status>,
status: MastodonStatus,
action: ActionToolbarContainer.Action,
sender: UIButton
) async throws {
let managedObjectContext = provider.context.managedObjectContext
let _status: ManagedObjectRecord<Status>? = try? await managedObjectContext.perform {
guard let object = status.object(in: managedObjectContext) else { return nil }
let objectID = (object.reblog ?? object).objectID
return .init(objectID: objectID)
}
guard let status = _status else {
assertionFailure()
return
}
let _status = status.reblog ?? status
switch action {
case .reply:
@ -266,7 +259,7 @@ extension DataSourceFacade {
context: dependency.context,
authContext: dependency.authContext,
user: user,
status: menuContext.statusViewModel?.originalStatus?.asRecord
status: menuContext.statusViewModel?.originalStatus
)
_ = dependency.coordinator.present(
@ -297,7 +290,7 @@ extension DataSourceFacade {
)
case .bookmarkStatus:
Task {
guard let status = menuContext.statusViewModel?.originalStatus?.asRecord else {
guard let status = menuContext.statusViewModel?.originalStatus else {
assertionFailure()
return
}
@ -309,11 +302,7 @@ extension DataSourceFacade {
case .shareStatus:
Task {
let managedObjectContext = dependency.context.managedObjectContext
guard let status: ManagedObjectRecord<Status> = try? await managedObjectContext.perform(block: {
guard let object = menuContext.statusViewModel?.originalStatus?.asRecord.object(in: managedObjectContext) else { return nil }
let objectID = (object.reblog ?? object).objectID
return .init(objectID: objectID)
}) else {
guard let status: MastodonStatus = menuContext.statusViewModel?.originalStatus?.reblog ?? menuContext.statusViewModel?.originalStatus else {
assertionFailure()
return
}
@ -344,7 +333,7 @@ extension DataSourceFacade {
style: .destructive
) { [weak dependency] _ in
guard let dependency = dependency else { return }
guard let status = menuContext.statusViewModel?.originalStatus?.asRecord else { return }
guard let status = menuContext.statusViewModel?.originalStatus else { return }
Task {
try await DataSourceFacade.responseToDeleteStatus(
dependency: dependency,
@ -358,7 +347,7 @@ extension DataSourceFacade {
dependency.present(alertController, animated: true)
case .translateStatus:
guard let status = menuContext.statusViewModel?.originalStatus?.asRecord else { return }
guard let status = menuContext.statusViewModel?.originalStatus else { return }
do {
let translation = try await DataSourceFacade.translateStatus(provider: dependency,status: status)
@ -371,7 +360,7 @@ extension DataSourceFacade {
}
case .editStatus:
guard let status = menuContext.statusViewModel?.originalStatus?.asRecord.object(in: dependency.context.managedObjectContext) else { return }
guard let status = menuContext.statusViewModel?.originalStatus else { return }
let statusSource = try await dependency.context.apiService.getStatusSource(
forStatusID: status.id,
@ -402,12 +391,11 @@ extension DataSourceFacade {
static func responseToToggleSensitiveAction(
dependency: NeedsDependency,
status: ManagedObjectRecord<Status>
status: MastodonStatus
) async throws {
try await dependency.context.managedObjectContext.perform {
guard let _status = status.object(in: dependency.context.managedObjectContext) else { return }
let status = _status.reblog ?? _status
status.update(isSensitiveToggled: !status.isSensitiveToggled)
let _status = status.reblog ?? status
_status.isSensitiveToggled = !_status.isSensitiveToggled
}
}

View File

@ -9,15 +9,16 @@ import UIKit
import CoreData
import CoreDataStack
import MastodonCore
import MastodonSDK
extension DataSourceFacade {
static func coordinateToStatusThreadScene(
provider: ViewControllerWithDependencies & AuthContextProvider,
target: StatusTarget,
status: ManagedObjectRecord<Status>
status: MastodonStatus
) async {
let _root: StatusItem.Thread? = await {
let _redirectRecord = await DataSourceFacade.status(
let _root: StatusItem.Thread? = {
let _redirectRecord = DataSourceFacade.status(
managedObjectContext: provider.context.managedObjectContext,
status: status,
target: target

View File

@ -20,27 +20,11 @@ extension DataSourceFacade {
public static func translateStatus(
provider: Provider,
status: ManagedObjectRecord<Status>
status: MastodonStatus
) async throws -> Mastodon.Entity.Translation? {
let selectionFeedbackGenerator = await UISelectionFeedbackGenerator()
await selectionFeedbackGenerator.selectionChanged()
guard
let status = status.object(in: provider.context.managedObjectContext)
else {
return nil
}
if let reblog = status.reblog {
return try await translateStatus(provider: provider, status: reblog)
} else {
return try await translateStatus(provider: provider, status: status)
}
}
}
private extension DataSourceFacade {
static func translateStatus(provider: Provider, status: Status) async throws -> Mastodon.Entity.Translation? {
do {
let value = try await provider.context
.apiService

View File

@ -9,11 +9,12 @@ import Foundation
import CoreDataStack
import MetaTextKit
import MastodonCore
import MastodonSDK
extension DataSourceFacade {
static func responseToURLAction(
provider: DataSourceProvider & AuthContextProvider,
status: ManagedObjectRecord<Status>,
status: MastodonStatus,
url: URL
) async {
let domain = provider.authContext.mastodonAuthenticationBox.domain

View File

@ -10,6 +10,7 @@ import MetaTextKit
import CoreDataStack
import MastodonCore
import MastodonUI
import MastodonSDK
// MARK: - Notification AuthorMenuAction
extension NotificationTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider {
@ -31,7 +32,6 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut
}
let _author: ManagedObjectRecord<MastodonUser>? = try await self.context.managedObjectContext.perform {
guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil }
return .init(objectID: notification.account.objectID)
}
guard let author = _author else {
@ -71,7 +71,6 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut
return
}
let _author: ManagedObjectRecord<MastodonUser>? = try await self.context.managedObjectContext.perform {
guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil }
return .init(objectID: notification.account.objectID)
}
guard let author = _author else {
@ -155,7 +154,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut
}
private struct NotificationMediaTransitionContext {
let status: ManagedObjectRecord<Status>
let status: MastodonStatus
let needsToggleMediaSensitive: Bool
}
@ -180,16 +179,13 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Med
return
}
let managedObjectContext = self.context.managedObjectContext
let _mediaTransitionContext: NotificationMediaTransitionContext? = try await managedObjectContext.perform {
guard let notification = record.object(in: managedObjectContext) else { return nil }
guard let _status = notification.status else { return nil }
let status = _status.reblog ?? _status
let _mediaTransitionContext: NotificationMediaTransitionContext? = {
guard let status = record.status?.reblog ?? record.status else { return nil }
return NotificationMediaTransitionContext(
status: .init(objectID: status.objectID),
needsToggleMediaSensitive: status.isSensitiveToggled ? !status.sensitive : status.sensitive
status: status,
needsToggleMediaSensitive: status.isSensitiveToggled ? !(status.entity.sensitive == true) : (status.entity.sensitive == true)
)
}
}()
guard let mediaTransitionContext = _mediaTransitionContext else { return }
@ -233,15 +229,13 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Med
}
let managedObjectContext = self.context.managedObjectContext
let _mediaTransitionContext: NotificationMediaTransitionContext? = try await managedObjectContext.perform {
guard let notification = record.object(in: managedObjectContext) else { return nil }
guard let _status = notification.status else { return nil }
let status = _status.reblog ?? _status
let _mediaTransitionContext: NotificationMediaTransitionContext? = {
guard let status = record.status?.reblog ?? record.status else { return nil }
return NotificationMediaTransitionContext(
status: .init(objectID: status.objectID),
needsToggleMediaSensitive: status.isMediaSensitive ? !status.isSensitiveToggled : false
status: status,
needsToggleMediaSensitive: status.entity.sensitive == true ? !status.isSensitiveToggled : false
)
}
}()
guard let mediaTransitionContext = _mediaTransitionContext else { return }
@ -286,12 +280,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut
assertionFailure("only works for status data provider")
return
}
let _status: ManagedObjectRecord<Status>? = try await self.context.managedObjectContext.perform {
guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil }
guard let status = notification.status else { return nil }
return .init(objectID: status.objectID)
}
guard let status = _status else {
guard let status = notification.status?.reblog ?? notification.status else {
assertionFailure()
return
}
@ -323,18 +312,10 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut
assertionFailure("only works for status data provider")
return
}
let _author: ManagedObjectRecord<MastodonUser>? = try await self.context.managedObjectContext.perform {
guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil }
guard let status = notification.status else { return nil }
return .init(objectID: status.author.objectID)
}
guard let author = _author else {
assertionFailure()
return
}
await DataSourceFacade.coordinateToProfileScene(
provider: self,
user: author
user: notification.account.asRecord
)
} // end Task
}
@ -367,12 +348,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut
assertionFailure("only works for notification item")
return
}
let _status: ManagedObjectRecord<Status>? = try await self.context.managedObjectContext.perform {
guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil }
guard let status = notification.status else { return nil }
return .init(objectID: status.objectID)
}
guard let status = _status else {
guard let status = notification.status?.reblog ?? notification.status else {
assertionFailure()
return
}
@ -400,12 +376,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut
assertionFailure("only works for notification item")
return
}
let _status: ManagedObjectRecord<Status>? = try await self.context.managedObjectContext.perform {
guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil }
guard let status = notification.status else { return nil }
return .init(objectID: status.objectID)
}
guard let status = _status else {
guard let status = notification.status?.reblog ?? notification.status else {
assertionFailure()
return
}
@ -465,12 +436,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut
assertionFailure("only works for notification item")
return
}
let _status: ManagedObjectRecord<Status>? = try await self.context.managedObjectContext.perform {
guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil }
guard let status = notification.status else { return nil }
return .init(objectID: status.objectID)
}
guard let status = _status else {
guard let status = notification.status?.reblog ?? notification.status else {
assertionFailure()
return
}
@ -497,12 +463,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut
assertionFailure("only works for notification item")
return
}
let _status: ManagedObjectRecord<Status>? = try await self.context.managedObjectContext.perform {
guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil }
guard let status = notification.status else { return nil }
return .init(objectID: status.objectID)
}
guard let status = _status else {
guard let status = notification.status?.reblog ?? notification.status else {
assertionFailure()
return
}

View File

@ -38,10 +38,9 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte
break
case .reply:
let _replyToAuthor: ManagedObjectRecord<MastodonUser>? = try? await context.managedObjectContext.perform {
guard let status = status.object(in: self.context.managedObjectContext) else { return nil }
guard let inReplyToAccountID = status.inReplyToAccountID else { return nil }
guard let inReplyToAccountID = status.entity.inReplyToAccountID else { return nil }
let request = MastodonUser.sortedFetchRequest
request.predicate = MastodonUser.predicate(domain: status.author.domain, id: inReplyToAccountID)
request.predicate = MastodonUser.predicate(domain: status.entity.account.domain ?? "", id: inReplyToAccountID)
request.fetchLimit = 1
guard let author = self.context.managedObjectContext.safeFetch(request).first else { return nil }
return .init(objectID: author.objectID)
@ -184,7 +183,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte
cardControlMenu statusCardControl: StatusCardControl
) -> [LabeledAction]? {
guard let card = statusView.viewModel.card,
let url = card.url else {
let url = URL(string: card.url) else {
return nil
}
@ -206,8 +205,8 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte
URLActivityItemWithMetadata(url: url) { metadata in
metadata.title = card.title
if let image = card.imageURL {
metadata.iconProvider = ImageProvider(url: image, filter: nil).itemProvider
if let image = card.image, let url = URL(string: image) {
metadata.iconProvider = ImageProvider(url: url, filter: nil).itemProvider
}
}
],
@ -471,8 +470,10 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte
return
}
let _author: ManagedObjectRecord<MastodonUser>? = try await self.context.managedObjectContext.perform {
guard let _status = status.object(in: self.context.managedObjectContext) else { return nil }
let author = (_status.reblog ?? _status).author
let request = MastodonUser.sortedFetchRequest
request.predicate = MastodonUser.predicate(domain: status.entity.account.domain ?? "", id: status.entity.account.id)
request.fetchLimit = 1
guard let author = self.context.managedObjectContext.safeFetch(request).first else { return nil }
return .init(objectID: author.objectID)
}
guard let author = _author else {
@ -679,11 +680,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte
assertionFailure("only works for status data provider")
return
}
guard let status = status.object(in: context.managedObjectContext) else {
return await coordinator.hideLoading()
}
do {
let edits = try await context.apiService.getHistory(forStatusID: status.id, authenticationBox: authContext.mastodonAuthenticationBox).value

View File

@ -8,6 +8,7 @@
import UIKit
import CoreDataStack
import MastodonCore
import MastodonSDK
extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvider & StatusTableViewControllerNavigateableRelay {
@ -55,7 +56,7 @@ extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvid
extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvider & AuthContextProvider {
@MainActor
private func statusRecord() async -> ManagedObjectRecord<Status>? {
private func statusRecord() async -> MastodonStatus? {
guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow else { return nil }
let source = DataSourceItem.Source(indexPath: indexPathForSelectedRow)
guard let item = await item(from: source) else { return nil }
@ -64,12 +65,7 @@ extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvid
case .status(let record):
return record
case .notification(let record):
let _statusRecord: ManagedObjectRecord<Status>? = try? await context.managedObjectContext.perform {
guard let notification = record.object(in: self.context.managedObjectContext) else { return nil }
guard let status = notification.status else { return nil }
return .init(objectID: status.objectID)
}
guard let statusRecord = _statusRecord else {
guard let statusRecord = record.status else {
return nil
}
return statusRecord

View File

@ -10,6 +10,7 @@ import CoreDataStack
import MastodonCore
import MastodonUI
import MastodonLocalization
import MastodonSDK
extension UITableViewDelegate where Self: DataSourceProvider & AuthContextProvider {
@ -42,11 +43,7 @@ extension UITableViewDelegate where Self: DataSourceProvider & AuthContextProvid
case .notification(let notification):
let managedObjectContext = context.managedObjectContext
let _status: ManagedObjectRecord<Status>? = try await managedObjectContext.perform {
guard let notification = notification.object(in: managedObjectContext) else { return nil }
guard let status = notification.status else { return nil }
return .init(objectID: status.objectID)
}
let _status: MastodonStatus? = notification.status
if let status = _status {
await DataSourceFacade.coordinateToStatusThreadScene(
provider: self,
@ -54,10 +51,7 @@ extension UITableViewDelegate where Self: DataSourceProvider & AuthContextProvid
status: status
)
} else {
let _author: ManagedObjectRecord<MastodonUser>? = try await managedObjectContext.perform {
guard let notification = notification.object(in: managedObjectContext) else { return nil }
return .init(objectID: notification.account.objectID)
}
let _author: ManagedObjectRecord<MastodonUser>? = notification.account.asRecord
if let author = _author {
await DataSourceFacade.coordinateToProfileScene(
provider: self,

View File

@ -12,10 +12,10 @@ import MastodonSDK
import class CoreDataStack.Notification
enum DataSourceItem: Hashable {
case status(record: ManagedObjectRecord<Status>)
case status(record: MastodonStatus)
case user(record: ManagedObjectRecord<MastodonUser>)
case hashtag(tag: TagKind)
case notification(record: ManagedObjectRecord<Notification>)
case notification(record: MastodonNotification)
case account(account: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?)
}

View File

@ -21,7 +21,7 @@ final class ComposeViewModel {
enum Context {
case composeStatus
case editStatus(status: Status, statusSource: Mastodon.Entity.StatusSource)
case editStatus(status: MastodonStatus, statusSource: Mastodon.Entity.StatusSource)
}
var disposeBag = Set<AnyCancellable>()

View File

@ -145,10 +145,10 @@ extension DiscoveryCommunityViewModel.State {
self.maxID = newMaxID
var hasNewStatusesAppend = false
var statusIDs = isReloading ? [] : viewModel.statusFetchedResultsController.statusIDs
var statusIDs = isReloading ? [] : await viewModel.statusFetchedResultsController.records
for status in response.value {
guard !statusIDs.contains(status.id) else { continue }
statusIDs.append(status.id)
guard !statusIDs.contains(where: { $0.id == status.id }) else { continue }
statusIDs.append(.fromEntity(status))
hasNewStatusesAppend = true
}
@ -158,7 +158,7 @@ extension DiscoveryCommunityViewModel.State {
} else {
await enter(state: NoMore.self)
}
viewModel.statusFetchedResultsController.statusIDs = statusIDs
await viewModel.statusFetchedResultsController.setRecords(statusIDs)
viewModel.didLoadLatest.send()
} catch {

View File

@ -41,14 +41,11 @@ final class DiscoveryCommunityViewModel {
let didLoadLatest = PassthroughSubject<Void, Never>()
@MainActor
init(context: AppContext, authContext: AuthContext) {
self.context = context
self.authContext = authContext
self.statusFetchedResultsController = StatusFetchedResultsController(
managedObjectContext: context.managedObjectContext,
domain: authContext.mastodonAuthenticationBox.domain,
additionalTweetPredicate: nil
)
self.statusFetchedResultsController = StatusFetchedResultsController()
// end init
}
}

View File

@ -27,6 +27,7 @@ final class DiscoveryViewModel {
@Published var viewControllers: [ScrollViewContainer & PageViewController]
@MainActor
init(context: AppContext, coordinator: SceneCoordinator, authContext: AuthContext) {
self.context = context
self.authContext = authContext

View File

@ -143,10 +143,10 @@ extension DiscoveryPostsViewModel.State {
self.offset = newOffset
var hasNewStatusesAppend = false
var statusIDs = isReloading ? [] : viewModel.statusFetchedResultsController.statusIDs
var statusIDs = isReloading ? [] : await viewModel.statusFetchedResultsController.records
for status in response.value {
guard !statusIDs.contains(status.id) else { continue }
statusIDs.append(status.id)
guard !statusIDs.contains(where: { $0.id == status.id }) else { continue }
statusIDs.append(.fromEntity(status))
hasNewStatusesAppend = true
}
@ -155,7 +155,7 @@ extension DiscoveryPostsViewModel.State {
} else {
await enter(state: NoMore.self)
}
viewModel.statusFetchedResultsController.statusIDs = statusIDs
await viewModel.statusFetchedResultsController.setRecords(statusIDs)
viewModel.didLoadLatest.send()
} catch {

View File

@ -41,14 +41,11 @@ final class DiscoveryPostsViewModel {
let didLoadLatest = PassthroughSubject<Void, Never>()
@Published var isServerSupportEndpoint = true
@MainActor
init(context: AppContext, authContext: AuthContext) {
self.context = context
self.authContext = authContext
self.statusFetchedResultsController = StatusFetchedResultsController(
managedObjectContext: context.managedObjectContext,
domain: authContext.mastodonAuthenticationBox.domain,
additionalTweetPredicate: nil
)
self.statusFetchedResultsController = StatusFetchedResultsController()
// end init
Task {

View File

@ -8,6 +8,7 @@
import Foundation
import GameplayKit
import CoreDataStack
import MastodonSDK
extension HashtagTimelineViewModel {
class State: GKState {
@ -145,10 +146,10 @@ extension HashtagTimelineViewModel.State {
self.maxID = newMaxID
var hasNewStatusesAppend = false
var statusIDs = isReloading ? [] : viewModel.fetchedResultsController.statusIDs
var statusIDs = isReloading ? [] : await viewModel.fetchedResultsController.records.map { $0.entity }
for status in response.value {
guard !statusIDs.contains(status.id) else { continue }
statusIDs.append(status.id)
guard !statusIDs.contains(status) else { continue }
statusIDs.append(status)
hasNewStatusesAppend = true
}
@ -158,7 +159,7 @@ extension HashtagTimelineViewModel.State {
await enter(state: NoMore.self)
}
viewModel.fetchedResultsController.append(statusIDs: statusIDs)
await viewModel.fetchedResultsController.setRecords(statusIDs.map { MastodonStatus.fromEntity($0) })
viewModel.didLoadLatest.send()
} catch {
await enter(state: Fail.self)

View File

@ -50,15 +50,12 @@ final class HashtagTimelineViewModel {
return stateMachine
}()
@MainActor
init(context: AppContext, authContext: AuthContext, hashtag: String) {
self.context = context
self.authContext = authContext
self.hashtag = hashtag
self.fetchedResultsController = StatusFetchedResultsController(
managedObjectContext: context.managedObjectContext,
domain: authContext.mastodonAuthenticationBox.domain,
additionalTweetPredicate: nil
)
self.fetchedResultsController = StatusFetchedResultsController()
updateTagInformation()
// end init
}

View File

@ -20,17 +20,16 @@ extension HomeTimelineViewController: DataSourceProvider {
}
switch item {
case .feed(let record):
case .feed(let feed):
let managedObjectContext = context.managedObjectContext
let item: DataSourceItem? = try? await managedObjectContext.perform {
guard let feed = record.object(in: managedObjectContext) else { return nil }
let item: DataSourceItem? = {
guard feed.kind == .home else { return nil }
if let status = feed.status {
return .status(record: .init(objectID: status.objectID))
return .status(record: status)
} else {
return nil
}
}
}()
return item
default:
return nil

View File

@ -52,37 +52,37 @@ extension HomeTimelineViewModel {
snapshot.appendItems(newItems, toSection: .main)
return snapshot
}()
let parentManagedObjectContext = self.context.managedObjectContext
let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
managedObjectContext.parent = parentManagedObjectContext
try? await managedObjectContext.perform {
let anchors: [Feed] = {
let request = Feed.sortedFetchRequest
request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
Feed.hasMorePredicate(),
self.fetchedResultsController.predicate,
])
do {
return try managedObjectContext.fetch(request)
} catch {
assertionFailure(error.localizedDescription)
return []
}
}()
let itemIdentifiers = newSnapshot.itemIdentifiers
for (index, item) in itemIdentifiers.enumerated() {
guard case let .feed(record) = item else { continue }
guard anchors.contains(where: { feed in feed.objectID == record.objectID }) else { continue }
let isLast = index + 1 == itemIdentifiers.count
if isLast {
newSnapshot.insertItems([.bottomLoader], afterItem: item)
} else {
newSnapshot.insertItems([.feedLoader(record: record)], afterItem: item)
}
}
}
#warning("We probably need to replace the code below")
// let parentManagedObjectContext = self.context.managedObjectContext
// let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
// managedObjectContext.parent = parentManagedObjectContext
// try? await managedObjectContext.perform {
// let anchors: [Feed] = {
// let request = Feed.sortedFetchRequest
// request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
// Feed.hasMorePredicate(),
// self.fetchedResultsController.predicate,
// ])
// do {
// return try managedObjectContext.fetch(request)
// } catch {
// assertionFailure(error.localizedDescription)
// return []
// }
// }()
//
// let itemIdentifiers = newSnapshot.itemIdentifiers
// for (index, item) in itemIdentifiers.enumerated() {
// guard case let .feed(record) = item else { continue }
// guard anchors.contains(where: { feed in feed.objectID == record.objectID }) else { continue }
// let isLast = index + 1 == itemIdentifiers.count
// if isLast {
// newSnapshot.insertItems([.bottomLoader], afterItem: item)
// } else {
// newSnapshot.insertItems([.feedLoader(record: record)], afterItem: item)
// }
// }
// }
let hasChanges = newSnapshot.itemIdentifiers != oldSnapshot.itemIdentifiers
if !hasChanges && !self.hasPendingStatusEditReload {

View File

@ -84,14 +84,10 @@ extension HomeTimelineViewModel.LoadLatestState {
guard let viewModel else { return }
let latestFeedRecords = viewModel.fetchedResultsController.records.prefix(APIService.onceRequestStatusMaxCount)
let parentManagedObjectContext = viewModel.fetchedResultsController.managedObjectContext
let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
managedObjectContext.parent = parentManagedObjectContext
Task {
let latestStatusIDs: [Status.ID] = latestFeedRecords.compactMap { record in
guard let feed = record.object(in: managedObjectContext) else { return nil }
return feed.status?.id
return record.status?.id
}
do {

View File

@ -52,13 +52,10 @@ extension HomeTimelineViewModel.LoadOldestState {
}
Task {
let managedObjectContext = viewModel.fetchedResultsController.managedObjectContext
let _maxID: Mastodon.Entity.Status.ID? = try await managedObjectContext.perform {
guard let feed = lastFeedRecord.object(in: managedObjectContext),
let status = feed.status
else { return nil }
let _maxID: Mastodon.Entity.Status.ID? = {
guard let status = lastFeedRecord.status else { return nil }
return status.id
}
}()
guard let maxID = _maxID else {
await self.enter(state: Fail.self)

View File

@ -80,15 +80,9 @@ final class HomeTimelineViewModel: NSObject {
init(context: AppContext, authContext: AuthContext) {
self.context = context
self.authContext = authContext
self.fetchedResultsController = FeedFetchedResultsController(managedObjectContext: context.managedObjectContext)
self.fetchedResultsController = FeedFetchedResultsController()
self.homeTimelineNavigationBarTitleViewModel = HomeTimelineNavigationBarTitleViewModel(context: context)
super.init()
fetchedResultsController.predicate = Feed.predicate(
kind: .home,
acct: .mastodon(domain: authContext.mastodonAuthenticationBox.domain, userID: authContext.mastodonAuthenticationBox.userID)
)
homeTimelineNeedRefresh
.sink { [weak self] _ in
self?.loadLatestStateMachine.enter(LoadLatestState.Loading.self)
@ -116,7 +110,7 @@ extension HomeTimelineViewModel {
extension HomeTimelineViewModel {
func timelineDidReachEnd() {
fetchedResultsController.fetchNextBatch()
#warning("Check if required, e.g. when locally caching MastodonStatus")
}
}
@ -128,47 +122,41 @@ extension HomeTimelineViewModel {
guard let diffableDataSource = diffableDataSource else { return }
var snapshot = diffableDataSource.snapshot()
let managedObjectContext = context.managedObjectContext
let key = "LoadMore@\(record.objectID)"
guard let feed = record.object(in: managedObjectContext) else { return }
guard let status = feed.status else { return }
// let managedObjectContext = context.managedObjectContext
// let key = "LoadMore@\(record.objectID)"
//
// guard let feed = record.object(in: managedObjectContext) else { return }
// keep transient property live
managedObjectContext.cache(feed, key: key)
defer {
managedObjectContext.cache(nil, key: key)
}
do {
// update state
try await managedObjectContext.performChanges {
feed.update(isLoadingMore: true)
}
} catch {
assertionFailure(error.localizedDescription)
}
guard let status = record.status else { return }
record.isLoadingMore = true
// // keep transient property live
// managedObjectContext.cache(feed, key: key)
// defer {
// managedObjectContext.cache(nil, key: key)
// }
// do {
// // update state
// try await managedObjectContext.performChanges {
// feed.update(isLoadingMore: true)
// }
// } catch {
// assertionFailure(error.localizedDescription)
// }
// reconfigure item
snapshot.reconfigureItems([item])
await updateSnapshotUsingReloadData(snapshot: snapshot)
// fetch data
do {
let maxID = status.id
_ = try await context.apiService.homeTimeline(
maxID: maxID,
authenticationBox: authContext.mastodonAuthenticationBox
)
} catch {
do {
// restore state
try await managedObjectContext.performChanges {
feed.update(isLoadingMore: false)
}
} catch {
assertionFailure(error.localizedDescription)
}
}
let maxID = status.id
_ = try? await context.apiService.homeTimeline(
maxID: maxID,
authenticationBox: authContext.mastodonAuthenticationBox
)
record.isLoadingMore = false
// reconfigure item again
snapshot.reconfigureItems([item])

View File

@ -8,6 +8,7 @@
import UIKit
import Combine
import CoreDataStack
import MastodonSDK
extension NotificationTableViewCell {
final class ViewModel {
@ -18,7 +19,7 @@ extension NotificationTableViewCell {
}
enum Value {
case feed(Feed)
case feed(MastodonFeed)
}
}
}

View File

@ -6,6 +6,7 @@
//
import UIKit
import MastodonSDK
extension NotificationTimelineViewController: DataSourceProvider {
func item(from source: DataSourceItem.Source) async -> DataSourceItem? {
@ -20,17 +21,16 @@ extension NotificationTimelineViewController: DataSourceProvider {
}
switch item {
case .feed(let record):
case .feed(let feed):
let managedObjectContext = context.managedObjectContext
let item: DataSourceItem? = try? await managedObjectContext.perform {
guard let feed = record.object(in: managedObjectContext) else { return nil }
let item: DataSourceItem? = {
guard feed.kind == .notificationAll || feed.kind == .notificationMentions else { return nil }
if let notification = feed.notification {
return .notification(record: .init(objectID: notification.objectID))
if let notification = feed.notification, let mastodonNotification = MastodonNotification.fromEntity(notification, using: managedObjectContext) {
return .notification(record: mastodonNotification)
} else {
return nil
}
}
}()
return item
default:
return nil

View File

@ -280,14 +280,13 @@ extension NotificationTimelineViewController: TableViewControllerNavigateable {
Task { @MainActor in
switch item {
case .feed(let record):
guard let feed = record.object(in: self.context.managedObjectContext) else { return }
guard let notification = feed.notification else { return }
guard let notification = record.notification else { return }
if let stauts = notification.status {
if let status = notification.status {
let threadViewModel = ThreadViewModel(
context: self.context,
authContext: self.viewModel.authContext,
optionalRoot: .root(context: .init(status: .init(objectID: stauts.objectID)))
optionalRoot: .root(context: .init(status: .fromEntity(status)))
)
_ = self.coordinator.present(
scene: .thread(viewModel: threadViewModel),
@ -295,16 +294,25 @@ extension NotificationTimelineViewController: TableViewControllerNavigateable {
transition: .show
)
} else {
let profileViewModel = ProfileViewModel(
context: self.context,
authContext: self.viewModel.authContext,
optionalMastodonUser: notification.account
)
_ = self.coordinator.present(
scene: .profile(viewModel: profileViewModel),
from: self,
transition: .show
)
context.managedObjectContext.perform {
let mastodonUserRequest = MastodonUser.sortedFetchRequest
mastodonUserRequest.predicate = MastodonUser.predicate(domain: notification.account.domain ?? "", id: notification.account.id)
mastodonUserRequest.fetchLimit = 1
guard let mastodonUser = try? self.context.managedObjectContext.fetch(mastodonUserRequest).first else {
return
}
let profileViewModel = ProfileViewModel(
context: self.context,
authContext: self.viewModel.authContext,
optionalMastodonUser: mastodonUser
)
_ = self.coordinator.present(
scene: .profile(viewModel: profileViewModel),
from: self,
transition: .show
)
}
}
default:
break

View File

@ -48,36 +48,37 @@ extension NotificationTimelineViewModel {
return snapshot
}()
let parentManagedObjectContext = self.context.managedObjectContext
let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
managedObjectContext.parent = parentManagedObjectContext
try? await managedObjectContext.perform {
let anchors: [Feed] = {
let request = Feed.sortedFetchRequest
request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
Feed.hasMorePredicate(),
self.feedFetchedResultsController.predicate,
])
do {
return try managedObjectContext.fetch(request)
} catch {
assertionFailure(error.localizedDescription)
return []
}
}()
let itemIdentifiers = newSnapshot.itemIdentifiers
for (index, item) in itemIdentifiers.enumerated() {
guard case let .feed(record) = item else { continue }
guard anchors.contains(where: { feed in feed.objectID == record.objectID }) else { continue }
let isLast = index + 1 == itemIdentifiers.count
if isLast {
newSnapshot.insertItems([.bottomLoader], afterItem: item)
} else {
newSnapshot.insertItems([.feedLoader(record: record)], afterItem: item)
}
}
}
#warning("Do we still need the code below?")
// let parentManagedObjectContext = self.context.managedObjectContext
// let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
// managedObjectContext.parent = parentManagedObjectContext
// try? await managedObjectContext.perform {
// let anchors: [Feed] = {
// let request = Feed.sortedFetchRequest
// request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
// Feed.hasMorePredicate(),
// self.feedFetchedResultsController.predicate,
// ])
// do {
// return try managedObjectContext.fetch(request)
// } catch {
// assertionFailure(error.localizedDescription)
// return []
// }
// }()
//
// let itemIdentifiers = newSnapshot.itemIdentifiers
// for (index, item) in itemIdentifiers.enumerated() {
// guard case let .feed(record) = item else { continue }
// guard anchors.contains(where: { feed in feed.objectID == record.objectID }) else { continue }
// let isLast = index + 1 == itemIdentifiers.count
// if isLast {
// newSnapshot.insertItems([.bottomLoader], afterItem: item)
// } else {
// newSnapshot.insertItems([.feedLoader(record: record)], afterItem: item)
// }
// }
// }
let hasChanges = newSnapshot.itemIdentifiers != oldSnapshot.itemIdentifiers
if !hasChanges {

View File

@ -55,12 +55,7 @@ extension NotificationTimelineViewModel.LoadOldestState {
Task {
let managedObjectContext = viewModel.context.managedObjectContext
let _maxID: Mastodon.Entity.Notification.ID? = try await managedObjectContext.perform {
guard let feed = lastFeedRecord.object(in: managedObjectContext),
let notification = feed.notification
else { return nil }
return notification.id
}
let _maxID: Mastodon.Entity.Notification.ID? = lastFeedRecord.notification?.id
guard let maxID = _maxID else {
await self.enter(state: Fail.self)

View File

@ -43,6 +43,7 @@ final class NotificationTimelineViewModel {
return stateMachine
}()
@MainActor
init(
context: AppContext,
authContext: AuthContext,
@ -51,13 +52,7 @@ final class NotificationTimelineViewModel {
self.context = context
self.authContext = authContext
self.scope = scope
self.feedFetchedResultsController = FeedFetchedResultsController(managedObjectContext: context.managedObjectContext)
// end init
feedFetchedResultsController.predicate = NotificationTimelineViewModel.feedPredicate(
authenticationBox: authContext.mastodonAuthenticationBox,
scope: scope
)
self.feedFetchedResultsController = FeedFetchedResultsController()
}
@ -125,29 +120,16 @@ extension NotificationTimelineViewModel {
// load timeline gap
func loadMore(item: NotificationItem) async {
guard case let .feedLoader(record) = item else { return }
let managedObjectContext = context.managedObjectContext
let key = "LoadMore@\(record.objectID)"
// return when already loading state
guard managedObjectContext.cache(froKey: key) == nil else { return }
guard let feed = record.object(in: managedObjectContext) else { return }
guard let maxID = feed.notification?.id else { return }
// keep transient property live
managedObjectContext.cache(feed, key: key)
defer {
managedObjectContext.cache(nil, key: key)
}
guard let maxID = record.notification?.id else { return }
// fetch data
do {
_ = try await context.apiService.notifications(
maxID: maxID,
scope: scope,
authenticationBox: authContext.mastodonAuthenticationBox
)
} catch {
if let notifications = try? await context.apiService.notifications(
maxID: maxID,
scope: scope,
authenticationBox: authContext.mastodonAuthenticationBox
) {
self.feedFetchedResultsController.records += notifications.value.map { MastodonFeed.fromNotification($0, kind: record.kind) }
}
}

View File

@ -57,7 +57,9 @@ extension BookmarkViewModel.State {
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
// reset
viewModel.statusFetchedResultsController.statusIDs = []
DispatchQueue.main.async {
viewModel.statusFetchedResultsController.reset()
}
stateMachine.enter(Loading.self)
}
@ -128,10 +130,10 @@ extension BookmarkViewModel.State {
)
var hasNewStatusesAppend = false
var statusIDs = viewModel.statusFetchedResultsController.statusIDs
var statusIDs = await viewModel.statusFetchedResultsController.records.map { $0.entity }
for status in response.value {
guard !statusIDs.contains(status.id) else { continue }
statusIDs.append(status.id)
guard !statusIDs.contains(status) else { continue }
statusIDs.append(status)
hasNewStatusesAppend = true
}
@ -147,7 +149,9 @@ extension BookmarkViewModel.State {
} else {
await enter(state: NoMore.self)
}
viewModel.statusFetchedResultsController.statusIDs = statusIDs
await viewModel.statusFetchedResultsController.setRecords(statusIDs.map { MastodonStatus.fromEntity($0) })
} catch {
await enter(state: Fail.self)
}

View File

@ -38,14 +38,11 @@ final class BookmarkViewModel {
return stateMachine
}()
@MainActor
init(context: AppContext, authContext: AuthContext) {
self.context = context
self.authContext = authContext
self.statusFetchedResultsController = StatusFetchedResultsController(
managedObjectContext: context.managedObjectContext,
domain: authContext.mastodonAuthenticationBox.domain,
additionalTweetPredicate: nil
)
self.statusFetchedResultsController = StatusFetchedResultsController()
}
}

View File

@ -11,6 +11,7 @@ import MastodonCore
final class CachedProfileViewModel: ProfileViewModel {
@MainActor
init(context: AppContext, authContext: AuthContext, mastodonUser: MastodonUser) {
super.init(context: context, authContext: authContext, optionalMastodonUser: mastodonUser)
}

View File

@ -55,10 +55,12 @@ extension FavoriteViewModel.State {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
// reset
viewModel.statusFetchedResultsController.statusIDs = []
stateMachine.enter(Loading.self)
Task {
// reset
await viewModel.statusFetchedResultsController.reset()
stateMachine.enter(Loading.self)
}
}
}
@ -127,10 +129,10 @@ extension FavoriteViewModel.State {
)
var hasNewStatusesAppend = false
var statusIDs = viewModel.statusFetchedResultsController.statusIDs
var statusIDs = await viewModel.statusFetchedResultsController.records
for status in response.value {
guard !statusIDs.contains(status.id) else { continue }
statusIDs.append(status.id)
guard !statusIDs.contains(where: { $0.id == status.id }) else { continue }
statusIDs.append(.fromEntity(status))
hasNewStatusesAppend = true
}
@ -146,7 +148,7 @@ extension FavoriteViewModel.State {
} else {
await enter(state: NoMore.self)
}
viewModel.statusFetchedResultsController.statusIDs = statusIDs
await viewModel.statusFetchedResultsController.setRecords(statusIDs)
} catch {
await enter(state: Fail.self)
}

View File

@ -37,14 +37,11 @@ final class FavoriteViewModel {
return stateMachine
}()
@MainActor
init(context: AppContext, authContext: AuthContext) {
self.context = context
self.authContext = authContext
self.statusFetchedResultsController = StatusFetchedResultsController(
managedObjectContext: context.managedObjectContext,
domain: authContext.mastodonAuthenticationBox.domain,
additionalTweetPredicate: nil
)
self.statusFetchedResultsController = StatusFetchedResultsController()
}
}

View File

@ -14,6 +14,7 @@ import MastodonSDK
final class MeProfileViewModel: ProfileViewModel {
@MainActor
init(context: AppContext, authContext: AuthContext) {
let user = authContext.mastodonAuthenticationBox.authentication.user(in: context.managedObjectContext)
super.init(

View File

@ -56,6 +56,7 @@ class ProfileViewModel: NSObject {
// @Published var protected: Bool? = nil
// let needsPagePinToTop = CurrentValueSubject<Bool, Never>(false)
@MainActor
init(context: AppContext, authContext: AuthContext, optionalMastodonUser mastodonUser: MastodonUser?) {
self.context = context
self.authContext = authContext

View File

@ -13,6 +13,7 @@ import MastodonCore
final class RemoteProfileViewModel: ProfileViewModel {
@MainActor
init(context: AppContext, authContext: AuthContext, userID: Mastodon.Entity.Account.ID) {
super.init(context: context, authContext: authContext, optionalMastodonUser: nil)
@ -51,6 +52,7 @@ final class RemoteProfileViewModel: ProfileViewModel {
.store(in: &disposeBag)
}
@MainActor
init(context: AppContext, authContext: AuthContext, notificationID: Mastodon.Entity.Notification.ID) {
super.init(context: context, authContext: authContext, optionalMastodonUser: nil)
@ -89,6 +91,7 @@ final class RemoteProfileViewModel: ProfileViewModel {
} // end Task
}
@MainActor
init(context: AppContext, authContext: AuthContext, acct: String){
super.init(context: context, authContext: authContext, optionalMastodonUser: nil)

View File

@ -54,11 +54,13 @@ extension UserTimelineViewModel.State {
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
// reset
viewModel.statusFetchedResultsController.statusIDs = []
stateMachine.enter(Loading.self)
Task {
// reset
await viewModel.statusFetchedResultsController.reset()
stateMachine.enter(Loading.self)
}
}
}
@ -112,17 +114,17 @@ extension UserTimelineViewModel.State {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
let maxID = viewModel.statusFetchedResultsController.statusIDs.last
guard let userID = viewModel.userIdentifier?.userID, !userID.isEmpty else {
stateMachine.enter(Fail.self)
return
}
let queryFilter = viewModel.queryFilter
Task {
let maxID = await viewModel.statusFetchedResultsController.records.last?.id
guard let userID = viewModel.userIdentifier?.userID, !userID.isEmpty else {
stateMachine.enter(Fail.self)
return
}
let queryFilter = viewModel.queryFilter
do {
let response = try await viewModel.context.apiService.userTimeline(
accountID: userID,
@ -135,10 +137,10 @@ extension UserTimelineViewModel.State {
)
var hasNewStatusesAppend = false
var statusIDs = viewModel.statusFetchedResultsController.statusIDs
var statusIDs = await viewModel.statusFetchedResultsController.records
for status in response.value {
guard !statusIDs.contains(status.id) else { continue }
statusIDs.append(status.id)
guard !statusIDs.contains(where: { $0.id == status.id }) else { continue }
statusIDs.append(.fromEntity(status))
hasNewStatusesAppend = true
}
@ -147,7 +149,7 @@ extension UserTimelineViewModel.State {
} else {
await enter(state: NoMore.self)
}
viewModel.statusFetchedResultsController.statusIDs = statusIDs
await viewModel.statusFetchedResultsController.setRecords(statusIDs)
} catch {
await enter(state: Fail.self)

View File

@ -48,6 +48,7 @@ final class UserTimelineViewModel {
return stateMachine
}()
@MainActor
init(
context: AppContext,
authContext: AuthContext,
@ -57,11 +58,7 @@ final class UserTimelineViewModel {
self.context = context
self.authContext = authContext
self.title = title
self.statusFetchedResultsController = StatusFetchedResultsController(
managedObjectContext: context.managedObjectContext,
domain: authContext.mastodonAuthenticationBox.domain,
additionalTweetPredicate: nil
)
self.statusFetchedResultsController = StatusFetchedResultsController()
self.queryFilter = queryFilter
}
}

View File

@ -10,6 +10,7 @@ import Combine
import CoreDataStack
import GameplayKit
import MastodonCore
import MastodonSDK
final class UserListViewModel {
var disposeBag = Set<AnyCancellable>()
@ -55,7 +56,7 @@ final class UserListViewModel {
extension UserListViewModel {
// TODO: refactor follower and following into user list
enum Kind {
case rebloggedBy(status: ManagedObjectRecord<Status>)
case favoritedBy(status: ManagedObjectRecord<Status>)
case rebloggedBy(status: MastodonStatus)
case favoritedBy(status: MastodonStatus)
}
}

View File

@ -29,17 +29,18 @@ class ReportViewModel {
let context: AppContext
let authContext: AuthContext
let user: ManagedObjectRecord<MastodonUser>
let status: ManagedObjectRecord<Status>?
let status: MastodonStatus?
// output
@Published var isReporting = false
@Published var isReportSuccess = false
@MainActor
init(
context: AppContext,
authContext: AuthContext,
user: ManagedObjectRecord<MastodonUser>,
status: ManagedObjectRecord<Status>?
status: MastodonStatus?
) {
self.context = context
self.authContext = authContext
@ -101,17 +102,15 @@ extension ReportViewModel {
// the status picker is essential step in report flow
// only check isSkip or not
let statusIDs: [Status.ID]? = {
let statusIDs: [MastodonStatus.ID]? = {
if self.reportStatusViewModel.isSkip {
let _id: Status.ID? = self.reportStatusViewModel.status.flatMap { record -> Status.ID? in
guard let status = record.object(in: managedObjectContext) else { return nil }
return status.id
let _id: MastodonStatus.ID? = self.reportStatusViewModel.status.flatMap { record -> MastodonStatus.ID? in
return record.id
}
return _id.flatMap { [$0] }
return _id.flatMap { [$0] } ?? []
} else {
return self.reportStatusViewModel.selectStatuses.compactMap { record -> Status.ID? in
guard let status = record.object(in: managedObjectContext) else { return nil }
return status.id
return self.reportStatusViewModel.selectStatuses.compactMap { record -> MastodonStatus.ID? in
return record.id
}
}
}()

View File

@ -64,9 +64,10 @@ extension ReportStatusViewModel.State {
super.didEnter(from: previousState)
guard let viewModel else { return }
let maxID = viewModel.statusFetchedResultsController.statusIDs.last
Task {
let maxID = await viewModel.statusFetchedResultsController.records.last?.id
let managedObjectContext = viewModel.context.managedObjectContext
let _userID: MastodonUser.ID? = try await managedObjectContext.perform {
guard let user = viewModel.user.object(in: managedObjectContext) else { return nil }
@ -89,10 +90,10 @@ extension ReportStatusViewModel.State {
)
var hasNewStatusesAppend = false
var statusIDs = viewModel.statusFetchedResultsController.statusIDs
var statusIDs = await viewModel.statusFetchedResultsController.records
for status in response.value {
guard !statusIDs.contains(status.id) else { continue }
statusIDs.append(status.id)
guard !statusIDs.contains(where: { $0.id == status.id }) else { continue }
statusIDs.append(.fromEntity(status))
hasNewStatusesAppend = true
}
@ -101,7 +102,7 @@ extension ReportStatusViewModel.State {
} else {
await enter(state: NoMore.self)
}
viewModel.statusFetchedResultsController.statusIDs = statusIDs
await viewModel.statusFetchedResultsController.setRecords(statusIDs)
} catch {
await enter(state: Fail.self)

View File

@ -25,12 +25,12 @@ class ReportStatusViewModel {
let context: AppContext
let authContext: AuthContext
let user: ManagedObjectRecord<MastodonUser>
let status: ManagedObjectRecord<Status>?
let status: MastodonStatus?
let statusFetchedResultsController: StatusFetchedResultsController
let listBatchFetchViewModel = ListBatchFetchViewModel()
@Published var isSkip = false
@Published var selectStatuses = OrderedSet<ManagedObjectRecord<Status>>()
@Published var selectStatuses = OrderedSet<MastodonStatus>()
// output
var diffableDataSource: UITableViewDiffableDataSource<ReportSection, ReportItem>?
@ -48,21 +48,18 @@ class ReportStatusViewModel {
@Published var isNextButtonEnabled = false
@MainActor
init(
context: AppContext,
authContext: AuthContext,
user: ManagedObjectRecord<MastodonUser>,
status: ManagedObjectRecord<Status>?
status: MastodonStatus?
) {
self.context = context
self.authContext = authContext
self.user = user
self.status = status
self.statusFetchedResultsController = StatusFetchedResultsController(
managedObjectContext: context.managedObjectContext,
domain: authContext.mastodonAuthenticationBox.domain,
additionalTweetPredicate: nil
)
self.statusFetchedResultsController = StatusFetchedResultsController()
// end init
if let status = status {

View File

@ -6,13 +6,13 @@
//
import UIKit
import CoreDataStack
import MastodonSDK
extension ReportStatusTableViewCell {
final class ViewModel {
let value: Status
let value: MastodonStatus
init(value: Status) {
init(value: MastodonStatus) {
self.value = value
}
}

View File

@ -87,7 +87,8 @@ class MainTabBarController: UITabBarController {
return image.withRenderingMode(.alwaysTemplate).resized(size: CGSize(width: 80, height: 80))
}
func viewController(context: AppContext, authContext: AuthContext?, coordinator: SceneCoordinator) -> UIViewController {
@MainActor
func viewController(context: AppContext, authContext: AuthContext?, coordinator: SceneCoordinator) async -> UIViewController {
guard let authContext = authContext else {
return UITableViewController()
}
@ -170,21 +171,26 @@ extension MainTabBarController {
view.backgroundColor = .systemBackground
// seealso: `ThemeService.apply(theme:)`
let tabs = Tab.allCases
let viewControllers: [UIViewController] = tabs.map { tab in
let viewController = tab.viewController(context: context, authContext: authContext, coordinator: coordinator)
viewController.tabBarItem.tag = tab.tag
viewController.tabBarItem.title = tab.title // needs for acessiblity large content label
viewController.tabBarItem.image = tab.image.imageWithoutBaseline()
viewController.tabBarItem.largeContentSizeImage = tab.largeImage.imageWithoutBaseline()
viewController.tabBarItem.accessibilityLabel = tab.title
viewController.tabBarItem.accessibilityUserInputLabels = tab.inputLabels
viewController.tabBarItem.imageInsets = UIEdgeInsets(top: 6, left: 0, bottom: -6, right: 0)
return viewController
Task { @MainActor in
let tabs = Tab.allCases
var viewControllers = [UIViewController]()
for tab in tabs {
let viewController = await tab.viewController(context: context, authContext: authContext, coordinator: coordinator)
viewController.tabBarItem.tag = tab.tag
viewController.tabBarItem.title = tab.title // needs for acessiblity large content label
viewController.tabBarItem.image = tab.image.imageWithoutBaseline()
viewController.tabBarItem.largeContentSizeImage = tab.largeImage.imageWithoutBaseline()
viewController.tabBarItem.accessibilityLabel = tab.title
viewController.tabBarItem.accessibilityUserInputLabels = tab.inputLabels
viewController.tabBarItem.imageInsets = UIEdgeInsets(top: 6, left: 0, bottom: -6, right: 0)
viewControllers.append(viewController)
}
_viewControllers = viewControllers
setViewControllers(viewControllers, animated: false)
selectedIndex = 0
}
_viewControllers = viewControllers
setViewControllers(viewControllers, animated: false)
selectedIndex = 0
// hacky workaround for FB11986255 (Setting accessibilityUserInputLabels on a UITabBarItem has no effect)
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) {

View File

@ -75,22 +75,10 @@ extension SearchResultOverviewCoordinator: SearchResultsOverviewTableViewControl
showProfile(viewController, for: account)
} else if let status = searchResult.statuses.first {
let status = try await managedObjectContext.perform {
return Persistence.Status.fetch(in: managedObjectContext, context: Persistence.Status.PersistContext(
domain: authContext.mastodonAuthenticationBox.domain,
entity: status,
me: authContext.mastodonAuthenticationBox.authentication.user(in: managedObjectContext),
statusCache: nil,
userCache: nil,
networkDate: Date()))
}
guard let status else { return }
await DataSourceFacade.coordinateToStatusThreadScene(
provider: viewController,
target: .status, // remove reblog wrapper
status: status.asRecord
status: MastodonStatus.fromEntity(status)
)
} else if let url = URL(string: urlString) {
let prefixedURL: URL?

View File

@ -12,7 +12,7 @@ import MastodonSDK
enum SearchResultItem: Hashable {
case user(ManagedObjectRecord<MastodonUser>)
case status(ManagedObjectRecord<Status>)
case status(MastodonStatus)
case hashtag(tag: Mastodon.Entity.Tag)
case bottomLoader(attribute: BottomLoaderAttribute)
}

View File

@ -58,18 +58,15 @@ extension SearchResultSection {
)
}
return cell
case .status(let record):
case .status(let status):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell
context.managedObjectContext.performAndWait {
guard let status = record.object(in: context.managedObjectContext) else { return }
configure(
context: context,
tableView: tableView,
cell: cell,
viewModel: StatusTableViewCell.ViewModel(value: .status(status)),
configuration: configuration
)
}
configure(
context: context,
tableView: tableView,
cell: cell,
viewModel: StatusTableViewCell.ViewModel(value: .status(status)),
configuration: configuration
)
return cell
case .hashtag(let tag):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: HashtagTableViewCell.self)) as! HashtagTableViewCell

View File

@ -124,7 +124,7 @@ extension SearchResultViewModel.State {
guard stateMachine.currentState is Loading else { return }
let userIDs = response.value.accounts.map { $0.id }
let statusIDs = response.value.statuses.map { $0.id }
let statusIDs = response.value.statuses.map { MastodonStatus.fromEntity($0) }
let isNoMore = userIDs.isEmpty && statusIDs.isEmpty
@ -137,12 +137,12 @@ extension SearchResultViewModel.State {
// reset data source when the search is refresh
if offset == nil {
viewModel.userFetchedResultsController.userIDs = []
viewModel.statusFetchedResultsController.statusIDs = []
await viewModel.statusFetchedResultsController.reset()
viewModel.hashtags = []
}
viewModel.userFetchedResultsController.append(userIDs: userIDs)
viewModel.statusFetchedResultsController.append(statusIDs: statusIDs)
await viewModel.statusFetchedResultsController.appendRecords(statusIDs)
var hashtags = viewModel.hashtags
for hashtag in response.value.hashtags where !hashtags.contains(hashtag) {

View File

@ -45,6 +45,7 @@ final class SearchResultViewModel {
}()
let didDataSourceUpdate = PassthroughSubject<Void, Never>()
@MainActor
init(context: AppContext, authContext: AuthContext, searchScope: SearchScope = .all, searchText: String) {
self.context = context
self.authContext = authContext
@ -56,10 +57,6 @@ final class SearchResultViewModel {
domain: authContext.mastodonAuthenticationBox.domain,
additionalPredicate: nil
)
self.statusFetchedResultsController = StatusFetchedResultsController(
managedObjectContext: context.managedObjectContext,
domain: authContext.mastodonAuthenticationBox.domain,
additionalTweetPredicate: nil
)
self.statusFetchedResultsController = StatusFetchedResultsController()
}
}

View File

@ -16,30 +16,29 @@ import MastodonAsset
import MastodonCore
import MastodonLocalization
import class CoreDataStack.Notification
import MastodonSDK
extension NotificationView {
public func configure(feed: Feed) {
guard let notification = feed.notification else {
public func configure(feed: MastodonFeed) {
guard
let notification = feed.notification,
let managedObjectContext = viewModel.context?.managedObjectContext
else {
assertionFailure()
return
}
configure(notification: notification)
MastodonNotification.fromEntity(notification, using: managedObjectContext).map(configure(notification:))
}
}
extension NotificationView {
public func configure(notification: Notification) {
public func configure(notification: MastodonNotification) {
viewModel.objects.insert(notification)
configureAuthor(notification: notification)
guard let type = MastodonNotificationType(rawValue: notification.typeRaw) else {
assertionFailure()
return
}
switch type {
switch notification.entity.type {
case .follow:
setAuthorContainerBottomPaddingViewDisplay()
case .followRequest:
@ -63,7 +62,7 @@ extension NotificationView {
}
extension NotificationView {
private func configureAuthor(notification: Notification) {
private func configureAuthor(notification: MastodonNotification) {
let author = notification.account
// author avatar
@ -98,19 +97,18 @@ extension NotificationView {
.assign(to: \.authorUsername, on: viewModel)
.store(in: &disposeBag)
// timestamp
viewModel.timestamp = notification.createAt
viewModel.timestamp = notification.entity.createdAt
viewModel.visibility = notification.status?.visibility ?? ._other("")
viewModel.visibility = notification.entity.status?.mastodonVisibility ?? ._other("")
// notification type indicator
Publishers.CombineLatest3(
notification.publisher(for: \.typeRaw),
Publishers.CombineLatest(
author.publisher(for: \.displayName),
author.publisher(for: \.emojis)
)
.sink { [weak self] typeRaw, _, emojis in
.sink { [weak self] _, emojis in
guard let self = self else { return }
guard let type = MastodonNotificationType(rawValue: typeRaw) else {
guard let type = MastodonNotificationType(rawValue: notification.entity.type.rawValue) else {
self.viewModel.notificationIndicatorText = nil
return
}
@ -205,13 +203,8 @@ extension NotificationView {
.store(in: &disposeBag)
// follow request state
notification.publisher(for: \.followRequestState)
.assign(to: \.followRequestState, on: viewModel)
.store(in: &disposeBag)
notification.publisher(for: \.transientFollowRequestState)
.assign(to: \.transientFollowRequestState, on: viewModel)
.store(in: &disposeBag)
viewModel.followRequestState = notification.followRequestState
viewModel.transientFollowRequestState = notification.transientFollowRequestState
// Following
author.publisher(for: \.followingBy)

View File

@ -6,7 +6,7 @@
//
import UIKit
import CoreDataStack
import MastodonSDK
extension StatusTableViewCell {
final class ViewModel {
@ -17,8 +17,8 @@ extension StatusTableViewCell {
}
enum Value {
case feed(Feed)
case status(Status)
case feed(MastodonFeed)
case status(MastodonStatus)
}
}
}
@ -38,13 +38,7 @@ extension StatusTableViewCell {
switch viewModel.value {
case .feed(let feed):
statusView.configure(feed: feed)
feed.publisher(for: \.hasMore)
.sink { [weak self] hasMore in
guard let self = self else { return }
self.separatorLine.isHidden = hasMore
}
.store(in: &disposeBag)
self.separatorLine.isHidden = feed.hasMore
case .status(let status):
statusView.configure(status: status)

View File

@ -6,7 +6,7 @@
//
import UIKit
import CoreDataStack
import MastodonSDK
extension StatusThreadRootTableViewCell {
final class ViewModel {
@ -17,7 +17,7 @@ extension StatusThreadRootTableViewCell {
}
enum Value {
case status(Status)
case status(MastodonStatus)
}
}
}

View File

@ -6,12 +6,12 @@
//
import Foundation
import CoreDataStack
import MastodonSDK
import MastodonCore
final class CachedThreadViewModel: ThreadViewModel {
init(context: AppContext, authContext: AuthContext, status: Status) {
let threadContext = StatusItem.Thread.Context(status: .init(objectID: status.objectID))
init(context: AppContext, authContext: AuthContext, status: MastodonStatus) {
let threadContext = StatusItem.Thread.Context(status: status)
super.init(
context: context,
authContext: authContext,

View File

@ -72,7 +72,7 @@ class StatusEditHistoryTableViewCell: UITableViewCell {
NSLayoutConstraint.activate(constraints)
}
func configure(status: Status, statusEdit: Mastodon.Entity.StatusEdit, dateText: String) {
func configure(status: MastodonStatus, statusEdit: Mastodon.Entity.StatusEdit, dateText: String) {
dateLabel.text = dateText
statusHistoryView.statusView.configure(status: status, statusEdit: statusEdit)
}

View File

@ -8,7 +8,7 @@ import UIKit
import MastodonSDK
struct StatusEditHistoryViewModel {
let status: Status
let status: MastodonStatus
let edits: [Mastodon.Entity.StatusEdit]
let appContext: AppContext

View File

@ -20,7 +20,7 @@ final class MastodonStatusThreadViewModel {
// input
let context: AppContext
@Published private(set) var deletedObjectIDs: Set<NSManagedObjectID> = Set()
@Published private(set) var deletedObjectIDs: Set<MastodonStatus.ID> = Set()
// output
@Published var __ancestors: [StatusItem] = []
@ -41,7 +41,7 @@ final class MastodonStatusThreadViewModel {
let newItems = items.filter { item in
switch item {
case .thread(let thread):
return !deletedObjectIDs.contains(thread.record.objectID)
return !deletedObjectIDs.contains(thread.record.id)
default:
assertionFailure()
return false
@ -60,7 +60,7 @@ final class MastodonStatusThreadViewModel {
let newItems = items.filter { item in
switch item {
case .thread(let thread):
return !deletedObjectIDs.contains(thread.record.objectID)
return !deletedObjectIDs.contains(thread.record.id)
default:
assertionFailure()
return false
@ -94,19 +94,20 @@ extension MastodonStatusThreadViewModel {
}
var newItems: [StatusItem] = []
for (i, node) in nodes.enumerated() {
guard let status = dictionary[node.statusID] else { continue }
let isLast = i == nodes.count - 1
let record = ManagedObjectRecord<Status>(objectID: status.objectID)
let context = StatusItem.Thread.Context(
status: record,
displayUpperConversationLink: !isLast,
displayBottomConversationLink: true
)
let item = StatusItem.thread(.leaf(context: context))
newItems.append(item)
}
#warning("Potentially this can be removed and replaced by native threading logic")
// for (i, node) in nodes.enumerated() {
// guard let status = dictionary[node.statusID] else { continue }
// let isLast = i == nodes.count - 1
//
// let record = ManagedObjectRecord<Status>(objectID: status.objectID)
// let context = StatusItem.Thread.Context(
// status: record,
// displayUpperConversationLink: !isLast,
// displayBottomConversationLink: true
// )
// let item = StatusItem.thread(.leaf(context: context))
// newItems.append(item)
// }
let items = self.__ancestors + newItems
self.__ancestors = items
@ -132,31 +133,32 @@ extension MastodonStatusThreadViewModel {
}
var newItems: [StatusItem] = []
for node in nodes {
guard let status = dictionary[node.statusID] else { continue }
// first tier
let record = ManagedObjectRecord<Status>(objectID: status.objectID)
let context = StatusItem.Thread.Context(
status: record
)
let item = StatusItem.thread(.leaf(context: context))
newItems.append(item)
// second tier
if let child = node.children.first {
guard let secondaryStatus = dictionary[child.statusID] else { continue }
let secondaryRecord = ManagedObjectRecord<Status>(objectID: secondaryStatus.objectID)
let secondaryContext = StatusItem.Thread.Context(
status: secondaryRecord,
displayUpperConversationLink: true
)
let secondaryItem = StatusItem.thread(.leaf(context: secondaryContext))
newItems.append(secondaryItem)
// update first tier context
context.displayBottomConversationLink = true
}
}
#warning("Potentially this can be removed and replaced by native threading logic")
// for node in nodes {
// guard let status = dictionary[node.statusID] else { continue }
// // first tier
// let record = ManagedObjectRecord<Status>(objectID: status.objectID)
// let context = StatusItem.Thread.Context(
// status: record
// )
// let item = StatusItem.thread(.leaf(context: context))
// newItems.append(item)
//
// // second tier
// if let child = node.children.first {
// guard let secondaryStatus = dictionary[child.statusID] else { continue }
// let secondaryRecord = ManagedObjectRecord<Status>(objectID: secondaryStatus.objectID)
// let secondaryContext = StatusItem.Thread.Context(
// status: secondaryRecord,
// displayUpperConversationLink: true
// )
// let secondaryItem = StatusItem.thread(.leaf(context: secondaryContext))
// newItems.append(secondaryItem)
//
// // update first tier context
// context.displayBottomConversationLink = true
// }
// }
var items = self.__descendants
for item in newItems {
@ -262,12 +264,3 @@ extension MastodonStatusThreadViewModel.Node {
}
extension MastodonStatusThreadViewModel {
func delete(objectIDs: [NSManagedObjectID]) {
var set = deletedObjectIDs
for objectID in objectIDs {
set.insert(objectID)
}
self.deletedObjectIDs = set
}
}

View File

@ -30,15 +30,7 @@ final class RemoteThreadViewModel: ThreadViewModel {
authenticationBox: authContext.mastodonAuthenticationBox
)
let managedObjectContext = context.managedObjectContext
let request = Status.sortedFetchRequest
request.fetchLimit = 1
request.predicate = Status.predicate(domain: domain, id: response.value.id)
guard let status = managedObjectContext.safeFetch(request).first else {
assertionFailure()
return
}
let threadContext = StatusItem.Thread.Context(status: .init(objectID: status.objectID))
let threadContext = StatusItem.Thread.Context(status: .fromEntity(response.value))
self.root = .root(context: threadContext)
} // end Task
@ -62,17 +54,9 @@ final class RemoteThreadViewModel: ThreadViewModel {
authenticationBox: authContext.mastodonAuthenticationBox
)
guard let statusID = response.value.status?.id else { return }
guard let status = response.value.status else { return }
let managedObjectContext = context.managedObjectContext
let request = Status.sortedFetchRequest
request.fetchLimit = 1
request.predicate = Status.predicate(domain: domain, id: statusID)
guard let status = managedObjectContext.safeFetch(request).first else {
assertionFailure()
return
}
let threadContext = StatusItem.Thread.Context(status: .init(objectID: status.objectID))
let threadContext = StatusItem.Thread.Context(status: .fromEntity(status))
self.root = .root(context: threadContext)
} // end Task
}

View File

@ -38,8 +38,7 @@ extension ThreadViewModel {
snapshot.appendSections([.main])
if let root = self.root {
if case let .root(threadContext) = root,
let status = threadContext.status.object(in: context.managedObjectContext),
status.inReplyToID != nil
threadContext.status.entity.inReplyToID != nil
{
snapshot.appendItems([.topLoader], toSection: .main)
}
@ -81,8 +80,7 @@ extension ThreadViewModel {
// top loader
let _hasReplyTo: Bool? = try? await self.context.managedObjectContext.perform {
guard case let .root(threadContext) = root else { return nil }
guard let status = threadContext.status.object(in: self.context.managedObjectContext) else { return nil }
return status.inReplyToID != nil
return threadContext.status.entity.inReplyToID != nil
}
if let hasReplyTo = _hasReplyTo, hasReplyTo {
let state = self.loadThreadStateMachine.currentState

View File

@ -55,40 +55,25 @@ class ThreadViewModel {
self.root = optionalRoot
self.mastodonStatusThreadViewModel = MastodonStatusThreadViewModel(context: context)
// end init
ManagedObjectObserver.observe(context: context.managedObjectContext)
.sink(receiveCompletion: { completion in
// do nohting
}, receiveValue: { [weak self] changes in
guard let self = self else { return }
let objectIDs: [NSManagedObjectID] = changes.changeTypes.compactMap { changeType in
guard case let .delete(object) = changeType else { return nil }
return object.objectID
}
self.delete(objectIDs: objectIDs)
})
.store(in: &disposeBag)
$root
.receive(on: DispatchQueue.main)
.sink { [weak self] root in
guard let self = self else { return }
guard case let .root(threadContext) = root else { return }
guard let status = threadContext.status.object(in: self.context.managedObjectContext) else { return }
let status = threadContext.status
// bind threadContext
self.threadContext = .init(
domain: status.domain,
domain: authContext.mastodonAuthenticationBox.domain, //status.domain,
statusID: status.id,
replyToID: status.inReplyToID
replyToID: status.entity.inReplyToID
)
// bind titleView
self.navigationBarTitle = {
let title = L10n.Scene.Thread.title(status.author.displayNameWithFallback)
let content = MastodonContent(content: title, emojis: status.author.emojis.asDictionary)
let title = L10n.Scene.Thread.title(status.entity.account.displayNameWithFallback)
let content = MastodonContent(content: title, emojis: status.entity.account.emojis?.asDictionary ?? [:])
return try? MastodonMetaContent.convert(document: content)
}()
}
@ -116,16 +101,3 @@ extension ThreadViewModel {
}
}
extension ThreadViewModel {
func delete(objectIDs: [NSManagedObjectID]) {
if let root = self.root,
case let .root(threadContext) = root,
objectIDs.contains(threadContext.status.objectID)
{
self.root = nil
}
self.mastodonStatusThreadViewModel.delete(objectIDs: objectIDs)
}
}

View File

@ -9,87 +9,11 @@
import Foundation
import UIKit
import Combine
import CoreData
import CoreDataStack
import MastodonSDK
final public class FeedFetchedResultsController: NSObject {
private enum Constants {
static let defaultFetchLimit = 100
}
var disposeBag = Set<AnyCancellable>()
private let fetchedResultsController: NSFetchedResultsController<Feed>
public var managedObjectContext: NSManagedObjectContext {
fetchedResultsController.managedObjectContext
}
// input
@Published public var predicate = Feed.predicate(kind: .none, acct: .none)
// output
private let _objectIDs = PassthroughSubject<[NSManagedObjectID], Never>()
@Published public var records: [ManagedObjectRecord<Feed>] = []
final public class FeedFetchedResultsController {
public func fetchNextBatch() {
fetchedResultsController.fetchRequest.fetchLimit += Constants.defaultFetchLimit
try? fetchedResultsController.performFetch()
}
public init(managedObjectContext: NSManagedObjectContext) {
self.fetchedResultsController = {
let fetchRequest = Feed.sortedFetchRequest
// make sure initial query return empty results
fetchRequest.returnsObjectsAsFaults = false
fetchRequest.shouldRefreshRefetchedObjects = true
fetchRequest.fetchBatchSize = 15
fetchRequest.fetchLimit = Constants.defaultFetchLimit
let controller = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: managedObjectContext,
sectionNameKeyPath: nil,
cacheName: nil
)
return controller
}()
super.init()
// debounce output to prevent UI update issues
_objectIDs
.throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true)
.map { objectIDs in objectIDs.map { ManagedObjectRecord(objectID: $0) } }
.assign(to: &$records)
fetchedResultsController.delegate = self
$predicate
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] predicate in
guard let self = self else { return }
self.fetchedResultsController.fetchRequest.predicate = predicate
do {
try self.fetchedResultsController.performFetch()
} catch {
assertionFailure(error.localizedDescription)
}
}
.store(in: &disposeBag)
}
@Published public var records: [MastodonFeed] = []
public init() {}
}
// MARK: - NSFetchedResultsControllerDelegate
extension FeedFetchedResultsController: NSFetchedResultsControllerDelegate {
public func controller(
_ controller: NSFetchedResultsController<NSFetchRequestResult>,
didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference
) {
let snapshot = snapshot as NSDiffableDataSourceSnapshot<String, NSManagedObjectID>
self._objectIDs.send(snapshot.itemIdentifiers)
}
}

View File

@ -11,93 +11,27 @@ import CoreData
import CoreDataStack
import MastodonSDK
public final class StatusFetchedResultsController: NSObject {
var disposeBag = Set<AnyCancellable>()
let fetchedResultsController: NSFetchedResultsController<Status>
// input
@Published public var domain: String? = nil
@Published public var statusIDs: [Mastodon.Entity.Status.ID] = []
public final class StatusFetchedResultsController {
@MainActor
@Published public private(set) var records: [MastodonStatus] = []
// output
let _objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([])
@Published public private(set) var records: [ManagedObjectRecord<Status>] = []
public init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate?) {
self.domain = domain ?? ""
self.fetchedResultsController = {
let fetchRequest = Status.sortedFetchRequest
fetchRequest.predicate = Status.predicate(domain: domain ?? "", ids: [])
fetchRequest.returnsObjectsAsFaults = false
fetchRequest.fetchBatchSize = 20
let controller = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: managedObjectContext,
sectionNameKeyPath: nil,
cacheName: nil
)
return controller
}()
super.init()
// debounce output to prevent UI update issues
_objectIDs
.throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true)
.map { objectIDs in objectIDs.map { ManagedObjectRecord(objectID: $0) } }
.assign(to: &$records)
fetchedResultsController.delegate = self
Publishers.CombineLatest(
self.$domain.removeDuplicates(),
self.$statusIDs.removeDuplicates()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] domain, ids in
guard let self = self else { return }
var predicates = [Status.predicate(domain: domain ?? "", ids: ids)]
if let additionalPredicate = additionalTweetPredicate {
predicates.append(additionalPredicate)
}
self.fetchedResultsController.fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
do {
try self.fetchedResultsController.performFetch()
} catch {
assertionFailure(error.localizedDescription)
}
}
.store(in: &disposeBag)
@MainActor
public init(records: [MastodonStatus] = []) {
self.records = records
}
}
extension StatusFetchedResultsController {
public func append(statusIDs: [Mastodon.Entity.Status.ID]) {
var result = self.statusIDs
for statusID in statusIDs where !result.contains(statusID) {
result.append(statusID)
}
self.statusIDs = result
@MainActor
public func reset() {
records = []
}
}
// MARK: - NSFetchedResultsControllerDelegate
extension StatusFetchedResultsController: NSFetchedResultsControllerDelegate {
public func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
let indexes = statusIDs
let objects = fetchedResultsController.fetchedObjects ?? []
let items: [NSManagedObjectID] = objects
.compactMap { object in
indexes.firstIndex(of: object.id).map { index in (index, object) }
}
.sorted { $0.0 < $1.0 }
.map { $0.1.objectID }
self._objectIDs.value = items
@MainActor
public func setRecords(_ records: [MastodonStatus]) {
self.records = records
}
@MainActor
public func appendRecords(_ records: [MastodonStatus]) {
self.records += records
}
}

View File

@ -19,26 +19,26 @@ extension APIService {
}
public func bookmark(
record: ManagedObjectRecord<Status>,
record: MastodonStatus,
authenticationBox: MastodonAuthenticationBox
) async throws -> Mastodon.Response.Content<Mastodon.Entity.Status> {
let managedObjectContext = backgroundManagedObjectContext
// update bookmark state and retrieve bookmark context
let bookmarkContext: MastodonBookmarkContext = try await managedObjectContext.performChanges {
let authentication = authenticationBox.authentication
guard
let _status = record.object(in: managedObjectContext),
let me = authentication.user(in: managedObjectContext)
else {
throw APIError.implicit(.badRequest)
}
let _status = record.entity
let status = _status.reblog ?? _status
let isBookmarked = status.bookmarkedBy.contains(me)
status.update(bookmarked: !isBookmarked, by: me)
let isBookmarked = status.bookmarked == true
let context = MastodonBookmarkContext(
statusID: status.id,
isBookmarked: isBookmarked
@ -60,38 +60,12 @@ extension APIService {
} catch {
result = .failure(error)
}
let response = try result.get()
// update bookmark state
try await managedObjectContext.performChanges {
let authentication = authenticationBox.authentication
guard
let _status = record.object(in: managedObjectContext),
let me = authentication.user(in: managedObjectContext)
else { return }
let status = _status.reblog ?? _status
switch result {
case .success(let response):
_ = Persistence.Status.createOrMerge(
in: managedObjectContext,
context: Persistence.Status.PersistContext(
domain: authenticationBox.domain,
entity: response.value,
me: me,
statusCache: nil,
userCache: nil,
networkDate: response.networkDate
)
)
case .failure:
// rollback
status.update(bookmarked: bookmarkContext.isBookmarked, by: me)
}
}
record.entity = response.value
let response = try result.get()
return response
}

View File

@ -14,13 +14,13 @@ import CoreDataStack
extension APIService {
private struct MastodonFavoriteContext {
let statusID: Status.ID
let statusID: MastodonStatus.ID
let isFavorited: Bool
let favoritedCount: Int64
}
public func favorite(
record: ManagedObjectRecord<Status>,
status: MastodonStatus,
authenticationBox: MastodonAuthenticationBox
) async throws -> Mastodon.Response.Content<Mastodon.Entity.Status> {
@ -31,18 +31,15 @@ extension APIService {
let authentication = authenticationBox.authentication
guard
let _status = record.object(in: managedObjectContext),
let me = authentication.user(in: managedObjectContext)
else {
throw APIError.implicit(.badRequest)
}
let status = _status.reblog ?? _status
let isFavorited = status.favouritedBy.contains(me)
let favoritedCount = status.favouritesCount
let favoriteCount = isFavorited ? favoritedCount - 1 : favoritedCount + 1
status.update(liked: !isFavorited, by: me)
status.update(favouritesCount: favoriteCount)
let _status = status.reblog ?? status
let isFavorited = status.entity.favourited == true
let favoritedCount = Int64(status.entity.favouritesCount)
let context = MastodonFavoriteContext(
statusID: status.id,
isFavorited: isFavorited,
@ -66,40 +63,6 @@ extension APIService {
result = .failure(error)
}
// update like state
try await managedObjectContext.performChanges {
let authentication = authenticationBox.authentication
guard
let _status = record.object(in: managedObjectContext),
let me = authentication.user(in: managedObjectContext)
else { return }
let status = _status.reblog ?? _status
switch result {
case .success(let response):
_ = Persistence.Status.createOrMerge(
in: managedObjectContext,
context: Persistence.Status.PersistContext(
domain: authenticationBox.domain,
entity: response.value,
me: me,
statusCache: nil,
userCache: nil,
networkDate: response.networkDate
)
)
if favoriteContext.isFavorited {
status.update(favouritesCount: max(0, status.favouritesCount - 1)) // undo API return count has delay. Needs -1 local
}
case .failure:
// rollback
status.update(liked: favoriteContext.isFavorited, by: me)
status.update(favouritesCount: favoriteContext.favoritedCount)
}
}
let response = try result.get()
return response
}
@ -152,19 +115,11 @@ extension APIService {
extension APIService {
public func favoritedBy(
status: ManagedObjectRecord<Status>,
status: MastodonStatus,
query: Mastodon.API.Statuses.FavoriteByQuery,
authenticationBox: MastodonAuthenticationBox
) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Account]> {
let managedObjectContext = backgroundManagedObjectContext
let _statusID: Status.ID? = try? await managedObjectContext.perform {
guard let _status = status.object(in: managedObjectContext) else { return nil }
let status = _status.reblog ?? _status
return status.id
}
guard let statusID = _statusID else {
throw APIError.implicit(.badRequest)
}
let statusID: String = status.reblog?.id ?? status.id
let response = try await Mastodon.API.Statuses.favoriteBy(
session: session,
@ -173,21 +128,7 @@ extension APIService {
query: query,
authorization: authenticationBox.userAuthorization
).singleOutput()
try await managedObjectContext.performChanges {
for entity in response.value {
_ = Persistence.MastodonUser.createOrMerge(
in: managedObjectContext,
context: .init(
domain: authenticationBox.domain,
entity: entity,
cache: nil,
networkDate: response.networkDate
)
)
} // end for in
}
return response
} // end func
}

View File

@ -20,7 +20,7 @@ extension APIService {
}
public func reblog(
record: ManagedObjectRecord<Status>,
status: MastodonStatus,
authenticationBox: MastodonAuthenticationBox
) async throws -> Mastodon.Response.Content<Mastodon.Entity.Status> {
let managedObjectContext = backgroundManagedObjectContext
@ -30,19 +30,16 @@ extension APIService {
let authentication = authenticationBox.authentication
guard
let me = authentication.user(in: managedObjectContext),
let _status = record.object(in: managedObjectContext)
let me = authentication.user(in: managedObjectContext)
else { return nil }
let status = _status.reblog ?? _status
let isReblogged = status.rebloggedBy.contains(me)
let rebloggedCount = status.reblogsCount
let reblogCount = isReblogged ? rebloggedCount - 1 : rebloggedCount + 1
status.update(reblogged: !isReblogged, by: me)
status.update(reblogsCount: Int64(max(0, reblogCount)))
let _status = status.reblog ?? status
let isReblogged = _status.entity.reblogged == true
let rebloggedCount = Int64(_status.entity.reblogsCount)
let reblogContext = MastodonReblogContext(
statusID: status.id,
isReblogged: isReblogged,
statusID: _status.id,
isReblogged: !isReblogged,
rebloggedCount: rebloggedCount
)
return reblogContext
@ -65,41 +62,7 @@ extension APIService {
} catch {
result = .failure(error)
}
// update repost state
try await managedObjectContext.performChanges {
let authentication = authenticationBox.authentication
guard
let me = authentication.user(in: managedObjectContext),
let _status = record.object(in: managedObjectContext)
else { return }
let status = _status.reblog ?? _status
switch result {
case .success(let response):
_ = Persistence.Status.createOrMerge(
in: managedObjectContext,
context: Persistence.Status.PersistContext(
domain: authentication.domain,
entity: response.value,
me: me,
statusCache: nil,
userCache: nil,
networkDate: response.networkDate
)
)
if reblogContext.isReblogged {
status.update(reblogsCount: max(0, status.reblogsCount - 1)) // undo API return count has delay. Needs -1 local
}
case .failure:
// rollback
status.update(reblogged: reblogContext.isReblogged, by: me)
status.update(reblogsCount: reblogContext.rebloggedCount)
}
}
let response = try result.get()
return response
}
@ -108,19 +71,12 @@ extension APIService {
extension APIService {
public func rebloggedBy(
status: ManagedObjectRecord<Status>,
status: MastodonStatus,
query: Mastodon.API.Statuses.RebloggedByQuery,
authenticationBox: MastodonAuthenticationBox
) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Account]> {
let managedObjectContext = backgroundManagedObjectContext
let _statusID: Status.ID? = try? await managedObjectContext.perform {
guard let _status = status.object(in: managedObjectContext) else { return nil }
let status = _status.reblog ?? _status
return status.id
}
guard let statusID = _statusID else {
throw APIError.implicit(.badRequest)
}
let statusID: Status.ID = status.reblog?.id ?? status.id
let response = try await Mastodon.API.Statuses.rebloggedBy(
session: session,
@ -130,6 +86,7 @@ extension APIService {
authorization: authenticationBox.userAuthorization
).singleOutput()
#warning("Is this still required?")
try await managedObjectContext.performChanges {
for entity in response.value {
_ = Persistence.MastodonUser.createOrMerge(

View File

@ -47,14 +47,14 @@ extension APIService {
}
public func deleteStatus(
status: ManagedObjectRecord<Status>,
status: MastodonStatus,
authenticationBox: MastodonAuthenticationBox
) async throws -> Mastodon.Response.Content<Mastodon.Entity.Status> {
let authorization = authenticationBox.userAuthorization
let managedObjectContext = backgroundManagedObjectContext
let _query: Mastodon.API.Statuses.DeleteStatusQuery? = try? await managedObjectContext.perform {
guard let _status = status.object(in: managedObjectContext) else { return nil }
let _status = status.entity
let status = _status.reblog ?? _status
return Mastodon.API.Statuses.DeleteStatusQuery(id: status.id)
}
@ -68,12 +68,7 @@ extension APIService {
query: query,
authorization: authorization
).singleOutput()
try await managedObjectContext.performChanges {
guard let status = status.object(in: managedObjectContext) else { return }
managedObjectContext.delete(status)
}
return response
}

View File

@ -104,6 +104,15 @@ extension Mastodon.Entity.Account: Equatable {
//MARK: - Convenience
extension Mastodon.Entity.Account {
public var acctWithDomain: String {
if !acct.contains("@") {
// Safe concat due to username cannot contains "@"
return username + "@" + (domain ?? "")
} else {
return acct
}
}
public func acctWithDomainIfMissing(_ localDomain: String) -> String {
guard acct.contains("@") else {
return "\(acct)@\(localDomain)"

View File

@ -83,3 +83,13 @@ extension Mastodon.Entity.Card {
}
}
}
extension Mastodon.Entity.Card: Hashable {
public static func == (lhs: Mastodon.Entity.Card, rhs: Mastodon.Entity.Card) -> Bool {
lhs.url == rhs.url
}
public func hash(into hasher: inout Hasher) {
hasher.combine(url)
}
}

View File

@ -88,3 +88,13 @@ extension Mastodon.Entity.Notification {
}
}
}
extension Mastodon.Entity.Notification: Hashable {
public static func == (lhs: Mastodon.Entity.Notification, rhs: Mastodon.Entity.Notification) -> Bool {
lhs.id == rhs.id
}
public func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}

View File

@ -133,3 +133,14 @@ extension Mastodon.Entity.Status {
}
}
}
extension Mastodon.Entity.Status: Hashable {
public static func == (lhs: Mastodon.Entity.Status, rhs: Mastodon.Entity.Status) -> Bool {
lhs.uri == rhs.uri && lhs.id == rhs.id
}
public func hash(into hasher: inout Hasher) {
hasher.combine(uri)
hasher.combine(id)
}
}

View File

@ -0,0 +1,56 @@
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
import Foundation
import CoreDataStack
public final class MastodonFeed {
public var hasMore: Bool = false
public var isLoadingMore: Bool = false
public let status: MastodonStatus?
public let notification: Mastodon.Entity.Notification?
public let kind: Feed.Kind
init(hasMore: Bool, isLoadingMore: Bool, status: MastodonStatus?, notification: Mastodon.Entity.Notification?, kind: Feed.Kind) {
self.hasMore = hasMore
self.isLoadingMore = isLoadingMore
self.status = status
self.notification = notification
self.kind = kind
}
}
public extension MastodonFeed {
static func fromStatus(_ status: MastodonStatus, kind: Feed.Kind) -> MastodonFeed {
MastodonFeed(
hasMore: false,
isLoadingMore: false,
status: status,
notification: nil,
kind: kind
)
}
static func fromNotification(_ notification: Mastodon.Entity.Notification, kind: Feed.Kind) -> MastodonFeed {
MastodonFeed(
hasMore: false,
isLoadingMore: false,
status: nil,
notification: notification,
kind: kind
)
}
}
extension MastodonFeed: Hashable {
public static func == (lhs: MastodonFeed, rhs: MastodonFeed) -> Bool {
lhs.status?.id == rhs.status?.id || lhs.notification?.id == rhs.notification?.id
}
public func hash(into hasher: inout Hasher) {
hasher.combine(status)
hasher.combine(notification)
}
}

View File

@ -0,0 +1,45 @@
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
import Foundation
import CoreDataStack
public final class MastodonNotification {
public let entity: Mastodon.Entity.Notification
public var id: Mastodon.Entity.Notification.ID {
entity.id
}
public let account: MastodonUser
public let status: MastodonStatus?
public let feeds: [MastodonFeed]
public var followRequestState: MastodonFollowRequestState = .init(state: .none)
public var transientFollowRequestState: MastodonFollowRequestState = .init(state: .none)
public init(entity: Mastodon.Entity.Notification, account: MastodonUser, status: MastodonStatus?, feeds: [MastodonFeed]) {
self.entity = entity
self.account = account
self.status = status
self.feeds = feeds
}
}
public extension MastodonNotification {
static func fromEntity(_ entity: Mastodon.Entity.Notification, using managedObjectContext: NSManagedObjectContext) -> MastodonNotification? {
guard let user = MastodonUser.fetch(in: managedObjectContext, configurationBlock: { request in
request.predicate = MastodonUser.predicate(domain: entity.account.domain ?? "", id: entity.account.id)
}).first else { return nil }
return MastodonNotification(entity: entity, account: user, status: entity.status.map(MastodonStatus.fromEntity), feeds: [])
}
}
extension MastodonNotification: Hashable {
public static func == (lhs: MastodonNotification, rhs: MastodonNotification) -> Bool {
lhs.id == rhs.id
}
public func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}

View File

@ -0,0 +1,54 @@
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
import Foundation
import Combine
import CoreDataStack
public final class MastodonStatus: ObservableObject {
public typealias ID = Mastodon.Entity.Status.ID
@Published public var entity: Mastodon.Entity.Status
@Published public private(set) var reblog: MastodonStatus?
@Published public var isSensitiveToggled: Bool = false
init(entity: Mastodon.Entity.Status, isSensitiveToggled: Bool) {
self.entity = entity
self.isSensitiveToggled = isSensitiveToggled
if let reblog = entity.reblog {
self.reblog = MastodonStatus.fromEntity(reblog)
} else {
self.reblog = nil
}
}
public var id: ID {
entity.id
}
}
extension MastodonStatus {
public static func fromEntity(_ entity: Mastodon.Entity.Status) -> MastodonStatus {
return MastodonStatus(entity: entity, isSensitiveToggled: false)
}
}
extension MastodonStatus: Hashable {
public static func == (lhs: MastodonStatus, rhs: MastodonStatus) -> Bool {
lhs.entity.id == rhs.entity.id
}
public func hash(into hasher: inout Hasher) {
hasher.combine(entity)
hasher.combine(isSensitiveToggled)
}
}
public extension Mastodon.Entity.Status {
var mastodonVisibility: MastodonVisibility? {
guard let visibility = visibility?.rawValue else { return nil }
return MastodonVisibility(rawValue: visibility)
}
}

View File

@ -2,6 +2,7 @@
import Foundation
import CoreDataStack
import MastodonSDK
public protocol StatusCompatible {
var reblog: Status? { get }

View File

@ -60,10 +60,8 @@ extension ComposeContentViewModel {
cell.statusView.frame.size.width = tableView.frame.width
// configure status
context.managedObjectContext.performAndWait {
guard let replyTo = status.object(in: context.managedObjectContext) else { return }
cell.statusView.configure(status: replyTo)
}
cell.statusView.configure(status: status)
}
}
}

View File

@ -23,7 +23,7 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
public enum ComposeContext {
case composeStatus
case editStatus(status: Status, statusSource: Mastodon.Entity.StatusSource)
case editStatus(status: MastodonStatus, statusSource: Mastodon.Entity.StatusSource)
}
var disposeBag = Set<AnyCancellable>()
@ -163,24 +163,18 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
}()
// set visibility for reply post
if case .reply(let record) = destination {
context.managedObjectContext.performAndWait {
guard let status = record.object(in: context.managedObjectContext) else {
assertionFailure()
return
}
let repliedStatusVisibility = status.visibility
switch repliedStatusVisibility {
case .public, .unlisted:
// keep default
break
case .private:
visibility = .private
case .direct:
visibility = .direct
case ._other:
assertionFailure()
break
}
let repliedStatusVisibility = record.entity.visibility
switch repliedStatusVisibility {
case .public, .unlisted:
// keep default
break
case .private:
visibility = .private
case .direct:
visibility = .direct
case ._other, .none:
assertionFailure()
break
}
}
return visibility
@ -189,26 +183,7 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
self.customEmojiViewModel = context.emojiService.dequeueCustomEmojiViewModel(
for: authContext.mastodonAuthenticationBox.domain
)
if case let ComposeContext.editStatus(status, _) = composeContext {
if status.isContentSensitive {
isContentWarningActive = true
contentWarning = status.spoilerText ?? ""
}
if let poll = status.poll {
isPollActive = !poll.expired
pollMultipleConfigurationOption = poll.multiple
if let pollExpiresAt = poll.expiresAt {
pollExpireConfigurationOption = .init(closestDateToExpiry: pollExpiresAt)
}
pollOptions = poll.options.sortedByIndex().map {
let option = PollComposeItem.Option()
option.text = $0.title
return option
}
}
}
let recentLanguages = context.settingService.currentSetting.value?.recentLanguages ?? []
self.recentLanguages = recentLanguages
self.language = recentLanguages.first ?? Locale.current.language.languageCode?.identifier ?? "en"
@ -220,17 +195,14 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
switch destination {
case .reply(let record):
context.managedObjectContext.performAndWait {
guard let status = record.object(in: context.managedObjectContext) else {
assertionFailure()
return
}
let status = record.entity
let author = authContext.mastodonAuthenticationBox.authentication.user(in: context.managedObjectContext)
var mentionAccts: [String] = []
if author?.id != status.author.id {
mentionAccts.append("@" + status.author.acct)
if author?.id != status.account.id {
mentionAccts.append("@" + status.account.acct)
}
let mentions = status.mentions
let mentions = status.mentions ?? []
.filter { author?.id != $0.id }
for mention in mentions {
let acct = "@" + mention.acct
@ -288,11 +260,11 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
case .composeStatus:
self.isVisibilityButtonEnabled = true
case let .editStatus(status, _):
if let visibility = Mastodon.Entity.Status.Visibility(rawValue: status.visibility.rawValue) {
if let visibility = status.entity.visibility {
self.visibility = visibility
}
self.isVisibilityButtonEnabled = false
self.attachmentViewModels = status.attachments.compactMap {
self.attachmentViewModels = status.entity.mastodonAttachments.compactMap {
guard let assetURL = $0.assetURL, let url = URL(string: assetURL) else { return nil }
let attachmentViewModel = AttachmentViewModel(
api: context.apiService,
@ -306,6 +278,27 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
}
}
if case let ComposeContext.editStatus(status, _) = composeContext {
if status.entity.sensitive == true {
isContentWarningActive = true
contentWarning = status.entity.spoilerText ?? ""
}
Task {
if let poll = await status.getPoll(in: context.managedObjectContext) {
isPollActive = !poll.expired
pollMultipleConfigurationOption = poll.multiple
if let pollExpiresAt = poll.expiresAt {
pollExpireConfigurationOption = .init(closestDateToExpiry: pollExpiresAt)
}
pollOptions = poll.options.sortedByIndex().map {
let option = PollComposeItem.Option()
option.text = $0.title
return option
}
}
}
}
bind()
}
@ -503,7 +496,7 @@ extension ComposeContentViewModel {
extension ComposeContentViewModel {
public enum Destination {
case topLevel
case reply(parent: ManagedObjectRecord<Status>)
case reply(parent: MastodonStatus)
}
public enum ScrollViewState {

View File

@ -19,7 +19,7 @@ public final class MastodonStatusPublisher: NSObject, ProgressReporting {
// author
public let author: ManagedObjectRecord<MastodonUser>
// refer
public let replyTo: ManagedObjectRecord<Status>?
public let replyTo: MastodonStatus?
// content warning
public let isContentWarningComposing: Bool
public let contentWarning: String
@ -48,7 +48,7 @@ public final class MastodonStatusPublisher: NSObject, ProgressReporting {
public init(
author: ManagedObjectRecord<MastodonUser>,
replyTo: ManagedObjectRecord<Status>?,
replyTo: MastodonStatus?,
isContentWarningComposing: Bool,
contentWarning: String,
content: String,
@ -162,7 +162,7 @@ extension MastodonStatusPublisher: StatusPublisher {
return self.pollExpireConfigurationOption.seconds
}()
let inReplyToID: Mastodon.Entity.Status.ID? = try await api.backgroundManagedObjectContext.perform {
guard let replyTo = self.replyTo?.object(in: api.backgroundManagedObjectContext) else { return nil }
guard let replyTo = self.replyTo else { return nil }
return replyTo.id
}

View File

@ -180,7 +180,7 @@ extension MediaView.Configuration {
}
extension MediaView {
public static func configuration(status: StatusCompatible) -> [MediaView.Configuration] {
public static func configuration(status: MastodonStatus) -> [MediaView.Configuration] {
func videoInfo(from attachment: MastodonAttachment) -> MediaView.Configuration.VideoInfo {
MediaView.Configuration.VideoInfo(
aspectRadio: attachment.size,
@ -191,8 +191,8 @@ extension MediaView {
)
}
let status: StatusCompatible = status.reblog ?? status
let attachments = status.attachments
// let status: StatusCompatible = status.reblog ?? status
let attachments = status.entity.mastodonAttachments
let configurations = attachments.enumerated().map { (idx, attachment) -> MediaView.Configuration in
let configuration: MediaView.Configuration = {
switch attachment.kind {
@ -236,7 +236,7 @@ extension MediaView {
}()
configuration.load()
configuration.isReveal = status.isMediaSensitive ? status.isSensitiveToggled : true
configuration.isReveal = status.entity.sensitive == true ? status.isSensitiveToggled : true
return configuration
}

View File

@ -19,7 +19,7 @@ import CoreDataStack
extension NotificationView {
public final class ViewModel: ObservableObject {
public var disposeBag = Set<AnyCancellable>()
public var objects = Set<NSManagedObject>()
public var objects = Set<AnyHashable>()
@Published public var context: AppContext?
@Published public var authContext: AuthContext?

View File

@ -13,6 +13,7 @@ import MastodonLocalization
import CoreDataStack
import UIKit
import WebKit
import MastodonSDK
public protocol StatusCardControlDelegate: AnyObject {
func statusCardControl(_ statusCardControl: StatusCardControl, didTapURL url: URL)
@ -133,20 +134,24 @@ public final class StatusCardControl: UIControl {
fatalError("init(coder:) has not been implemented")
}
public func configure(card: Card) {
public func configure(card: Mastodon.Entity.Card) {
let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines)
if let host = card.url?.host {
let url = URL(string: card.url)
if let host = url?.host {
accessibilityLabel = "\(title) \(host)"
} else {
accessibilityLabel = title
}
titleLabel.text = title
linkLabel.text = card.url?.host
linkLabel.text = url?.host
imageView.contentMode = .center
imageView.sd_setImage(
with: card.imageURL,
with: {
guard let image = card.image else { return nil }
return URL(string: image)
}(),
placeholderImage: icon(for: card.layout)
) { [weak self] image, _, _, _ in
if image != nil {
@ -333,6 +338,18 @@ private extension Card {
}
}
private extension Mastodon.Entity.Card {
var layout: StatusCardControl.Layout {
var aspectRatio = CGFloat(width ?? 1) / CGFloat(height ?? 1)
if !aspectRatio.isFinite {
aspectRatio = 1
}
return (abs(aspectRatio - 1) < 0.05 || image == nil) && html == nil
? .compact
: .large(aspectRatio: aspectRatio)
}
}
private extension UILayoutPriority {
static let zero = UILayoutPriority(rawValue: 0)
}

View File

@ -19,7 +19,7 @@ extension StatusView {
static let statusFilterWorkingQueue = DispatchQueue(label: "StatusFilterWorkingQueue")
public func configure(feed: Feed) {
public func configure(feed: MastodonFeed) {
switch feed.kind {
case .home:
guard let status = feed.status else {
@ -40,18 +40,12 @@ extension StatusView {
extension StatusView {
public func configure(status: Status, statusEdit: Mastodon.Entity.StatusEdit) {
viewModel.objects.insert(status)
if let reblog = status.reblog {
viewModel.objects.insert(reblog)
}
public func configure(status: MastodonStatus, statusEdit: Mastodon.Entity.StatusEdit) {
configureHeader(status: status)
let author = (status.reblog ?? status).author
let author = (status.reblog ?? status).entity.account
configureAuthor(author: author)
let timestamp = (status.reblog ?? status).publisher(for: \.createdAt)
configureTimestamp(timestamp: timestamp.eraseToAnyPublisher())
configureApplicationName(status.application?.name)
configureTimestamp(timestamp: (status.reblog ?? status).entity.createdAt)
configureApplicationName(status.entity.application?.name)
configureMedia(status: status)
configurePollHistory(statusEdit: statusEdit)
configureCard(status: status)
@ -66,18 +60,13 @@ extension StatusView {
viewModel.isContentReveal = true
}
public func configure(status: Status) {
viewModel.objects.insert(status)
if let reblog = status.reblog {
viewModel.objects.insert(reblog)
}
public func configure(status: MastodonStatus) {
configureHeader(status: status)
let author = (status.reblog ?? status).author
let author = (status.reblog ?? status).entity.account
configureAuthor(author: author)
let timestamp = (status.reblog ?? status).publisher(for: \.createdAt)
configureTimestamp(timestamp: timestamp.eraseToAnyPublisher())
configureApplicationName(status.application?.name)
let timestamp = (status.reblog ?? status).entity.createdAt
configureTimestamp(timestamp: timestamp)
configureApplicationName(status.entity.application?.name)
configureContent(status: status)
configureMedia(status: status)
configurePoll(status: status)
@ -96,14 +85,13 @@ extension StatusView {
}
extension StatusView {
private func configureHeader(status: Status) {
private func configureHeader(status: MastodonStatus) {
if let _ = status.reblog {
Publishers.CombineLatest(
status.author.publisher(for: \.displayName),
status.author.publisher(for: \.emojis)
)
.map { name, emojis -> StatusView.ViewModel.Header in
let text = L10n.Common.Controls.Status.userReblogged(status.author.displayNameWithFallback)
let name = status.entity.account.displayName
let emojis = status.entity.account.emojis ?? []
viewModel.header = {
let text = L10n.Common.Controls.Status.userReblogged(status.entity.account.displayNameWithFallback)
let content = MastodonContent(content: text, emojis: emojis.asDictionary)
do {
let metaContent = try MastodonMetaContent.convert(document: content)
@ -112,12 +100,10 @@ extension StatusView {
let metaContent = PlaintextMetaContent(string: name)
return .repost(info: .init(header: metaContent))
}
}
.assign(to: \.header, on: viewModel)
.store(in: &disposeBag)
} else if let _ = status.inReplyToID,
let inReplyToAccountID = status.inReplyToAccountID
}()
} else if let _ = status.entity.inReplyToID,
let inReplyToAccountID = status.entity.inReplyToAccountID
{
func createHeader(
name: String?,
@ -139,20 +125,31 @@ extension StatusView {
return header
}
if let replyTo = status.replyTo {
if let inReplyToID = status.entity.inReplyToID {
// A. replyTo status exist
let header = createHeader(name: replyTo.author.displayNameWithFallback, emojis: replyTo.author.emojis.asDictionary)
viewModel.header = header
if let authenticationBox = viewModel.authContext?.mastodonAuthenticationBox {
Task {
if let replyTo = try? await Mastodon.API.Statuses.status(
session: .shared,
domain: authenticationBox.domain,
statusID: inReplyToID,
authorization: authenticationBox.userAuthorization
).singleOutput().value {
let header = createHeader(name: replyTo.account.displayNameWithFallback, emojis: replyTo.account.emojis?.asDictionary ?? [:])
viewModel.header = header
}
}
}
} else {
// B. replyTo status not exist
let request = MastodonUser.sortedFetchRequest
request.predicate = MastodonUser.predicate(domain: status.domain, id: inReplyToAccountID)
if let user = status.managedObjectContext?.safeFetch(request).first {
// B1. replyTo user exist
let header = createHeader(name: user.displayNameWithFallback, emojis: user.emojis.asDictionary)
viewModel.header = header
} else {
// let request = MastodonUser.sortedFetchRequest
// request.predicate = MastodonUser.predicate(domain: status.domain, id: inReplyToAccountID)
// if let user = status.managedObjectContext?.safeFetch(request).first {
// // B1. replyTo user exist
// let header = createHeader(name: user.displayNameWithFallback, emojis: user.emojis.asDictionary)
// viewModel.header = header
// } else {
// B2. replyTo user not exist
let header = createHeader(name: nil, emojis: nil)
viewModel.header = header
@ -178,7 +175,7 @@ extension StatusView {
}
.store(in: &disposeBag)
} // end if let
} // end else B2.
// } // end else B2.
} // end else B.
} else {
@ -186,90 +183,56 @@ extension StatusView {
}
}
public func configureAuthor(author: MastodonUser) {
public func configureAuthor(author: Mastodon.Entity.Account) {
// author avatar
Publishers.CombineLatest(
author.publisher(for: \.avatar),
UserDefaults.shared.publisher(for: \.preferredStaticAvatar)
)
.map { _ in author.avatarImageURL() }
.assign(to: \.authorAvatarImageURL, on: viewModel)
.store(in: &disposeBag)
viewModel.authorAvatarImageURL = author.avatarImageURL()
let emojis = author.emojis?.asDictionary ?? [:]
// author name
Publishers.CombineLatest(
author.publisher(for: \.displayName),
author.publisher(for: \.emojis)
)
.map { _, emojis in
viewModel.authorName = {
do {
let content = MastodonContent(content: author.displayNameWithFallback, emojis: emojis.asDictionary)
let content = MastodonContent(content: author.displayNameWithFallback, emojis: emojis)
let metaContent = try MastodonMetaContent.convert(document: content)
return metaContent
} catch {
assertionFailure(error.localizedDescription)
return PlaintextMetaContent(string: author.displayNameWithFallback)
}
}
.assign(to: \.authorName, on: viewModel)
.store(in: &disposeBag)
}()
// author username
author.publisher(for: \.acct)
.map { $0 as String? }
.assign(to: \.authorUsername, on: viewModel)
.store(in: &disposeBag)
// locked
author.publisher(for: \.locked)
.assign(to: \.locked, on: viewModel)
.store(in: &disposeBag)
// isMuting
author.publisher(for: \.mutingBy)
.map { [weak viewModel] mutingBy in
guard let viewModel = viewModel else { return false }
guard let authContext = viewModel.authContext else { return false }
return mutingBy.contains(where: {
$0.id == authContext.mastodonAuthenticationBox.userID && $0.domain == authContext.mastodonAuthenticationBox.domain
})
}
.assign(to: \.isMuting, on: viewModel)
.store(in: &disposeBag)
// isBlocking
author.publisher(for: \.blockingBy)
.map { [weak viewModel] blockingBy in
guard let viewModel = viewModel else { return false }
guard let authContext = viewModel.authContext else { return false }
return blockingBy.contains(where: {
$0.id == authContext.mastodonAuthenticationBox.userID && $0.domain == authContext.mastodonAuthenticationBox.domain
})
}
.assign(to: \.isBlocking, on: viewModel)
.store(in: &disposeBag)
// isMyself
Publishers.CombineLatest(
author.publisher(for: \.domain),
author.publisher(for: \.id)
)
.map { [weak viewModel] domain, id in
guard let viewModel = viewModel else { return false }
guard let authContext = viewModel.authContext else { return false }
return authContext.mastodonAuthenticationBox.domain == domain && authContext.mastodonAuthenticationBox.userID == id
}
.assign(to: \.isMyself, on: viewModel)
.store(in: &disposeBag)
viewModel.authorUsername = author.acct
// Following
author.publisher(for: \.followingBy)
.map { [weak viewModel] followingBy in
guard let viewModel = viewModel else { return false }
guard let authContext = viewModel.authContext else { return false }
return followingBy.contains(where: {
$0.id == authContext.mastodonAuthenticationBox.userID && $0.domain == authContext.mastodonAuthenticationBox.domain
})
// locked
viewModel.locked = author.locked
// isMuting, isBlocking, Following
Task {
guard let auth = viewModel.authContext?.mastodonAuthenticationBox else { return }
if let relationship = try? await Mastodon.API.Account.relationships(
session: .shared,
domain: auth.domain,
query: .init(ids: [author.id]),
authorization: auth.userAuthorization
).singleOutput().value {
guard let rel = relationship.first else { return }
DispatchQueue.main.async { [self] in
viewModel.isMuting = rel.muting ?? false
viewModel.isBlocking = rel.blocking
viewModel.isFollowed = rel.followedBy
}
}
.assign(to: \.isFollowed, on: viewModel)
.store(in: &disposeBag)
}
// isMyself
viewModel.isMyself = {
guard let authContext = viewModel.authContext else { return false }
return authContext.mastodonAuthenticationBox.domain == author.domain && authContext.mastodonAuthenticationBox.userID == author.id
}()
}
private func configureTimestamp(timestamp: AnyPublisher<Date, Never>) {
private func configureTimestamp(timestamp: Date) {
// timestamp
viewModel.timestampFormatter = { (date: Date, isEdited: Bool) in
if isEdited {
@ -277,10 +240,7 @@ extension StatusView {
}
return date.localizedSlowedTimeAgoSinceNow
}
timestamp
.map { $0 as Date? }
.assign(to: \.timestamp, on: viewModel)
.store(in: &disposeBag)
viewModel.timestamp = timestamp
}
private func configureApplicationName(_ applicationName: String?) {
@ -294,7 +254,7 @@ extension StatusView {
configure(status: originalStatus)
}
func configureTranslated(status: Status) {
func configureTranslated(status: MastodonStatus) {
guard let translation = viewModel.translation,
let translatedContent = translation.content else {
viewModel.isCurrentlyTranslating = false
@ -303,7 +263,7 @@ extension StatusView {
// content
do {
let content = MastodonContent(content: translatedContent, emojis: status.emojis.asDictionary)
let content = MastodonContent(content: translatedContent, emojis: status.entity.emojis?.asDictionary ?? [:])
let metaContent = try MastodonMetaContent.convert(document: content)
viewModel.content = metaContent
viewModel.isCurrentlyTranslating = false
@ -313,13 +273,13 @@ extension StatusView {
}
}
private func configureContent(statusEdit: Mastodon.Entity.StatusEdit, status: Status) {
private func configureContent(statusEdit: Mastodon.Entity.StatusEdit, status: MastodonStatus) {
statusEdit.spoilerText.map {
viewModel.spoilerContent = PlaintextMetaContent(string: $0)
}
// language
viewModel.language = (status.reblog ?? status).language
viewModel.language = (status.reblog ?? status).entity.language
// content
do {
let content = MastodonContent(content: statusEdit.content, emojis: statusEdit.emojis?.asDictionary ?? [:])
@ -332,7 +292,7 @@ extension StatusView {
}
}
private func configureContent(status: Status) {
private func configureContent(status: MastodonStatus) {
guard viewModel.translation == nil else {
return configureTranslated(status: status)
}
@ -340,9 +300,9 @@ extension StatusView {
let status = status.reblog ?? status
// spoilerText
if let spoilerText = status.spoilerText, !spoilerText.isEmpty {
if let spoilerText = status.entity.spoilerText, !spoilerText.isEmpty {
do {
let content = MastodonContent(content: spoilerText, emojis: status.emojis.asDictionary)
let content = MastodonContent(content: spoilerText, emojis: status.entity.emojis?.asDictionary ?? [:])
let metaContent = try MastodonMetaContent.convert(document: content)
viewModel.spoilerContent = metaContent
} catch {
@ -353,10 +313,10 @@ extension StatusView {
viewModel.spoilerContent = nil
}
// language
viewModel.language = (status.reblog ?? status).language
viewModel.language = (status.reblog ?? status).entity.language
// content
do {
let content = MastodonContent(content: status.content, emojis: status.emojis.asDictionary)
let content = MastodonContent(content: status.entity.content ?? "", emojis: status.entity.emojis?.asDictionary ?? [:])
let metaContent = try MastodonMetaContent.convert(document: content)
viewModel.content = metaContent
viewModel.isCurrentlyTranslating = false
@ -365,21 +325,18 @@ extension StatusView {
viewModel.content = PlaintextMetaContent(string: "")
}
// visibility
status.publisher(for: \.visibilityRaw)
.compactMap { MastodonVisibility(rawValue: $0) }
.assign(to: \.visibility, on: viewModel)
.store(in: &disposeBag)
viewModel.visibility = status.entity.mastodonVisibility
// sensitive
viewModel.isContentSensitive = status.isContentSensitive
status.publisher(for: \.isSensitiveToggled)
.assign(to: \.isSensitiveToggled, on: viewModel)
.store(in: &disposeBag)
viewModel.isContentSensitive = status.entity.sensitive == true
viewModel.isSensitiveToggled = status.isSensitiveToggled
}
private func configureMedia(status: StatusCompatible) {
private func configureMedia(status: MastodonStatus) {
let status = status.reblog ?? status
viewModel.isMediaSensitive = status.isMediaSensitive
viewModel.isMediaSensitive = status.entity.sensitive == true
let configurations = MediaView.configuration(status: status)
viewModel.mediaViewConfigurations = configurations
@ -405,39 +362,32 @@ extension StatusView {
pollTableViewDiffableDataSource?.applySnapshotUsingReloadData(_snapshot)
}
private func configurePoll(status: Status) {
let status = status.reblog ?? status
if let poll = status.poll {
viewModel.objects.insert(poll)
}
private func configurePoll(status: MastodonStatus) {
Task {
guard
let context = viewModel.context?.managedObjectContext,
let poll = await status.getPoll(in: context)
else { return }
let status = status.reblog ?? status
viewModel.managedObjects.insert(poll)
// pollItems
status.publisher(for: \.poll)
.sink { [weak self] poll in
guard let self = self else { return }
guard let poll = poll else {
self.viewModel.pollItems = []
return
// pollItems
let options = poll.options.sorted(by: { $0.index < $1.index })
let items: [PollItem] = options.map { .option(record: .init(objectID: $0.objectID)) }
self.viewModel.pollItems = items
// isVoteButtonEnabled
poll.publisher(for: \.updatedAt)
.sink { [weak self] _ in
guard let self = self else { return }
let options = poll.options
let hasSelectedOption = options.contains(where: { $0.isSelected })
self.viewModel.isVoteButtonEnabled = hasSelectedOption
}
let options = poll.options.sorted(by: { $0.index < $1.index })
let items: [PollItem] = options.map { .option(record: .init(objectID: $0.objectID)) }
self.viewModel.pollItems = items
}
.store(in: &disposeBag)
// isVoteButtonEnabled
status.poll?.publisher(for: \.updatedAt)
.sink { [weak self] _ in
guard let self = self else { return }
guard let poll = status.poll else { return }
let options = poll.options
let hasSelectedOption = options.contains(where: { $0.isSelected })
self.viewModel.isVoteButtonEnabled = hasSelectedOption
}
.store(in: &disposeBag)
// isVotable
if let poll = status.poll {
.store(in: &disposeBag)
// isVotable
Publishers.CombineLatest(
poll.publisher(for: \.votedBy),
poll.publisher(for: \.expired)
@ -451,100 +401,62 @@ extension StatusView {
return !isVoted && !expired
}
.assign(to: &viewModel.$isVotable)
// votesCount
poll.publisher(for: \.votesCount)
.map { Int($0) }
.assign(to: \.voteCount, on: viewModel)
.store(in: &disposeBag)
// voterCount
poll.publisher(for: \.votersCount)
.map { Int($0) }
.assign(to: \.voterCount, on: viewModel)
.store(in: &disposeBag)
// expireAt
poll.publisher(for: \.expiresAt)
.assign(to: \.expireAt, on: viewModel)
.store(in: &disposeBag)
// expired
poll.publisher(for: \.expired)
.assign(to: \.expired, on: viewModel)
.store(in: &disposeBag)
// isVoting
poll.publisher(for: \.isVoting)
.assign(to: \.isVoting, on: viewModel)
.store(in: &disposeBag)
}
// votesCount
status.poll?.publisher(for: \.votesCount)
.map { Int($0) }
.assign(to: \.voteCount, on: viewModel)
.store(in: &disposeBag)
// voterCount
status.poll?.publisher(for: \.votersCount)
.map { Int($0) }
.assign(to: \.voterCount, on: viewModel)
.store(in: &disposeBag)
// expireAt
status.poll?.publisher(for: \.expiresAt)
.assign(to: \.expireAt, on: viewModel)
.store(in: &disposeBag)
// expired
status.poll?.publisher(for: \.expired)
.assign(to: \.expired, on: viewModel)
.store(in: &disposeBag)
// isVoting
status.poll?.publisher(for: \.isVoting)
.assign(to: \.isVoting, on: viewModel)
.store(in: &disposeBag)
}
private func configureCard(status: Status) {
private func configureCard(status: MastodonStatus) {
let status = status.reblog ?? status
if viewModel.mediaViewConfigurations.isEmpty {
status.publisher(for: \.card)
.assign(to: \.card, on: viewModel)
.store(in: &disposeBag)
viewModel.card = status.entity.card
} else {
viewModel.card = nil
}
}
private func configureToolbar(status: Status) {
private func configureToolbar(status: MastodonStatus) {
let status = status.reblog ?? status
status.publisher(for: \.repliesCount)
.map(Int.init)
.assign(to: \.replyCount, on: viewModel)
.store(in: &disposeBag)
status.publisher(for: \.reblogsCount)
.map(Int.init)
.assign(to: \.reblogCount, on: viewModel)
.store(in: &disposeBag)
status.publisher(for: \.favouritesCount)
.map(Int.init)
.assign(to: \.favoriteCount, on: viewModel)
.store(in: &disposeBag)
status.publisher(for: \.editedAt)
.assign(to: \.editedAt, on: viewModel)
.store(in: &disposeBag)
viewModel.replyCount = status.entity.repliesCount ?? 0
viewModel.reblogCount = status.entity.reblogsCount
viewModel.favoriteCount = status.entity.favouritesCount
viewModel.editedAt = status.entity.editedAt
// relationship
status.publisher(for: \.rebloggedBy)
.map { [weak viewModel] rebloggedBy in
guard let viewModel = viewModel else { return false }
guard let authContext = viewModel.authContext else { return false }
return rebloggedBy.contains(where: {
$0.id == authContext.mastodonAuthenticationBox.userID && $0.domain == authContext.mastodonAuthenticationBox.domain
})
}
.assign(to: \.isReblog, on: viewModel)
.store(in: &disposeBag)
status.publisher(for: \.favouritedBy)
.map { [weak viewModel]favouritedBy in
guard let viewModel = viewModel else { return false }
guard let authContext = viewModel.authContext else { return false }
return favouritedBy.contains(where: {
$0.id == authContext.mastodonAuthenticationBox.userID && $0.domain == authContext.mastodonAuthenticationBox.domain
})
}
.assign(to: \.isFavorite, on: viewModel)
.store(in: &disposeBag)
status.publisher(for: \.bookmarkedBy)
.map { [weak viewModel] bookmarkedBy in
guard let viewModel = viewModel else { return false }
guard let authContext = viewModel.authContext else { return false }
return bookmarkedBy.contains(where: {
$0.id == authContext.mastodonAuthenticationBox.userID && $0.domain == authContext.mastodonAuthenticationBox.domain
})
}
.assign(to: \.isBookmark, on: viewModel)
.store(in: &disposeBag)
viewModel.isReblog = status.entity.reblogged == true
viewModel.isFavorite = status.entity.favourited == true
viewModel.isBookmark = status.entity.bookmarked == true
}
private func configureFilter(status: Status) {
private func configureFilter(status: MastodonStatus) {
let status = status.reblog ?? status
let content = status.content.lowercased()
guard let content = status.entity.content?.lowercased() else { return }
Publishers.CombineLatest(
viewModel.$activeFilters,
@ -595,3 +507,16 @@ extension StatusView {
}
}
extension MastodonStatus {
func getPoll(in context: NSManagedObjectContext) async -> Poll? {
guard
let domain = entity.account.domain,
let pollId = entity.poll?.id
else { return nil }
return try? await context.perform {
let predicate = Poll.predicate(domain: domain, id: pollId)
return Poll.findOrFetch(in: context, matching: predicate)
}
}
}

View File

@ -22,11 +22,12 @@ extension StatusView {
public final class ViewModel: ObservableObject {
var disposeBag = Set<AnyCancellable>()
var observations = Set<NSKeyValueObservation>()
public var objects = Set<NSManagedObject>()
public var objects = Set<MastodonStatus>()
public var managedObjects = Set<NSManagedObject>()
public var context: AppContext?
public var authContext: AuthContext?
public var originalStatus: Status?
public var originalStatus: MastodonStatus?
// Header
@Published public var header: Header = .none
@ -77,7 +78,7 @@ extension StatusView {
@Published public var expired: Bool = false
// Card
@Published public var card: Card?
@Published public var card: Mastodon.Entity.Card?
// Visibility
@Published public var visibility: MastodonVisibility = .public

View File

@ -404,7 +404,7 @@ extension StatusView {
}
@objc private func statusCardControlPressed(_ sender: StatusCardControl) {
guard let url = viewModel.card?.url else { return }
guard let urlString = viewModel.card?.url, let url = URL(string: urlString) else { return }
delegate?.statusView(self, didTapCardWithURL: url)
}

View File

@ -7,7 +7,7 @@
import UIKit
import Combine
import CoreDataStack
import MastodonSDK
extension TimelineMiddleLoaderTableViewCell {
public class ViewModel {
@ -34,15 +34,10 @@ extension TimelineMiddleLoaderTableViewCell.ViewModel {
extension TimelineMiddleLoaderTableViewCell {
public func configure(
feed: Feed,
feed: MastodonFeed,
delegate: TimelineMiddleLoaderTableViewCellDelegate?
) {
feed.publisher(for: \.isLoadingMore)
.sink { [weak self] isLoadingMore in
guard let self = self else { return }
self.viewModel.isFetching = isLoadingMore
}
.store(in: &disposeBag)
self.viewModel.isFetching = feed.isLoadingMore
self.delegate = delegate
}