Merge pull request #5 from tootsuite/feature/refresh

Feature/refresh
This commit is contained in:
sxiaojian88 2021-02-04 11:35:47 +08:00 committed by GitHub
commit db7b2528af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 658 additions and 58 deletions

View File

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

View File

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

View File

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

View File

@ -16,11 +16,14 @@
2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */; }; 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */; };
2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */; }; 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */; };
2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8E25C8228A004A627A /* UIButton.swift */; }; 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8E25C8228A004A627A /* UIButton.swift */; };
2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */; };
2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */; }; 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */; };
2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46976325C2A71500CF4AA9 /* UIIamge.swift */; }; 2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46976325C2A71500CF4AA9 /* UIIamge.swift */; };
2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */; }; 2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */; };
2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; }; 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; };
2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; }; 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; };
2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */; };
2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */; };
2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */; }; 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */; };
2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */; }; 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */; };
2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */; }; 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */; };
@ -32,6 +35,9 @@
2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0725C7E9A8004F19B8 /* Tag.swift */; }; 2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0725C7E9A8004F19B8 /* Tag.swift */; };
2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0D25C7E9C9004F19B8 /* History.swift */; }; 2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0D25C7E9C9004F19B8 /* History.swift */; };
2D927F1425C7EDD9004F19B8 /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F1325C7EDD9004F19B8 /* Emoji.swift */; }; 2D927F1425C7EDD9004F19B8 /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F1325C7EDD9004F19B8 /* Emoji.swift */; };
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */; };
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; };
2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; };
2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF123A625C3B0210020F248 /* ActiveLabel.swift */; }; 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 */; }; 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 */; };
@ -153,10 +159,13 @@
2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionToolBarContainer.swift; sourceTree = "<group>"; }; 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionToolBarContainer.swift; sourceTree = "<group>"; };
2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HitTestExpandedButton.swift; sourceTree = "<group>"; }; 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HitTestExpandedButton.swift; sourceTree = "<group>"; };
2D42FF8E25C8228A004A627A /* UIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIButton.swift; sourceTree = "<group>"; }; 2D42FF8E25C8228A004A627A /* UIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIButton.swift; sourceTree = "<group>"; };
2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+State.swift"; sourceTree = "<group>"; };
2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSLayoutConstraint.swift; sourceTree = "<group>"; }; 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSLayoutConstraint.swift; sourceTree = "<group>"; };
2D46976325C2A71500CF4AA9 /* UIIamge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIIamge.swift; sourceTree = "<group>"; }; 2D46976325C2A71500CF4AA9 /* UIIamge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIIamge.swift; sourceTree = "<group>"; };
2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Timeline.swift"; sourceTree = "<group>"; }; 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Timeline.swift"; sourceTree = "<group>"; };
2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = "<group>"; }; 2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = "<group>"; };
2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; };
2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Toot.swift"; sourceTree = "<group>"; };
2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewController.swift; sourceTree = "<group>"; }; 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewController.swift; sourceTree = "<group>"; };
2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewModel.swift; sourceTree = "<group>"; }; 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewModel.swift; sourceTree = "<group>"; };
2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewController+StatusProvider.swift"; sourceTree = "<group>"; }; 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewController+StatusProvider.swift"; sourceTree = "<group>"; };
@ -168,6 +177,10 @@
2D927F0725C7E9A8004F19B8 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; }; 2D927F0725C7E9A8004F19B8 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; };
2D927F0D25C7E9C9004F19B8 /* History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = History.swift; sourceTree = "<group>"; }; 2D927F0D25C7E9C9004F19B8 /* History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = History.swift; sourceTree = "<group>"; };
2D927F1325C7EDD9004F19B8 /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = "<group>"; }; 2D927F1325C7EDD9004F19B8 /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = "<group>"; };
2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLoaderTableViewCell.swift; sourceTree = "<group>"; };
2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = "<group>"; };
2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; };
2DA7D05625CA693F00804E11 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; };
2DF123A625C3B0210020F248 /* ActiveLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveLabel.swift; sourceTree = "<group>"; }; 2DF123A625C3B0210020F248 /* ActiveLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveLabel.swift; sourceTree = "<group>"; };
2E1F6A67FDF9771D3E064FDC /* Pods-Mastodon.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.debug.xcconfig"; sourceTree = "<group>"; }; 2E1F6A67FDF9771D3E064FDC /* Pods-Mastodon.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.debug.xcconfig"; sourceTree = "<group>"; };
3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@ -350,6 +363,14 @@
path = Persist; path = Persist;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
2D69CFF225CA9E2200C3A1B2 /* Protocol */ = {
isa = PBXGroup;
children = (
2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */,
);
path = Protocol;
sourceTree = "<group>";
};
2D76316325C14BAC00929FB9 /* PublicTimeline */ = { 2D76316325C14BAC00929FB9 /* PublicTimeline */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -357,6 +378,7 @@
2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */, 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */,
2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */, 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */,
2D76318225C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift */, 2D76318225C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift */,
2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */,
); );
path = PublicTimeline; path = PublicTimeline;
sourceTree = "<group>"; sourceTree = "<group>";
@ -403,6 +425,8 @@
602D783BEC22881EBAD84419 /* Pods_Mastodon.framework */, 602D783BEC22881EBAD84419 /* Pods_Mastodon.framework */,
A1B4523A7981F1044DE89C21 /* Pods_Mastodon_MastodonUITests.framework */, A1B4523A7981F1044DE89C21 /* Pods_Mastodon_MastodonUITests.framework */,
2D7631A725C1535600929FB9 /* TimelinePostTableViewCell.swift */, 2D7631A725C1535600929FB9 /* TimelinePostTableViewCell.swift */,
2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */,
2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */,
); );
path = TableviewCell; path = TableviewCell;
sourceTree = "<group>"; sourceTree = "<group>";
@ -500,6 +524,7 @@
children = ( children = (
DB427DE325BAA00100D1B89D /* Info.plist */, DB427DE325BAA00100D1B89D /* Info.plist */,
DB89BA1025C10FF5008580ED /* Mastodon.entitlements */, DB89BA1025C10FF5008580ED /* Mastodon.entitlements */,
2D69CFF225CA9E2200C3A1B2 /* Protocol */,
2D76319C25C151DE00929FB9 /* Diffiable */, 2D76319C25C151DE00929FB9 /* Diffiable */,
DB8AF52A25C13561002E6C99 /* State */, DB8AF52A25C13561002E6C99 /* State */,
2D61335525C1886800CAE157 /* Service */, 2D61335525C1886800CAE157 /* Service */,
@ -549,6 +574,7 @@
DB45FB0925CA87BC005A8AC7 /* CoreData */ = { DB45FB0925CA87BC005A8AC7 /* CoreData */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */,
DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */, DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */,
DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */, DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */,
); );
@ -600,6 +626,7 @@
2D927F0D25C7E9C9004F19B8 /* History.swift */, 2D927F0D25C7E9C9004F19B8 /* History.swift */,
2D927F1325C7EDD9004F19B8 /* Emoji.swift */, 2D927F1325C7EDD9004F19B8 /* Emoji.swift */,
DB45FAEC25CA7A9A005A8AC7 /* MastodonAuthentication.swift */, DB45FAEC25CA7A9A005A8AC7 /* MastodonAuthentication.swift */,
2DA7D05625CA693F00804E11 /* Application.swift */,
); );
path = Entity; path = Entity;
sourceTree = "<group>"; sourceTree = "<group>";
@ -673,6 +700,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */, CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */,
2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */,
); );
name = "Recovered References"; name = "Recovered References";
sourceTree = "<group>"; sourceTree = "<group>";
@ -1041,12 +1069,15 @@
2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */, 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */,
2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */, 2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */,
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */,
DB98338825C945ED00AD9700 /* Assets.swift in Sources */, DB98338825C945ED00AD9700 /* Assets.swift in Sources */,
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */,
DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */, DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */,
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */,
2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */,
2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */, 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */,
2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */, 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */,
2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */,
2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */, 2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */,
DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */,
2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */,
@ -1069,10 +1100,12 @@
DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */, DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */,
2D42FF6B25C817D2004A627A /* MastodonContent.swift in Sources */, 2D42FF6B25C817D2004A627A /* MastodonContent.swift in Sources */,
DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */, DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */,
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */,
DB8AF55725C137A8002E6C99 /* HomeViewController.swift in Sources */, DB8AF55725C137A8002E6C99 /* HomeViewController.swift in Sources */,
2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */, 2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */,
2D7631A825C1535600929FB9 /* TimelinePostTableViewCell.swift in Sources */, 2D7631A825C1535600929FB9 /* TimelinePostTableViewCell.swift in Sources */,
2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */, 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */,
2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */,
DB01409625C40B6700F9F3CF /* AuthenticationViewController.swift in Sources */, DB01409625C40B6700F9F3CF /* AuthenticationViewController.swift in Sources */,
DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */, DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */,
DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */,
@ -1100,6 +1133,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
2DA7D05725CA693F00804E11 /* Application.swift in Sources */,
2D927F1425C7EDD9004F19B8 /* Emoji.swift in Sources */, 2D927F1425C7EDD9004F19B8 /* Emoji.swift in Sources */,
DB89BA1225C1105C008580ED /* CoreDataStack.swift in Sources */, DB89BA1225C1105C008580ED /* CoreDataStack.swift in Sources */,
DB89BA1C25C1107F008580ED /* NSManagedObjectContext.swift in Sources */, DB89BA1C25C1107F008580ED /* NSManagedObjectContext.swift in Sources */,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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