add public timeline

This commit is contained in:
sunxiaojian 2021-01-28 16:10:30 +08:00
parent 6b56764a4c
commit cb690ffa4e
23 changed files with 1032 additions and 30 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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