add public timeline
This commit is contained in:
parent
6b56764a4c
commit
cb690ffa4e
|
@ -1,26 +1,28 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20C69" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17511" systemVersion="20D5029f" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="HomeTimelineIndex" representedClassName=".HomeTimelineIndex" syncable="YES">
|
||||
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="domain" attributeType="String"/>
|
||||
<attribute name="identifier" attributeType="String"/>
|
||||
<attribute name="userIdentifier" attributeType="String"/>
|
||||
<relationship name="toots" maxCount="1" deletionRule="Nullify" destinationEntity="Toots" inverseName="homeTimelineIndex" inverseEntity="Toots"/>
|
||||
<relationship name="toots" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="homeTimelineIndex" inverseEntity="Toot"/>
|
||||
</entity>
|
||||
<entity name="MastodonUser" representedClassName=".MastodonUser" syncable="YES">
|
||||
<attribute name="acct" attributeType="String"/>
|
||||
<attribute name="avatar" optional="YES" attributeType="String"/>
|
||||
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="displayName" optional="YES" attributeType="String"/>
|
||||
<attribute name="domain" attributeType="String"/>
|
||||
<attribute name="id" attributeType="String"/>
|
||||
<attribute name="identifier" attributeType="String"/>
|
||||
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="username" attributeType="String"/>
|
||||
<relationship name="toots" toMany="YES" deletionRule="Nullify" destinationEntity="Toots" inverseName="author" inverseEntity="Toots"/>
|
||||
<relationship name="toots" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="author" inverseEntity="Toot"/>
|
||||
</entity>
|
||||
<entity name="Toots" representedClassName=".Toots" syncable="YES">
|
||||
<entity name="Toot" representedClassName=".Toot" syncable="YES">
|
||||
<attribute name="content" attributeType="String"/>
|
||||
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="deletedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="domain" attributeType="String"/>
|
||||
<attribute name="id" attributeType="String"/>
|
||||
<attribute name="identifier" attributeType="String"/>
|
||||
|
@ -29,8 +31,8 @@
|
|||
<relationship name="homeTimelineIndex" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="HomeTimelineIndex" inverseName="toots" inverseEntity="HomeTimelineIndex"/>
|
||||
</entity>
|
||||
<elements>
|
||||
<element name="Toots" positionX="-248.4609375" positionY="17.3203125" width="128" height="163"/>
|
||||
<element name="MastodonUser" positionX="9.34375" positionY="71.8828125" width="128" height="178"/>
|
||||
<element name="HomeTimelineIndex" positionX="-108" positionY="135" width="128" height="118"/>
|
||||
<element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="104"/>
|
||||
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="179"/>
|
||||
<element name="Toot" positionX="0" positionY="0" width="128" height="164"/>
|
||||
</elements>
|
||||
</model>
|
|
@ -38,7 +38,7 @@ public final class CoreDataStack {
|
|||
}()
|
||||
|
||||
static func persistentContainer() -> NSPersistentContainer {
|
||||
let bundles = [Bundle(for: Toots.self)]
|
||||
let bundles = [Bundle(for: Toot.self)]
|
||||
guard let managedObjectModel = NSManagedObjectModel.mergedModel(from: bundles) else {
|
||||
fatalError("cannot locate bundles")
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
import Foundation
|
||||
import CoreData
|
||||
|
||||
final class HomeTimelineIndex: NSManagedObject {
|
||||
final public class HomeTimelineIndex: NSManagedObject {
|
||||
|
||||
public typealias ID = String
|
||||
@NSManaged public private(set) var identifier: ID
|
||||
|
@ -18,7 +18,7 @@ final class HomeTimelineIndex: NSManagedObject {
|
|||
@NSManaged public private(set) var createdAt: Date
|
||||
|
||||
// many-to-one relationship
|
||||
@NSManaged public private(set) var toots: Toots
|
||||
@NSManaged public private(set) var toot: Toot
|
||||
|
||||
}
|
||||
|
||||
|
@ -28,16 +28,16 @@ extension HomeTimelineIndex {
|
|||
public static func insert(
|
||||
into context: NSManagedObjectContext,
|
||||
property: Property,
|
||||
toots: Toots
|
||||
toot: Toot
|
||||
) -> HomeTimelineIndex {
|
||||
let index: HomeTimelineIndex = context.insertObject()
|
||||
|
||||
index.identifier = property.identifier
|
||||
index.domain = property.domain
|
||||
index.userIdentifier = toots.author.identifier
|
||||
index.createdAt = toots.createdAt
|
||||
index.userIdentifier = toot.author.identifier
|
||||
index.createdAt = toot.createdAt
|
||||
|
||||
index.toots = toots
|
||||
index.toot = toot
|
||||
|
||||
return index
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
import Foundation
|
||||
import CoreData
|
||||
|
||||
final class MastodonUser: NSManagedObject {
|
||||
final public class MastodonUser: NSManagedObject {
|
||||
|
||||
public typealias ID = String
|
||||
@NSManaged public private(set) var identifier: ID
|
||||
|
@ -22,7 +22,7 @@ final class MastodonUser: NSManagedObject {
|
|||
@NSManaged public private(set) var createdAt: Date
|
||||
@NSManaged public private(set) var updatedAt: Date
|
||||
|
||||
@NSManaged public private(set) var toots: Set<Toots>?
|
||||
@NSManaged public private(set) var toots: Set<Toot>?
|
||||
|
||||
}
|
||||
|
||||
|
@ -70,7 +70,6 @@ extension MastodonUser {
|
|||
acct: String,
|
||||
username: String,
|
||||
displayName: String?,
|
||||
content: String,
|
||||
createdAt: Date,
|
||||
networkDate: Date
|
||||
) {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// Toots.swift
|
||||
// Toot.swift
|
||||
// CoreDataStack
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021/1/27.
|
||||
|
@ -8,7 +8,7 @@
|
|||
import Foundation
|
||||
import CoreData
|
||||
|
||||
final class Toots: NSManagedObject {
|
||||
final public class Toot: NSManagedObject {
|
||||
|
||||
public typealias ID = String
|
||||
@NSManaged public private(set) var identifier: ID
|
||||
|
@ -19,6 +19,7 @@ final class Toots: NSManagedObject {
|
|||
|
||||
@NSManaged public private(set) var createdAt: Date
|
||||
@NSManaged public private(set) var updatedAt: Date
|
||||
@NSManaged public private(set) var deletedAt: Date?
|
||||
|
||||
// many-to-one relationship
|
||||
@NSManaged public private(set) var author: MastodonUser
|
||||
|
@ -28,15 +29,15 @@ final class Toots: NSManagedObject {
|
|||
|
||||
}
|
||||
|
||||
extension Toots {
|
||||
extension Toot {
|
||||
|
||||
@discardableResult
|
||||
public static func insert(
|
||||
into context: NSManagedObjectContext,
|
||||
property: Property,
|
||||
author: MastodonUser
|
||||
) -> Toots {
|
||||
let toots: Toots = context.insertObject()
|
||||
) -> Toot {
|
||||
let toots: Toot = context.insertObject()
|
||||
|
||||
toots.identifier = property.identifier
|
||||
toots.domain = property.domain
|
||||
|
@ -53,7 +54,7 @@ extension Toots {
|
|||
|
||||
}
|
||||
|
||||
extension Toots {
|
||||
extension Toot {
|
||||
public struct Property {
|
||||
public let identifier: String
|
||||
public let domain: String
|
||||
|
@ -80,9 +81,28 @@ extension Toots {
|
|||
}
|
||||
}
|
||||
|
||||
extension Toots: Managed {
|
||||
extension Toot: Managed {
|
||||
public static var defaultSortDescriptors: [NSSortDescriptor] {
|
||||
return [NSSortDescriptor(keyPath: \Toots.createdAt, ascending: false)]
|
||||
return [NSSortDescriptor(keyPath: \Toot.createdAt, ascending: false)]
|
||||
}
|
||||
}
|
||||
|
||||
extension Toot {
|
||||
|
||||
public static func predicate(idStr: String) -> NSPredicate {
|
||||
return NSPredicate(format: "%K == %@", #keyPath(Toot.id), idStr)
|
||||
}
|
||||
|
||||
public static func predicate(idStrs: [String]) -> NSPredicate {
|
||||
return NSPredicate(format: "%K IN %@", #keyPath(Toot.id), idStrs)
|
||||
}
|
||||
|
||||
public static func notDeleted() -> NSPredicate {
|
||||
return NSPredicate(format: "%K == nil", #keyPath(Toot.deletedAt))
|
||||
}
|
||||
|
||||
public static func deleted() -> NSPredicate {
|
||||
return NSPredicate(format: "%K != nil", #keyPath(Toot.deletedAt))
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
//
|
||||
// UIFont.swift
|
||||
// CoreDataStack
|
||||
//
|
||||
// Created by sxiaojian on 2021/1/28.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIFont {
|
||||
|
||||
// refs: https://stackoverflow.com/questions/26371024/limit-supported-dynamic-type-font-sizes
|
||||
static func preferredFont(withTextStyle textStyle: UIFont.TextStyle, maxSize: CGFloat) -> UIFont {
|
||||
// Get the descriptor
|
||||
let fontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle)
|
||||
|
||||
// Return a font with the minimum size
|
||||
return UIFont(descriptor: fontDescriptor, size: min(fontDescriptor.pointSize, maxSize))
|
||||
}
|
||||
|
||||
public static func preferredMonospacedFont(withTextStyle textStyle: UIFont.TextStyle, compatibleWith traitCollection: UITraitCollection? = nil) -> UIFont {
|
||||
let fontDescription = UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle).addingAttributes([
|
||||
UIFontDescriptor.AttributeName.featureSettings: [
|
||||
[
|
||||
UIFontDescriptor.FeatureKey.featureIdentifier:
|
||||
kNumberSpacingType,
|
||||
UIFontDescriptor.FeatureKey.typeIdentifier:
|
||||
kMonospacedNumbersSelector
|
||||
]
|
||||
]
|
||||
])
|
||||
return UIFontMetrics(forTextStyle: textStyle).scaledFont(for: UIFont(descriptor: fontDescription, size: 0), compatibleWith: traitCollection)
|
||||
}
|
||||
|
||||
}
|
|
@ -7,6 +7,21 @@
|
|||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */; };
|
||||
2D152A8C25C295CC009AA50C /* TimelinePostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A8B25C295CC009AA50C /* TimelinePostView.swift */; };
|
||||
2D152A9225C2980C009AA50C /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A9125C2980C009AA50C /* UIFont.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 */; };
|
||||
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 */; };
|
||||
2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76318225C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift */; };
|
||||
2D76319F25C1521200929FB9 /* TimelineSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76319E25C1521200929FB9 /* TimelineSection.swift */; };
|
||||
2D7631A825C1535600929FB9 /* TimelinePostTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7631A725C1535600929FB9 /* TimelinePostTableViewCell.swift */; };
|
||||
2D7631B325C159F700929FB9 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7631B225C159F700929FB9 /* Item.swift */; };
|
||||
3533495136D843E85211E3E2 /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A1B4523A7981F1044DE89C21 /* Pods_Mastodon_MastodonUITests.framework */; };
|
||||
5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; };
|
||||
5E44BF88AD33646E64727BCF /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */; };
|
||||
|
@ -31,7 +46,7 @@
|
|||
DB89BA1B25C1107F008580ED /* Collection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA1825C1107F008580ED /* Collection.swift */; };
|
||||
DB89BA1C25C1107F008580ED /* NSManagedObjectContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA1925C1107F008580ED /* NSManagedObjectContext.swift */; };
|
||||
DB89BA1D25C1107F008580ED /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA1A25C1107F008580ED /* URL.swift */; };
|
||||
DB89BA2725C110B4008580ED /* Toots.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA2625C110B4008580ED /* Toots.swift */; };
|
||||
DB89BA2725C110B4008580ED /* Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA2625C110B4008580ED /* Toot.swift */; };
|
||||
DB89BA3725C1145C008580ED /* CoreData.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA3525C1145C008580ED /* CoreData.xcdatamodeld */; };
|
||||
DB89BA4325C1165F008580ED /* NetworkUpdatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA4125C1165F008580ED /* NetworkUpdatable.swift */; };
|
||||
DB89BA4425C1165F008580ED /* Managed.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA4225C1165F008580ED /* Managed.swift */; };
|
||||
|
@ -100,6 +115,20 @@
|
|||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+PublicTimeline.swift"; sourceTree = "<group>"; };
|
||||
2D152A8B25C295CC009AA50C /* TimelinePostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinePostView.swift; sourceTree = "<group>"; };
|
||||
2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.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>"; };
|
||||
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>"; };
|
||||
2D76318225C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+Diffable.swift"; sourceTree = "<group>"; };
|
||||
2D76319E25C1521200929FB9 /* TimelineSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineSection.swift; sourceTree = "<group>"; };
|
||||
2D7631A725C1535600929FB9 /* TimelinePostTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinePostTableViewCell.swift; sourceTree = "<group>"; };
|
||||
2D7631B225C159F700929FB9 /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.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>"; };
|
||||
459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
602D783BEC22881EBAD84419 /* Pods_Mastodon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
|
@ -136,7 +165,7 @@
|
|||
DB89BA1825C1107F008580ED /* Collection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = "<group>"; };
|
||||
DB89BA1925C1107F008580ED /* NSManagedObjectContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSManagedObjectContext.swift; sourceTree = "<group>"; };
|
||||
DB89BA1A25C1107F008580ED /* URL.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = "<group>"; };
|
||||
DB89BA2625C110B4008580ED /* Toots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toots.swift; sourceTree = "<group>"; };
|
||||
DB89BA2625C110B4008580ED /* Toot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toot.swift; sourceTree = "<group>"; };
|
||||
DB89BA3625C1145C008580ED /* CoreData.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CoreData.xcdatamodel; sourceTree = "<group>"; };
|
||||
DB89BA4125C1165F008580ED /* NetworkUpdatable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkUpdatable.swift; sourceTree = "<group>"; };
|
||||
DB89BA4225C1165F008580ED /* Managed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Managed.swift; sourceTree = "<group>"; };
|
||||
|
@ -162,6 +191,7 @@
|
|||
files = (
|
||||
DB89BA0325C10FD0008580ED /* CoreDataStack.framework in Frameworks */,
|
||||
5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */,
|
||||
2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */,
|
||||
7A9135D4559750AF07CA9BE4 /* Pods_Mastodon.framework in Frameworks */,
|
||||
DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */,
|
||||
);
|
||||
|
@ -214,6 +244,93 @@
|
|||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2D152A8A25C295B8009AA50C /* Content */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2D152A8B25C295CC009AA50C /* TimelinePostView.swift */,
|
||||
);
|
||||
path = Content;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2D61335525C1886800CAE157 /* Service */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2D61335D25C1894B00CAE157 /* APIService.swift */,
|
||||
2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */,
|
||||
2D61335625C1887F00CAE157 /* Persist */,
|
||||
);
|
||||
path = Service;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2D61335625C1887F00CAE157 /* Persist */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */,
|
||||
);
|
||||
path = Persist;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2D76316325C14BAC00929FB9 /* PublicTimeline */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */,
|
||||
2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */,
|
||||
2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */,
|
||||
2D76318225C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift */,
|
||||
);
|
||||
path = PublicTimeline;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2D76319C25C151DE00929FB9 /* Diffiable */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2D7631B125C159E700929FB9 /* Item */,
|
||||
2D76319D25C151F600929FB9 /* Section */,
|
||||
);
|
||||
path = Diffiable;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2D76319D25C151F600929FB9 /* Section */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2D76319E25C1521200929FB9 /* TimelineSection.swift */,
|
||||
);
|
||||
path = Section;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2D7631A425C1532200929FB9 /* Share */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2D7631A525C1532D00929FB9 /* View */,
|
||||
);
|
||||
path = Share;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2D7631A525C1532D00929FB9 /* View */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2D152A8A25C295B8009AA50C /* Content */,
|
||||
2D7631A625C1533800929FB9 /* TableviewCell */,
|
||||
);
|
||||
path = View;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2D7631A625C1533800929FB9 /* TableviewCell */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2D7631A725C1535600929FB9 /* TimelinePostTableViewCell.swift */,
|
||||
);
|
||||
path = TableviewCell;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2D7631B125C159E700929FB9 /* Item */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2D7631B225C159F700929FB9 /* Item.swift */,
|
||||
);
|
||||
path = Item;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4E8E8B18DB8471A676012CF9 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -287,7 +404,9 @@
|
|||
children = (
|
||||
DB89BA1025C10FF5008580ED /* Mastodon.entitlements */,
|
||||
DB427DE325BAA00100D1B89D /* Info.plist */,
|
||||
2D76319C25C151DE00929FB9 /* Diffiable */,
|
||||
DB8AF52A25C13561002E6C99 /* State */,
|
||||
2D61335525C1886800CAE157 /* Service */,
|
||||
DB8AF56225C138BC002E6C99 /* Extension */,
|
||||
DB8AF55525C1379F002E6C99 /* Scene */,
|
||||
DB8AF54125C13647002E6C99 /* Coordinator */,
|
||||
|
@ -345,6 +464,7 @@
|
|||
DB89BA1825C1107F008580ED /* Collection.swift */,
|
||||
DB89BA1925C1107F008580ED /* NSManagedObjectContext.swift */,
|
||||
DB89BA1A25C1107F008580ED /* URL.swift */,
|
||||
2D152A9125C2980C009AA50C /* UIFont.swift */,
|
||||
);
|
||||
path = Extension;
|
||||
sourceTree = "<group>";
|
||||
|
@ -352,7 +472,7 @@
|
|||
DB89BA2C25C110B7008580ED /* Entity */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB89BA2625C110B4008580ED /* Toots.swift */,
|
||||
DB89BA2625C110B4008580ED /* Toot.swift */,
|
||||
DB8AF52425C131D1002E6C99 /* MastodonUser.swift */,
|
||||
DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */,
|
||||
);
|
||||
|
@ -398,6 +518,8 @@
|
|||
DB8AF55525C1379F002E6C99 /* Scene */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2D7631A425C1532200929FB9 /* Share */,
|
||||
2D76316325C14BAC00929FB9 /* PublicTimeline */,
|
||||
DB8AF54E25C13703002E6C99 /* MainTab */,
|
||||
DB8AF55625C137A8002E6C99 /* HomeViewController.swift */,
|
||||
);
|
||||
|
@ -408,6 +530,8 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
DB8AF55C25C138B7002E6C99 /* UIViewController.swift */,
|
||||
2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */,
|
||||
2D46976325C2A71500CF4AA9 /* UIIamge.swift */,
|
||||
);
|
||||
path = Extension;
|
||||
sourceTree = "<group>";
|
||||
|
@ -447,6 +571,7 @@
|
|||
packageProductDependencies = (
|
||||
DB3D0FF225BAA61700EAA174 /* AlamofireImage */,
|
||||
5D526FE125BE9AC400460CB9 /* MastodonSDK */,
|
||||
2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */,
|
||||
);
|
||||
productName = Mastodon;
|
||||
productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */;
|
||||
|
@ -569,6 +694,7 @@
|
|||
mainGroup = DB427DC925BAA00100D1B89D;
|
||||
packageReferences = (
|
||||
DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */,
|
||||
2D61336725C18A4F00CAE157 /* XCRemoteSwiftPackageReference "AlamofireNetworkActivityIndicator" */,
|
||||
);
|
||||
productRefGroup = DB427DD325BAA00100D1B89D /* Products */;
|
||||
projectDirPath = "";
|
||||
|
@ -750,16 +876,29 @@
|
|||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
2D7631B325C159F700929FB9 /* Item.swift in Sources */,
|
||||
2D61335E25C1894B00CAE157 /* APIService.swift in Sources */,
|
||||
DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */,
|
||||
2D152A8C25C295CC009AA50C /* TimelinePostView.swift in Sources */,
|
||||
2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */,
|
||||
DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */,
|
||||
2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */,
|
||||
2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */,
|
||||
2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */,
|
||||
DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */,
|
||||
DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */,
|
||||
DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */,
|
||||
2D76319F25C1521200929FB9 /* TimelineSection.swift in Sources */,
|
||||
DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */,
|
||||
DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */,
|
||||
2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */,
|
||||
2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */,
|
||||
DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */,
|
||||
DB8AF55725C137A8002E6C99 /* HomeViewController.swift in Sources */,
|
||||
2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */,
|
||||
2D7631A825C1535600929FB9 /* TimelinePostTableViewCell.swift in Sources */,
|
||||
DB3D102525BAA7B400EAA174 /* Strings.swift in Sources */,
|
||||
2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */,
|
||||
DB3D102425BAA7B400EAA174 /* Assets.swift in Sources */,
|
||||
DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */,
|
||||
);
|
||||
|
@ -790,7 +929,8 @@
|
|||
DB89BA3725C1145C008580ED /* CoreData.xcdatamodeld in Sources */,
|
||||
DB8AF52525C131D1002E6C99 /* MastodonUser.swift in Sources */,
|
||||
DB89BA1B25C1107F008580ED /* Collection.swift in Sources */,
|
||||
DB89BA2725C110B4008580ED /* Toots.swift in Sources */,
|
||||
DB89BA2725C110B4008580ED /* Toot.swift in Sources */,
|
||||
2D152A9225C2980C009AA50C /* UIFont.swift in Sources */,
|
||||
DB89BA4425C1165F008580ED /* Managed.swift in Sources */,
|
||||
DB89BA4325C1165F008580ED /* NetworkUpdatable.swift in Sources */,
|
||||
DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */,
|
||||
|
@ -1263,6 +1403,14 @@
|
|||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
2D61336725C18A4F00CAE157 /* XCRemoteSwiftPackageReference "AlamofireNetworkActivityIndicator" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/Alamofire/AlamofireNetworkActivityIndicator";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 3.1.0;
|
||||
};
|
||||
};
|
||||
DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/Alamofire/AlamofireImage.git";
|
||||
|
@ -1274,6 +1422,11 @@
|
|||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 2D61336725C18A4F00CAE157 /* XCRemoteSwiftPackageReference "AlamofireNetworkActivityIndicator" */;
|
||||
productName = AlamofireNetworkActivityIndicator;
|
||||
};
|
||||
5D526FE125BE9AC400460CB9 /* MastodonSDK */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = MastodonSDK;
|
||||
|
|
|
@ -19,6 +19,15 @@
|
|||
"version": "4.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "AlamofireNetworkActivityIndicator",
|
||||
"repositoryURL": "https://github.com/Alamofire/AlamofireNetworkActivityIndicator",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "392bed083e8d193aca16bfa684ee24e4bcff0510",
|
||||
"version": "3.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "swift-nio",
|
||||
"repositoryURL": "https://github.com/apple/swift-nio.git",
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
//
|
||||
// Item.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/1/27.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
import MastodonSDK
|
||||
import CoreDataStack
|
||||
|
||||
/// Note: update Equatable when change case
|
||||
enum Item {
|
||||
|
||||
// normal list
|
||||
case toot(objectID: NSManagedObjectID)
|
||||
}
|
||||
|
||||
extension Item: Equatable {
|
||||
static func == (lhs: Item, rhs: Item) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.toot(let objectIDLeft), .toot(let objectIDRight)):
|
||||
return objectIDLeft == objectIDRight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Item: Hashable {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
switch self {
|
||||
case .toot(let objectID):
|
||||
hasher.combine(objectID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
//
|
||||
// TimelineSection.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/1/27.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import os.log
|
||||
import UIKit
|
||||
|
||||
enum TimelineSection: Equatable, Hashable {
|
||||
case main
|
||||
}
|
||||
|
||||
extension TimelineSection {
|
||||
static func tableViewDiffableDataSource(
|
||||
for tableView: UITableView,
|
||||
dependency: NeedsDependency,
|
||||
managedObjectContext: NSManagedObjectContext,
|
||||
timestampUpdatePublisher: AnyPublisher<Date, Never>,
|
||||
timelinePostTableViewCellDelegate: TimelinePostTableViewCellDelegate
|
||||
) -> UITableViewDiffableDataSource<TimelineSection, Item> {
|
||||
UITableViewDiffableDataSource(tableView: tableView) { [weak timelinePostTableViewCellDelegate] tableView, indexPath, item -> UITableViewCell? in
|
||||
guard let timelinePostTableViewCellDelegate = timelinePostTableViewCellDelegate else { return UITableViewCell() }
|
||||
|
||||
switch item {
|
||||
case .toot(let objectID):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelinePostTableViewCell.self), for: indexPath) as! TimelinePostTableViewCell
|
||||
|
||||
// configure cell
|
||||
managedObjectContext.performAndWait {
|
||||
let toot = managedObjectContext.object(with: objectID) as! Toot
|
||||
TimelineSection.configure(cell: cell, toot: toot)
|
||||
}
|
||||
cell.delegate = timelinePostTableViewCellDelegate
|
||||
return cell
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func configure(
|
||||
cell: TimelinePostTableViewCell,
|
||||
toot: Toot
|
||||
) {
|
||||
cell.timelinePostView.nameLabel.text = toot.author.displayName
|
||||
cell.timelinePostView.usernameLabel.text = toot.author.username
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
extension TimelineSection {
|
||||
private static func formattedNumberTitleForActionButton(_ number: Int?) -> String {
|
||||
guard let number = number, number > 0 else { return "" }
|
||||
return String(number)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
//
|
||||
// NSLayoutConstraint.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/1/28.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension NSLayoutConstraint {
|
||||
func priority(_ priority: UILayoutPriority) -> Self {
|
||||
self.priority = priority
|
||||
return self
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
//
|
||||
// UIIamge.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/1/28.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import CoreImage
|
||||
import CoreImage.CIFilterBuiltins
|
||||
|
||||
extension UIImage {
|
||||
|
||||
static func placeholder(size: CGSize = CGSize(width: 1, height: 1), color: UIColor) -> UIImage {
|
||||
let render = UIGraphicsImageRenderer(size: size)
|
||||
|
||||
return render.image { (context: UIGraphicsImageRendererContext) in
|
||||
context.cgContext.setFillColor(color.cgColor)
|
||||
context.fill(CGRect(origin: .zero, size: size))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// refs: https://www.hackingwithswift.com/example-code/media/how-to-read-the-average-color-of-a-uiimage-using-ciareaaverage
|
||||
extension UIImage {
|
||||
@available(iOS 14.0, *)
|
||||
var dominantColor: UIColor? {
|
||||
guard let inputImage = CIImage(image: self) else { return nil }
|
||||
|
||||
let filter = CIFilter.areaAverage()
|
||||
filter.inputImage = inputImage
|
||||
filter.extent = inputImage.extent
|
||||
guard let outputImage = filter.outputImage else { return nil }
|
||||
|
||||
var bitmap = [UInt8](repeating: 0, count: 4)
|
||||
let context = CIContext(options: [.workingColorSpace: kCFNull])
|
||||
context.render(outputImage, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil)
|
||||
|
||||
return UIColor(red: CGFloat(bitmap[0]) / 255, green: CGFloat(bitmap[1]) / 255, blue: CGFloat(bitmap[2]) / 255, alpha: CGFloat(bitmap[3]) / 255)
|
||||
}
|
||||
}
|
|
@ -19,17 +19,19 @@ class MainTabBarController: UITabBarController {
|
|||
|
||||
enum Tab: Int, CaseIterable {
|
||||
case home
|
||||
|
||||
case publicTimeline
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .home: return "Home"
|
||||
case .publicTimeline : return "public"
|
||||
}
|
||||
}
|
||||
|
||||
var image: UIImage {
|
||||
switch self {
|
||||
case .home: return UIImage(systemName: "house")!
|
||||
case .publicTimeline: return UIImage(systemName: "flame")!
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -41,6 +43,12 @@ class MainTabBarController: UITabBarController {
|
|||
_viewController.context = context
|
||||
_viewController.coordinator = coordinator
|
||||
viewController = _viewController
|
||||
case .publicTimeline:
|
||||
let _viewController = PublicTimelineViewController()
|
||||
_viewController.viewModel = PublicTimelineViewModel(context: context)
|
||||
_viewController.context = context
|
||||
_viewController.coordinator = coordinator
|
||||
viewController = _viewController
|
||||
}
|
||||
viewController.title = self.title
|
||||
return UINavigationController(rootViewController: viewController)
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
//
|
||||
// PublicTimelineViewController+StatusProvider.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/1/27.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
// MARK: - StatusProvider
|
||||
extension PublicTimelineViewController {
|
||||
|
||||
func toot() -> Future<Toot?, Never> {
|
||||
return Future { promise in promise(.success(nil)) }
|
||||
}
|
||||
|
||||
func toot(for cell: UITableViewCell, indexPath: IndexPath?) -> Future<Toot?, Never> {
|
||||
return Future { promise in
|
||||
guard let diffableDataSource = self.viewModel.diffableDataSource else {
|
||||
assertionFailure()
|
||||
promise(.success(nil))
|
||||
return
|
||||
}
|
||||
guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell),
|
||||
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
|
||||
promise(.success(nil))
|
||||
return
|
||||
}
|
||||
|
||||
switch item {
|
||||
case .toot(let objectID):
|
||||
let managedObjectContext = self.viewModel.fetchedResultsController.managedObjectContext
|
||||
managedObjectContext.perform {
|
||||
let toot = managedObjectContext.object(with: objectID) as? Toot
|
||||
promise(.success(toot))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toot(for cell: UICollectionViewCell) -> Future<Toot?, Never> {
|
||||
return Future { promise in promise(.success(nil)) }
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,108 @@
|
|||
//
|
||||
// PublicTimelineViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/1/27.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import AVKit
|
||||
import Combine
|
||||
import CoreDataStack
|
||||
import GameplayKit
|
||||
|
||||
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!
|
||||
|
||||
|
||||
lazy var tableView: UITableView = {
|
||||
let tableView = UITableView()
|
||||
tableView.register(TimelinePostTableViewCell.self, forCellReuseIdentifier: String(describing: TimelinePostTableViewCell.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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension PublicTimelineViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(tableView)
|
||||
tableView.backgroundColor = .systemBackground
|
||||
NSLayoutConstraint.activate([
|
||||
tableView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
|
||||
viewModel.tableView = tableView
|
||||
tableView.delegate = self
|
||||
viewModel.setupDiffableDataSource(
|
||||
for: tableView,
|
||||
dependency: self,
|
||||
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.tweetIDs.value = tootsIDs
|
||||
}
|
||||
.store(in: &viewModel.disposeBag)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 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 }
|
||||
|
||||
guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else {
|
||||
return 200
|
||||
}
|
||||
|
||||
return ceil(frame.height)
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
let key = item.hashValue
|
||||
let frame = cell.frame
|
||||
viewModel.cellFrameCache.setObject(NSValue(cgRect: frame), forKey: NSNumber(value: key))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
//
|
||||
// PublicTimelineViewModel+Diffable.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/1/27.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
|
||||
extension PublicTimelineViewModel {
|
||||
func setupDiffableDataSource(
|
||||
for tableView: UITableView,
|
||||
dependency: NeedsDependency,
|
||||
timelinePostTableViewCellDelegate: TimelinePostTableViewCellDelegate
|
||||
) {
|
||||
let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common)
|
||||
.autoconnect()
|
||||
.share()
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
diffableDataSource = TimelineSection.tableViewDiffableDataSource(
|
||||
for: tableView,
|
||||
dependency: dependency,
|
||||
managedObjectContext: fetchedResultsController.managedObjectContext,
|
||||
timestampUpdatePublisher: timestampUpdatePublisher,
|
||||
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)
|
||||
|
||||
let indexes = tweetIDs.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 }
|
||||
return indexes.firstIndex(of: toot.id).map { index in (index, toot) }
|
||||
}
|
||||
.sorted { $0.0 < $1.0 }
|
||||
.map { Item.toot(objectID: $0.1.objectID) }
|
||||
self.items.value = items
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
//
|
||||
// PublicTimelineViewModel.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/1/27.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import GameplayKit
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
import AlamofireImage
|
||||
|
||||
|
||||
class PublicTimelineViewModel: NSObject {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
let fetchedResultsController: NSFetchedResultsController<Toot>
|
||||
weak var tableView: UITableView?
|
||||
|
||||
// output
|
||||
var diffableDataSource: UITableViewDiffableDataSource<TimelineSection, Item>?
|
||||
|
||||
let tweetIDs = CurrentValueSubject<[String], Never>([])
|
||||
let items = CurrentValueSubject<[Item], Never>([])
|
||||
var cellFrameCache = NSCache<NSNumber, NSValue>()
|
||||
|
||||
init(context: AppContext) {
|
||||
self.context = context
|
||||
self.fetchedResultsController = {
|
||||
let fetchRequest = Toot.sortedFetchRequest
|
||||
fetchRequest.predicate = Toot.predicate(idStrs: [])
|
||||
fetchRequest.returnsObjectsAsFaults = false
|
||||
fetchRequest.fetchBatchSize = 20
|
||||
let controller = NSFetchedResultsController(
|
||||
fetchRequest: fetchRequest,
|
||||
managedObjectContext: context.managedObjectContext,
|
||||
sectionNameKeyPath: nil,
|
||||
cacheName: nil
|
||||
)
|
||||
|
||||
return controller
|
||||
}()
|
||||
super.init()
|
||||
|
||||
self.fetchedResultsController.delegate = self
|
||||
|
||||
items
|
||||
.receive(on: DispatchQueue.main)
|
||||
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
||||
.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)
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<TimelineSection, Item>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(items)
|
||||
|
||||
diffableDataSource.apply(snapshot, animatingDifferences: !items.isEmpty)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
tweetIDs
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] ids in
|
||||
guard let self = self else { return }
|
||||
self.fetchedResultsController.fetchRequest.predicate = Toot.predicate(idStrs: ids)
|
||||
do {
|
||||
try self.fetchedResultsController.performFetch()
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
deinit {
|
||||
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(count: 20, domain: "mstdn.jp")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,114 @@
|
|||
//
|
||||
// TimelinePostView.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/1/28.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import AVKit
|
||||
|
||||
final class TimelinePostView: UIView {
|
||||
|
||||
static let avatarImageViewSize = CGSize(width: 44, height: 44)
|
||||
|
||||
let avatarImageView = UIImageView()
|
||||
|
||||
let nameLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = .preferredFont(forTextStyle: .headline)
|
||||
label.textColor = .label
|
||||
label.text = "Alice"
|
||||
return label
|
||||
}()
|
||||
|
||||
let usernameLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = .preferredFont(forTextStyle: .subheadline)
|
||||
label.textColor = .secondaryLabel
|
||||
label.text = "@alice"
|
||||
return label
|
||||
}()
|
||||
|
||||
let dateLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFont.preferredMonospacedFont(withTextStyle: .callout)
|
||||
label.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft ? .left : .right
|
||||
label.textColor = .secondaryLabel
|
||||
label.text = "1d"
|
||||
return label
|
||||
}()
|
||||
|
||||
let mainContainerStackView = UIStackView()
|
||||
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
_init()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
_init()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension TimelinePostView {
|
||||
|
||||
func _init() {
|
||||
// container: [retweet | post]
|
||||
let containerStackView = UIStackView()
|
||||
containerStackView.axis = .vertical
|
||||
containerStackView.spacing = 8
|
||||
//containerStackView.alignment = .top
|
||||
containerStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(containerStackView)
|
||||
NSLayoutConstraint.activate([
|
||||
containerStackView.topAnchor.constraint(equalTo: topAnchor),
|
||||
containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
trailingAnchor.constraint(equalTo: containerStackView.trailingAnchor),
|
||||
bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor),
|
||||
])
|
||||
|
||||
// post container: [user avatar | tweet container]
|
||||
let postContainerStackView = UIStackView()
|
||||
containerStackView.addArrangedSubview(postContainerStackView)
|
||||
postContainerStackView.axis = .horizontal
|
||||
postContainerStackView.spacing = 10
|
||||
postContainerStackView.alignment = .top
|
||||
|
||||
// user avatar
|
||||
avatarImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
postContainerStackView.addArrangedSubview(avatarImageView)
|
||||
NSLayoutConstraint.activate([
|
||||
avatarImageView.widthAnchor.constraint(equalToConstant: TimelinePostView.avatarImageViewSize.width).priority(.required - 1),
|
||||
avatarImageView.heightAnchor.constraint(equalToConstant: TimelinePostView.avatarImageViewSize.height).priority(.required - 1),
|
||||
])
|
||||
|
||||
// tweet container: [user meta container | main container | action toolbar]
|
||||
let tweetContainerStackView = UIStackView()
|
||||
postContainerStackView.addArrangedSubview(tweetContainerStackView)
|
||||
tweetContainerStackView.axis = .vertical
|
||||
tweetContainerStackView.spacing = 2
|
||||
|
||||
// user meta container: [name | lock | username | date | menu]
|
||||
let userMetaContainerStackView = UIStackView()
|
||||
tweetContainerStackView.addArrangedSubview(userMetaContainerStackView)
|
||||
userMetaContainerStackView.axis = .horizontal
|
||||
userMetaContainerStackView.alignment = .center
|
||||
userMetaContainerStackView.spacing = 6
|
||||
userMetaContainerStackView.addArrangedSubview(nameLabel)
|
||||
userMetaContainerStackView.addArrangedSubview(usernameLabel)
|
||||
userMetaContainerStackView.addArrangedSubview(dateLabel)
|
||||
nameLabel.setContentHuggingPriority(.defaultHigh + 10, for: .horizontal)
|
||||
nameLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
|
||||
usernameLabel.setContentHuggingPriority(.defaultHigh + 3, for: .horizontal)
|
||||
usernameLabel.setContentCompressionResistancePriority(.defaultHigh - 1, for: .horizontal)
|
||||
dateLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
dateLabel.setContentCompressionResistancePriority(.required - 2, for: .horizontal)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
//
|
||||
// TimelinePostTableViewCell.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/1/27.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import AVKit
|
||||
import Combine
|
||||
|
||||
|
||||
protocol TimelinePostTableViewCellDelegate: class {
|
||||
|
||||
}
|
||||
|
||||
final class TimelinePostTableViewCell: UITableViewCell {
|
||||
|
||||
static let verticalMargin: CGFloat = 16 // without retweet indicator
|
||||
static let verticalMarginAlt: CGFloat = 8 // with retweet indicator
|
||||
|
||||
weak var delegate: TimelinePostTableViewCellDelegate?
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
var observations = Set<NSKeyValueObservation>()
|
||||
|
||||
let timelinePostView = TimelinePostView()
|
||||
|
||||
var timelinePostViewTopLayoutConstraint: NSLayoutConstraint!
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
disposeBag.removeAll()
|
||||
observations.removeAll()
|
||||
}
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
_init()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
_init()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension TimelinePostTableViewCell {
|
||||
|
||||
private func _init() {
|
||||
timelinePostView.translatesAutoresizingMaskIntoConstraints = false
|
||||
timelinePostViewTopLayoutConstraint = timelinePostView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: TimelinePostTableViewCell.verticalMargin)
|
||||
contentView.addSubview(timelinePostView)
|
||||
NSLayoutConstraint.activate([
|
||||
timelinePostViewTopLayoutConstraint,
|
||||
timelinePostView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
|
||||
contentView.readableContentGuide.trailingAnchor.constraint(equalTo: timelinePostView.trailingAnchor),
|
||||
contentView.bottomAnchor.constraint(equalTo: timelinePostView.bottomAnchor), // use action toolbar margin
|
||||
])
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
//
|
||||
// APIService+PublicTimeline.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/1/28.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import DateToolsSwift
|
||||
import MastodonSDK
|
||||
|
||||
extension APIService {
|
||||
|
||||
static let publicTimelineRequestWindowInSec: TimeInterval = 15 * 60
|
||||
|
||||
// incoming tweet - retweet relationship could be:
|
||||
// A1. incoming tweet NOT in local timeline, retweet NOT in local (never see tweet and retweet)
|
||||
// A2. incoming tweet NOT in local timeline, retweet in local (never see tweet but saw retweet before)
|
||||
// A3. incoming tweet in local timeline, retweet MUST in local (saw tweet before)
|
||||
func publicTimeline(
|
||||
count: Int = 20,
|
||||
domain: String
|
||||
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Toot]>, Error> {
|
||||
|
||||
return Mastodon.API.Timeline.public(
|
||||
session: session,
|
||||
domain: domain,
|
||||
query: Mastodon.API.Timeline.PublicTimelineQuery()
|
||||
)
|
||||
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Toot]>,Error> in
|
||||
return APIService.Persist.persistTimeline(
|
||||
domain: domain,
|
||||
managedObjectContext: self.backgroundManagedObjectContext,
|
||||
response: response,
|
||||
persistType: Persist.PersistTimelineType.publicHomeTimeline
|
||||
)
|
||||
.setFailureType(to: Error.self)
|
||||
.tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Toot]> in
|
||||
switch result {
|
||||
case .success:
|
||||
return response
|
||||
case .failure(let error):
|
||||
throw error
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
//
|
||||
// APIService.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/1/27.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
import AlamofireImage
|
||||
import AlamofireNetworkActivityIndicator
|
||||
|
||||
final class APIService {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
// internal
|
||||
let session: URLSession
|
||||
|
||||
|
||||
// input
|
||||
let backgroundManagedObjectContext: NSManagedObjectContext
|
||||
|
||||
|
||||
init(backgroundManagedObjectContext: NSManagedObjectContext) {
|
||||
self.backgroundManagedObjectContext = backgroundManagedObjectContext
|
||||
self.session = URLSession(configuration: .default)
|
||||
|
||||
// setup cache. 10MB RAM + 50MB Disk
|
||||
URLCache.shared = URLCache(memoryCapacity: 10 * 1024 * 1024, diskCapacity: 50 * 1024 * 1024, diskPath: nil)
|
||||
|
||||
// enable network activity manager for AlamofireImage
|
||||
NetworkActivityIndicatorManager.shared.isEnabled = true
|
||||
NetworkActivityIndicatorManager.shared.startDelay = 0.2
|
||||
NetworkActivityIndicatorManager.shared.completionDelay = 0.5
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension APIService {
|
||||
public enum Persist { }
|
||||
public enum CoreData { }
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
//
|
||||
// APIService+Persist+Timeline.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/1/27.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
extension APIService.Persist {
|
||||
enum PersistTimelineType {
|
||||
case publicHomeTimeline
|
||||
}
|
||||
static func persistTimeline(
|
||||
domain: String,
|
||||
managedObjectContext: NSManagedObjectContext,
|
||||
response: Mastodon.Response.Content<[Mastodon.Entity.Toot]>,
|
||||
persistType: PersistTimelineType
|
||||
) -> AnyPublisher<Result<Void, Error>, Never> {
|
||||
return managedObjectContext.performChanges {
|
||||
let toots = response.value
|
||||
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, createdAt: $0.createdAt, networkDate: $0.createdAt)
|
||||
let author = MastodonUser.insert(into: managedObjectContext, property: userProperty)
|
||||
let tootProperty = Toot.Property(id: $0.id, domain: domain, content: $0.content, createdAt: $0.createdAt, networkDate: $0.createdAt)
|
||||
Toot.insert(into: managedObjectContext, property: tootProperty, author: author)
|
||||
}
|
||||
}.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
|
@ -21,6 +21,7 @@ class AppContext: ObservableObject {
|
|||
let managedObjectContext: NSManagedObjectContext
|
||||
let backgroundManagedObjectContext: NSManagedObjectContext
|
||||
|
||||
let apiService: APIService
|
||||
|
||||
let documentStore: DocumentStore
|
||||
private var documentStoreSubscription: AnyCancellable!
|
||||
|
@ -35,6 +36,10 @@ class AppContext: ObservableObject {
|
|||
managedObjectContext = _managedObjectContext
|
||||
backgroundManagedObjectContext = _backgroundManagedObjectContext
|
||||
|
||||
let _apiService = APIService(backgroundManagedObjectContext: _backgroundManagedObjectContext)
|
||||
apiService = _apiService
|
||||
|
||||
|
||||
documentStore = DocumentStore()
|
||||
documentStoreSubscription = documentStore.objectWillChange
|
||||
.receive(on: DispatchQueue.main)
|
||||
|
|
Loading…
Reference in New Issue