Merge branch 'develop' into feature/home-timeline-api

This commit is contained in:
CMK 2021-02-04 13:49:56 +08:00
commit ade8b68a65
18 changed files with 658 additions and 58 deletions

View File

@ -1,5 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20D64" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20D5029f" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Application" representedClassName=".Application" syncable="YES">
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String"/>
<attribute name="vapidKey" optional="YES" attributeType="String"/>
<attribute name="website" optional="YES" attributeType="String"/>
<relationship name="toots" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="application" inverseEntity="Toot"/>
</entity>
<entity name="Emoji" representedClassName=".Emoji" syncable="YES">
<attribute name="category" optional="YES" attributeType="String"/>
<attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/>
@ -95,6 +102,7 @@
<attribute name="uri" attributeType="String"/>
<attribute name="url" attributeType="String"/>
<attribute name="visibility" optional="YES" attributeType="String"/>
<relationship name="application" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Application" inverseName="toots" inverseEntity="Application"/>
<relationship name="author" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="toots" inverseEntity="MastodonUser"/>
<relationship name="bookmarked" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="bookmarked" inverseEntity="MastodonUser"/>
<relationship name="emojis" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Emoji" inverseName="toot" inverseEntity="Emoji"/>
@ -109,13 +117,14 @@
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Tag" inverseName="toot" inverseEntity="Tag"/>
</entity>
<elements>
<element name="Application" positionX="160" positionY="192" width="128" height="104"/>
<element name="Emoji" positionX="45" positionY="135" width="128" height="149"/>
<element name="History" positionX="27" positionY="126" width="128" height="119"/>
<element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="104"/>
<element name="MastodonAuthentication" positionX="18" positionY="162" width="128" height="209"/>
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="284"/>
<element name="Mention" positionX="9" positionY="108" width="128" height="134"/>
<element name="Tag" positionX="18" positionY="117" width="128" height="119"/>
<element name="Toot" positionX="0" positionY="0" width="128" height="494"/>
<element name="MastodonAuthentication" positionX="18" positionY="162" width="128" height="209"/>
</elements>
</model>

View File

@ -0,0 +1,61 @@
//
// Application.swift
// CoreDataStack
//
// Created by sxiaojian on 2021/2/3.
//
import CoreData
import Foundation
public final class Application: NSManagedObject {
public typealias ID = UUID
@NSManaged public private(set) var identifier: ID
@NSManaged public private(set) var createAt: Date
@NSManaged public private(set) var name: String
@NSManaged public private(set) var website: String?
@NSManaged public private(set) var vapidKey: String?
// one-to-many relationship
@NSManaged public private(set) var toots: Set<Toot>
}
public extension Application {
override func awakeFromInsert() {
super.awakeFromInsert()
identifier = UUID()
}
@discardableResult
static func insert(
into context: NSManagedObjectContext,
property: Property
) -> Application {
let app: Application = context.insertObject()
app.name = property.name
app.website = property.website
app.vapidKey = property.vapidKey
return app
}
}
public extension Application {
struct Property {
public let name: String
public let website: String?
public let vapidKey: String?
public init(name: String, website: String?, vapidKey: String?) {
self.name = name
self.website = website
self.vapidKey = vapidKey
}
}
}
extension Application: Managed {
public static var defaultSortDescriptors: [NSSortDescriptor] {
return [NSSortDescriptor(keyPath: \Application.createAt, ascending: false)]
}
}

View File

@ -21,6 +21,7 @@ public final class Toot: NSManagedObject {
@NSManaged public private(set) var visibility: String?
@NSManaged public private(set) var sensitive: Bool
@NSManaged public private(set) var spoilerText: String?
@NSManaged public private(set) var application: Application?
// Informational
@NSManaged public private(set) var reblogsCount: NSNumber
@ -88,6 +89,8 @@ public extension Toot {
toot.sensitive = property.sensitive
toot.spoilerText = property.spoilerText
toot.application = property.application
if let mentions = property.mentions {
toot.mutableSetValue(forKey: #keyPath(Toot.mentions)).addObjects(from: mentions)
}
@ -123,11 +126,9 @@ public extension Toot {
if let bookmarkedBy = property.bookmarkedBy {
toot.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).add(bookmarkedBy)
}
// TODO: not implement yet
// if let pinnedBy = property.pinnedBy {
// toot.mutableSetValue(forKey: #keyPath(Toot.pinnedBy))
// }
if let pinnedBy = property.pinnedBy {
toot.mutableSetValue(forKey: #keyPath(Toot.pinnedBy)).add(pinnedBy)
}
toot.updatedAt = property.updatedAt
toot.deletedAt = property.deletedAt
@ -137,6 +138,28 @@ public extension Toot {
return toot
}
func update(reblogsCount: NSNumber) {
if self.reblogsCount.intValue != reblogsCount.intValue {
self.reblogsCount = reblogsCount
}
}
func update(favouritesCount: NSNumber) {
if self.favouritesCount.intValue != favouritesCount.intValue {
self.favouritesCount = favouritesCount
}
}
func update(repliesCount: NSNumber?) {
guard let count = repliesCount else {
return
}
if self.repliesCount?.intValue != count.intValue {
self.repliesCount = repliesCount
}
}
func didUpdate(at networkDate: Date) {
self.updatedAt = networkDate
}
}
public extension Toot {
@ -150,6 +173,7 @@ public extension Toot {
visibility: String?,
sensitive: Bool,
spoilerText: String?,
application: Application?,
mentions: [Mention]?,
emojis: [Emoji]?,
tags: [Tag]?,
@ -181,6 +205,7 @@ public extension Toot {
self.visibility = visibility
self.sensitive = sensitive
self.spoilerText = spoilerText
self.application = application
self.mentions = mentions
self.emojis = emojis
self.tags = tags
@ -215,6 +240,7 @@ public extension Toot {
public let visibility: String?
public let sensitive: Bool
public let spoilerText: String?
public let application: Application?
public let mentions: [Mention]?
public let emojis: [Emoji]?

View File

@ -16,11 +16,14 @@
2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */; };
2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */; };
2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8E25C8228A004A627A /* UIButton.swift */; };
2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */; };
2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */; };
2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46976325C2A71500CF4AA9 /* UIIamge.swift */; };
2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */; };
2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; };
2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; };
2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */; };
2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */; };
2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */; };
2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */; };
2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */; };
@ -32,6 +35,9 @@
2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0725C7E9A8004F19B8 /* Tag.swift */; };
2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0D25C7E9C9004F19B8 /* History.swift */; };
2D927F1425C7EDD9004F19B8 /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F1325C7EDD9004F19B8 /* Emoji.swift */; };
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */; };
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; };
2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; };
2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF123A625C3B0210020F248 /* ActiveLabel.swift */; };
45B49097460EDE530AD5AA72 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; };
5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; };
@ -152,10 +158,13 @@
2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionToolBarContainer.swift; sourceTree = "<group>"; };
2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HitTestExpandedButton.swift; sourceTree = "<group>"; };
2D42FF8E25C8228A004A627A /* UIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIButton.swift; sourceTree = "<group>"; };
2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+State.swift"; sourceTree = "<group>"; };
2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSLayoutConstraint.swift; sourceTree = "<group>"; };
2D46976325C2A71500CF4AA9 /* UIIamge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIIamge.swift; sourceTree = "<group>"; };
2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Timeline.swift"; sourceTree = "<group>"; };
2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = "<group>"; };
2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; };
2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Toot.swift"; sourceTree = "<group>"; };
2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewController.swift; sourceTree = "<group>"; };
2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewModel.swift; sourceTree = "<group>"; };
2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewController+StatusProvider.swift"; sourceTree = "<group>"; };
@ -167,6 +176,10 @@
2D927F0725C7E9A8004F19B8 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; };
2D927F0D25C7E9C9004F19B8 /* History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = History.swift; sourceTree = "<group>"; };
2D927F1325C7EDD9004F19B8 /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = "<group>"; };
2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLoaderTableViewCell.swift; sourceTree = "<group>"; };
2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = "<group>"; };
2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; };
2DA7D05625CA693F00804E11 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; };
2DF123A625C3B0210020F248 /* ActiveLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveLabel.swift; sourceTree = "<group>"; };
2E1F6A67FDF9771D3E064FDC /* Pods-Mastodon.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.debug.xcconfig"; sourceTree = "<group>"; };
3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@ -346,6 +359,14 @@
path = Persist;
sourceTree = "<group>";
};
2D69CFF225CA9E2200C3A1B2 /* Protocol */ = {
isa = PBXGroup;
children = (
2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */,
);
path = Protocol;
sourceTree = "<group>";
};
2D76316325C14BAC00929FB9 /* PublicTimeline */ = {
isa = PBXGroup;
children = (
@ -353,6 +374,7 @@
2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */,
2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */,
2D76318225C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift */,
2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */,
);
path = PublicTimeline;
sourceTree = "<group>";
@ -397,6 +419,8 @@
isa = PBXGroup;
children = (
2D7631A725C1535600929FB9 /* TimelinePostTableViewCell.swift */,
2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */,
2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */,
);
path = TableviewCell;
sourceTree = "<group>";
@ -494,6 +518,7 @@
children = (
DB427DE325BAA00100D1B89D /* Info.plist */,
DB89BA1025C10FF5008580ED /* Mastodon.entitlements */,
2D69CFF225CA9E2200C3A1B2 /* Protocol */,
2D76319C25C151DE00929FB9 /* Diffiable */,
DB8AF52A25C13561002E6C99 /* State */,
2D61335525C1886800CAE157 /* Service */,
@ -544,6 +569,7 @@
DB45FB0925CA87BC005A8AC7 /* CoreData */ = {
isa = PBXGroup;
children = (
2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */,
DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */,
DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */,
);
@ -595,6 +621,7 @@
2D927F0D25C7E9C9004F19B8 /* History.swift */,
2D927F1325C7EDD9004F19B8 /* Emoji.swift */,
DB45FAEC25CA7A9A005A8AC7 /* MastodonAuthentication.swift */,
2DA7D05625CA693F00804E11 /* Application.swift */,
);
path = Entity;
sourceTree = "<group>";
@ -668,6 +695,7 @@
isa = PBXGroup;
children = (
CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */,
2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */,
);
name = "Recovered References";
sourceTree = "<group>";
@ -1037,12 +1065,15 @@
2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */,
2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */,
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */,
DB98338825C945ED00AD9700 /* Assets.swift in Sources */,
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */,
DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */,
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */,
2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */,
2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */,
2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */,
2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */,
2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */,
DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */,
2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */,
@ -1065,10 +1096,12 @@
DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */,
2D42FF6B25C817D2004A627A /* MastodonContent.swift in Sources */,
DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */,
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */,
DB8AF55725C137A8002E6C99 /* HomeViewController.swift in Sources */,
2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */,
2D7631A825C1535600929FB9 /* TimelinePostTableViewCell.swift in Sources */,
2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */,
2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */,
DB01409625C40B6700F9F3CF /* AuthenticationViewController.swift in Sources */,
DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */,
DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */,
@ -1096,6 +1129,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
2DA7D05725CA693F00804E11 /* Application.swift in Sources */,
2D927F1425C7EDD9004F19B8 /* Emoji.swift in Sources */,
DB89BA1225C1105C008580ED /* CoreDataStack.swift in Sources */,
DB89BA1C25C1107F008580ED /* NSManagedObjectContext.swift in Sources */,

View File

@ -5,16 +5,17 @@
// Created by sxiaojian on 2021/1/27.
//
import Foundation
import CoreData
import MastodonSDK
import CoreDataStack
import Foundation
import MastodonSDK
/// Note: update Equatable when change case
enum Item {
// normal list
case toot(objectID: NSManagedObjectID)
case bottomLoader
}
extension Item: Equatable {
@ -22,6 +23,10 @@ extension Item: Equatable {
switch (lhs, rhs) {
case (.toot(let objectIDLeft), .toot(let objectIDRight)):
return objectIDLeft == objectIDRight
case (.bottomLoader, .bottomLoader):
return true
default:
return false
}
}
}
@ -31,7 +36,8 @@ extension Item: Hashable {
switch self {
case .toot(let objectID):
hasher.combine(objectID)
case .bottomLoader:
hasher.combine(String(describing: Item.bottomLoader.self))
}
}
}

View File

@ -37,6 +37,10 @@ extension TimelineSection {
}
cell.delegate = timelinePostTableViewCellDelegate
return cell
case .bottomLoader:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
cell.activityIndicatorView.startAnimating()
return cell
}
}
}

View File

@ -10,6 +10,15 @@ import Foundation
// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length
// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces
internal enum L10n {
internal enum Common {
internal enum Controls {
internal enum Timeline {
/// Load More
internal static let loadMore = L10n.tr("Localizable", "Common.Controls.Timeline.LoadMore")
}
}
}
}
// swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length
// swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces

View File

@ -0,0 +1,42 @@
//
// LoadMoreConfigurableTableViewContainer.swift
// Mastodon
//
// Created by sxiaojian on 2021/2/3.
//
import UIKit
import GameplayKit
/// The tableView container driven by state machines with "LoadMore" logic
protocol LoadMoreConfigurableTableViewContainer: UIViewController {
associatedtype BottomLoaderTableViewCell: UITableViewCell
associatedtype LoadingState: GKState
var loadMoreConfigurableTableView: UITableView { get }
var loadMoreConfigurableStateMachine: GKStateMachine { get }
func handleScrollViewDidScroll(_ scrollView: UIScrollView)
}
extension LoadMoreConfigurableTableViewContainer {
func handleScrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView === loadMoreConfigurableTableView else { return }
let cells = loadMoreConfigurableTableView.visibleCells.compactMap { $0 as? BottomLoaderTableViewCell }
guard let loaderTableViewCell = cells.first else { return }
if let tabBar = tabBarController?.tabBar, let window = view.window {
let loaderTableViewCellFrameInWindow = loadMoreConfigurableTableView.convert(loaderTableViewCell.frame, to: nil)
let windowHeight = window.frame.height
let loaderAppear = (loaderTableViewCellFrameInWindow.origin.y + 0.8 * loaderTableViewCell.frame.height) < (windowHeight - tabBar.frame.height)
if loaderAppear {
loadMoreConfigurableStateMachine.enter(LoadingState.self)
} else {
// do nothing
}
} else {
loadMoreConfigurableStateMachine.enter(LoadingState.self)
}
}
}

View File

@ -5,3 +5,4 @@
Created by MainasuK Cirno on 2021/1/22.
*/
"Common.Controls.Timeline.LoadMore" = "Load More";

View File

@ -38,6 +38,8 @@ extension PublicTimelineViewController {
let toot = managedObjectContext.object(with: objectID) as? Toot
promise(.success(toot))
}
default:
promise(.success(nil))
}
}
}

View File

@ -5,41 +5,56 @@
// Created by sxiaojian on 2021/1/27.
//
import os.log
import UIKit
import AVKit
import Combine
import CoreDataStack
import GameplayKit
import os.log
import UIKit
final class PublicTimelineViewController: UIViewController, NeedsDependency, TimelinePostTableViewCellDelegate {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var disposeBag = Set<AnyCancellable>()
var viewModel: PublicTimelineViewModel!
let refreshControl = UIRefreshControl()
lazy var tableView: UITableView = {
let tableView = UITableView()
tableView.register(TimelinePostTableViewCell.self, forCellReuseIdentifier: String(describing: TimelinePostTableViewCell.self))
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
tableView.rowHeight = UITableView.automaticDimension
tableView.separatorStyle = .none
return tableView
}()
deinit {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function)
}
}
extension PublicTimelineViewController {
override func viewDidLoad() {
super.viewDidLoad()
tableView.refreshControl = refreshControl
refreshControl.addTarget(self, action: #selector(PublicTimelineViewController.refreshControlValueChanged(_:)), for: .valueChanged)
// bind refresh control
viewModel.isFetchingLatestTimeline
.receive(on: DispatchQueue.main)
.sink { [weak self] isFetching in
guard let self = self else { return }
if !isFetching {
UIView.animate(withDuration: 0.5) { [weak self] in
guard let self = self else { return }
self.refreshControl.endRefreshing()
}
}
}
.store(in: &disposeBag)
tableView.translatesAutoresizingMaskIntoConstraints = false
tableView.backgroundColor = Asset.Colors.tootDark.color
view.addSubview(tableView)
@ -60,29 +75,35 @@ extension PublicTimelineViewController {
timelinePostTableViewCellDelegate: self
)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
viewModel.fetchLatest()
.receive(on: DispatchQueue.main)
.sink { completion in
switch completion {
case .failure(let error):
os_log("%{public}s[%{public}ld], %{public}s: fetch user timeline latest response error: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
case .finished:
break
}
} receiveValue: { response in
let tootsIDs = response.value.map { $0.id }
self.viewModel.tootIDs.value = tootsIDs
}
.store(in: &viewModel.disposeBag)
viewModel.stateMachine.enter(PublicTimelineViewModel.State.Loading.self)
}
}
// MARK: - UIScrollViewDelegate
extension PublicTimelineViewController {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
handleScrollViewDidScroll(scrollView)
}
}
// MARK: - Selector
extension PublicTimelineViewController {
@objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
guard viewModel.stateMachine.enter(PublicTimelineViewModel.State.Loading.self) else {
sender.endRefreshing()
return
}
}
}
// MARK: - UITableViewDelegate
extension PublicTimelineViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
guard let diffableDataSource = viewModel.diffableDataSource else { return 100 }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 }
@ -94,12 +115,9 @@ extension PublicTimelineViewController: UITableViewDelegate {
return ceil(frame.height)
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {}
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
@ -108,3 +126,11 @@ extension PublicTimelineViewController: UITableViewDelegate {
viewModel.cellFrameCache.setObject(NSValue(cgRect: frame), forKey: NSNumber(value: key))
}
}
// MARK: - LoadMoreConfigurableTableViewContainer
extension PublicTimelineViewController: LoadMoreConfigurableTableViewContainer {
typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
typealias LoadingState = PublicTimelineViewModel.State.LoadingMore
var loadMoreConfigurableTableView: UITableView { return tableView }
var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine }
}

View File

@ -5,10 +5,10 @@
// Created by sxiaojian on 2021/1/27.
//
import os.log
import UIKit
import CoreData
import CoreDataStack
import os.log
import UIKit
extension PublicTimelineViewModel {
func setupDiffableDataSource(
@ -20,26 +20,27 @@ extension PublicTimelineViewModel {
.autoconnect()
.share()
.eraseToAnyPublisher()
diffableDataSource = TimelineSection.tableViewDiffableDataSource(
for: tableView,
dependency: dependency,
managedObjectContext: fetchedResultsController.managedObjectContext,
timestampUpdatePublisher: timestampUpdatePublisher,
timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate)
timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate
)
items.value = []
}
}
// MARK: - NSFetchedResultsControllerDelegate
extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate {
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function)
let indexes = tootIDs.value
let toots = fetchedResultsController.fetchedObjects ?? []
guard toots.count == indexes.count else { return }
let items: [Item] = toots
.compactMap { toot -> (Int, Toot)? in
guard toot.deletedAt == nil else { return nil }
@ -49,5 +50,4 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate {
.map { Item.toot(objectID: $0.1.objectID) }
self.items.value = items
}
}

View File

@ -0,0 +1,138 @@
//
// PublicTimelineViewModel+State.swift
// Mastodon
//
// Created by sxiaojian on 2021/2/2.
//
import Foundation
import GameplayKit
import MastodonSDK
import os.log
extension PublicTimelineViewModel {
class State: GKState {
weak var viewModel: PublicTimelineViewModel?
init(viewModel: PublicTimelineViewModel) {
self.viewModel = viewModel
}
override func didEnter(from previousState: GKState?) {
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", (#file as NSString).lastPathComponent, #line, #function, self.debugDescription, previousState.debugDescription)
}
}
}
extension PublicTimelineViewModel.State {
class Initial: PublicTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Loading.Type:
return true
default:
return false
}
}
}
class Loading: PublicTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Fail.Type:
return true
case is Idle.Type:
return true
default:
return false
}
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
viewModel.fetchLatest()
.receive(on: DispatchQueue.main)
.sink { completion in
switch completion {
case .failure(let error):
os_log("%{public}s[%{public}ld], %{public}s: fetch user timeline latest response error: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
stateMachine.enter(Fail.self)
case .finished:
break
}
} receiveValue: { response in
viewModel.isFetchingLatestTimeline.value = false
let tootsIDs = response.value.map { $0.id }
viewModel.tootIDs.value = tootsIDs
stateMachine.enter(Idle.self)
}
.store(in: &viewModel.disposeBag)
}
}
class Fail: PublicTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Loading.Type, is LoadingMore.Type:
return true
default:
return false
}
}
}
class Idle: PublicTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Loading.Type, is LoadingMore.Type:
return true
default:
return false
}
}
}
class LoadingMore: PublicTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Fail.Type:
return true
case is Idle.Type:
return true
default:
return false
}
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
viewModel.loadMore()
.sink { completion in
switch completion {
case .failure(let error):
stateMachine.enter(Fail.self)
os_log("%{public}s[%{public}ld], %{public}s: load more fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
case .finished:
break
}
} receiveValue: { response in
stateMachine.enter(Idle.self)
var oldTootsIDs = viewModel.tootIDs.value
for toot in response.value {
if !oldTootsIDs.contains(toot.id) {
oldTootsIDs.append(toot.id)
}
}
viewModel.tootIDs.value = oldTootsIDs
}
.store(in: &viewModel.disposeBag)
}
}
}

View File

@ -5,28 +5,39 @@
// Created by sxiaojian on 2021/1/27.
//
import os.log
import UIKit
import GameplayKit
import AlamofireImage
import Combine
import CoreData
import CoreDataStack
import GameplayKit
import MastodonSDK
import AlamofireImage
import os.log
import UIKit
class PublicTimelineViewModel: NSObject {
var disposeBag = Set<AnyCancellable>()
// input
let context: AppContext
let fetchedResultsController: NSFetchedResultsController<Toot>
let isFetchingLatestTimeline = CurrentValueSubject<Bool, Never>(false)
weak var tableView: UITableView?
// output
var diffableDataSource: UITableViewDiffableDataSource<TimelineSection, Item>?
lazy var stateMachine: GKStateMachine = {
let stateMachine = GKStateMachine(states: [
State.Initial(viewModel: self),
State.Loading(viewModel: self),
State.Fail(viewModel: self),
State.Idle(viewModel: self),
State.LoadingMore(viewModel: self),
])
stateMachine.enter(State.Initial.self)
return stateMachine
}()
let tootIDs = CurrentValueSubject<[String], Never>([])
let items = CurrentValueSubject<[Item], Never>([])
var cellFrameCache = NSCache<NSNumber, NSValue>()
@ -49,7 +60,7 @@ class PublicTimelineViewModel: NSObject {
}()
super.init()
self.fetchedResultsController.delegate = self
fetchedResultsController.delegate = self
items
.receive(on: DispatchQueue.main)
@ -57,12 +68,19 @@ class PublicTimelineViewModel: NSObject {
.sink { [weak self] items in
guard let self = self else { return }
guard let diffableDataSource = self.diffableDataSource else { return }
os_log("%{public}s[%{public}ld], %{public}s: items did change", ((#file as NSString).lastPathComponent), #line, #function)
os_log("%{public}s[%{public}ld], %{public}s: items did change", (#file as NSString).lastPathComponent, #line, #function)
var snapshot = NSDiffableDataSourceSnapshot<TimelineSection, Item>()
snapshot.appendSections([.main])
snapshot.appendItems(items)
if let currentState = self.stateMachine.currentState {
switch currentState {
case is State.Idle, is State.LoadingMore, is State.Fail:
snapshot.appendItems([.bottomLoader], toSection: .main)
default:
break
}
}
diffableDataSource.apply(snapshot, animatingDifferences: !items.isEmpty)
}
.store(in: &disposeBag)
@ -82,14 +100,16 @@ class PublicTimelineViewModel: NSObject {
}
deinit {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function)
}
}
extension PublicTimelineViewModel {
func fetchLatest() -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Toot]>, Error> {
return context.apiService.publicTimeline(domain: "mstdn.jp")
}
func loadMore() -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Toot]>, Error> {
return context.apiService.publicTimeline(domain: "mstdn.jp")
}
}

View File

@ -0,0 +1,18 @@
//
// TimelineBottomLoaderTableViewCell.swift
// Mastodon
//
// Created by sxiaojian on 2021/2/3.
//
import UIKit
import Combine
final class TimelineBottomLoaderTableViewCell: TimelineLoaderTableViewCell {
override func _init() {
super._init()
activityIndicatorView.isHidden = false
activityIndicatorView.startAnimating()
}
}

View File

@ -0,0 +1,70 @@
//
// TimelineLoaderTableViewCell.swift
// Mastodon
//
// Created by sxiaojian on 2021/2/3.
//
import UIKit
import Combine
class TimelineLoaderTableViewCell: UITableViewCell {
static let cellHeight: CGFloat = 48
var disposeBag = Set<AnyCancellable>()
let loadMoreButton: UIButton = {
let button = UIButton(type: .system)
button.titleLabel?.font = .preferredFont(forTextStyle: .headline)
button.setTitle(L10n.Common.Controls.Timeline.loadMore, for: .normal)
return button
}()
let activityIndicatorView: UIActivityIndicatorView = {
let activityIndicatorView = UIActivityIndicatorView(style: .medium)
activityIndicatorView.tintColor = .white
activityIndicatorView.hidesWhenStopped = true
return activityIndicatorView
}()
override func prepareForReuse() {
super.prepareForReuse()
disposeBag.removeAll()
}
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
func _init() {
selectionStyle = .none
backgroundColor = Asset.Colors.tootDark.color
loadMoreButton.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(loadMoreButton)
NSLayoutConstraint.activate([
loadMoreButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
loadMoreButton.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
contentView.readableContentGuide.trailingAnchor.constraint(equalTo: loadMoreButton.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: loadMoreButton.bottomAnchor, constant: 8),
loadMoreButton.heightAnchor.constraint(equalToConstant: TimelineLoaderTableViewCell.cellHeight - 2 * 8).priority(.defaultHigh),
])
activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
addSubview(activityIndicatorView)
NSLayoutConstraint.activate([
activityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor),
activityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor),
])
loadMoreButton.isHidden = true
activityIndicatorView.isHidden = true
}
}

View File

@ -0,0 +1,130 @@
//
// APIService+CoreData+Toot.swift
// Mastodon
//
// Created by sxiaojian on 2021/2/3.
//
import Foundation
import CoreData
import CoreDataStack
import CommonOSLog
import MastodonSDK
extension APIService.CoreData {
static func createOrMergeTweet(
into managedObjectContext: NSManagedObjectContext,
for requestMastodonUser: MastodonUser,
entity: Mastodon.Entity.Toot,
domain: String,
networkDate: Date,
log: OSLog
) -> (Toot: Toot, isTweetCreated: Bool, isMastodonUserCreated: Bool) {
// build tree
let reblog = entity.reblog.flatMap { entity -> Toot in
let (toot, _, _) = createOrMergeTweet(into: managedObjectContext, for: requestMastodonUser, entity: entity,domain: domain, networkDate: networkDate, log: log)
return toot
}
// fetch old Toot
let oldTweet: Toot? = {
let request = Toot.sortedFetchRequest
request.predicate = Toot.predicate(idStr: entity.id)
request.returnsObjectsAsFaults = false
do {
return try managedObjectContext.fetch(request).first
} catch {
assertionFailure(error.localizedDescription)
return nil
}
}()
if let oldTweet = oldTweet {
// merge old Toot
APIService.CoreData.mergeToot(for: requestMastodonUser, old: oldTweet,in: domain, entity: entity, networkDate: networkDate)
return (oldTweet, false, false)
} else {
let (mastodonUser, isMastodonUserCreated) = createOrMergeMastodonUser(into: managedObjectContext, for: requestMastodonUser,in: domain, entity: entity.account, networkDate: networkDate, log: log)
let application = entity.application.flatMap { app -> Application? in
Application.insert(into: managedObjectContext, property: Application.Property(name: app.name, website: app.website, vapidKey: app.vapidKey))
}
let metions = entity.mentions?.compactMap({ (mention) -> Mention in
Mention.insert(into: managedObjectContext, property: Mention.Property(id: mention.id, username: mention.username, acct: mention.acct, url: mention.url))
})
let emojis = entity.emojis?.compactMap({ (emoji) -> Emoji in
Emoji.insert(into: managedObjectContext, property: Emoji.Property(shortcode: emoji.shortcode, url: emoji.url, staticURL: emoji.staticURL, visibleInPicker: emoji.visibleInPicker, category: emoji.category))
})
let tags = entity.tags?.compactMap({ (tag) -> Tag in
let histories = tag.history?.compactMap({ (history) -> History in
History.insert(into: managedObjectContext, property: History.Property(day: history.day, uses: history.uses, accounts: history.accounts))
})
return Tag.insert(into: managedObjectContext, property: Tag.Property(name: tag.name, url: tag.url, histories: histories))
})
let tootProperty = Toot.Property(
domain: domain,
id: entity.id,
uri: entity.uri,
createdAt: entity.createdAt,
content: entity.content,
visibility: entity.visibility?.rawValue,
sensitive: entity.sensitive ?? false,
spoilerText: entity.spoilerText,
application: application,
mentions: metions,
emojis: emojis,
tags: tags,
reblogsCount: NSNumber(value: entity.reblogsCount),
favouritesCount: NSNumber(value: entity.favouritesCount),
repliesCount: (entity.repliesCount != nil) ? NSNumber(value: entity.repliesCount!) : nil,
url: entity.uri,
inReplyToID: entity.inReplyToID,
inReplyToAccountID: entity.inReplyToAccountID,
reblog: reblog,
language: entity.language,
text: entity.text,
favouritedBy: (entity.favourited ?? false) ? mastodonUser : nil,
rebloggedBy: (entity.reblogged ?? false) ? mastodonUser : nil,
mutedBy: (entity.muted ?? false) ? mastodonUser : nil,
bookmarkedBy: (entity.bookmarked ?? false) ? mastodonUser : nil,
pinnedBy: (entity.pinned ?? false) ? mastodonUser : nil,
updatedAt: networkDate,
deletedAt: nil,
author: requestMastodonUser,
homeTimelineIndexes: nil)
let toot = Toot.insert(into: managedObjectContext, property: tootProperty, author: mastodonUser)
return (toot, true, isMastodonUserCreated)
}
}
static func mergeToot(for requestMastodonUser: MastodonUser?, old toot: Toot,in domain: String, entity: Mastodon.Entity.Toot, networkDate: Date) {
guard networkDate > toot.updatedAt else { return }
// merge
if entity.favouritesCount != toot.favouritesCount.intValue {
toot.update(favouritesCount:NSNumber(value: entity.favouritesCount))
}
if let repliesCount = entity.repliesCount {
if (repliesCount != toot.repliesCount?.intValue) {
toot.update(repliesCount:NSNumber(value: repliesCount))
}
}
if entity.reblogsCount != toot.reblogsCount.intValue {
toot.update(reblogsCount:NSNumber(value: entity.reblogsCount))
}
// set updateAt
toot.didUpdate(at: networkDate)
// merge user
mergeMastodonUser(for: requestMastodonUser, old: toot.author, in: domain, entity: entity.account, networkDate: networkDate)
// merge indirect reblog & quote
if let reblog = toot.reblog, let reblogEntity = entity.reblog {
mergeToot(for: requestMastodonUser, old: reblog,in: domain, entity: reblogEntity, networkDate: networkDate)
}
}
}

View File

@ -28,6 +28,9 @@ extension APIService.Persist {
let _ = toots.map {
let userProperty = MastodonUser.Property(id: $0.account.id, domain: domain, acct: $0.account.acct, username: $0.account.username, displayName: $0.account.displayName,avatar: $0.account.avatar,avatarStatic: $0.account.avatarStatic, createdAt: $0.createdAt, networkDate: $0.createdAt)
let author = MastodonUser.insert(into: managedObjectContext, property: userProperty)
let application = $0.application.flatMap { app -> Application? in
Application.insert(into: managedObjectContext, property: Application.Property(name: app.name, website: app.website, vapidKey: app.vapidKey))
}
let metions = $0.mentions?.compactMap({ (mention) -> Mention in
Mention.insert(into: managedObjectContext, property: Mention.Property(id: mention.id, username: mention.username, acct: mention.acct, url: mention.url))
})
@ -50,6 +53,7 @@ extension APIService.Persist {
visibility: $0.visibility?.rawValue,
sensitive: $0.sensitive ?? false,
spoilerText: $0.spoilerText,
application: application,
mentions: metions,
emojis: emojis,
tags: tags,