diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index fd2b557b8..aae7f5927 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -1,26 +1,28 @@ - + - + + - + - + + @@ -29,8 +31,8 @@ - - - + + + \ No newline at end of file diff --git a/CoreDataStack/CoreDataStack.swift b/CoreDataStack/CoreDataStack.swift index d9046eafa..07b24a843 100644 --- a/CoreDataStack/CoreDataStack.swift +++ b/CoreDataStack/CoreDataStack.swift @@ -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") } diff --git a/CoreDataStack/Entity/HomeTimelineIndex.swift b/CoreDataStack/Entity/HomeTimelineIndex.swift index 3eb0a28df..7c4e4eb77 100644 --- a/CoreDataStack/Entity/HomeTimelineIndex.swift +++ b/CoreDataStack/Entity/HomeTimelineIndex.swift @@ -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 } diff --git a/CoreDataStack/Entity/MastodonUser.swift b/CoreDataStack/Entity/MastodonUser.swift index c649ca014..09b9f1604 100644 --- a/CoreDataStack/Entity/MastodonUser.swift +++ b/CoreDataStack/Entity/MastodonUser.swift @@ -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? + @NSManaged public private(set) var toots: Set? } @@ -70,7 +70,6 @@ extension MastodonUser { acct: String, username: String, displayName: String?, - content: String, createdAt: Date, networkDate: Date ) { diff --git a/CoreDataStack/Entity/Toots.swift b/CoreDataStack/Entity/Toot.swift similarity index 68% rename from CoreDataStack/Entity/Toots.swift rename to CoreDataStack/Entity/Toot.swift index ad5e64caa..546ec8ce3 100644 --- a/CoreDataStack/Entity/Toots.swift +++ b/CoreDataStack/Entity/Toot.swift @@ -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)) + } + +} diff --git a/CoreDataStack/Extension/UIFont.swift b/CoreDataStack/Extension/UIFont.swift new file mode 100644 index 000000000..a1a97a112 --- /dev/null +++ b/CoreDataStack/Extension/UIFont.swift @@ -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) + } + +} diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 4cef307c8..7e9ca0e89 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -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 = ""; }; + 2D152A8B25C295CC009AA50C /* TimelinePostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinePostView.swift; sourceTree = ""; }; + 2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; + 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSLayoutConstraint.swift; sourceTree = ""; }; + 2D46976325C2A71500CF4AA9 /* UIIamge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIIamge.swift; sourceTree = ""; }; + 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Timeline.swift"; sourceTree = ""; }; + 2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = ""; }; + 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewController.swift; sourceTree = ""; }; + 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewModel.swift; sourceTree = ""; }; + 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewController+StatusProvider.swift"; sourceTree = ""; }; + 2D76318225C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+Diffable.swift"; sourceTree = ""; }; + 2D76319E25C1521200929FB9 /* TimelineSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineSection.swift; sourceTree = ""; }; + 2D7631A725C1535600929FB9 /* TimelinePostTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinePostTableViewCell.swift; sourceTree = ""; }; + 2D7631B225C159F700929FB9 /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; 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 = ""; }; DB89BA1925C1107F008580ED /* NSManagedObjectContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSManagedObjectContext.swift; sourceTree = ""; }; DB89BA1A25C1107F008580ED /* URL.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = ""; }; - DB89BA2625C110B4008580ED /* Toots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toots.swift; sourceTree = ""; }; + DB89BA2625C110B4008580ED /* Toot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toot.swift; sourceTree = ""; }; DB89BA3625C1145C008580ED /* CoreData.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CoreData.xcdatamodel; sourceTree = ""; }; DB89BA4125C1165F008580ED /* NetworkUpdatable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkUpdatable.swift; sourceTree = ""; }; DB89BA4225C1165F008580ED /* Managed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Managed.swift; sourceTree = ""; }; @@ -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 = ""; }; + 2D152A8A25C295B8009AA50C /* Content */ = { + isa = PBXGroup; + children = ( + 2D152A8B25C295CC009AA50C /* TimelinePostView.swift */, + ); + path = Content; + sourceTree = ""; + }; + 2D61335525C1886800CAE157 /* Service */ = { + isa = PBXGroup; + children = ( + 2D61335D25C1894B00CAE157 /* APIService.swift */, + 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */, + 2D61335625C1887F00CAE157 /* Persist */, + ); + path = Service; + sourceTree = ""; + }; + 2D61335625C1887F00CAE157 /* Persist */ = { + isa = PBXGroup; + children = ( + 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */, + ); + path = Persist; + sourceTree = ""; + }; + 2D76316325C14BAC00929FB9 /* PublicTimeline */ = { + isa = PBXGroup; + children = ( + 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */, + 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */, + 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */, + 2D76318225C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift */, + ); + path = PublicTimeline; + sourceTree = ""; + }; + 2D76319C25C151DE00929FB9 /* Diffiable */ = { + isa = PBXGroup; + children = ( + 2D7631B125C159E700929FB9 /* Item */, + 2D76319D25C151F600929FB9 /* Section */, + ); + path = Diffiable; + sourceTree = ""; + }; + 2D76319D25C151F600929FB9 /* Section */ = { + isa = PBXGroup; + children = ( + 2D76319E25C1521200929FB9 /* TimelineSection.swift */, + ); + path = Section; + sourceTree = ""; + }; + 2D7631A425C1532200929FB9 /* Share */ = { + isa = PBXGroup; + children = ( + 2D7631A525C1532D00929FB9 /* View */, + ); + path = Share; + sourceTree = ""; + }; + 2D7631A525C1532D00929FB9 /* View */ = { + isa = PBXGroup; + children = ( + 2D152A8A25C295B8009AA50C /* Content */, + 2D7631A625C1533800929FB9 /* TableviewCell */, + ); + path = View; + sourceTree = ""; + }; + 2D7631A625C1533800929FB9 /* TableviewCell */ = { + isa = PBXGroup; + children = ( + 2D7631A725C1535600929FB9 /* TimelinePostTableViewCell.swift */, + ); + path = TableviewCell; + sourceTree = ""; + }; + 2D7631B125C159E700929FB9 /* Item */ = { + isa = PBXGroup; + children = ( + 2D7631B225C159F700929FB9 /* Item.swift */, + ); + path = Item; + sourceTree = ""; + }; 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 = ""; @@ -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 = ""; @@ -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; diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 17197be49..ce4ca455c 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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", diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift new file mode 100644 index 000000000..d04261a3f --- /dev/null +++ b/Mastodon/Diffiable/Item/Item.swift @@ -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) + } + } +} + diff --git a/Mastodon/Diffiable/Section/TimelineSection.swift b/Mastodon/Diffiable/Section/TimelineSection.swift new file mode 100644 index 000000000..e9fc68be8 --- /dev/null +++ b/Mastodon/Diffiable/Section/TimelineSection.swift @@ -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, + timelinePostTableViewCellDelegate: TimelinePostTableViewCellDelegate + ) -> UITableViewDiffableDataSource { + 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) + } +} diff --git a/Mastodon/Extension/NSLayoutConstraint.swift b/Mastodon/Extension/NSLayoutConstraint.swift new file mode 100644 index 000000000..cae353187 --- /dev/null +++ b/Mastodon/Extension/NSLayoutConstraint.swift @@ -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 + } +} diff --git a/Mastodon/Extension/UIIamge.swift b/Mastodon/Extension/UIIamge.swift new file mode 100644 index 000000000..20069f7c7 --- /dev/null +++ b/Mastodon/Extension/UIIamge.swift @@ -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) + } +} diff --git a/Mastodon/Scene/MainTab/MainTabBarController.swift b/Mastodon/Scene/MainTab/MainTabBarController.swift index f15db318b..9cabb7322 100644 --- a/Mastodon/Scene/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/MainTab/MainTabBarController.swift @@ -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) diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift new file mode 100644 index 000000000..d27531cc7 --- /dev/null +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift @@ -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 { + return Future { promise in promise(.success(nil)) } + } + + func toot(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { + 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 { + return Future { promise in promise(.success(nil)) } + } + +} diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift new file mode 100644 index 000000000..251b98451 --- /dev/null +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift @@ -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() + 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)) + } +} diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift new file mode 100644 index 000000000..575884ce6 --- /dev/null +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift @@ -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, 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 + } + +} diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift new file mode 100644 index 000000000..2e43bd6d2 --- /dev/null +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift @@ -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() + + // input + let context: AppContext + let fetchedResultsController: NSFetchedResultsController + weak var tableView: UITableView? + + // output + var diffableDataSource: UITableViewDiffableDataSource? + + let tweetIDs = CurrentValueSubject<[String], Never>([]) + let items = CurrentValueSubject<[Item], Never>([]) + var cellFrameCache = NSCache() + + 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() + 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, Error> { + return context.apiService.publicTimeline(count: 20, domain: "mstdn.jp") + } +} diff --git a/Mastodon/Scene/Share/View/Content/TimelinePostView.swift b/Mastodon/Scene/Share/View/Content/TimelinePostView.swift new file mode 100644 index 000000000..12ba1ccc5 --- /dev/null +++ b/Mastodon/Scene/Share/View/Content/TimelinePostView.swift @@ -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) + + } + +} + diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelinePostTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelinePostTableViewCell.swift new file mode 100644 index 000000000..3ca5e0c22 --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelinePostTableViewCell.swift @@ -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() + var observations = Set() + + 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 + ]) + } + +} diff --git a/Mastodon/Service/APIService+PublicTimeline.swift b/Mastodon/Service/APIService+PublicTimeline.swift new file mode 100644 index 000000000..ac05de184 --- /dev/null +++ b/Mastodon/Service/APIService+PublicTimeline.swift @@ -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, Error> { + + return Mastodon.API.Timeline.public( + session: session, + domain: domain, + query: Mastodon.API.Timeline.PublicTimelineQuery() + ) + .flatMap { response -> AnyPublisher,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() + } +} diff --git a/Mastodon/Service/APIService.swift b/Mastodon/Service/APIService.swift new file mode 100644 index 000000000..e79fb1176 --- /dev/null +++ b/Mastodon/Service/APIService.swift @@ -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() + + // 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 { } +} diff --git a/Mastodon/Service/Persist/APIService+Persist+Timeline.swift b/Mastodon/Service/Persist/APIService+Persist+Timeline.swift new file mode 100644 index 000000000..b43ee1010 --- /dev/null +++ b/Mastodon/Service/Persist/APIService+Persist+Timeline.swift @@ -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, 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() + } +} diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index 0742597c4..19cb4757d 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -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)