feat: add APIService.Persist.persistTimeline method and make public timeline load oldest works
This commit is contained in:
parent
ade8b68a65
commit
2ebb12b86e
|
@ -27,9 +27,11 @@
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="HomeTimelineIndex" representedClassName=".HomeTimelineIndex" syncable="YES">
|
<entity name="HomeTimelineIndex" representedClassName=".HomeTimelineIndex" syncable="YES">
|
||||||
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="deletedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
<attribute name="domain" attributeType="String"/>
|
<attribute name="domain" attributeType="String"/>
|
||||||
|
<attribute name="hasMore" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
<attribute name="identifier" attributeType="String"/>
|
<attribute name="identifier" attributeType="String"/>
|
||||||
<attribute name="userIdentifier" attributeType="String"/>
|
<attribute name="userID" attributeType="String"/>
|
||||||
<relationship name="toots" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="homeTimelineIndexes" inverseEntity="Toot"/>
|
<relationship name="toots" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="homeTimelineIndexes" inverseEntity="Toot"/>
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="MastodonAuthentication" representedClassName=".MastodonAuthentication" syncable="YES">
|
<entity name="MastodonAuthentication" representedClassName=".MastodonAuthentication" syncable="YES">
|
||||||
|
@ -120,11 +122,11 @@
|
||||||
<element name="Application" positionX="160" positionY="192" width="128" height="104"/>
|
<element name="Application" positionX="160" positionY="192" width="128" height="104"/>
|
||||||
<element name="Emoji" positionX="45" positionY="135" width="128" height="149"/>
|
<element name="Emoji" positionX="45" positionY="135" width="128" height="149"/>
|
||||||
<element name="History" positionX="27" positionY="126" width="128" height="119"/>
|
<element name="History" positionX="27" positionY="126" width="128" height="119"/>
|
||||||
<element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="104"/>
|
<element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="134"/>
|
||||||
<element name="MastodonAuthentication" positionX="18" positionY="162" width="128" height="209"/>
|
<element name="MastodonAuthentication" positionX="18" positionY="162" width="128" height="209"/>
|
||||||
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="284"/>
|
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="284"/>
|
||||||
<element name="Mention" positionX="9" positionY="108" width="128" height="134"/>
|
<element name="Mention" positionX="9" positionY="108" width="128" height="134"/>
|
||||||
<element name="Tag" positionX="18" positionY="117" width="128" height="119"/>
|
<element name="Tag" positionX="18" positionY="117" width="128" height="119"/>
|
||||||
<element name="Toot" positionX="0" positionY="0" width="128" height="494"/>
|
<element name="Toot" positionX="0" positionY="0" width="128" height="494"/>
|
||||||
</elements>
|
</elements>
|
||||||
</model>
|
</model>
|
||||||
|
|
|
@ -13,9 +13,13 @@ final public class HomeTimelineIndex: NSManagedObject {
|
||||||
public typealias ID = String
|
public typealias ID = String
|
||||||
@NSManaged public private(set) var identifier: ID
|
@NSManaged public private(set) var identifier: ID
|
||||||
@NSManaged public private(set) var domain: String
|
@NSManaged public private(set) var domain: String
|
||||||
@NSManaged public private(set) var userIdentifier: String
|
@NSManaged public private(set) var userID: String
|
||||||
|
|
||||||
|
@NSManaged public private(set) var hasMore: Bool // default NO
|
||||||
|
|
||||||
@NSManaged public private(set) var createdAt: Date
|
@NSManaged public private(set) var createdAt: Date
|
||||||
|
@NSManaged public private(set) var deletedAt: Date?
|
||||||
|
|
||||||
|
|
||||||
// many-to-one relationship
|
// many-to-one relationship
|
||||||
@NSManaged public private(set) var toot: Toot
|
@NSManaged public private(set) var toot: Toot
|
||||||
|
@ -34,7 +38,7 @@ extension HomeTimelineIndex {
|
||||||
|
|
||||||
index.identifier = property.identifier
|
index.identifier = property.identifier
|
||||||
index.domain = property.domain
|
index.domain = property.domain
|
||||||
index.userIdentifier = toot.author.identifier
|
index.userID = toot.author.id
|
||||||
index.createdAt = toot.createdAt
|
index.createdAt = toot.createdAt
|
||||||
|
|
||||||
index.toot = toot
|
index.toot = toot
|
||||||
|
@ -42,6 +46,17 @@ extension HomeTimelineIndex {
|
||||||
return index
|
return index
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func update(hasMore: Bool) {
|
||||||
|
if self.hasMore != hasMore {
|
||||||
|
self.hasMore = hasMore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// internal method for Toot call
|
||||||
|
func softDelete() {
|
||||||
|
deletedAt = Date()
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension HomeTimelineIndex {
|
extension HomeTimelineIndex {
|
||||||
|
|
|
@ -37,9 +37,7 @@ final public class MastodonUser: NSManagedObject {
|
||||||
@NSManaged public private(set) var reblogged: Set<Toot>?
|
@NSManaged public private(set) var reblogged: Set<Toot>?
|
||||||
@NSManaged public private(set) var muted: Set<Toot>?
|
@NSManaged public private(set) var muted: Set<Toot>?
|
||||||
@NSManaged public private(set) var bookmarked: Set<Toot>?
|
@NSManaged public private(set) var bookmarked: Set<Toot>?
|
||||||
|
|
||||||
@NSManaged public private(set) var retweets: Set<Toot>?
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension MastodonUser {
|
extension MastodonUser {
|
||||||
|
|
|
@ -10,6 +10,7 @@ import Foundation
|
||||||
|
|
||||||
public final class Toot: NSManagedObject {
|
public final class Toot: NSManagedObject {
|
||||||
public typealias ID = String
|
public typealias ID = String
|
||||||
|
|
||||||
@NSManaged public private(set) var identifier: ID
|
@NSManaged public private(set) var identifier: ID
|
||||||
@NSManaged public private(set) var domain: String
|
@NSManaged public private(set) var domain: String
|
||||||
|
|
||||||
|
@ -36,6 +37,8 @@ public final class Toot: NSManagedObject {
|
||||||
@NSManaged public private(set) var text: String?
|
@NSManaged public private(set) var text: String?
|
||||||
|
|
||||||
// many-to-one relastionship
|
// many-to-one relastionship
|
||||||
|
@NSManaged public private(set) var author: MastodonUser
|
||||||
|
@NSManaged public private(set) var reblog: Toot?
|
||||||
@NSManaged public private(set) var favouritedBy: MastodonUser?
|
@NSManaged public private(set) var favouritedBy: MastodonUser?
|
||||||
@NSManaged public private(set) var rebloggedBy: MastodonUser?
|
@NSManaged public private(set) var rebloggedBy: MastodonUser?
|
||||||
@NSManaged public private(set) var mutedBy: MastodonUser?
|
@NSManaged public private(set) var mutedBy: MastodonUser?
|
||||||
|
@ -43,29 +46,16 @@ public final class Toot: NSManagedObject {
|
||||||
|
|
||||||
// one-to-one relastionship
|
// one-to-one relastionship
|
||||||
@NSManaged public private(set) var pinnedBy: MastodonUser?
|
@NSManaged public private(set) var pinnedBy: MastodonUser?
|
||||||
|
|
||||||
|
// one-to-many relationship
|
||||||
|
@NSManaged public private(set) var reblogFrom: Set<Toot>?
|
||||||
|
@NSManaged public private(set) var mentions: Set<Mention>?
|
||||||
|
@NSManaged public private(set) var emojis: Set<Emoji>?
|
||||||
|
@NSManaged public private(set) var tags: Set<Tag>?
|
||||||
|
@NSManaged public private(set) var homeTimelineIndexes: Set<HomeTimelineIndex>?
|
||||||
|
|
||||||
@NSManaged public private(set) var updatedAt: Date
|
@NSManaged public private(set) var updatedAt: Date
|
||||||
@NSManaged public private(set) var deletedAt: Date?
|
@NSManaged public private(set) var deletedAt: Date?
|
||||||
|
|
||||||
// one-to-many relationship
|
|
||||||
@NSManaged public private(set) var reblogFrom: Set<Toot>?
|
|
||||||
|
|
||||||
// one-to-many relationship
|
|
||||||
@NSManaged public private(set) var mentions: Set<Mention>?
|
|
||||||
// one-to-many relationship
|
|
||||||
@NSManaged public private(set) var emojis: Set<Emoji>?
|
|
||||||
|
|
||||||
// one-to-many relationship
|
|
||||||
@NSManaged public private(set) var tags: Set<Tag>?
|
|
||||||
|
|
||||||
// many-to-one relastionship
|
|
||||||
@NSManaged public private(set) var reblog: Toot?
|
|
||||||
|
|
||||||
// many-to-one relationship
|
|
||||||
@NSManaged public private(set) var author: MastodonUser
|
|
||||||
|
|
||||||
// one-to-many relationship
|
|
||||||
@NSManaged public private(set) var homeTimelineIndexes: Set<HomeTimelineIndex>?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension Toot {
|
public extension Toot {
|
||||||
|
@ -73,7 +63,17 @@ public extension Toot {
|
||||||
static func insert(
|
static func insert(
|
||||||
into context: NSManagedObjectContext,
|
into context: NSManagedObjectContext,
|
||||||
property: Property,
|
property: Property,
|
||||||
author: MastodonUser
|
author: MastodonUser,
|
||||||
|
reblog: Toot?,
|
||||||
|
application: Application?,
|
||||||
|
mentions: [Mention]?,
|
||||||
|
emojis: [Emoji]?,
|
||||||
|
tags: [Tag]?,
|
||||||
|
favouritedBy: MastodonUser?,
|
||||||
|
rebloggedBy: MastodonUser?,
|
||||||
|
mutedBy: MastodonUser?,
|
||||||
|
bookmarkedBy: MastodonUser?,
|
||||||
|
pinnedBy: MastodonUser?
|
||||||
) -> Toot {
|
) -> Toot {
|
||||||
let toot: Toot = context.insertObject()
|
let toot: Toot = context.insertObject()
|
||||||
|
|
||||||
|
@ -88,20 +88,7 @@ public extension Toot {
|
||||||
toot.visibility = property.visibility
|
toot.visibility = property.visibility
|
||||||
toot.sensitive = property.sensitive
|
toot.sensitive = property.sensitive
|
||||||
toot.spoilerText = property.spoilerText
|
toot.spoilerText = property.spoilerText
|
||||||
|
toot.application = application
|
||||||
toot.application = property.application
|
|
||||||
|
|
||||||
if let mentions = property.mentions {
|
|
||||||
toot.mutableSetValue(forKey: #keyPath(Toot.mentions)).addObjects(from: mentions)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let emojis = property.emojis {
|
|
||||||
toot.mutableSetValue(forKey: #keyPath(Toot.emojis)).addObjects(from: emojis)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let tags = property.tags {
|
|
||||||
toot.mutableSetValue(forKey: #keyPath(Toot.tags)).addObjects(from: tags)
|
|
||||||
}
|
|
||||||
|
|
||||||
toot.reblogsCount = property.reblogsCount
|
toot.reblogsCount = property.reblogsCount
|
||||||
toot.favouritesCount = property.favouritesCount
|
toot.favouritesCount = property.favouritesCount
|
||||||
|
@ -110,31 +97,39 @@ public extension Toot {
|
||||||
toot.url = property.url
|
toot.url = property.url
|
||||||
toot.inReplyToID = property.inReplyToID
|
toot.inReplyToID = property.inReplyToID
|
||||||
toot.inReplyToAccountID = property.inReplyToAccountID
|
toot.inReplyToAccountID = property.inReplyToAccountID
|
||||||
toot.reblog = property.reblog
|
|
||||||
toot.language = property.language
|
toot.language = property.language
|
||||||
toot.text = property.text
|
toot.text = property.text
|
||||||
|
|
||||||
if let favouritedBy = property.favouritedBy {
|
toot.author = author
|
||||||
|
toot.reblog = reblog
|
||||||
|
|
||||||
|
if let mentions = mentions {
|
||||||
|
toot.mutableSetValue(forKey: #keyPath(Toot.mentions)).addObjects(from: mentions)
|
||||||
|
}
|
||||||
|
if let emojis = emojis {
|
||||||
|
toot.mutableSetValue(forKey: #keyPath(Toot.emojis)).addObjects(from: emojis)
|
||||||
|
}
|
||||||
|
if let tags = tags {
|
||||||
|
toot.mutableSetValue(forKey: #keyPath(Toot.tags)).addObjects(from: tags)
|
||||||
|
}
|
||||||
|
if let favouritedBy = favouritedBy {
|
||||||
toot.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).add(favouritedBy)
|
toot.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).add(favouritedBy)
|
||||||
}
|
}
|
||||||
if let rebloggedBy = property.rebloggedBy {
|
if let rebloggedBy = rebloggedBy {
|
||||||
toot.mutableSetValue(forKey: #keyPath(Toot.rebloggedBy)).add(rebloggedBy)
|
toot.mutableSetValue(forKey: #keyPath(Toot.rebloggedBy)).add(rebloggedBy)
|
||||||
}
|
}
|
||||||
if let mutedBy = property.mutedBy {
|
if let mutedBy = mutedBy {
|
||||||
toot.mutableSetValue(forKey: #keyPath(Toot.mutedBy)).add(mutedBy)
|
toot.mutableSetValue(forKey: #keyPath(Toot.mutedBy)).add(mutedBy)
|
||||||
}
|
}
|
||||||
if let bookmarkedBy = property.bookmarkedBy {
|
if let bookmarkedBy = bookmarkedBy {
|
||||||
toot.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).add(bookmarkedBy)
|
toot.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).add(bookmarkedBy)
|
||||||
}
|
}
|
||||||
if let pinnedBy = property.pinnedBy {
|
if let pinnedBy = pinnedBy {
|
||||||
toot.mutableSetValue(forKey: #keyPath(Toot.pinnedBy)).add(pinnedBy)
|
toot.mutableSetValue(forKey: #keyPath(Toot.pinnedBy)).add(pinnedBy)
|
||||||
}
|
}
|
||||||
|
|
||||||
toot.updatedAt = property.updatedAt
|
toot.updatedAt = property.networkDate
|
||||||
toot.deletedAt = property.deletedAt
|
|
||||||
toot.author = property.author
|
|
||||||
toot.content = property.content
|
|
||||||
toot.homeTimelineIndexes = property.homeTimelineIndexes
|
|
||||||
|
|
||||||
return toot
|
return toot
|
||||||
}
|
}
|
||||||
|
@ -164,70 +159,6 @@ public extension Toot {
|
||||||
|
|
||||||
public extension Toot {
|
public extension Toot {
|
||||||
struct Property {
|
struct Property {
|
||||||
public init(
|
|
||||||
domain: String,
|
|
||||||
id: String,
|
|
||||||
uri: String,
|
|
||||||
createdAt: Date,
|
|
||||||
content: String,
|
|
||||||
visibility: String?,
|
|
||||||
sensitive: Bool,
|
|
||||||
spoilerText: String?,
|
|
||||||
application: Application?,
|
|
||||||
mentions: [Mention]?,
|
|
||||||
emojis: [Emoji]?,
|
|
||||||
tags: [Tag]?,
|
|
||||||
reblogsCount: NSNumber,
|
|
||||||
favouritesCount: NSNumber,
|
|
||||||
repliesCount: NSNumber?,
|
|
||||||
url: String?,
|
|
||||||
inReplyToID: Toot.ID?,
|
|
||||||
inReplyToAccountID: MastodonUser.ID?,
|
|
||||||
reblog: Toot?,
|
|
||||||
language: String?,
|
|
||||||
text: String?,
|
|
||||||
favouritedBy: MastodonUser?,
|
|
||||||
rebloggedBy: MastodonUser?,
|
|
||||||
mutedBy: MastodonUser?,
|
|
||||||
bookmarkedBy: MastodonUser?,
|
|
||||||
pinnedBy: MastodonUser?,
|
|
||||||
updatedAt: Date,
|
|
||||||
deletedAt: Date?,
|
|
||||||
author: MastodonUser,
|
|
||||||
homeTimelineIndexes: Set<HomeTimelineIndex>?)
|
|
||||||
{
|
|
||||||
self.identifier = id + "@" + domain
|
|
||||||
self.domain = domain
|
|
||||||
self.id = id
|
|
||||||
self.uri = uri
|
|
||||||
self.createdAt = createdAt
|
|
||||||
self.content = content
|
|
||||||
self.visibility = visibility
|
|
||||||
self.sensitive = sensitive
|
|
||||||
self.spoilerText = spoilerText
|
|
||||||
self.application = application
|
|
||||||
self.mentions = mentions
|
|
||||||
self.emojis = emojis
|
|
||||||
self.tags = tags
|
|
||||||
self.reblogsCount = reblogsCount
|
|
||||||
self.favouritesCount = favouritesCount
|
|
||||||
self.repliesCount = repliesCount
|
|
||||||
self.url = url
|
|
||||||
self.inReplyToID = inReplyToID
|
|
||||||
self.inReplyToAccountID = inReplyToAccountID
|
|
||||||
self.reblog = reblog
|
|
||||||
self.language = language
|
|
||||||
self.text = text
|
|
||||||
self.favouritedBy = favouritedBy
|
|
||||||
self.rebloggedBy = rebloggedBy
|
|
||||||
self.mutedBy = mutedBy
|
|
||||||
self.bookmarkedBy = bookmarkedBy
|
|
||||||
self.pinnedBy = pinnedBy
|
|
||||||
self.updatedAt = updatedAt
|
|
||||||
self.deletedAt = deletedAt
|
|
||||||
self.author = author
|
|
||||||
self.homeTimelineIndexes = homeTimelineIndexes
|
|
||||||
}
|
|
||||||
|
|
||||||
public let identifier: ID
|
public let identifier: ID
|
||||||
public let domain: String
|
public let domain: String
|
||||||
|
@ -240,11 +171,7 @@ public extension Toot {
|
||||||
public let visibility: String?
|
public let visibility: String?
|
||||||
public let sensitive: Bool
|
public let sensitive: Bool
|
||||||
public let spoilerText: String?
|
public let spoilerText: String?
|
||||||
public let application: Application?
|
|
||||||
|
|
||||||
public let mentions: [Mention]?
|
|
||||||
public let emojis: [Emoji]?
|
|
||||||
public let tags: [Tag]?
|
|
||||||
public let reblogsCount: NSNumber
|
public let reblogsCount: NSNumber
|
||||||
public let favouritesCount: NSNumber
|
public let favouritesCount: NSNumber
|
||||||
public let repliesCount: NSNumber?
|
public let repliesCount: NSNumber?
|
||||||
|
@ -252,22 +179,50 @@ public extension Toot {
|
||||||
public let url: String?
|
public let url: String?
|
||||||
public let inReplyToID: Toot.ID?
|
public let inReplyToID: Toot.ID?
|
||||||
public let inReplyToAccountID: MastodonUser.ID?
|
public let inReplyToAccountID: MastodonUser.ID?
|
||||||
public let reblog: Toot?
|
|
||||||
public let language: String? // (ISO 639 Part @1 two-letter language code)
|
public let language: String? // (ISO 639 Part @1 two-letter language code)
|
||||||
public let text: String?
|
public let text: String?
|
||||||
|
|
||||||
|
public let networkDate: Date
|
||||||
|
|
||||||
public let favouritedBy: MastodonUser?
|
public init(
|
||||||
public let rebloggedBy: MastodonUser?
|
domain: String,
|
||||||
public let mutedBy: MastodonUser?
|
id: String,
|
||||||
public let bookmarkedBy: MastodonUser?
|
uri: String,
|
||||||
public let pinnedBy: MastodonUser?
|
createdAt: Date,
|
||||||
|
content: String,
|
||||||
|
visibility: String?,
|
||||||
|
sensitive: Bool,
|
||||||
|
spoilerText: String?,
|
||||||
|
reblogsCount: NSNumber,
|
||||||
|
favouritesCount: NSNumber,
|
||||||
|
repliesCount: NSNumber?,
|
||||||
|
url: String?,
|
||||||
|
inReplyToID: Toot.ID?,
|
||||||
|
inReplyToAccountID: MastodonUser.ID?,
|
||||||
|
language: String?,
|
||||||
|
text: String?,
|
||||||
|
networkDate: Date
|
||||||
|
) {
|
||||||
|
self.identifier = id + "@" + domain
|
||||||
|
self.domain = domain
|
||||||
|
self.id = id
|
||||||
|
self.uri = uri
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.content = content
|
||||||
|
self.visibility = visibility
|
||||||
|
self.sensitive = sensitive
|
||||||
|
self.spoilerText = spoilerText
|
||||||
|
self.reblogsCount = reblogsCount
|
||||||
|
self.favouritesCount = favouritesCount
|
||||||
|
self.repliesCount = repliesCount
|
||||||
|
self.url = url
|
||||||
|
self.inReplyToID = inReplyToID
|
||||||
|
self.inReplyToAccountID = inReplyToAccountID
|
||||||
|
self.language = language
|
||||||
|
self.text = text
|
||||||
|
self.networkDate = networkDate
|
||||||
|
}
|
||||||
|
|
||||||
public let updatedAt: Date
|
|
||||||
public let deletedAt: Date?
|
|
||||||
|
|
||||||
public let author: MastodonUser
|
|
||||||
|
|
||||||
public let homeTimelineIndexes: Set<HomeTimelineIndex>?
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -277,20 +232,39 @@ extension Toot: Managed {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension Toot {
|
extension Toot {
|
||||||
static func predicate(idStr: String) -> NSPredicate {
|
|
||||||
return NSPredicate(format: "%K == %@", #keyPath(Toot.id), idStr)
|
static func predicate(domain: String) -> NSPredicate {
|
||||||
|
return NSPredicate(format: "%K == %@", #keyPath(Toot.domain), domain)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func predicate(idStrs: [String]) -> NSPredicate {
|
static func predicate(id: String) -> NSPredicate {
|
||||||
return NSPredicate(format: "%K IN %@", #keyPath(Toot.id), idStrs)
|
return NSPredicate(format: "%K == %@", #keyPath(Toot.id), id)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func notDeleted() -> NSPredicate {
|
public static func predicate(domain: String, id: String) -> NSPredicate {
|
||||||
|
return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||||
|
predicate(domain: domain),
|
||||||
|
predicate(id: id)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
static func predicate(ids: [String]) -> NSPredicate {
|
||||||
|
return NSPredicate(format: "%K IN %@", #keyPath(Toot.id), ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func predicate(domain: String, ids: [String]) -> NSPredicate {
|
||||||
|
return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||||
|
predicate(domain: domain),
|
||||||
|
predicate(ids: ids)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func notDeleted() -> NSPredicate {
|
||||||
return NSPredicate(format: "%K == nil", #keyPath(Toot.deletedAt))
|
return NSPredicate(format: "%K == nil", #keyPath(Toot.deletedAt))
|
||||||
}
|
}
|
||||||
|
|
||||||
static func deleted() -> NSPredicate {
|
public static func deleted() -> NSPredicate {
|
||||||
return NSPredicate(format: "%K != nil", #keyPath(Toot.deletedAt))
|
return NSPredicate(format: "%K != nil", #keyPath(Toot.deletedAt))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
// CoreDataStack
|
// CoreDataStack
|
||||||
//
|
//
|
||||||
// Created by Cirno MainasuK on 2020-10-14.
|
// Created by Cirno MainasuK on 2020-10-14.
|
||||||
// Copyright © 2020 Twidere. All rights reserved.
|
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
// CoreDataStack
|
// CoreDataStack
|
||||||
//
|
//
|
||||||
// Created by Cirno MainasuK on 2020-8-10.
|
// Created by Cirno MainasuK on 2020-8-10.
|
||||||
// Copyright © 2020 Dimension. All rights reserved.
|
|
||||||
//
|
//
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
// CoreDataStack
|
// CoreDataStack
|
||||||
//
|
//
|
||||||
// Created by Cirno MainasuK on 2020-8-6.
|
// Created by Cirno MainasuK on 2020-8-6.
|
||||||
// Copyright © 2020 Dimension. All rights reserved.
|
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
|
@ -49,6 +49,7 @@
|
||||||
DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */; };
|
DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */; };
|
||||||
DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB0140BC25C40D7500F9F3CF /* CommonOSLog */; };
|
DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB0140BC25C40D7500F9F3CF /* CommonOSLog */; };
|
||||||
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140CE25C42AEE00F9F3CF /* OSLog.swift */; };
|
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140CE25C42AEE00F9F3CF /* OSLog.swift */; };
|
||||||
|
DB084B5725CBC56C00F898ED /* Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB084B5625CBC56C00F898ED /* Toot.swift */; };
|
||||||
DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB3D0FF225BAA61700EAA174 /* AlamofireImage */; };
|
DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB3D0FF225BAA61700EAA174 /* AlamofireImage */; };
|
||||||
DB3D100D25BAA75E00EAA174 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB3D100F25BAA75E00EAA174 /* Localizable.strings */; };
|
DB3D100D25BAA75E00EAA174 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB3D100F25BAA75E00EAA174 /* Localizable.strings */; };
|
||||||
DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DD525BAA00100D1B89D /* AppDelegate.swift */; };
|
DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DD525BAA00100D1B89D /* AppDelegate.swift */; };
|
||||||
|
@ -195,6 +196,7 @@
|
||||||
DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift; sourceTree = "<group>"; };
|
DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift; sourceTree = "<group>"; };
|
||||||
DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModel.swift; sourceTree = "<group>"; };
|
DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModel.swift; sourceTree = "<group>"; };
|
||||||
DB0140CE25C42AEE00F9F3CF /* OSLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = "<group>"; };
|
DB0140CE25C42AEE00F9F3CF /* OSLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = "<group>"; };
|
||||||
|
DB084B5625CBC56C00F898ED /* Toot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toot.swift; sourceTree = "<group>"; };
|
||||||
DB3D0FED25BAA42200EAA174 /* MastodonSDK */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MastodonSDK; sourceTree = "<group>"; };
|
DB3D0FED25BAA42200EAA174 /* MastodonSDK */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MastodonSDK; sourceTree = "<group>"; };
|
||||||
DB3D100E25BAA75E00EAA174 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
DB3D100E25BAA75E00EAA174 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
DB427DD225BAA00100D1B89D /* Mastodon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mastodon.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
DB427DD225BAA00100D1B89D /* Mastodon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mastodon.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
@ -463,6 +465,15 @@
|
||||||
path = PinBased;
|
path = PinBased;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
DB084B5125CBC56300F898ED /* CoreDataStack */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */,
|
||||||
|
DB084B5625CBC56C00F898ED /* Toot.swift */,
|
||||||
|
);
|
||||||
|
path = CoreDataStack;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
DB3D0FF725BAA68500EAA174 /* Supporting Files */ = {
|
DB3D0FF725BAA68500EAA174 /* Supporting Files */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -553,8 +564,8 @@
|
||||||
DB45FB0425CA87B4005A8AC7 /* APIService */ = {
|
DB45FB0425CA87B4005A8AC7 /* APIService */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
2D61335625C1887F00CAE157 /* Persist */,
|
|
||||||
DB45FB0925CA87BC005A8AC7 /* CoreData */,
|
DB45FB0925CA87BC005A8AC7 /* CoreData */,
|
||||||
|
2D61335625C1887F00CAE157 /* Persist */,
|
||||||
2D61335D25C1894B00CAE157 /* APIService.swift */,
|
2D61335D25C1894B00CAE157 /* APIService.swift */,
|
||||||
DB98337E25C9452D00AD9700 /* APIService+APIError.swift */,
|
DB98337E25C9452D00AD9700 /* APIService+APIError.swift */,
|
||||||
DB98336A25C9420100AD9700 /* APIService+App.swift */,
|
DB98336A25C9420100AD9700 /* APIService+App.swift */,
|
||||||
|
@ -677,7 +688,7 @@
|
||||||
DB8AF56225C138BC002E6C99 /* Extension */ = {
|
DB8AF56225C138BC002E6C99 /* Extension */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */,
|
DB084B5125CBC56300F898ED /* CoreDataStack */,
|
||||||
DB0140CE25C42AEE00F9F3CF /* OSLog.swift */,
|
DB0140CE25C42AEE00F9F3CF /* OSLog.swift */,
|
||||||
DB8AF55C25C138B7002E6C99 /* UIViewController.swift */,
|
DB8AF55C25C138B7002E6C99 /* UIViewController.swift */,
|
||||||
2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */,
|
2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */,
|
||||||
|
@ -1084,6 +1095,7 @@
|
||||||
DB98334725C8056600AD9700 /* AuthenticationViewModel.swift in Sources */,
|
DB98334725C8056600AD9700 /* AuthenticationViewModel.swift in Sources */,
|
||||||
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */,
|
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */,
|
||||||
2D76319F25C1521200929FB9 /* TimelineSection.swift in Sources */,
|
2D76319F25C1521200929FB9 /* TimelineSection.swift in Sources */,
|
||||||
|
DB084B5725CBC56C00F898ED /* Toot.swift in Sources */,
|
||||||
DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */,
|
DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */,
|
||||||
DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */,
|
DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */,
|
||||||
DB98338725C945ED00AD9700 /* Strings.swift in Sources */,
|
DB98338725C945ED00AD9700 /* Strings.swift in Sources */,
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>7</integer>
|
<integer>8</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>Mastodon.xcscheme_^#shared#^_</key>
|
<key>Mastodon.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
//
|
||||||
|
// Toot.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by MainasuK Cirno on 2021/2/4.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreDataStack
|
||||||
|
import MastodonSDK
|
||||||
|
|
||||||
|
extension Toot.Property {
|
||||||
|
init(entity: Mastodon.Entity.Status, domain: String, networkDate: Date) {
|
||||||
|
self.init(
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
language: entity.language,
|
||||||
|
text: entity.text,
|
||||||
|
networkDate: networkDate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -76,10 +76,6 @@ extension PublicTimelineViewController {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
|
||||||
super.viewWillAppear(animated)
|
|
||||||
viewModel.stateMachine.enter(PublicTimelineViewModel.State.Loading.self)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - UIScrollViewDelegate
|
// MARK: - UIScrollViewDelegate
|
||||||
|
@ -87,11 +83,9 @@ extension PublicTimelineViewController {
|
||||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
handleScrollViewDidScroll(scrollView)
|
handleScrollViewDidScroll(scrollView)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Selector
|
// MARK: - Selector
|
||||||
|
|
||||||
extension PublicTimelineViewController {
|
extension PublicTimelineViewController {
|
||||||
@objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
|
@objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
|
||||||
guard viewModel.stateMachine.enter(PublicTimelineViewModel.State.Loading.self) else {
|
guard viewModel.stateMachine.enter(PublicTimelineViewModel.State.Loading.self) else {
|
||||||
|
@ -102,7 +96,6 @@ extension PublicTimelineViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - UITableViewDelegate
|
// MARK: - UITableViewDelegate
|
||||||
|
|
||||||
extension PublicTimelineViewController: UITableViewDelegate {
|
extension PublicTimelineViewController: UITableViewDelegate {
|
||||||
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||||
guard let diffableDataSource = viewModel.diffableDataSource else { return 100 }
|
guard let diffableDataSource = viewModel.diffableDataSource else { return 100 }
|
||||||
|
|
|
@ -29,6 +29,7 @@ extension PublicTimelineViewModel {
|
||||||
timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate
|
timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate
|
||||||
)
|
)
|
||||||
items.value = []
|
items.value = []
|
||||||
|
stateMachine.enter(PublicTimelineViewModel.State.Loading.self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -51,8 +51,12 @@ extension PublicTimelineViewModel.State {
|
||||||
override func didEnter(from previousState: GKState?) {
|
override func didEnter(from previousState: GKState?) {
|
||||||
super.didEnter(from: previousState)
|
super.didEnter(from: previousState)
|
||||||
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||||
|
guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||||
|
stateMachine.enter(Fail.self)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
viewModel.fetchLatest()
|
viewModel.context.apiService.publicTimeline(domain: activeMastodonAuthenticationBox.domain)
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { completion in
|
.sink { completion in
|
||||||
switch completion {
|
switch completion {
|
||||||
|
@ -82,6 +86,14 @@ extension PublicTimelineViewModel.State {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func didEnter(from previousState: GKState?) {
|
||||||
|
super.didEnter(from: previousState)
|
||||||
|
guard let viewModel = viewModel else { return }
|
||||||
|
|
||||||
|
// trigger items update
|
||||||
|
viewModel.items.value = viewModel.items.value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Idle: PublicTimelineViewModel.State {
|
class Idle: PublicTimelineViewModel.State {
|
||||||
|
@ -110,29 +122,36 @@ extension PublicTimelineViewModel.State {
|
||||||
override func didEnter(from previousState: GKState?) {
|
override func didEnter(from previousState: GKState?) {
|
||||||
super.didEnter(from: previousState)
|
super.didEnter(from: previousState)
|
||||||
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||||
|
guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||||
viewModel.loadMore()
|
stateMachine.enter(Fail.self)
|
||||||
.sink { completion in
|
return
|
||||||
switch completion {
|
}
|
||||||
case .failure(let error):
|
let maxID = viewModel.tootIDs.value.last
|
||||||
stateMachine.enter(Fail.self)
|
viewModel.context.apiService.publicTimeline(
|
||||||
os_log("%{public}s[%{public}ld], %{public}s: load more fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
|
domain: activeMastodonAuthenticationBox.domain,
|
||||||
case .finished:
|
maxID: maxID
|
||||||
break
|
)
|
||||||
}
|
.sink { completion in
|
||||||
} receiveValue: { response in
|
switch completion {
|
||||||
stateMachine.enter(Idle.self)
|
case .failure(let error):
|
||||||
var oldTootsIDs = viewModel.tootIDs.value
|
stateMachine.enter(Fail.self)
|
||||||
for toot in response.value {
|
os_log("%{public}s[%{public}ld], %{public}s: load more fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
|
||||||
if !oldTootsIDs.contains(toot.id) {
|
case .finished:
|
||||||
oldTootsIDs.append(toot.id)
|
break
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.tootIDs.value = oldTootsIDs
|
|
||||||
|
|
||||||
}
|
}
|
||||||
.store(in: &viewModel.disposeBag)
|
} 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,7 +46,7 @@ class PublicTimelineViewModel: NSObject {
|
||||||
self.context = context
|
self.context = context
|
||||||
self.fetchedResultsController = {
|
self.fetchedResultsController = {
|
||||||
let fetchRequest = Toot.sortedFetchRequest
|
let fetchRequest = Toot.sortedFetchRequest
|
||||||
fetchRequest.predicate = Toot.predicate(idStrs: [])
|
fetchRequest.predicate = Toot.predicate(domain: "", ids: [])
|
||||||
fetchRequest.returnsObjectsAsFaults = false
|
fetchRequest.returnsObjectsAsFaults = false
|
||||||
fetchRequest.fetchBatchSize = 20
|
fetchRequest.fetchBatchSize = 20
|
||||||
let controller = NSFetchedResultsController(
|
let controller = NSFetchedResultsController(
|
||||||
|
@ -89,7 +89,8 @@ class PublicTimelineViewModel: NSObject {
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] ids in
|
.sink { [weak self] ids in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.fetchedResultsController.fetchRequest.predicate = Toot.predicate(idStrs: ids)
|
let domain = self.context.authenticationService.activeMastodonAuthenticationBox.value?.domain ?? ""
|
||||||
|
self.fetchedResultsController.fetchRequest.predicate = Toot.predicate(domain: domain, ids: ids)
|
||||||
do {
|
do {
|
||||||
try self.fetchedResultsController.performFetch()
|
try self.fetchedResultsController.performFetch()
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -105,9 +106,6 @@ class PublicTimelineViewModel: NSObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension PublicTimelineViewModel {
|
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> {
|
func loadMore() -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Toot]>, Error> {
|
||||||
return context.apiService.publicTimeline(domain: "mstdn.jp")
|
return context.apiService.publicTimeline(domain: "mstdn.jp")
|
||||||
|
|
|
@ -9,6 +9,7 @@ import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import CoreData
|
import CoreData
|
||||||
import CoreDataStack
|
import CoreDataStack
|
||||||
|
import CommonOSLog
|
||||||
import DateToolsSwift
|
import DateToolsSwift
|
||||||
import MastodonSDK
|
import MastodonSDK
|
||||||
|
|
||||||
|
@ -19,15 +20,17 @@ extension APIService {
|
||||||
sinceID: Mastodon.Entity.Status.ID? = nil,
|
sinceID: Mastodon.Entity.Status.ID? = nil,
|
||||||
maxID: Mastodon.Entity.Status.ID? = nil,
|
maxID: Mastodon.Entity.Status.ID? = nil,
|
||||||
limit: Int = 100,
|
limit: Int = 100,
|
||||||
|
local: Bool? = nil,
|
||||||
authorizationBox: AuthenticationService.MastodonAuthenticationBox
|
authorizationBox: AuthenticationService.MastodonAuthenticationBox
|
||||||
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Toot]>, Error> {
|
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Toot]>, Error> {
|
||||||
let authorization = authorizationBox.userAuthorization
|
let authorization = authorizationBox.userAuthorization
|
||||||
|
let requestMastodonUserID = authorizationBox.userID
|
||||||
let query = Mastodon.API.Timeline.HomeTimelineQuery(
|
let query = Mastodon.API.Timeline.HomeTimelineQuery(
|
||||||
maxID: maxID,
|
maxID: maxID,
|
||||||
sinceID: sinceID,
|
sinceID: sinceID,
|
||||||
minID: nil, // prefer sinceID
|
minID: nil, // prefer sinceID
|
||||||
limit: limit,
|
limit: limit,
|
||||||
local: nil // TODO:
|
local: local
|
||||||
)
|
)
|
||||||
|
|
||||||
return Mastodon.API.Timeline.home(
|
return Mastodon.API.Timeline.home(
|
||||||
|
@ -38,10 +41,13 @@ extension APIService {
|
||||||
)
|
)
|
||||||
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Toot]>, Error> in
|
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Toot]>, Error> in
|
||||||
return APIService.Persist.persistTimeline(
|
return APIService.Persist.persistTimeline(
|
||||||
domain: domain,
|
|
||||||
managedObjectContext: self.backgroundManagedObjectContext,
|
managedObjectContext: self.backgroundManagedObjectContext,
|
||||||
|
domain: domain,
|
||||||
|
query: query,
|
||||||
response: response,
|
response: response,
|
||||||
persistType: .homeTimeline
|
persistType: .home,
|
||||||
|
requestMastodonUserID: requestMastodonUserID,
|
||||||
|
log: OSLog.api
|
||||||
)
|
)
|
||||||
.setFailureType(to: Error.self)
|
.setFailureType(to: Error.self)
|
||||||
.tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Toot]> in
|
.tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Toot]> in
|
||||||
|
|
|
@ -9,6 +9,7 @@ import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import CoreData
|
import CoreData
|
||||||
import CoreDataStack
|
import CoreDataStack
|
||||||
|
import CommonOSLog
|
||||||
import DateToolsSwift
|
import DateToolsSwift
|
||||||
import MastodonSDK
|
import MastodonSDK
|
||||||
|
|
||||||
|
@ -39,10 +40,13 @@ extension APIService {
|
||||||
)
|
)
|
||||||
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Toot]>, Error> in
|
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Toot]>, Error> in
|
||||||
return APIService.Persist.persistTimeline(
|
return APIService.Persist.persistTimeline(
|
||||||
domain: domain,
|
|
||||||
managedObjectContext: self.backgroundManagedObjectContext,
|
managedObjectContext: self.backgroundManagedObjectContext,
|
||||||
|
domain: domain,
|
||||||
|
query: query,
|
||||||
response: response,
|
response: response,
|
||||||
persistType: Persist.PersistTimelineType.publicTimeline
|
persistType: .public,
|
||||||
|
requestMastodonUserID: nil,
|
||||||
|
log: OSLog.api
|
||||||
)
|
)
|
||||||
.setFailureType(to: Error.self)
|
.setFailureType(to: Error.self)
|
||||||
.tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Toot]> in
|
.tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Toot]> in
|
||||||
|
|
|
@ -13,25 +13,26 @@ import MastodonSDK
|
||||||
|
|
||||||
extension APIService.CoreData {
|
extension APIService.CoreData {
|
||||||
|
|
||||||
static func createOrMergeTweet(
|
static func createOrMergeToot(
|
||||||
into managedObjectContext: NSManagedObjectContext,
|
into managedObjectContext: NSManagedObjectContext,
|
||||||
for requestMastodonUser: MastodonUser,
|
for requestMastodonUser: MastodonUser?,
|
||||||
entity: Mastodon.Entity.Toot,
|
entity: Mastodon.Entity.Toot,
|
||||||
domain: String,
|
domain: String,
|
||||||
networkDate: Date,
|
networkDate: Date,
|
||||||
log: OSLog
|
log: OSLog
|
||||||
) -> (Toot: Toot, isTweetCreated: Bool, isMastodonUserCreated: Bool) {
|
) -> (Toot: Toot, isTootCreated: Bool, isMastodonUserCreated: Bool) {
|
||||||
|
|
||||||
// build tree
|
// build tree
|
||||||
let reblog = entity.reblog.flatMap { entity -> Toot in
|
let reblog = entity.reblog.flatMap { entity -> Toot in
|
||||||
let (toot, _, _) = createOrMergeTweet(into: managedObjectContext, for: requestMastodonUser, entity: entity,domain: domain, networkDate: networkDate, log: log)
|
let (toot, _, _) = createOrMergeToot(into: managedObjectContext, for: requestMastodonUser, entity: entity, domain: domain, networkDate: networkDate, log: log)
|
||||||
return toot
|
return toot
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetch old Toot
|
// fetch old Toot
|
||||||
let oldTweet: Toot? = {
|
let oldToot: Toot? = {
|
||||||
let request = Toot.sortedFetchRequest
|
let request = Toot.sortedFetchRequest
|
||||||
request.predicate = Toot.predicate(idStr: entity.id)
|
request.predicate = Toot.predicate(domain: domain, id: entity.id)
|
||||||
|
request.fetchLimit = 1
|
||||||
request.returnsObjectsAsFaults = false
|
request.returnsObjectsAsFaults = false
|
||||||
do {
|
do {
|
||||||
return try managedObjectContext.fetch(request).first
|
return try managedObjectContext.fetch(request).first
|
||||||
|
@ -41,64 +42,47 @@ extension APIService.CoreData {
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if let oldTweet = oldTweet {
|
if let oldToot = oldToot {
|
||||||
// merge old Toot
|
// merge old Toot
|
||||||
APIService.CoreData.mergeToot(for: requestMastodonUser, old: oldTweet,in: domain, entity: entity, networkDate: networkDate)
|
APIService.CoreData.mergeToot(for: requestMastodonUser, old: oldToot,in: domain, entity: entity, networkDate: networkDate)
|
||||||
return (oldTweet, false, false)
|
return (oldToot, false, false)
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
let (mastodonUser, isMastodonUserCreated) = createOrMergeMastodonUser(into: managedObjectContext, for: requestMastodonUser,in: domain, entity: entity.account, networkDate: networkDate, log: log)
|
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
|
let application = entity.application.flatMap { app -> Application? in
|
||||||
Application.insert(into: managedObjectContext, property: Application.Property(name: app.name, website: app.website, vapidKey: app.vapidKey))
|
Application.insert(into: managedObjectContext, property: Application.Property(name: app.name, website: app.website, vapidKey: app.vapidKey))
|
||||||
}
|
}
|
||||||
|
let metions = entity.mentions?.compactMap { mention -> Mention in
|
||||||
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))
|
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
|
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))
|
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 tags = entity.tags?.compactMap { tag -> Tag in
|
||||||
let histories = tag.history?.compactMap({ (history) -> History 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))
|
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))
|
return Tag.insert(into: managedObjectContext, property: Tag.Property(name: tag.name, url: tag.url, histories: histories))
|
||||||
})
|
}
|
||||||
let tootProperty = Toot.Property(
|
let tootProperty = Toot.Property(entity: entity, domain: domain, networkDate: networkDate)
|
||||||
domain: domain,
|
let toot = Toot.insert(
|
||||||
id: entity.id,
|
into: managedObjectContext,
|
||||||
uri: entity.uri,
|
property: tootProperty,
|
||||||
createdAt: entity.createdAt,
|
author: mastodonUser,
|
||||||
content: entity.content,
|
reblog: reblog,
|
||||||
visibility: entity.visibility?.rawValue,
|
|
||||||
sensitive: entity.sensitive ?? false,
|
|
||||||
spoilerText: entity.spoilerText,
|
|
||||||
application: application,
|
application: application,
|
||||||
mentions: metions,
|
mentions: metions,
|
||||||
emojis: emojis,
|
emojis: emojis,
|
||||||
tags: tags,
|
tags: tags,
|
||||||
reblogsCount: NSNumber(value: entity.reblogsCount),
|
favouritedBy: requestMastodonUser,
|
||||||
favouritesCount: NSNumber(value: entity.favouritesCount),
|
rebloggedBy: requestMastodonUser,
|
||||||
repliesCount: (entity.repliesCount != nil) ? NSNumber(value: entity.repliesCount!) : nil,
|
mutedBy: requestMastodonUser,
|
||||||
url: entity.uri,
|
bookmarkedBy: requestMastodonUser,
|
||||||
inReplyToID: entity.inReplyToID,
|
pinnedBy: requestMastodonUser
|
||||||
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)
|
return (toot, true, isMastodonUserCreated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func mergeToot(for requestMastodonUser: MastodonUser?, old toot: Toot,in domain: String, entity: Mastodon.Entity.Toot, networkDate: Date) {
|
static func mergeToot(for requestMastodonUser: MastodonUser?, old toot: Toot,in domain: String, entity: Mastodon.Entity.Toot, networkDate: Date) {
|
||||||
guard networkDate > toot.updatedAt else { return }
|
guard networkDate > toot.updatedAt else { return }
|
||||||
|
|
||||||
|
@ -114,8 +98,7 @@ extension APIService.CoreData {
|
||||||
if entity.reblogsCount != toot.reblogsCount.intValue {
|
if entity.reblogsCount != toot.reblogsCount.intValue {
|
||||||
toot.update(reblogsCount:NSNumber(value: entity.reblogsCount))
|
toot.update(reblogsCount:NSNumber(value: entity.reblogsCount))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// set updateAt
|
// set updateAt
|
||||||
toot.didUpdate(at: networkDate)
|
toot.didUpdate(at: networkDate)
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import os.log
|
import os.log
|
||||||
|
import func QuartzCore.CACurrentMediaTime
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import CoreData
|
import CoreData
|
||||||
|
@ -13,71 +14,190 @@ import CoreDataStack
|
||||||
import MastodonSDK
|
import MastodonSDK
|
||||||
|
|
||||||
extension APIService.Persist {
|
extension APIService.Persist {
|
||||||
|
|
||||||
enum PersistTimelineType {
|
enum PersistTimelineType {
|
||||||
case publicTimeline
|
case `public`
|
||||||
case homeTimeline
|
case home
|
||||||
}
|
}
|
||||||
|
|
||||||
static func persistTimeline(
|
static func persistTimeline(
|
||||||
domain: String,
|
|
||||||
managedObjectContext: NSManagedObjectContext,
|
managedObjectContext: NSManagedObjectContext,
|
||||||
|
domain: String,
|
||||||
|
query: Mastodon.API.Timeline.TimelineQuery,
|
||||||
response: Mastodon.Response.Content<[Mastodon.Entity.Toot]>,
|
response: Mastodon.Response.Content<[Mastodon.Entity.Toot]>,
|
||||||
persistType: PersistTimelineType
|
persistType: PersistTimelineType,
|
||||||
|
requestMastodonUserID: MastodonUser.ID?, // could be nil when response from public endpoint
|
||||||
|
log: OSLog
|
||||||
) -> AnyPublisher<Result<Void, Error>, Never> {
|
) -> AnyPublisher<Result<Void, Error>, Never> {
|
||||||
|
let toots = response.value
|
||||||
|
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: persist %{public}ld toots…", ((#file as NSString).lastPathComponent), #line, #function, toots.count)
|
||||||
|
|
||||||
return managedObjectContext.performChanges {
|
return managedObjectContext.performChanges {
|
||||||
let toots = response.value
|
let contextTaskSignpostID = OSSignpostID(log: log)
|
||||||
let _ = toots.map {
|
let start = CACurrentMediaTime()
|
||||||
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)
|
os_signpost(.begin, log: log, name: #function, signpostID: contextTaskSignpostID)
|
||||||
let author = MastodonUser.insert(into: managedObjectContext, property: userProperty)
|
defer {
|
||||||
let application = $0.application.flatMap { app -> Application? in
|
os_signpost(.end, log: .api, name: #function, signpostID: contextTaskSignpostID)
|
||||||
Application.insert(into: managedObjectContext, property: Application.Property(name: app.name, website: app.website, vapidKey: app.vapidKey))
|
let end = CACurrentMediaTime()
|
||||||
}
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: persist cost %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start)
|
||||||
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))
|
|
||||||
})
|
|
||||||
let emojis = $0.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 = $0.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: $0.id,
|
|
||||||
uri: $0.uri,
|
|
||||||
createdAt: $0.createdAt,
|
|
||||||
content: $0.content,
|
|
||||||
visibility: $0.visibility?.rawValue,
|
|
||||||
sensitive: $0.sensitive ?? false,
|
|
||||||
spoilerText: $0.spoilerText,
|
|
||||||
application: application,
|
|
||||||
mentions: metions,
|
|
||||||
emojis: emojis,
|
|
||||||
tags: tags,
|
|
||||||
reblogsCount: NSNumber(value: $0.reblogsCount),
|
|
||||||
favouritesCount: NSNumber(value: $0.favouritesCount),
|
|
||||||
repliesCount: ($0.repliesCount != nil) ? NSNumber(value: $0.repliesCount!) : nil,
|
|
||||||
url: $0.uri,
|
|
||||||
inReplyToID: $0.inReplyToID,
|
|
||||||
inReplyToAccountID: $0.inReplyToAccountID,
|
|
||||||
reblog: nil, //TODO need fix
|
|
||||||
language: $0.language,
|
|
||||||
text: $0.text,
|
|
||||||
favouritedBy: ($0.favourited ?? false) ? author : nil,
|
|
||||||
rebloggedBy: ($0.reblogged ?? false) ? author : nil,
|
|
||||||
mutedBy: ($0.muted ?? false) ? author : nil,
|
|
||||||
bookmarkedBy: ($0.bookmarked ?? false) ? author : nil,
|
|
||||||
pinnedBy: ($0.pinned ?? false) ? author : nil,
|
|
||||||
updatedAt: response.networkDate,
|
|
||||||
deletedAt: nil,
|
|
||||||
author: author,
|
|
||||||
homeTimelineIndexes: nil)
|
|
||||||
Toot.insert(into: managedObjectContext, property: tootProperty, author: author)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// load request mastodon user
|
||||||
|
let requestMastodonUser: MastodonUser? = {
|
||||||
|
guard let requestMastodonUserID = requestMastodonUserID else { return nil }
|
||||||
|
let request = MastodonUser.sortedFetchRequest
|
||||||
|
request.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID)
|
||||||
|
request.fetchLimit = 1
|
||||||
|
request.returnsObjectsAsFaults = false
|
||||||
|
do {
|
||||||
|
return try managedObjectContext.fetch(request).first
|
||||||
|
} catch {
|
||||||
|
assertionFailure(error.localizedDescription)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// load working set into context to avoid cache miss
|
||||||
|
let cacheTaskSignpostID = OSSignpostID(log: log)
|
||||||
|
os_signpost(.begin, log: log, name: "load toots into cache", signpostID: cacheTaskSignpostID)
|
||||||
|
let workingIDRecord = APIService.Persist.WorkingIDRecord.workingID(entities: toots)
|
||||||
|
|
||||||
|
// contains toots and reblogs
|
||||||
|
let _tootCache: [Toot] = {
|
||||||
|
let request = Toot.sortedFetchRequest
|
||||||
|
let idSet = workingIDRecord.statusIDSet
|
||||||
|
.union(workingIDRecord.reblogIDSet)
|
||||||
|
let ids = Array(idSet)
|
||||||
|
request.predicate = Toot.predicate(domain: domain, ids: ids)
|
||||||
|
request.returnsObjectsAsFaults = false
|
||||||
|
request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)]
|
||||||
|
do {
|
||||||
|
return try managedObjectContext.fetch(request)
|
||||||
|
} catch {
|
||||||
|
assertionFailure(error.localizedDescription)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
os_signpost(.event, log: log, name: "load toots into cache", signpostID: cacheTaskSignpostID, "cached %{public}ld toots", _tootCache.count)
|
||||||
|
os_signpost(.end, log: log, name: "load toots into cache", signpostID: cacheTaskSignpostID)
|
||||||
|
|
||||||
|
// remote timeline merge local timeline record set
|
||||||
|
// declare it before do working
|
||||||
|
let mergedOldTootsInTimeline = _tootCache.filter {
|
||||||
|
return $0.homeTimelineIndexes?.contains(where: { $0.userID == requestMastodonUserID }) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
let updateDatabaseTaskSignpostID = OSSignpostID(log: log)
|
||||||
|
let recordType: WorkingRecord.RecordType = {
|
||||||
|
switch persistType {
|
||||||
|
case .public: return .publicTimeline
|
||||||
|
case .home: return .homeTimeline
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var workingRecords: [WorkingRecord] = []
|
||||||
|
os_signpost(.begin, log: log, name: "update database", signpostID: updateDatabaseTaskSignpostID)
|
||||||
|
for entity in toots {
|
||||||
|
let processEntityTaskSignpostID = OSSignpostID(log: log)
|
||||||
|
os_signpost(.begin, log: log, name: "update database - process entity", signpostID: processEntityTaskSignpostID, "process entity %{public}s", entity.id)
|
||||||
|
defer {
|
||||||
|
os_signpost(.end, log: log, name: "update database - process entity", signpostID: processEntityTaskSignpostID, "process entity %{public}s", entity.id)
|
||||||
|
}
|
||||||
|
let record = WorkingRecord.createOrMergeToot(
|
||||||
|
into: managedObjectContext,
|
||||||
|
for: requestMastodonUser,
|
||||||
|
domain: domain,
|
||||||
|
entity: entity,
|
||||||
|
recordType: recordType,
|
||||||
|
networkDate: response.networkDate,
|
||||||
|
log: log
|
||||||
|
)
|
||||||
|
workingRecords.append(record)
|
||||||
|
} // end for…
|
||||||
|
os_signpost(.end, log: log, name: "update database", signpostID: updateDatabaseTaskSignpostID)
|
||||||
|
|
||||||
|
// home & mention timeline tasks
|
||||||
|
switch persistType {
|
||||||
|
case .home:
|
||||||
|
// Task 1: update anchor hasMore
|
||||||
|
// update maxID anchor hasMore attribute when fetching on timeline
|
||||||
|
// do not use working records due to anchor toot is removable on the remote
|
||||||
|
var anchorToot: Toot?
|
||||||
|
if let maxID = query.maxID {
|
||||||
|
do {
|
||||||
|
// load anchor toot from database
|
||||||
|
let request = Toot.sortedFetchRequest
|
||||||
|
request.predicate = Toot.predicate(domain: domain, id: maxID)
|
||||||
|
request.returnsObjectsAsFaults = false
|
||||||
|
request.fetchLimit = 1
|
||||||
|
anchorToot = try managedObjectContext.fetch(request).first
|
||||||
|
if persistType == .home {
|
||||||
|
let timelineIndex = anchorToot.flatMap { toot in
|
||||||
|
toot.homeTimelineIndexes?.first(where: { $0.userID == requestMastodonUserID })
|
||||||
|
}
|
||||||
|
timelineIndex?.update(hasMore: false)
|
||||||
|
} else {
|
||||||
|
assertionFailure()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
assertionFailure(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task 2: set last toot hasMore when fetched toots not overlap with the timeline in the local database
|
||||||
|
let _oldestRecord = workingRecords
|
||||||
|
.sorted(by: { $0.status.createdAt < $1.status.createdAt })
|
||||||
|
.first
|
||||||
|
if let oldestRecord = _oldestRecord {
|
||||||
|
if let anchorToot = anchorToot {
|
||||||
|
// using anchor. set hasMore when (overlap itself OR no overlap) AND oldest record NOT anchor
|
||||||
|
let isNoOverlap = mergedOldTootsInTimeline.isEmpty
|
||||||
|
let isOnlyOverlapItself = mergedOldTootsInTimeline.count == 1 && mergedOldTootsInTimeline.first?.id == anchorToot.id
|
||||||
|
let isAnchorEqualOldestRecord = oldestRecord.status.id == anchorToot.id
|
||||||
|
if (isNoOverlap || isOnlyOverlapItself) && !isAnchorEqualOldestRecord {
|
||||||
|
if persistType == .home {
|
||||||
|
let timelineIndex = oldestRecord.status.homeTimelineIndexes?
|
||||||
|
.first(where: { $0.userID == requestMastodonUserID })
|
||||||
|
timelineIndex?.update(hasMore: true)
|
||||||
|
} else {
|
||||||
|
assertionFailure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if mergedOldTootsInTimeline.isEmpty {
|
||||||
|
// no anchor. set hasMore when no overlap
|
||||||
|
if persistType == .home {
|
||||||
|
let timelineIndex = oldestRecord.status.homeTimelineIndexes?
|
||||||
|
.first(where: { $0.userID == requestMastodonUserID })
|
||||||
|
timelineIndex?.update(hasMore: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// empty working record. mark anchor hasMore in the task 1
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// print working record tree map
|
||||||
|
#if DEBUG
|
||||||
|
DispatchQueue.global(qos: .utility).async {
|
||||||
|
let logs = workingRecords
|
||||||
|
.map { record in record.log() }
|
||||||
|
.joined(separator: "\n")
|
||||||
|
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: working status: \n%s", ((#file as NSString).lastPathComponent), #line, #function, logs)
|
||||||
|
let counting = workingRecords
|
||||||
|
.map { record in record.count() }
|
||||||
|
.reduce(into: WorkingRecord.Counting(), { result, next in result = result + next })
|
||||||
|
let newTootsInTimeLineCount = workingRecords.reduce(0, { result, next in
|
||||||
|
return next.statusProcessType == .create ? result + 1 : result
|
||||||
|
})
|
||||||
|
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: toot: insert %{public}ldT(%{public}ldTRQ), merge %{public}ldT(%{public}ldTRQ)", ((#file as NSString).lastPathComponent), #line, #function, newTootsInTimeLineCount, counting.status.create, mergedOldTootsInTimeline.count, counting.status.merge)
|
||||||
|
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: mastodon user: insert %{public}ld, merge %{public}ld", ((#file as NSString).lastPathComponent), #line, #function, counting.user.create, counting.user.merge)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
.handleEvents(receiveOutput: { result in
|
.handleEvents(receiveOutput: { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success:
|
case .success:
|
||||||
|
@ -92,3 +212,232 @@ extension APIService.Persist {
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension APIService.Persist {
|
||||||
|
|
||||||
|
struct WorkingIDRecord {
|
||||||
|
var statusIDSet: Set<String>
|
||||||
|
var reblogIDSet: Set<String>
|
||||||
|
var userIDSet: Set<String>
|
||||||
|
|
||||||
|
enum RecordType {
|
||||||
|
case timeline
|
||||||
|
case reblog
|
||||||
|
}
|
||||||
|
|
||||||
|
init(statusIDSet: Set<String> = Set(), reblogIDSet: Set<String> = Set(), userIDSet: Set<String> = Set()) {
|
||||||
|
self.statusIDSet = statusIDSet
|
||||||
|
self.reblogIDSet = reblogIDSet
|
||||||
|
self.userIDSet = userIDSet
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func union(record: WorkingIDRecord) {
|
||||||
|
statusIDSet = statusIDSet.union(record.statusIDSet)
|
||||||
|
reblogIDSet = reblogIDSet.union(record.reblogIDSet)
|
||||||
|
userIDSet = userIDSet.union(record.userIDSet)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func workingID(entities: [Mastodon.Entity.Status]) -> WorkingIDRecord {
|
||||||
|
var value = WorkingIDRecord()
|
||||||
|
for entity in entities {
|
||||||
|
let child = workingID(entity: entity, recordType: .timeline)
|
||||||
|
value.union(record: child)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func workingID(entity: Mastodon.Entity.Status, recordType: RecordType) -> WorkingIDRecord {
|
||||||
|
var value = WorkingIDRecord()
|
||||||
|
switch recordType {
|
||||||
|
case .timeline: value.statusIDSet = Set([entity.id])
|
||||||
|
case .reblog: value.reblogIDSet = Set([entity.id])
|
||||||
|
}
|
||||||
|
value.userIDSet = Set([entity.account.id])
|
||||||
|
|
||||||
|
if let reblog = entity.reblog {
|
||||||
|
let child = workingID(entity: reblog, recordType: .reblog)
|
||||||
|
value.union(record: child)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WorkingRecord {
|
||||||
|
|
||||||
|
let status: Toot
|
||||||
|
let children: [WorkingRecord]
|
||||||
|
let recordType: RecordType
|
||||||
|
let statusProcessType: ProcessType
|
||||||
|
let userProcessType: ProcessType
|
||||||
|
|
||||||
|
init(
|
||||||
|
status: Toot,
|
||||||
|
children: [APIService.Persist.WorkingRecord],
|
||||||
|
recordType: APIService.Persist.WorkingRecord.RecordType,
|
||||||
|
tootProcessType: ProcessType,
|
||||||
|
userProcessType: ProcessType
|
||||||
|
) {
|
||||||
|
self.status = status
|
||||||
|
self.children = children
|
||||||
|
self.recordType = recordType
|
||||||
|
self.statusProcessType = tootProcessType
|
||||||
|
self.userProcessType = userProcessType
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RecordType {
|
||||||
|
case publicTimeline
|
||||||
|
case homeTimeline
|
||||||
|
case mentionTimeline
|
||||||
|
case userTimeline
|
||||||
|
case favoriteTimeline
|
||||||
|
case searchTimeline
|
||||||
|
|
||||||
|
case reblog
|
||||||
|
|
||||||
|
var flag: String {
|
||||||
|
switch self {
|
||||||
|
case .publicTimeline: return "P"
|
||||||
|
case .homeTimeline: return "H"
|
||||||
|
case .mentionTimeline: return "M"
|
||||||
|
case .userTimeline: return "U"
|
||||||
|
case .favoriteTimeline: return "F"
|
||||||
|
case .searchTimeline: return "S"
|
||||||
|
case .reblog: return "R"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ProcessType {
|
||||||
|
case create
|
||||||
|
case merge
|
||||||
|
|
||||||
|
var flag: String {
|
||||||
|
switch self {
|
||||||
|
case .create: return "+"
|
||||||
|
case .merge: return "-"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func log(indentLevel: Int = 0) -> String {
|
||||||
|
let indent = Array(repeating: " ", count: indentLevel).joined()
|
||||||
|
let tootPreview = status.content.prefix(32).replacingOccurrences(of: "\n", with: " ")
|
||||||
|
let message = "\(indent)[\(statusProcessType.flag)\(recordType.flag)](\(status.id)) [\(userProcessType.flag)](\(status.author.id))@\(status.author.username) ~> \(tootPreview)"
|
||||||
|
|
||||||
|
var childrenMessages: [String] = []
|
||||||
|
for child in children {
|
||||||
|
childrenMessages.append(child.log(indentLevel: indentLevel + 1))
|
||||||
|
}
|
||||||
|
let result = [[message] + childrenMessages]
|
||||||
|
.flatMap { $0 }
|
||||||
|
.joined(separator: "\n")
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Counting {
|
||||||
|
var status = Counter()
|
||||||
|
var user = Counter()
|
||||||
|
|
||||||
|
static func + (left: Counting, right: Counting) -> Counting {
|
||||||
|
return Counting(
|
||||||
|
status: left.status + right.status,
|
||||||
|
user: left.user + right.user
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Counter {
|
||||||
|
var create = 0
|
||||||
|
var merge = 0
|
||||||
|
|
||||||
|
static func + (left: Counter, right: Counter) -> Counter {
|
||||||
|
return Counter(
|
||||||
|
create: left.create + right.create,
|
||||||
|
merge: left.merge + right.merge
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func count() -> Counting {
|
||||||
|
var counting = Counting()
|
||||||
|
|
||||||
|
switch statusProcessType {
|
||||||
|
case .create: counting.status.create += 1
|
||||||
|
case .merge: counting.status.merge += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
switch userProcessType {
|
||||||
|
case .create: counting.user.create += 1
|
||||||
|
case .merge: counting.user.merge += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for child in children {
|
||||||
|
let childCounting = child.count()
|
||||||
|
counting = counting + childCounting
|
||||||
|
}
|
||||||
|
|
||||||
|
return counting
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle timelineIndex insert with APIService.Persist.createOrMergeToot
|
||||||
|
static func createOrMergeToot(
|
||||||
|
into managedObjectContext: NSManagedObjectContext,
|
||||||
|
for requestMastodonUser: MastodonUser?,
|
||||||
|
domain: String,
|
||||||
|
entity: Mastodon.Entity.Status,
|
||||||
|
recordType: RecordType,
|
||||||
|
networkDate: Date,
|
||||||
|
log: OSLog
|
||||||
|
) -> WorkingRecord {
|
||||||
|
let processEntityTaskSignpostID = OSSignpostID(log: log)
|
||||||
|
os_signpost(.begin, log: log, name: "update database - process entity: createorMergeToot", signpostID: processEntityTaskSignpostID, "process toot %{public}s", entity.id)
|
||||||
|
defer {
|
||||||
|
os_signpost(.end, log: log, name: "update database - process entity: createorMergeToot", signpostID: processEntityTaskSignpostID, "finish process toot %{public}s", entity.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// build tree
|
||||||
|
let reblogRecord: WorkingRecord? = entity.reblog.flatMap { entity -> WorkingRecord in
|
||||||
|
createOrMergeToot(into: managedObjectContext, for: requestMastodonUser, domain: domain, entity: entity, recordType: .reblog, networkDate: networkDate, log: log)
|
||||||
|
}
|
||||||
|
let children = [reblogRecord].compactMap { $0 }
|
||||||
|
|
||||||
|
let (status, isTootCreated, isTootUserCreated) = APIService.CoreData.createOrMergeToot(into: managedObjectContext, for: requestMastodonUser, entity: entity, domain: domain, networkDate: networkDate, log: log)
|
||||||
|
|
||||||
|
let result = WorkingRecord(
|
||||||
|
status: status,
|
||||||
|
children: children,
|
||||||
|
recordType: recordType,
|
||||||
|
tootProcessType: isTootCreated ? .create : .merge,
|
||||||
|
userProcessType: isTootUserCreated ? .create : .merge
|
||||||
|
)
|
||||||
|
|
||||||
|
switch (result.statusProcessType, recordType) {
|
||||||
|
case (.create, .homeTimeline), (.merge, .homeTimeline):
|
||||||
|
guard let requestMastodonUserID = requestMastodonUser?.id else {
|
||||||
|
assertionFailure("Request user is required for home timeline")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
let timelineIndex = status.homeTimelineIndexes?
|
||||||
|
.first { $0.userID == requestMastodonUserID }
|
||||||
|
if timelineIndex == nil {
|
||||||
|
let timelineIndexProperty = HomeTimelineIndex.Property(domain: domain)
|
||||||
|
let _ = HomeTimelineIndex.insert(
|
||||||
|
into: managedObjectContext,
|
||||||
|
property: timelineIndexProperty,
|
||||||
|
toot: status
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// enity already in home timeline
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
@ -62,6 +62,7 @@ class AuthenticationService: NSObject {
|
||||||
.map { authentication -> AuthenticationService.MastodonAuthenticationBox? in
|
.map { authentication -> AuthenticationService.MastodonAuthenticationBox? in
|
||||||
guard let authentication = authentication else { return nil }
|
guard let authentication = authentication else { return nil }
|
||||||
return AuthenticationService.MastodonAuthenticationBox(
|
return AuthenticationService.MastodonAuthenticationBox(
|
||||||
|
domain: authentication.domain,
|
||||||
userID: authentication.userID,
|
userID: authentication.userID,
|
||||||
appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken),
|
appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken),
|
||||||
userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken)
|
userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken)
|
||||||
|
@ -82,6 +83,7 @@ class AuthenticationService: NSObject {
|
||||||
|
|
||||||
extension AuthenticationService {
|
extension AuthenticationService {
|
||||||
struct MastodonAuthenticationBox {
|
struct MastodonAuthenticationBox {
|
||||||
|
let domain: String
|
||||||
let userID: MastodonUser.ID
|
let userID: MastodonUser.ID
|
||||||
let appAuthorization: Mastodon.API.OAuth.Authorization
|
let appAuthorization: Mastodon.API.OAuth.Authorization
|
||||||
let userAuthorization: Mastodon.API.OAuth.Authorization
|
let userAuthorization: Mastodon.API.OAuth.Authorization
|
||||||
|
|
|
@ -56,9 +56,16 @@ extension Mastodon.API.Timeline {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public protocol TimelineQueryType {
|
||||||
|
var maxID: Mastodon.Entity.Toot.ID? { get }
|
||||||
|
var sinceID: Mastodon.Entity.Toot.ID? { get }
|
||||||
|
}
|
||||||
|
|
||||||
extension Mastodon.API.Timeline {
|
extension Mastodon.API.Timeline {
|
||||||
|
|
||||||
public struct PublicTimelineQuery: Codable, GetQuery {
|
public typealias TimelineQuery = TimelineQueryType
|
||||||
|
|
||||||
|
public struct PublicTimelineQuery: Codable, TimelineQuery, GetQuery {
|
||||||
|
|
||||||
public let local: Bool?
|
public let local: Bool?
|
||||||
public let remote: Bool?
|
public let remote: Bool?
|
||||||
|
@ -100,7 +107,7 @@ extension Mastodon.API.Timeline {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct HomeTimelineQuery: Codable, GetQuery {
|
public struct HomeTimelineQuery: Codable, TimelineQuery, GetQuery {
|
||||||
public let maxID: Mastodon.Entity.Toot.ID?
|
public let maxID: Mastodon.Entity.Toot.ID?
|
||||||
public let sinceID: Mastodon.Entity.Toot.ID?
|
public let sinceID: Mastodon.Entity.Toot.ID?
|
||||||
public let minID: Mastodon.Entity.Toot.ID?
|
public let minID: Mastodon.Entity.Toot.ID?
|
||||||
|
|
Loading…
Reference in New Issue