Merge pull request #6 from tootsuite/feature/home-timeline-api

Add home timeline api and timeline persist method
This commit is contained in:
CMK 2021-02-04 14:57:38 +08:00 committed by GitHub
commit d1bac3b2c2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 891 additions and 331 deletions

View File

@ -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>

View File

@ -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 {

View File

@ -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 {

View File

@ -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))
} }
} }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -39,18 +39,17 @@
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; }; 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; };
2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; }; 2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; };
2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF123A625C3B0210020F248 /* ActiveLabel.swift */; }; 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF123A625C3B0210020F248 /* ActiveLabel.swift */; };
3533495136D843E85211E3E2 /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A1B4523A7981F1044DE89C21 /* Pods_Mastodon_MastodonUITests.framework */; };
45B49097460EDE530AD5AA72 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; }; 45B49097460EDE530AD5AA72 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; };
5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; }; 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; };
5E0DEC05797A7E6933788DDB /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */; }; 5E0DEC05797A7E6933788DDB /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */; };
5E44BF88AD33646E64727BCF /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */; }; 5E44BF88AD33646E64727BCF /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */; };
7A9135D4559750AF07CA9BE4 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 602D783BEC22881EBAD84419 /* Pods_Mastodon.framework */; };
DB01409625C40B6700F9F3CF /* AuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB01409525C40B6700F9F3CF /* AuthenticationViewController.swift */; }; DB01409625C40B6700F9F3CF /* AuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB01409525C40B6700F9F3CF /* AuthenticationViewController.swift */; };
DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */; }; DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */; };
DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */; }; DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */; };
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 */; };
@ -67,6 +66,7 @@
DB45FAED25CA7A9A005A8AC7 /* MastodonAuthentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAEC25CA7A9A005A8AC7 /* MastodonAuthentication.swift */; }; DB45FAED25CA7A9A005A8AC7 /* MastodonAuthentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAEC25CA7A9A005A8AC7 /* MastodonAuthentication.swift */; };
DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */; }; DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */; };
DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */; }; DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */; };
DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */; };
DB89B9F725C10FD0008580ED /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; }; DB89B9F725C10FD0008580ED /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; };
DB89B9FE25C10FD0008580ED /* CoreDataStackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89B9FD25C10FD0008580ED /* CoreDataStackTests.swift */; }; DB89B9FE25C10FD0008580ED /* CoreDataStackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89B9FD25C10FD0008580ED /* CoreDataStackTests.swift */; };
DB89BA0025C10FD0008580ED /* CoreDataStack.h in Headers */ = {isa = PBXBuildFile; fileRef = DB89B9F025C10FD0008580ED /* CoreDataStack.h */; settings = {ATTRIBUTES = (Public, ); }; }; DB89BA0025C10FD0008580ED /* CoreDataStack.h in Headers */ = {isa = PBXBuildFile; fileRef = DB89B9F025C10FD0008580ED /* CoreDataStack.h */; settings = {ATTRIBUTES = (Public, ); }; };
@ -186,10 +186,8 @@
3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = "<group>"; }; 459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = "<group>"; };
602D783BEC22881EBAD84419 /* Pods_Mastodon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon.framework; sourceTree = BUILT_PRODUCTS_DIR; };
75E3471C898DDD9631729B6E /* Pods-Mastodon.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.release.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.release.xcconfig"; sourceTree = "<group>"; }; 75E3471C898DDD9631729B6E /* Pods-Mastodon.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.release.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.release.xcconfig"; sourceTree = "<group>"; };
9780A4C98FFC65B32B50D1C0 /* Pods-MastodonTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.release.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.release.xcconfig"; sourceTree = "<group>"; }; 9780A4C98FFC65B32B50D1C0 /* Pods-MastodonTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.release.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.release.xcconfig"; sourceTree = "<group>"; };
A1B4523A7981F1044DE89C21 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon.framework; sourceTree = BUILT_PRODUCTS_DIR; }; A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon.framework; sourceTree = BUILT_PRODUCTS_DIR; };
BB482D32A7B9825BF5327C4F /* Pods-Mastodon-MastodonUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.release.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.release.xcconfig"; sourceTree = "<group>"; }; BB482D32A7B9825BF5327C4F /* Pods-Mastodon-MastodonUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.release.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.release.xcconfig"; sourceTree = "<group>"; };
CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@ -198,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; };
@ -220,6 +219,7 @@
DB45FAEC25CA7A9A005A8AC7 /* MastodonAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAuthentication.swift; sourceTree = "<group>"; }; DB45FAEC25CA7A9A005A8AC7 /* MastodonAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAuthentication.swift; sourceTree = "<group>"; };
DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+MastodonAuthentication.swift"; sourceTree = "<group>"; }; DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+MastodonAuthentication.swift"; sourceTree = "<group>"; };
DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = "<group>"; }; DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = "<group>"; };
DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HomeTimeline.swift"; sourceTree = "<group>"; };
DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CoreDataStack.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CoreDataStack.framework; sourceTree = BUILT_PRODUCTS_DIR; };
DB89B9F025C10FD0008580ED /* CoreDataStack.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CoreDataStack.h; sourceTree = "<group>"; }; DB89B9F025C10FD0008580ED /* CoreDataStack.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CoreDataStack.h; sourceTree = "<group>"; };
DB89B9F125C10FD0008580ED /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; DB89B9F125C10FD0008580ED /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
@ -267,7 +267,6 @@
2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */, 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */,
5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */, 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */,
2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */, 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */,
7A9135D4559750AF07CA9BE4 /* Pods_Mastodon.framework in Frameworks */,
DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */, DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */,
45B49097460EDE530AD5AA72 /* Pods_Mastodon.framework in Frameworks */, 45B49097460EDE530AD5AA72 /* Pods_Mastodon.framework in Frameworks */,
); );
@ -286,7 +285,6 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
3533495136D843E85211E3E2 /* Pods_Mastodon_MastodonUITests.framework in Frameworks */,
18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */, 18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -422,8 +420,6 @@
2D7631A625C1533800929FB9 /* TableviewCell */ = { 2D7631A625C1533800929FB9 /* TableviewCell */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
602D783BEC22881EBAD84419 /* Pods_Mastodon.framework */,
A1B4523A7981F1044DE89C21 /* Pods_Mastodon_MastodonUITests.framework */,
2D7631A725C1535600929FB9 /* TimelinePostTableViewCell.swift */, 2D7631A725C1535600929FB9 /* TimelinePostTableViewCell.swift */,
2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */, 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */,
2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */, 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */,
@ -469,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 = (
@ -559,14 +564,15 @@
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 */,
DB98337025C9443200AD9700 /* APIService+Authentication.swift */, DB98337025C9443200AD9700 /* APIService+Authentication.swift */,
DB98339B25C96DE600AD9700 /* APIService+Account.swift */, DB98339B25C96DE600AD9700 /* APIService+Account.swift */,
2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */, 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */,
DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */,
); );
path = APIService; path = APIService;
sourceTree = "<group>"; sourceTree = "<group>";
@ -682,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 */,
@ -1060,6 +1066,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */, DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */,
DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */,
2D7631B325C159F700929FB9 /* Item.swift in Sources */, 2D7631B325C159F700929FB9 /* Item.swift in Sources */,
2D61335E25C1894B00CAE157 /* APIService.swift in Sources */, 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */,
DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */, DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */,
@ -1088,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 */,

View File

@ -7,12 +7,12 @@
<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>
<key>orderHint</key> <key>orderHint</key>
<integer>0</integer> <integer>1</integer>
</dict> </dict>
</dict> </dict>
<key>SuppressBuildableAutocreation</key> <key>SuppressBuildableAutocreation</key>

View File

@ -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.flatMap { NSNumber(value: $0) },
url: entity.uri,
inReplyToID: entity.inReplyToID,
inReplyToAccountID: entity.inReplyToAccountID,
language: entity.language,
text: entity.text,
networkDate: networkDate
)
}
}

View File

@ -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 }

View File

@ -29,6 +29,7 @@ extension PublicTimelineViewModel {
timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate
) )
items.value = [] items.value = []
stateMachine.enter(PublicTimelineViewModel.State.Loading.self)
} }
} }

View File

@ -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)
} }
} }
} }

View File

@ -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,11 +106,8 @@ class PublicTimelineViewModel: NSObject {
} }
extension PublicTimelineViewModel { extension PublicTimelineViewModel {
func fetchLatest() -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Toot]>, Error> {
return context.apiService.publicTimeline(count: 20, 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(count: 20, domain: "mstdn.jp") return context.apiService.publicTimeline(domain: "mstdn.jp")
} }
} }

View File

@ -0,0 +1,66 @@
//
// APIService+HomeTimeline.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021/2/3.
//
import Foundation
import Combine
import CoreData
import CoreDataStack
import CommonOSLog
import DateToolsSwift
import MastodonSDK
extension APIService {
func homeTimeline(
domain: String,
sinceID: Mastodon.Entity.Status.ID? = nil,
maxID: Mastodon.Entity.Status.ID? = nil,
limit: Int = 100,
local: Bool? = nil,
authorizationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Toot]>, Error> {
let authorization = authorizationBox.userAuthorization
let requestMastodonUserID = authorizationBox.userID
let query = Mastodon.API.Timeline.HomeTimelineQuery(
maxID: maxID,
sinceID: sinceID,
minID: nil, // prefer sinceID
limit: limit,
local: local
)
return Mastodon.API.Timeline.home(
session: session,
domain: domain,
query: query,
authorization: authorization
)
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Toot]>, Error> in
return APIService.Persist.persistTimeline(
managedObjectContext: self.backgroundManagedObjectContext,
domain: domain,
query: query,
response: response,
persistType: .home,
requestMastodonUserID: requestMastodonUserID,
log: OSLog.api
)
.setFailureType(to: Error.self)
.tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Toot]> in
switch result {
case .success:
return response
case .failure(let error):
throw error
}
}
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
}

View File

@ -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
@ -17,21 +18,35 @@ extension APIService {
static let publicTimelineRequestWindowInSec: TimeInterval = 15 * 60 static let publicTimelineRequestWindowInSec: TimeInterval = 15 * 60
func publicTimeline( func publicTimeline(
count: Int = 20, domain: String,
domain: String sinceID: Mastodon.Entity.Status.ID? = nil,
maxID: Mastodon.Entity.Status.ID? = nil,
limit: Int = 100
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Toot]>, Error> { ) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Toot]>, Error> {
let query = Mastodon.API.Timeline.PublicTimelineQuery(
local: nil,
remote: nil,
onlyMedia: nil,
maxID: maxID,
sinceID: sinceID,
minID: nil, // prefer sinceID
limit: limit
)
return Mastodon.API.Timeline.public( return Mastodon.API.Timeline.public(
session: session, session: session,
domain: domain, domain: domain,
query: Mastodon.API.Timeline.PublicTimelineQuery() query: query
) )
.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.publicHomeTimeline 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

View File

@ -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)

View File

@ -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,70 +14,190 @@ import CoreDataStack
import MastodonSDK import MastodonSDK
extension APIService.Persist { extension APIService.Persist {
enum PersistTimelineType { enum PersistTimelineType {
case publicHomeTimeline case `public`
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:
@ -91,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
}
}
}

View File

@ -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

View File

@ -13,6 +13,9 @@ extension Mastodon.API.Timeline {
static func publicTimelineEndpointURL(domain: String) -> URL { static func publicTimelineEndpointURL(domain: String) -> URL {
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("timelines/public") return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("timelines/public")
} }
static func homeTimelineEndpointURL(domain: String) -> URL {
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("timelines/home")
}
public static func `public`( public static func `public`(
session: URLSession, session: URLSession,
@ -32,10 +35,37 @@ extension Mastodon.API.Timeline {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
public static func home(
session: URLSession,
domain: String,
query: HomeTimelineQuery,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Toot]>, Error> {
let request = Mastodon.API.get(
url: homeTimelineEndpointURL(domain: domain),
query: query,
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: [Mastodon.Entity.Toot].self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
}
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?
@ -76,4 +106,38 @@ extension Mastodon.API.Timeline {
return items return items
} }
} }
public struct HomeTimelineQuery: Codable, TimelineQuery, GetQuery {
public let maxID: Mastodon.Entity.Toot.ID?
public let sinceID: Mastodon.Entity.Toot.ID?
public let minID: Mastodon.Entity.Toot.ID?
public let limit: Int?
public let local: Bool?
public init(
maxID: Mastodon.Entity.Toot.ID? = nil,
sinceID: Mastodon.Entity.Toot.ID? = nil,
minID: Mastodon.Entity.Toot.ID? = nil,
limit: Int? = nil,
local: Bool? = nil
) {
self.maxID = maxID
self.sinceID = sinceID
self.minID = minID
self.limit = limit
self.local = local
}
var queryItems: [URLQueryItem]? {
var items: [URLQueryItem] = []
maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) }
sinceID.flatMap { items.append(URLQueryItem(name: "since_id", value: $0)) }
minID.flatMap { items.append(URLQueryItem(name: "min_id", value: $0)) }
limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) }
local.flatMap { items.append(URLQueryItem(name: "local", value: $0.queryItemValue)) }
guard !items.isEmpty else { return nil }
return items
}
}
} }

View File

@ -0,0 +1,71 @@
//
// MastodonSDK+API+TimelineTests.swift
//
//
// Created by MainasuK Cirno on 2021/2/3.
//
import os.log
import XCTest
import Combine
@testable import MastodonSDK
extension MastodonSDKTests {
func testPublicTimeline() throws {
try _testPublicTimeline(domain: domain)
}
private func _testPublicTimeline(domain: String) throws {
let theExpectation = expectation(description: "Fetch Public Timeline")
let query = Mastodon.API.Timeline.PublicTimelineQuery()
Mastodon.API.Timeline.public(session: session, domain: domain, query: query)
.receive(on: DispatchQueue.main)
.sink { completion in
switch completion {
case .failure(let error):
XCTFail(error.localizedDescription)
case .finished:
break
}
} receiveValue: { response in
XCTAssert(!response.value.isEmpty)
theExpectation.fulfill()
}
.store(in: &disposeBag)
wait(for: [theExpectation], timeout: 10.0)
}
}
extension MastodonSDKTests {
func testHomeTimeline() {
let domain = ""
let accessToken = ""
guard !domain.isEmpty, !accessToken.isEmpty else { return }
let query = Mastodon.API.Timeline.HomeTimelineQuery()
let authorization = Mastodon.API.OAuth.Authorization(accessToken: accessToken)
let theExpectation = expectation(description: "Fetch Home Timeline")
Mastodon.API.Timeline.home(session: session, domain: domain, query: query, authorization: authorization)
.receive(on: DispatchQueue.main)
.sink { completion in
switch completion {
case .failure(let error):
XCTFail(error.localizedDescription)
case .finished:
break
}
} receiveValue: { response in
XCTAssert(!response.value.isEmpty)
theExpectation.fulfill()
}
.store(in: &disposeBag)
wait(for: [theExpectation], timeout: 10.0)
}
}

View File

@ -14,33 +14,3 @@ final class MastodonSDKTests: XCTestCase {
} }
} }
extension MastodonSDKTests {
func testPublicTimeline() throws {
try _testPublicTimeline(domain: domain)
}
private func _testPublicTimeline(domain: String) throws {
let theExpectation = expectation(description: "Fetch Public Timeline")
let query = Mastodon.API.Timeline.PublicTimelineQuery()
Mastodon.API.Timeline.public(session: session, domain: domain, query: query)
.receive(on: DispatchQueue.main)
.sink { completion in
switch completion {
case .failure(let error):
XCTFail(error.localizedDescription)
case .finished:
break
}
} receiveValue: { response in
XCTAssert(!response.value.isEmpty)
theExpectation.fulfill()
}
.store(in: &disposeBag)
wait(for: [theExpectation], timeout: 10.0)
}
}