Merge branch 'develop' into feature/home-timeline-api
This commit is contained in:
commit
ade8b68a65
|
@ -1,5 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20D64" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20D5029f" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Application" representedClassName=".Application" syncable="YES">
|
||||
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="name" attributeType="String"/>
|
||||
<attribute name="vapidKey" optional="YES" attributeType="String"/>
|
||||
<attribute name="website" optional="YES" attributeType="String"/>
|
||||
<relationship name="toots" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="application" inverseEntity="Toot"/>
|
||||
</entity>
|
||||
<entity name="Emoji" representedClassName=".Emoji" syncable="YES">
|
||||
<attribute name="category" optional="YES" attributeType="String"/>
|
||||
<attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/>
|
||||
|
@ -95,6 +102,7 @@
|
|||
<attribute name="uri" attributeType="String"/>
|
||||
<attribute name="url" attributeType="String"/>
|
||||
<attribute name="visibility" optional="YES" attributeType="String"/>
|
||||
<relationship name="application" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Application" inverseName="toots" inverseEntity="Application"/>
|
||||
<relationship name="author" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="toots" inverseEntity="MastodonUser"/>
|
||||
<relationship name="bookmarked" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="bookmarked" inverseEntity="MastodonUser"/>
|
||||
<relationship name="emojis" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Emoji" inverseName="toot" inverseEntity="Emoji"/>
|
||||
|
@ -109,13 +117,14 @@
|
|||
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Tag" inverseName="toot" inverseEntity="Tag"/>
|
||||
</entity>
|
||||
<elements>
|
||||
<element name="Application" positionX="160" positionY="192" width="128" height="104"/>
|
||||
<element name="Emoji" positionX="45" positionY="135" width="128" height="149"/>
|
||||
<element name="History" positionX="27" positionY="126" width="128" height="119"/>
|
||||
<element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="104"/>
|
||||
<element name="MastodonAuthentication" positionX="18" positionY="162" width="128" height="209"/>
|
||||
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="284"/>
|
||||
<element name="Mention" positionX="9" positionY="108" width="128" height="134"/>
|
||||
<element name="Tag" positionX="18" positionY="117" width="128" height="119"/>
|
||||
<element name="Toot" positionX="0" positionY="0" width="128" height="494"/>
|
||||
<element name="MastodonAuthentication" positionX="18" positionY="162" width="128" height="209"/>
|
||||
</elements>
|
||||
</model>
|
|
@ -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)]
|
||||
}
|
||||
}
|
|
@ -21,6 +21,7 @@ public final class Toot: NSManagedObject {
|
|||
@NSManaged public private(set) var visibility: String?
|
||||
@NSManaged public private(set) var sensitive: Bool
|
||||
@NSManaged public private(set) var spoilerText: String?
|
||||
@NSManaged public private(set) var application: Application?
|
||||
|
||||
// Informational
|
||||
@NSManaged public private(set) var reblogsCount: NSNumber
|
||||
|
@ -88,6 +89,8 @@ public extension Toot {
|
|||
toot.sensitive = property.sensitive
|
||||
toot.spoilerText = property.spoilerText
|
||||
|
||||
toot.application = property.application
|
||||
|
||||
if let mentions = property.mentions {
|
||||
toot.mutableSetValue(forKey: #keyPath(Toot.mentions)).addObjects(from: mentions)
|
||||
}
|
||||
|
@ -123,11 +126,9 @@ public extension Toot {
|
|||
if let bookmarkedBy = property.bookmarkedBy {
|
||||
toot.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).add(bookmarkedBy)
|
||||
}
|
||||
|
||||
// TODO: not implement yet
|
||||
// if let pinnedBy = property.pinnedBy {
|
||||
// toot.mutableSetValue(forKey: #keyPath(Toot.pinnedBy))
|
||||
// }
|
||||
if let pinnedBy = property.pinnedBy {
|
||||
toot.mutableSetValue(forKey: #keyPath(Toot.pinnedBy)).add(pinnedBy)
|
||||
}
|
||||
|
||||
toot.updatedAt = property.updatedAt
|
||||
toot.deletedAt = property.deletedAt
|
||||
|
@ -137,6 +138,28 @@ public extension Toot {
|
|||
|
||||
return toot
|
||||
}
|
||||
func update(reblogsCount: NSNumber) {
|
||||
if self.reblogsCount.intValue != reblogsCount.intValue {
|
||||
self.reblogsCount = reblogsCount
|
||||
}
|
||||
}
|
||||
func update(favouritesCount: NSNumber) {
|
||||
if self.favouritesCount.intValue != favouritesCount.intValue {
|
||||
self.favouritesCount = favouritesCount
|
||||
}
|
||||
}
|
||||
func update(repliesCount: NSNumber?) {
|
||||
guard let count = repliesCount else {
|
||||
return
|
||||
}
|
||||
if self.repliesCount?.intValue != count.intValue {
|
||||
self.repliesCount = repliesCount
|
||||
}
|
||||
}
|
||||
func didUpdate(at networkDate: Date) {
|
||||
self.updatedAt = networkDate
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public extension Toot {
|
||||
|
@ -150,6 +173,7 @@ public extension Toot {
|
|||
visibility: String?,
|
||||
sensitive: Bool,
|
||||
spoilerText: String?,
|
||||
application: Application?,
|
||||
mentions: [Mention]?,
|
||||
emojis: [Emoji]?,
|
||||
tags: [Tag]?,
|
||||
|
@ -181,6 +205,7 @@ public extension Toot {
|
|||
self.visibility = visibility
|
||||
self.sensitive = sensitive
|
||||
self.spoilerText = spoilerText
|
||||
self.application = application
|
||||
self.mentions = mentions
|
||||
self.emojis = emojis
|
||||
self.tags = tags
|
||||
|
@ -215,6 +240,7 @@ public extension Toot {
|
|||
public let visibility: String?
|
||||
public let sensitive: Bool
|
||||
public let spoilerText: String?
|
||||
public let application: Application?
|
||||
|
||||
public let mentions: [Mention]?
|
||||
public let emojis: [Emoji]?
|
||||
|
|
|
@ -16,11 +16,14 @@
|
|||
2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */; };
|
||||
2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */; };
|
||||
2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8E25C8228A004A627A /* UIButton.swift */; };
|
||||
2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */; };
|
||||
2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */; };
|
||||
2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46976325C2A71500CF4AA9 /* UIIamge.swift */; };
|
||||
2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */; };
|
||||
2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; };
|
||||
2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; };
|
||||
2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */; };
|
||||
2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */; };
|
||||
2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */; };
|
||||
2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */; };
|
||||
2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */; };
|
||||
|
@ -32,6 +35,9 @@
|
|||
2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0725C7E9A8004F19B8 /* Tag.swift */; };
|
||||
2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0D25C7E9C9004F19B8 /* History.swift */; };
|
||||
2D927F1425C7EDD9004F19B8 /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F1325C7EDD9004F19B8 /* Emoji.swift */; };
|
||||
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */; };
|
||||
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; };
|
||||
2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; };
|
||||
2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF123A625C3B0210020F248 /* ActiveLabel.swift */; };
|
||||
45B49097460EDE530AD5AA72 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; };
|
||||
5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; };
|
||||
|
@ -152,10 +158,13 @@
|
|||
2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionToolBarContainer.swift; sourceTree = "<group>"; };
|
||||
2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HitTestExpandedButton.swift; sourceTree = "<group>"; };
|
||||
2D42FF8E25C8228A004A627A /* UIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIButton.swift; sourceTree = "<group>"; };
|
||||
2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+State.swift"; sourceTree = "<group>"; };
|
||||
2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSLayoutConstraint.swift; sourceTree = "<group>"; };
|
||||
2D46976325C2A71500CF4AA9 /* UIIamge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIIamge.swift; sourceTree = "<group>"; };
|
||||
2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Timeline.swift"; sourceTree = "<group>"; };
|
||||
2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = "<group>"; };
|
||||
2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; };
|
||||
2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Toot.swift"; sourceTree = "<group>"; };
|
||||
2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewController.swift; sourceTree = "<group>"; };
|
||||
2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewModel.swift; sourceTree = "<group>"; };
|
||||
2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewController+StatusProvider.swift"; sourceTree = "<group>"; };
|
||||
|
@ -167,6 +176,10 @@
|
|||
2D927F0725C7E9A8004F19B8 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; };
|
||||
2D927F0D25C7E9C9004F19B8 /* History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = History.swift; sourceTree = "<group>"; };
|
||||
2D927F1325C7EDD9004F19B8 /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = "<group>"; };
|
||||
2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLoaderTableViewCell.swift; sourceTree = "<group>"; };
|
||||
2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = "<group>"; };
|
||||
2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; };
|
||||
2DA7D05625CA693F00804E11 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; };
|
||||
2DF123A625C3B0210020F248 /* ActiveLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveLabel.swift; sourceTree = "<group>"; };
|
||||
2E1F6A67FDF9771D3E064FDC /* Pods-Mastodon.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
|
@ -346,6 +359,14 @@
|
|||
path = Persist;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2D69CFF225CA9E2200C3A1B2 /* Protocol */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */,
|
||||
);
|
||||
path = Protocol;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2D76316325C14BAC00929FB9 /* PublicTimeline */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -353,6 +374,7 @@
|
|||
2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */,
|
||||
2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */,
|
||||
2D76318225C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift */,
|
||||
2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */,
|
||||
);
|
||||
path = PublicTimeline;
|
||||
sourceTree = "<group>";
|
||||
|
@ -397,6 +419,8 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
2D7631A725C1535600929FB9 /* TimelinePostTableViewCell.swift */,
|
||||
2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */,
|
||||
2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */,
|
||||
);
|
||||
path = TableviewCell;
|
||||
sourceTree = "<group>";
|
||||
|
@ -494,6 +518,7 @@
|
|||
children = (
|
||||
DB427DE325BAA00100D1B89D /* Info.plist */,
|
||||
DB89BA1025C10FF5008580ED /* Mastodon.entitlements */,
|
||||
2D69CFF225CA9E2200C3A1B2 /* Protocol */,
|
||||
2D76319C25C151DE00929FB9 /* Diffiable */,
|
||||
DB8AF52A25C13561002E6C99 /* State */,
|
||||
2D61335525C1886800CAE157 /* Service */,
|
||||
|
@ -544,6 +569,7 @@
|
|||
DB45FB0925CA87BC005A8AC7 /* CoreData */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */,
|
||||
DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */,
|
||||
DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */,
|
||||
);
|
||||
|
@ -595,6 +621,7 @@
|
|||
2D927F0D25C7E9C9004F19B8 /* History.swift */,
|
||||
2D927F1325C7EDD9004F19B8 /* Emoji.swift */,
|
||||
DB45FAEC25CA7A9A005A8AC7 /* MastodonAuthentication.swift */,
|
||||
2DA7D05625CA693F00804E11 /* Application.swift */,
|
||||
);
|
||||
path = Entity;
|
||||
sourceTree = "<group>";
|
||||
|
@ -668,6 +695,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */,
|
||||
2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */,
|
||||
);
|
||||
name = "Recovered References";
|
||||
sourceTree = "<group>";
|
||||
|
@ -1037,12 +1065,15 @@
|
|||
2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */,
|
||||
2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */,
|
||||
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
|
||||
2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */,
|
||||
DB98338825C945ED00AD9700 /* Assets.swift in Sources */,
|
||||
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */,
|
||||
DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */,
|
||||
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */,
|
||||
2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */,
|
||||
2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */,
|
||||
2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */,
|
||||
2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */,
|
||||
2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */,
|
||||
DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */,
|
||||
2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */,
|
||||
|
@ -1065,10 +1096,12 @@
|
|||
DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */,
|
||||
2D42FF6B25C817D2004A627A /* MastodonContent.swift in Sources */,
|
||||
DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */,
|
||||
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */,
|
||||
DB8AF55725C137A8002E6C99 /* HomeViewController.swift in Sources */,
|
||||
2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */,
|
||||
2D7631A825C1535600929FB9 /* TimelinePostTableViewCell.swift in Sources */,
|
||||
2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */,
|
||||
2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */,
|
||||
DB01409625C40B6700F9F3CF /* AuthenticationViewController.swift in Sources */,
|
||||
DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */,
|
||||
DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */,
|
||||
|
@ -1096,6 +1129,7 @@
|
|||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
2DA7D05725CA693F00804E11 /* Application.swift in Sources */,
|
||||
2D927F1425C7EDD9004F19B8 /* Emoji.swift in Sources */,
|
||||
DB89BA1225C1105C008580ED /* CoreDataStack.swift in Sources */,
|
||||
DB89BA1C25C1107F008580ED /* NSManagedObjectContext.swift in Sources */,
|
||||
|
|
|
@ -5,16 +5,17 @@
|
|||
// Created by sxiaojian on 2021/1/27.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
import MastodonSDK
|
||||
import CoreDataStack
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
|
||||
/// Note: update Equatable when change case
|
||||
enum Item {
|
||||
|
||||
// normal list
|
||||
case toot(objectID: NSManagedObjectID)
|
||||
|
||||
case bottomLoader
|
||||
}
|
||||
|
||||
extension Item: Equatable {
|
||||
|
@ -22,6 +23,10 @@ extension Item: Equatable {
|
|||
switch (lhs, rhs) {
|
||||
case (.toot(let objectIDLeft), .toot(let objectIDRight)):
|
||||
return objectIDLeft == objectIDRight
|
||||
case (.bottomLoader, .bottomLoader):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -31,7 +36,8 @@ extension Item: Hashable {
|
|||
switch self {
|
||||
case .toot(let objectID):
|
||||
hasher.combine(objectID)
|
||||
case .bottomLoader:
|
||||
hasher.combine(String(describing: Item.bottomLoader.self))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -37,6 +37,10 @@ extension TimelineSection {
|
|||
}
|
||||
cell.delegate = timelinePostTableViewCellDelegate
|
||||
return cell
|
||||
case .bottomLoader:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
|
||||
cell.activityIndicatorView.startAnimating()
|
||||
return cell
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,15 @@ import Foundation
|
|||
// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length
|
||||
// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces
|
||||
internal enum L10n {
|
||||
|
||||
internal enum Common {
|
||||
internal enum Controls {
|
||||
internal enum Timeline {
|
||||
/// Load More
|
||||
internal static let loadMore = L10n.tr("Localizable", "Common.Controls.Timeline.LoadMore")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length
|
||||
// swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,3 +5,4 @@
|
|||
Created by MainasuK Cirno on 2021/1/22.
|
||||
|
||||
*/
|
||||
"Common.Controls.Timeline.LoadMore" = "Load More";
|
||||
|
|
|
@ -38,6 +38,8 @@ extension PublicTimelineViewController {
|
|||
let toot = managedObjectContext.object(with: objectID) as? Toot
|
||||
promise(.success(toot))
|
||||
}
|
||||
default:
|
||||
promise(.success(nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,41 +5,56 @@
|
|||
// Created by sxiaojian on 2021/1/27.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import AVKit
|
||||
import Combine
|
||||
import CoreDataStack
|
||||
import GameplayKit
|
||||
import os.log
|
||||
import UIKit
|
||||
|
||||
final class PublicTimelineViewController: UIViewController, NeedsDependency, TimelinePostTableViewCellDelegate {
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
var viewModel: PublicTimelineViewModel!
|
||||
|
||||
|
||||
let refreshControl = UIRefreshControl()
|
||||
|
||||
lazy var tableView: UITableView = {
|
||||
let tableView = UITableView()
|
||||
tableView.register(TimelinePostTableViewCell.self, forCellReuseIdentifier: String(describing: TimelinePostTableViewCell.self))
|
||||
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.separatorStyle = .none
|
||||
return tableView
|
||||
}()
|
||||
|
||||
deinit {
|
||||
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension PublicTimelineViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
tableView.refreshControl = refreshControl
|
||||
refreshControl.addTarget(self, action: #selector(PublicTimelineViewController.refreshControlValueChanged(_:)), for: .valueChanged)
|
||||
// bind refresh control
|
||||
viewModel.isFetchingLatestTimeline
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isFetching in
|
||||
guard let self = self else { return }
|
||||
if !isFetching {
|
||||
UIView.animate(withDuration: 0.5) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.refreshControl.endRefreshing()
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
tableView.backgroundColor = Asset.Colors.tootDark.color
|
||||
view.addSubview(tableView)
|
||||
|
@ -60,29 +75,35 @@ extension PublicTimelineViewController {
|
|||
timelinePostTableViewCellDelegate: self
|
||||
)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
viewModel.fetchLatest()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { completion in
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
os_log("%{public}s[%{public}ld], %{public}s: fetch user timeline latest response error: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||
case .finished:
|
||||
break
|
||||
}
|
||||
} receiveValue: { response in
|
||||
let tootsIDs = response.value.map { $0.id }
|
||||
self.viewModel.tootIDs.value = tootsIDs
|
||||
}
|
||||
.store(in: &viewModel.disposeBag)
|
||||
viewModel.stateMachine.enter(PublicTimelineViewModel.State.Loading.self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIScrollViewDelegate
|
||||
extension PublicTimelineViewController {
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
handleScrollViewDidScroll(scrollView)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Selector
|
||||
|
||||
extension PublicTimelineViewController {
|
||||
@objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
|
||||
guard viewModel.stateMachine.enter(PublicTimelineViewModel.State.Loading.self) else {
|
||||
sender.endRefreshing()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
|
||||
extension PublicTimelineViewController: UITableViewDelegate {
|
||||
|
||||
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return 100 }
|
||||
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 }
|
||||
|
@ -94,12 +115,9 @@ extension PublicTimelineViewController: UITableViewDelegate {
|
|||
return ceil(frame.height)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
|
||||
}
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {}
|
||||
|
||||
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
|
||||
|
@ -108,3 +126,11 @@ extension PublicTimelineViewController: UITableViewDelegate {
|
|||
viewModel.cellFrameCache.setObject(NSValue(cgRect: frame), forKey: NSNumber(value: key))
|
||||
}
|
||||
}
|
||||
// MARK: - LoadMoreConfigurableTableViewContainer
|
||||
extension PublicTimelineViewController: LoadMoreConfigurableTableViewContainer {
|
||||
typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
|
||||
typealias LoadingState = PublicTimelineViewModel.State.LoadingMore
|
||||
|
||||
var loadMoreConfigurableTableView: UITableView { return tableView }
|
||||
var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine }
|
||||
}
|
||||
|
|
|
@ -5,10 +5,10 @@
|
|||
// Created by sxiaojian on 2021/1/27.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import os.log
|
||||
import UIKit
|
||||
|
||||
extension PublicTimelineViewModel {
|
||||
func setupDiffableDataSource(
|
||||
|
@ -20,26 +20,27 @@ extension PublicTimelineViewModel {
|
|||
.autoconnect()
|
||||
.share()
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
|
||||
diffableDataSource = TimelineSection.tableViewDiffableDataSource(
|
||||
for: tableView,
|
||||
dependency: dependency,
|
||||
managedObjectContext: fetchedResultsController.managedObjectContext,
|
||||
timestampUpdatePublisher: timestampUpdatePublisher,
|
||||
timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate)
|
||||
timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate
|
||||
)
|
||||
items.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NSFetchedResultsControllerDelegate
|
||||
|
||||
extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate {
|
||||
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
|
||||
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
|
||||
os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function)
|
||||
|
||||
let indexes = tootIDs.value
|
||||
let toots = fetchedResultsController.fetchedObjects ?? []
|
||||
guard toots.count == indexes.count else { return }
|
||||
|
||||
let items: [Item] = toots
|
||||
.compactMap { toot -> (Int, Toot)? in
|
||||
guard toot.deletedAt == nil else { return nil }
|
||||
|
@ -49,5 +50,4 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate {
|
|||
.map { Item.toot(objectID: $0.1.objectID) }
|
||||
self.items.value = items
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,28 +5,39 @@
|
|||
// Created by sxiaojian on 2021/1/27.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import GameplayKit
|
||||
import AlamofireImage
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import GameplayKit
|
||||
import MastodonSDK
|
||||
import AlamofireImage
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
|
||||
class PublicTimelineViewModel: NSObject {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
let fetchedResultsController: NSFetchedResultsController<Toot>
|
||||
let isFetchingLatestTimeline = CurrentValueSubject<Bool, Never>(false)
|
||||
weak var tableView: UITableView?
|
||||
|
||||
// output
|
||||
var diffableDataSource: UITableViewDiffableDataSource<TimelineSection, Item>?
|
||||
|
||||
lazy var stateMachine: GKStateMachine = {
|
||||
let stateMachine = GKStateMachine(states: [
|
||||
State.Initial(viewModel: self),
|
||||
State.Loading(viewModel: self),
|
||||
State.Fail(viewModel: self),
|
||||
State.Idle(viewModel: self),
|
||||
State.LoadingMore(viewModel: self),
|
||||
])
|
||||
stateMachine.enter(State.Initial.self)
|
||||
return stateMachine
|
||||
}()
|
||||
|
||||
let tootIDs = CurrentValueSubject<[String], Never>([])
|
||||
let items = CurrentValueSubject<[Item], Never>([])
|
||||
var cellFrameCache = NSCache<NSNumber, NSValue>()
|
||||
|
@ -49,7 +60,7 @@ class PublicTimelineViewModel: NSObject {
|
|||
}()
|
||||
super.init()
|
||||
|
||||
self.fetchedResultsController.delegate = self
|
||||
fetchedResultsController.delegate = self
|
||||
|
||||
items
|
||||
.receive(on: DispatchQueue.main)
|
||||
|
@ -57,12 +68,19 @@ class PublicTimelineViewModel: NSObject {
|
|||
.sink { [weak self] items in
|
||||
guard let self = self else { return }
|
||||
guard let diffableDataSource = self.diffableDataSource else { return }
|
||||
os_log("%{public}s[%{public}ld], %{public}s: items did change", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
os_log("%{public}s[%{public}ld], %{public}s: items did change", (#file as NSString).lastPathComponent, #line, #function)
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<TimelineSection, Item>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(items)
|
||||
|
||||
if let currentState = self.stateMachine.currentState {
|
||||
switch currentState {
|
||||
case is State.Idle, is State.LoadingMore, is State.Fail:
|
||||
snapshot.appendItems([.bottomLoader], toSection: .main)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
diffableDataSource.apply(snapshot, animatingDifferences: !items.isEmpty)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
@ -82,14 +100,16 @@ class PublicTimelineViewModel: NSObject {
|
|||
}
|
||||
|
||||
deinit {
|
||||
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension PublicTimelineViewModel {
|
||||
|
||||
func fetchLatest() -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Toot]>, Error> {
|
||||
return context.apiService.publicTimeline(domain: "mstdn.jp")
|
||||
}
|
||||
|
||||
func loadMore() -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Toot]>, Error> {
|
||||
return context.apiService.publicTimeline(domain: "mstdn.jp")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -28,6 +28,9 @@ extension APIService.Persist {
|
|||
let _ = toots.map {
|
||||
let userProperty = MastodonUser.Property(id: $0.account.id, domain: domain, acct: $0.account.acct, username: $0.account.username, displayName: $0.account.displayName,avatar: $0.account.avatar,avatarStatic: $0.account.avatarStatic, createdAt: $0.createdAt, networkDate: $0.createdAt)
|
||||
let author = MastodonUser.insert(into: managedObjectContext, property: userProperty)
|
||||
let application = $0.application.flatMap { app -> Application? in
|
||||
Application.insert(into: managedObjectContext, property: Application.Property(name: app.name, website: app.website, vapidKey: app.vapidKey))
|
||||
}
|
||||
let metions = $0.mentions?.compactMap({ (mention) -> Mention in
|
||||
Mention.insert(into: managedObjectContext, property: Mention.Property(id: mention.id, username: mention.username, acct: mention.acct, url: mention.url))
|
||||
})
|
||||
|
@ -50,6 +53,7 @@ extension APIService.Persist {
|
|||
visibility: $0.visibility?.rawValue,
|
||||
sensitive: $0.sensitive ?? false,
|
||||
spoilerText: $0.spoilerText,
|
||||
application: application,
|
||||
mentions: metions,
|
||||
emojis: emojis,
|
||||
tags: tags,
|
||||
|
|
Loading…
Reference in New Issue