Merge branch 'develop' into feature/compose-attachment-image
# Conflicts: # Mastodon.xcodeproj/project.pbxproj # Mastodon/Service/APIService/APIService+Status.swift # MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift
This commit is contained in:
commit
d64a06aa9d
|
@ -1,5 +1,5 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20D5029f" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20D80" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||||
<entity name="Application" representedClassName=".Application" syncable="YES">
|
<entity name="Application" representedClassName=".Application" syncable="YES">
|
||||||
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
<attribute name="name" attributeType="String"/>
|
<attribute name="name" attributeType="String"/>
|
||||||
|
@ -159,6 +159,8 @@
|
||||||
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="reblogFrom" inverseEntity="Toot"/>
|
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="reblogFrom" inverseEntity="Toot"/>
|
||||||
<relationship name="reblogFrom" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="reblog" inverseEntity="Toot"/>
|
<relationship name="reblogFrom" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="reblog" inverseEntity="Toot"/>
|
||||||
<relationship name="rebloggedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="reblogged" inverseEntity="MastodonUser"/>
|
<relationship name="rebloggedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="reblogged" inverseEntity="MastodonUser"/>
|
||||||
|
<relationship name="replyFrom" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="replyTo" inverseEntity="Toot"/>
|
||||||
|
<relationship name="replyTo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="replyFrom" inverseEntity="Toot"/>
|
||||||
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Tag" inverseName="toot" inverseEntity="Tag"/>
|
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Tag" inverseName="toot" inverseEntity="Tag"/>
|
||||||
</entity>
|
</entity>
|
||||||
<elements>
|
<elements>
|
||||||
|
@ -173,6 +175,6 @@
|
||||||
<element name="Poll" positionX="72" positionY="162" width="128" height="194"/>
|
<element name="Poll" positionX="72" positionY="162" width="128" height="194"/>
|
||||||
<element name="PollOption" positionX="81" positionY="171" width="128" height="134"/>
|
<element name="PollOption" positionX="81" positionY="171" width="128" height="134"/>
|
||||||
<element name="Tag" positionX="18" positionY="117" width="128" height="119"/>
|
<element name="Tag" positionX="18" positionY="117" width="128" height="119"/>
|
||||||
<element name="Toot" positionX="0" positionY="0" width="128" height="539"/>
|
<element name="Toot" positionX="0" positionY="0" width="128" height="14"/>
|
||||||
</elements>
|
</elements>
|
||||||
</model>
|
</model>
|
|
@ -39,6 +39,7 @@ public final class Toot: NSManagedObject {
|
||||||
// many-to-one relastionship
|
// many-to-one relastionship
|
||||||
@NSManaged public private(set) var author: MastodonUser
|
@NSManaged public private(set) var author: MastodonUser
|
||||||
@NSManaged public private(set) var reblog: Toot?
|
@NSManaged public private(set) var reblog: Toot?
|
||||||
|
@NSManaged public private(set) var replyTo: Toot?
|
||||||
|
|
||||||
// many-to-many relastionship
|
// many-to-many relastionship
|
||||||
@NSManaged public private(set) var favouritedBy: Set<MastodonUser>?
|
@NSManaged public private(set) var favouritedBy: Set<MastodonUser>?
|
||||||
|
@ -57,6 +58,7 @@ public final class Toot: NSManagedObject {
|
||||||
@NSManaged public private(set) var tags: Set<Tag>?
|
@NSManaged public private(set) var tags: Set<Tag>?
|
||||||
@NSManaged public private(set) var homeTimelineIndexes: Set<HomeTimelineIndex>?
|
@NSManaged public private(set) var homeTimelineIndexes: Set<HomeTimelineIndex>?
|
||||||
@NSManaged public private(set) var mediaAttachments: Set<Attachment>?
|
@NSManaged public private(set) var mediaAttachments: Set<Attachment>?
|
||||||
|
@NSManaged public private(set) var replyFrom: Set<Toot>?
|
||||||
|
|
||||||
@NSManaged public private(set) var updatedAt: Date
|
@NSManaged public private(set) var updatedAt: Date
|
||||||
@NSManaged public private(set) var deletedAt: Date?
|
@NSManaged public private(set) var deletedAt: Date?
|
||||||
|
@ -70,6 +72,7 @@ public extension Toot {
|
||||||
author: MastodonUser,
|
author: MastodonUser,
|
||||||
reblog: Toot?,
|
reblog: Toot?,
|
||||||
application: Application?,
|
application: Application?,
|
||||||
|
replyTo: Toot?,
|
||||||
poll: Poll?,
|
poll: Poll?,
|
||||||
mentions: [Mention]?,
|
mentions: [Mention]?,
|
||||||
emojis: [Emoji]?,
|
emojis: [Emoji]?,
|
||||||
|
@ -142,16 +145,19 @@ public extension Toot {
|
||||||
|
|
||||||
return toot
|
return toot
|
||||||
}
|
}
|
||||||
|
|
||||||
func update(reblogsCount: NSNumber) {
|
func update(reblogsCount: NSNumber) {
|
||||||
if self.reblogsCount.intValue != reblogsCount.intValue {
|
if self.reblogsCount.intValue != reblogsCount.intValue {
|
||||||
self.reblogsCount = reblogsCount
|
self.reblogsCount = reblogsCount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func update(favouritesCount: NSNumber) {
|
func update(favouritesCount: NSNumber) {
|
||||||
if self.favouritesCount.intValue != favouritesCount.intValue {
|
if self.favouritesCount.intValue != favouritesCount.intValue {
|
||||||
self.favouritesCount = favouritesCount
|
self.favouritesCount = favouritesCount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func update(repliesCount: NSNumber?) {
|
func update(repliesCount: NSNumber?) {
|
||||||
guard let count = repliesCount else {
|
guard let count = repliesCount else {
|
||||||
return
|
return
|
||||||
|
@ -160,6 +166,13 @@ public extension Toot {
|
||||||
self.repliesCount = repliesCount
|
self.repliesCount = repliesCount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func update(replyTo: Toot?) {
|
||||||
|
if self.replyTo != replyTo {
|
||||||
|
self.replyTo = replyTo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func update(liked: Bool, mastodonUser: MastodonUser) {
|
func update(liked: Bool, mastodonUser: MastodonUser) {
|
||||||
if liked {
|
if liked {
|
||||||
if !(self.favouritedBy ?? Set()).contains(mastodonUser) {
|
if !(self.favouritedBy ?? Set()).contains(mastodonUser) {
|
||||||
|
@ -171,6 +184,7 @@ public extension Toot {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func update(reblogged: Bool, mastodonUser: MastodonUser) {
|
func update(reblogged: Bool, mastodonUser: MastodonUser) {
|
||||||
if reblogged {
|
if reblogged {
|
||||||
if !(self.rebloggedBy ?? Set()).contains(mastodonUser) {
|
if !(self.rebloggedBy ?? Set()).contains(mastodonUser) {
|
||||||
|
|
|
@ -42,6 +42,7 @@
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"user_reblogged": "%s reblogged",
|
"user_reblogged": "%s reblogged",
|
||||||
|
"user_replied_to": "Replied to %s",
|
||||||
"show_post": "Show Post",
|
"show_post": "Show Post",
|
||||||
"status_content_warning": "content warning",
|
"status_content_warning": "content warning",
|
||||||
"media_content_warning": "Tap to reveal that may be sensitive",
|
"media_content_warning": "Tap to reveal that may be sensitive",
|
||||||
|
@ -49,11 +50,11 @@
|
||||||
"vote": "Vote",
|
"vote": "Vote",
|
||||||
"vote_count": {
|
"vote_count": {
|
||||||
"single": "%d vote",
|
"single": "%d vote",
|
||||||
"multiple": "%d votes",
|
"multiple": "%d votes"
|
||||||
},
|
},
|
||||||
"voter_count": {
|
"voter_count": {
|
||||||
"single": "%d voter",
|
"single": "%d voter",
|
||||||
"multiple": "%d voters",
|
"multiple": "%d voters"
|
||||||
},
|
},
|
||||||
"time_left": "%s left",
|
"time_left": "%s left",
|
||||||
"closed": "Closed"
|
"closed": "Closed"
|
||||||
|
|
|
@ -57,13 +57,13 @@
|
||||||
2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D2725CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift */; };
|
2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D2725CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift */; };
|
||||||
2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */; };
|
2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */; };
|
||||||
2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */; };
|
2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */; };
|
||||||
2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */; };
|
2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Status.swift */; };
|
||||||
2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; };
|
2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; };
|
||||||
2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; };
|
2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; };
|
||||||
2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */; };
|
2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */; };
|
||||||
2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */; };
|
2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */; };
|
||||||
2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */; };
|
2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */; };
|
||||||
2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */; };
|
2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Status.swift */; };
|
||||||
2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */; };
|
2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */; };
|
||||||
2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */; };
|
2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */; };
|
||||||
2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */; };
|
2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */; };
|
||||||
|
@ -175,6 +175,11 @@
|
||||||
DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift */; };
|
DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift */; };
|
||||||
DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; };
|
DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; };
|
||||||
DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */; };
|
DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */; };
|
||||||
|
DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */; };
|
||||||
|
DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */; };
|
||||||
|
DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD4525F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift */; };
|
||||||
|
DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */; };
|
||||||
|
DB71FD5225F8CCAA00512AE1 /* APIService+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */; };
|
||||||
DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; };
|
DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; };
|
||||||
DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */; };
|
DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */; };
|
||||||
DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */; };
|
DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */; };
|
||||||
|
@ -338,12 +343,12 @@
|
||||||
2D5A3D2725CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewModel+Diffable.swift"; sourceTree = "<group>"; };
|
2D5A3D2725CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewModel+Diffable.swift"; sourceTree = "<group>"; };
|
||||||
2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewContainer.swift; sourceTree = "<group>"; };
|
2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewContainer.swift; sourceTree = "<group>"; };
|
||||||
2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewController+DebugAction.swift"; sourceTree = "<group>"; };
|
2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewController+DebugAction.swift"; sourceTree = "<group>"; };
|
||||||
2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Timeline.swift"; sourceTree = "<group>"; };
|
2D61335725C188A000CAE157 /* APIService+Persist+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Status.swift"; sourceTree = "<group>"; };
|
||||||
2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = "<group>"; };
|
2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = "<group>"; };
|
||||||
2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error+Detail.swift"; sourceTree = "<group>"; };
|
2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error+Detail.swift"; sourceTree = "<group>"; };
|
||||||
2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningOverlayView.swift; sourceTree = "<group>"; };
|
2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningOverlayView.swift; sourceTree = "<group>"; };
|
||||||
2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; };
|
2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; };
|
||||||
2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Toot.swift"; sourceTree = "<group>"; };
|
2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Status.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Status.swift"; sourceTree = "<group>"; };
|
||||||
2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewController.swift; sourceTree = "<group>"; };
|
2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewController.swift; sourceTree = "<group>"; };
|
||||||
2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewModel.swift; sourceTree = "<group>"; };
|
2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewModel.swift; sourceTree = "<group>"; };
|
||||||
2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewController+StatusProvider.swift"; sourceTree = "<group>"; };
|
2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewController+StatusProvider.swift"; sourceTree = "<group>"; };
|
||||||
|
@ -464,6 +469,11 @@
|
||||||
DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentTableViewCell.swift; sourceTree = "<group>"; };
|
DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentTableViewCell.swift; sourceTree = "<group>"; };
|
||||||
DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = "<group>"; };
|
DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = "<group>"; };
|
||||||
DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.swift; sourceTree = "<group>"; };
|
DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.swift; sourceTree = "<group>"; };
|
||||||
|
DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistMemo.swift"; sourceTree = "<group>"; };
|
||||||
|
DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistCache.swift"; sourceTree = "<group>"; };
|
||||||
|
DB71FD4525F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDataSourcePrefetching.swift"; sourceTree = "<group>"; };
|
||||||
|
DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPrefetchingService.swift; sourceTree = "<group>"; };
|
||||||
|
DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Status.swift"; sourceTree = "<group>"; };
|
||||||
DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = "<group>"; };
|
DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = "<group>"; };
|
||||||
DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = "<group>"; };
|
DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = "<group>"; };
|
||||||
DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = "<group>"; };
|
DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = "<group>"; };
|
||||||
|
@ -693,6 +703,7 @@
|
||||||
2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */,
|
2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */,
|
||||||
2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */,
|
2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */,
|
||||||
DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */,
|
DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */,
|
||||||
|
DB71FD4525F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift */,
|
||||||
);
|
);
|
||||||
path = StatusProvider;
|
path = StatusProvider;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -748,6 +759,8 @@
|
||||||
2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */,
|
2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */,
|
||||||
2DA6054625F716A2006356F9 /* PlaybackState.swift */,
|
2DA6054625F716A2006356F9 /* PlaybackState.swift */,
|
||||||
5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */,
|
5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */,
|
||||||
|
DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */,
|
||||||
|
DB49A61925FF327D00B98345 /* EmojiService */,
|
||||||
);
|
);
|
||||||
path = Service;
|
path = Service;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -755,7 +768,9 @@
|
||||||
2D61335625C1887F00CAE157 /* Persist */ = {
|
2D61335625C1887F00CAE157 /* Persist */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */,
|
2D61335725C188A000CAE157 /* APIService+Persist+Status.swift */,
|
||||||
|
DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */,
|
||||||
|
DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */,
|
||||||
);
|
);
|
||||||
path = Persist;
|
path = Persist;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1038,6 +1053,7 @@
|
||||||
DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */,
|
DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */,
|
||||||
DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */,
|
DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */,
|
||||||
DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */,
|
DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */,
|
||||||
|
DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */,
|
||||||
DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */,
|
DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */,
|
||||||
DB9A488326034BD7008B817C /* APIService+Status.swift */,
|
DB9A488326034BD7008B817C /* APIService+Status.swift */,
|
||||||
DB9A488F26035963008B817C /* APIService+Media.swift */,
|
DB9A488F26035963008B817C /* APIService+Media.swift */,
|
||||||
|
@ -1048,7 +1064,7 @@
|
||||||
DB45FB0925CA87BC005A8AC7 /* CoreData */ = {
|
DB45FB0925CA87BC005A8AC7 /* CoreData */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */,
|
2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Status.swift */,
|
||||||
DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */,
|
DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */,
|
||||||
DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */,
|
DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */,
|
||||||
);
|
);
|
||||||
|
@ -1760,6 +1776,7 @@
|
||||||
DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */,
|
DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */,
|
||||||
2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */,
|
2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */,
|
||||||
0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */,
|
0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */,
|
||||||
|
DB71FD5225F8CCAA00512AE1 /* APIService+Status.swift in Sources */,
|
||||||
2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */,
|
2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */,
|
||||||
DB1E347825F519300079D7DF /* PickServerItem.swift in Sources */,
|
DB1E347825F519300079D7DF /* PickServerItem.swift in Sources */,
|
||||||
DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */,
|
DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */,
|
||||||
|
@ -1778,6 +1795,7 @@
|
||||||
2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarState.swift in Sources */,
|
2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarState.swift in Sources */,
|
||||||
DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */,
|
DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */,
|
||||||
DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */,
|
DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */,
|
||||||
|
DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */,
|
||||||
2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */,
|
2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */,
|
||||||
2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */,
|
2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */,
|
||||||
DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */,
|
DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */,
|
||||||
|
@ -1786,11 +1804,12 @@
|
||||||
0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */,
|
0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */,
|
||||||
DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */,
|
DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */,
|
||||||
DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */,
|
DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */,
|
||||||
2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */,
|
2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */,
|
||||||
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
|
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
|
||||||
DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */,
|
DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */,
|
||||||
2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */,
|
2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */,
|
||||||
DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */,
|
DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */,
|
||||||
|
DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */,
|
||||||
DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */,
|
DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */,
|
||||||
2D939AB525EDD8A90076FA61 /* String.swift in Sources */,
|
2D939AB525EDD8A90076FA61 /* String.swift in Sources */,
|
||||||
DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */,
|
DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */,
|
||||||
|
@ -1822,6 +1841,7 @@
|
||||||
2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */,
|
2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */,
|
||||||
DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */,
|
DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */,
|
||||||
2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */,
|
2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */,
|
||||||
|
DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */,
|
||||||
2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */,
|
2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */,
|
||||||
0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */,
|
0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */,
|
||||||
2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */,
|
2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */,
|
||||||
|
@ -1879,6 +1899,7 @@
|
||||||
DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */,
|
DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */,
|
||||||
DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */,
|
DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */,
|
||||||
DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */,
|
DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */,
|
||||||
|
DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */,
|
||||||
DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */,
|
DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */,
|
||||||
DB98338725C945ED00AD9700 /* Strings.swift in Sources */,
|
DB98338725C945ED00AD9700 /* Strings.swift in Sources */,
|
||||||
DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */,
|
DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */,
|
||||||
|
|
|
@ -51,8 +51,8 @@
|
||||||
"repositoryURL": "https://github.com/onevcat/Kingfisher.git",
|
"repositoryURL": "https://github.com/onevcat/Kingfisher.git",
|
||||||
"state": {
|
"state": {
|
||||||
"branch": null,
|
"branch": null,
|
||||||
"revision": "81dd1ce8401137637663046c7314e7c885bcc56d",
|
"revision": "daebf8ddf974164d1b9a050c8231e263f3106b09",
|
||||||
"version": "6.1.1"
|
"version": "6.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -95,12 +95,17 @@ extension StatusSection {
|
||||||
statusItemAttribute: Item.StatusAttribute
|
statusItemAttribute: Item.StatusAttribute
|
||||||
) {
|
) {
|
||||||
// set header
|
// set header
|
||||||
cell.statusView.headerContainerStackView.isHidden = toot.reblog == nil
|
StatusSection.configureHeader(cell: cell, toot: toot)
|
||||||
cell.statusView.headerInfoLabel.text = {
|
ManagedObjectObserver.observe(object: toot)
|
||||||
let author = toot.author
|
.receive(on: DispatchQueue.main)
|
||||||
let name = author.displayName.isEmpty ? author.username : author.displayName
|
.sink { _ in
|
||||||
return L10n.Common.Controls.Status.userReblogged(name)
|
// do nothing
|
||||||
}()
|
} receiveValue: { change in
|
||||||
|
guard case .update(let object) = change.changeType,
|
||||||
|
let newToot = object as? Toot else { return }
|
||||||
|
StatusSection.configureHeader(cell: cell, toot: newToot)
|
||||||
|
}
|
||||||
|
.store(in: &cell.disposeBag)
|
||||||
|
|
||||||
// set name username
|
// set name username
|
||||||
cell.statusView.nameLabel.text = {
|
cell.statusView.nameLabel.text = {
|
||||||
|
@ -300,6 +305,31 @@ extension StatusSection {
|
||||||
.store(in: &cell.disposeBag)
|
.store(in: &cell.disposeBag)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func configureHeader(
|
||||||
|
cell: StatusTableViewCell,
|
||||||
|
toot: Toot
|
||||||
|
) {
|
||||||
|
if toot.reblog != nil {
|
||||||
|
cell.statusView.headerContainerStackView.isHidden = false
|
||||||
|
cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.boostIconImage)
|
||||||
|
cell.statusView.headerInfoLabel.text = {
|
||||||
|
let author = toot.author
|
||||||
|
let name = author.displayName.isEmpty ? author.username : author.displayName
|
||||||
|
return L10n.Common.Controls.Status.userReblogged(name)
|
||||||
|
}()
|
||||||
|
} else if let replyTo = toot.replyTo {
|
||||||
|
cell.statusView.headerContainerStackView.isHidden = false
|
||||||
|
cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage)
|
||||||
|
cell.statusView.headerInfoLabel.text = {
|
||||||
|
let author = replyTo.author
|
||||||
|
let name = author.displayName.isEmpty ? author.username : author.displayName
|
||||||
|
return L10n.Common.Controls.Status.userRepliedTo(name)
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
cell.statusView.headerContainerStackView.isHidden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static func configureActionToolBar(
|
static func configureActionToolBar(
|
||||||
cell: StatusTableViewCell,
|
cell: StatusTableViewCell,
|
||||||
toot: Toot,
|
toot: Toot,
|
||||||
|
|
|
@ -88,6 +88,10 @@ internal enum L10n {
|
||||||
internal static func userReblogged(_ p1: Any) -> String {
|
internal static func userReblogged(_ p1: Any) -> String {
|
||||||
return L10n.tr("Localizable", "Common.Controls.Status.UserReblogged", String(describing: p1))
|
return L10n.tr("Localizable", "Common.Controls.Status.UserReblogged", String(describing: p1))
|
||||||
}
|
}
|
||||||
|
/// Replied to %@
|
||||||
|
internal static func userRepliedTo(_ p1: Any) -> String {
|
||||||
|
return L10n.tr("Localizable", "Common.Controls.Status.UserRepliedTo", String(describing: p1))
|
||||||
|
}
|
||||||
internal enum Poll {
|
internal enum Poll {
|
||||||
/// Closed
|
/// Closed
|
||||||
internal static let closed = L10n.tr("Localizable", "Common.Controls.Status.Poll.Closed")
|
internal static let closed = L10n.tr("Localizable", "Common.Controls.Status.Poll.Closed")
|
||||||
|
|
|
@ -132,7 +132,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
|
||||||
|
|
||||||
guard let diffableDataSource = cell.statusView.pollTableViewDataSource else { return }
|
guard let diffableDataSource = cell.statusView.pollTableViewDataSource else { return }
|
||||||
let item = diffableDataSource.itemIdentifier(for: indexPath)
|
let item = diffableDataSource.itemIdentifier(for: indexPath)
|
||||||
guard case let .opion(objectID, attribute) = item else { return }
|
guard case let .opion(objectID, _) = item else { return }
|
||||||
guard let option = managedObjectContext.object(with: objectID) as? PollOption else { return }
|
guard let option = managedObjectContext.object(with: objectID) as? PollOption else { return }
|
||||||
|
|
||||||
let poll = option.poll
|
let poll = option.poll
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
//
|
||||||
|
// StatusProvider+UITableViewDataSourcePrefetching.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by MainasuK Cirno on 2021-3-10.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
|
|
||||||
|
extension StatusTableViewCellDelegate where Self: StatusProvider {
|
||||||
|
func handleTableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
|
||||||
|
// prefetch reply toot
|
||||||
|
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
|
||||||
|
let domain = activeMastodonAuthenticationBox.domain
|
||||||
|
|
||||||
|
var statusObjectIDs: [NSManagedObjectID] = []
|
||||||
|
for item in items(indexPaths: indexPaths) {
|
||||||
|
switch item {
|
||||||
|
case .homeTimelineIndex(let objectID, _):
|
||||||
|
let homeTimelineIndex = managedObjectContext.object(with: objectID) as! HomeTimelineIndex
|
||||||
|
statusObjectIDs.append(homeTimelineIndex.toot.objectID)
|
||||||
|
case .toot(let objectID, _):
|
||||||
|
statusObjectIDs.append(objectID)
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let backgroundManagedObjectContext = context.backgroundManagedObjectContext
|
||||||
|
backgroundManagedObjectContext.perform { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
for objectID in statusObjectIDs {
|
||||||
|
let toot = backgroundManagedObjectContext.object(with: objectID) as! Toot
|
||||||
|
guard let replyToID = toot.inReplyToID, toot.replyTo == nil else {
|
||||||
|
// skip
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
self.context.statusPrefetchingService.prefetchReplyTo(
|
||||||
|
domain: domain,
|
||||||
|
statusObjectID: toot.objectID,
|
||||||
|
statusID: toot.id,
|
||||||
|
replyToStatusID: replyToID,
|
||||||
|
authorizationBox: activeMastodonAuthenticationBox
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -20,4 +20,5 @@ protocol StatusProvider: NeedsDependency & DisposeBagCollectable & UIViewControl
|
||||||
var managedObjectContext: NSManagedObjectContext { get }
|
var managedObjectContext: NSManagedObjectContext { get }
|
||||||
var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? { get }
|
var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? { get }
|
||||||
func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item?
|
func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item?
|
||||||
|
func items(indexPaths: [IndexPath]) -> [Item]
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,6 +34,7 @@
|
||||||
"Common.Controls.Status.ShowPost" = "Show Post";
|
"Common.Controls.Status.ShowPost" = "Show Post";
|
||||||
"Common.Controls.Status.StatusContentWarning" = "content warning";
|
"Common.Controls.Status.StatusContentWarning" = "content warning";
|
||||||
"Common.Controls.Status.UserReblogged" = "%@ reblogged";
|
"Common.Controls.Status.UserReblogged" = "%@ reblogged";
|
||||||
|
"Common.Controls.Status.UserRepliedTo" = "Replied to %@";
|
||||||
"Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts";
|
"Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts";
|
||||||
"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts...";
|
"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts...";
|
||||||
"Common.Countable.Photo.Multiple" = "photos";
|
"Common.Countable.Photo.Multiple" = "photos";
|
||||||
|
|
|
@ -45,34 +45,30 @@ extension HomeTimelineViewController {
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.moveToTopGapAction(action)
|
self.moveToTopGapAction(action)
|
||||||
}),
|
}),
|
||||||
UIAction(title: "First Reblog Toot", image: nil, attributes: [], handler: { [weak self] action in
|
UIAction(title: "First Replied Status", image: nil, attributes: [], handler: { [weak self] action in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.moveToFirstReblogToot(action)
|
self.moveToFirstRepliedStatus(action)
|
||||||
}),
|
}),
|
||||||
UIAction(title: "First Poll Toot", image: nil, attributes: [], handler: { [weak self] action in
|
UIAction(title: "First Reblog Status", image: nil, attributes: [], handler: { [weak self] action in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.moveToFirstPollToot(action)
|
self.moveToFirstReblogStatus(action)
|
||||||
}),
|
}),
|
||||||
UIAction(title: "First Audio Toot", image: nil, attributes: [], handler: { [weak self] action in
|
UIAction(title: "First Poll Status", image: nil, attributes: [], handler: { [weak self] action in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.moveToFirstAudioToot(action)
|
self.moveToFirstPollStatus(action)
|
||||||
|
}),
|
||||||
|
UIAction(title: "First Audio Status", image: nil, attributes: [], handler: { [weak self] action in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.moveToFirstAudioStatus(action)
|
||||||
|
}),
|
||||||
|
UIAction(title: "First Video Status", image: nil, attributes: [], handler: { [weak self] action in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.moveToFirstVideoStatus(action)
|
||||||
|
}),
|
||||||
|
UIAction(title: "First GIF Toot", image: nil, attributes: [], handler: { [weak self] action in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.moveToFirstGIFStatus(action)
|
||||||
}),
|
}),
|
||||||
// UIAction(title: "First Reply Toot", image: nil, attributes: [], handler: { [weak self] action in
|
|
||||||
// guard let self = self else { return }
|
|
||||||
// self.moveToFirstReplyToot(action)
|
|
||||||
// }),
|
|
||||||
// UIAction(title: "First Reply Reblog", image: nil, attributes: [], handler: { [weak self] action in
|
|
||||||
// guard let self = self else { return }
|
|
||||||
// self.moveToFirstReplyReblog(action)
|
|
||||||
// }),
|
|
||||||
// UIAction(title: "First Video Toot", image: nil, attributes: [], handler: { [weak self] action in
|
|
||||||
// guard let self = self else { return }
|
|
||||||
// self.moveToFirstVideoToot(action)
|
|
||||||
// }),
|
|
||||||
// UIAction(title: "First GIF Toot", image: nil, attributes: [], handler: { [weak self] action in
|
|
||||||
// guard let self = self else { return }
|
|
||||||
// self.moveToFirstGIFToot(action)
|
|
||||||
// }),
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -109,7 +105,7 @@ extension HomeTimelineViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func moveToFirstReblogToot(_ sender: UIAction) {
|
@objc private func moveToFirstReblogStatus(_ sender: UIAction) {
|
||||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||||
let snapshotTransitioning = diffableDataSource.snapshot()
|
let snapshotTransitioning = diffableDataSource.snapshot()
|
||||||
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
|
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
|
||||||
|
@ -125,11 +121,11 @@ extension HomeTimelineViewController {
|
||||||
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
|
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
|
||||||
tableView.blinkRow(at: IndexPath(row: index, section: 0))
|
tableView.blinkRow(at: IndexPath(row: index, section: 0))
|
||||||
} else {
|
} else {
|
||||||
print("Not found reblog toot")
|
print("Not found reblog status")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func moveToFirstPollToot(_ sender: UIAction) {
|
@objc private func moveToFirstPollStatus(_ sender: UIAction) {
|
||||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||||
let snapshotTransitioning = diffableDataSource.snapshot()
|
let snapshotTransitioning = diffableDataSource.snapshot()
|
||||||
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
|
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
|
||||||
|
@ -150,7 +146,30 @@ extension HomeTimelineViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func moveToFirstAudioToot(_ sender: UIAction) {
|
@objc private func moveToFirstRepliedStatus(_ sender: UIAction) {
|
||||||
|
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||||
|
let snapshotTransitioning = diffableDataSource.snapshot()
|
||||||
|
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
|
||||||
|
switch item {
|
||||||
|
case .homeTimelineIndex(let objectID, _):
|
||||||
|
let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex
|
||||||
|
guard homeTimelineIndex.toot.inReplyToID != nil else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
|
||||||
|
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
|
||||||
|
tableView.blinkRow(at: IndexPath(row: index, section: 0))
|
||||||
|
} else {
|
||||||
|
print("Not found replied status")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func moveToFirstAudioStatus(_ sender: UIAction) {
|
||||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||||
let snapshotTransitioning = diffableDataSource.snapshot()
|
let snapshotTransitioning = diffableDataSource.snapshot()
|
||||||
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
|
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
|
||||||
|
@ -171,6 +190,48 @@ extension HomeTimelineViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func moveToFirstVideoStatus(_ sender: UIAction) {
|
||||||
|
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||||
|
let snapshotTransitioning = diffableDataSource.snapshot()
|
||||||
|
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
|
||||||
|
switch item {
|
||||||
|
case .homeTimelineIndex(let objectID, _):
|
||||||
|
let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex
|
||||||
|
let toot = homeTimelineIndex.toot.reblog ?? homeTimelineIndex.toot
|
||||||
|
return toot.mediaAttachments?.contains(where: { $0.type == .video }) ?? false
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
|
||||||
|
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
|
||||||
|
tableView.blinkRow(at: IndexPath(row: index, section: 0))
|
||||||
|
} else {
|
||||||
|
print("Not found video status")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func moveToFirstGIFStatus(_ sender: UIAction) {
|
||||||
|
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||||
|
let snapshotTransitioning = diffableDataSource.snapshot()
|
||||||
|
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
|
||||||
|
switch item {
|
||||||
|
case .homeTimelineIndex(let objectID, _):
|
||||||
|
let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex
|
||||||
|
let toot = homeTimelineIndex.toot.reblog ?? homeTimelineIndex.toot
|
||||||
|
return toot.mediaAttachments?.contains(where: { $0.type == .gifv }) ?? false
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
|
||||||
|
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
|
||||||
|
tableView.blinkRow(at: IndexPath(row: index, section: 0))
|
||||||
|
} else {
|
||||||
|
print("Not found GIF status")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func dropRecentStatusAction(_ sender: UIAction, count: Int) {
|
@objc private func dropRecentStatusAction(_ sender: UIAction, count: Int) {
|
||||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||||
let snapshotTransitioning = diffableDataSource.snapshot()
|
let snapshotTransitioning = diffableDataSource.snapshot()
|
||||||
|
|
|
@ -70,4 +70,18 @@ extension HomeTimelineViewController: StatusProvider {
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func items(indexPaths: [IndexPath]) -> [Item] {
|
||||||
|
guard let diffableDataSource = self.viewModel.diffableDataSource else {
|
||||||
|
assertionFailure()
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
var items: [Item] = []
|
||||||
|
for indexPath in indexPaths {
|
||||||
|
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue }
|
||||||
|
items.append(item)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -100,6 +100,7 @@ extension HomeTimelineViewController {
|
||||||
viewModel.viewController = self
|
viewModel.viewController = self
|
||||||
viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self
|
viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self
|
||||||
tableView.delegate = self
|
tableView.delegate = self
|
||||||
|
tableView.prefetchDataSource = self
|
||||||
viewModel.setupDiffableDataSource(
|
viewModel.setupDiffableDataSource(
|
||||||
for: tableView,
|
for: tableView,
|
||||||
dependency: self,
|
dependency: self,
|
||||||
|
@ -242,6 +243,13 @@ extension HomeTimelineViewController: UITableViewDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - UITableViewDataSourcePrefetching
|
||||||
|
extension HomeTimelineViewController: UITableViewDataSourcePrefetching {
|
||||||
|
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
|
||||||
|
handleTableView(tableView, prefetchRowsAt: indexPaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate
|
// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate
|
||||||
extension HomeTimelineViewController: ContentOffsetAdjustableTimelineViewControllerDelegate {
|
extension HomeTimelineViewController: ContentOffsetAdjustableTimelineViewControllerDelegate {
|
||||||
func navigationBar() -> UINavigationBar? {
|
func navigationBar() -> UINavigationBar? {
|
||||||
|
|
|
@ -375,7 +375,7 @@ extension MastodonPickServerViewController: UITableViewDelegate {
|
||||||
func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
|
func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
|
||||||
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
|
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
|
||||||
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return nil }
|
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return nil }
|
||||||
guard case let .server(server) = item else { return nil }
|
guard case .server = item else { return nil }
|
||||||
|
|
||||||
if tableView.indexPathForSelectedRow == indexPath {
|
if tableView.indexPathForSelectedRow == indexPath {
|
||||||
tableView.deselectRow(at: indexPath, animated: false)
|
tableView.deselectRow(at: indexPath, animated: false)
|
||||||
|
|
|
@ -45,7 +45,7 @@ extension MastodonPickServerViewModel.LoadIndexedServerState {
|
||||||
viewModel.context.apiService.servers(language: nil, category: nil)
|
viewModel.context.apiService.servers(language: nil, category: nil)
|
||||||
.sink { completion in
|
.sink { completion in
|
||||||
switch completion {
|
switch completion {
|
||||||
case .failure(let error):
|
case .failure:
|
||||||
// TODO: handle error
|
// TODO: handle error
|
||||||
stateMachine.enter(Fail.self)
|
stateMachine.enter(Fail.self)
|
||||||
case .finished:
|
case .finished:
|
||||||
|
@ -84,7 +84,7 @@ extension MastodonPickServerViewModel.LoadIndexedServerState {
|
||||||
override func didEnter(from previousState: GKState?) {
|
override func didEnter(from previousState: GKState?) {
|
||||||
super.didEnter(from: previousState)
|
super.didEnter(from: previousState)
|
||||||
|
|
||||||
guard let viewModel = self.viewModel, let stateMachine = self.stateMachine else { return }
|
guard let viewModel = self.viewModel else { return }
|
||||||
viewModel.isLoadingIndexedServers.value = false
|
viewModel.isLoadingIndexedServers.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -176,7 +176,7 @@ class MastodonPickServerViewModel: NSObject {
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let response):
|
case .success(let response):
|
||||||
self.unindexedServers.send(response.value)
|
self.unindexedServers.send(response.value)
|
||||||
case .failure(let error):
|
case .failure:
|
||||||
// TODO: What should be presented when user inputs invalid search text?
|
// TODO: What should be presented when user inputs invalid search text?
|
||||||
self.unindexedServers.send([])
|
self.unindexedServers.send([])
|
||||||
}
|
}
|
||||||
|
|
|
@ -70,4 +70,18 @@ extension PublicTimelineViewController: StatusProvider {
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func items(indexPaths: [IndexPath]) -> [Item] {
|
||||||
|
guard let diffableDataSource = self.viewModel.diffableDataSource else {
|
||||||
|
assertionFailure()
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
var items: [Item] = []
|
||||||
|
for indexPath in indexPaths {
|
||||||
|
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue }
|
||||||
|
items.append(item)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -73,6 +73,7 @@ extension PublicTimelineViewController {
|
||||||
viewModel.tableView = tableView
|
viewModel.tableView = tableView
|
||||||
viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self
|
viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self
|
||||||
tableView.delegate = self
|
tableView.delegate = self
|
||||||
|
tableView.prefetchDataSource = self
|
||||||
viewModel.setupDiffableDataSource(
|
viewModel.setupDiffableDataSource(
|
||||||
for: tableView,
|
for: tableView,
|
||||||
dependency: self,
|
dependency: self,
|
||||||
|
@ -133,6 +134,13 @@ extension PublicTimelineViewController: UITableViewDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - UITableViewDataSourcePrefetching
|
||||||
|
extension PublicTimelineViewController: UITableViewDataSourcePrefetching {
|
||||||
|
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
|
||||||
|
handleTableView(tableView, prefetchRowsAt: indexPaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate
|
// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate
|
||||||
extension PublicTimelineViewController: ContentOffsetAdjustableTimelineViewControllerDelegate {
|
extension PublicTimelineViewController: ContentOffsetAdjustableTimelineViewControllerDelegate {
|
||||||
func navigationBar() -> UINavigationBar? {
|
func navigationBar() -> UINavigationBar? {
|
||||||
|
|
|
@ -25,6 +25,29 @@ final class StatusView: UIView {
|
||||||
static let avatarImageCornerRadius: CGFloat = 4
|
static let avatarImageCornerRadius: CGFloat = 4
|
||||||
static let contentWarningBlurRadius: CGFloat = 12
|
static let contentWarningBlurRadius: CGFloat = 12
|
||||||
|
|
||||||
|
static let boostIconImage: UIImage = {
|
||||||
|
let font = UIFont.systemFont(ofSize: 13, weight: .medium)
|
||||||
|
let configuration = UIImage.SymbolConfiguration(font: font)
|
||||||
|
let image = UIImage(systemName: "arrow.2.squarepath", withConfiguration: configuration)!.withTintColor(Asset.Colors.Label.secondary.color)
|
||||||
|
return image
|
||||||
|
}()
|
||||||
|
|
||||||
|
static let replyIconImage: UIImage = {
|
||||||
|
let font = UIFont.systemFont(ofSize: 13, weight: .medium)
|
||||||
|
let configuration = UIImage.SymbolConfiguration(font: font)
|
||||||
|
let image = UIImage(systemName: "arrowshape.turn.up.left.fill", withConfiguration: configuration)!.withTintColor(Asset.Colors.Label.secondary.color)
|
||||||
|
return image
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func iconAttributedString(image: UIImage) -> NSAttributedString {
|
||||||
|
let attributedString = NSMutableAttributedString()
|
||||||
|
let imageTextAttachment = NSTextAttachment()
|
||||||
|
let imageAttribute = NSAttributedString(attachment: imageTextAttachment)
|
||||||
|
imageTextAttachment.image = image
|
||||||
|
attributedString.append(imageAttribute)
|
||||||
|
return attributedString
|
||||||
|
}
|
||||||
|
|
||||||
weak var delegate: StatusViewDelegate?
|
weak var delegate: StatusViewDelegate?
|
||||||
var isStatusTextSensitive = false
|
var isStatusTextSensitive = false
|
||||||
var pollTableViewDataSource: UITableViewDiffableDataSource<PollSection, PollItem>?
|
var pollTableViewDataSource: UITableViewDiffableDataSource<PollSection, PollItem>?
|
||||||
|
@ -34,14 +57,7 @@ final class StatusView: UIView {
|
||||||
|
|
||||||
let headerIconLabel: UILabel = {
|
let headerIconLabel: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
let attributedString = NSMutableAttributedString()
|
label.attributedText = StatusView.iconAttributedString(image: StatusView.boostIconImage)
|
||||||
let imageTextAttachment = NSTextAttachment()
|
|
||||||
let font = UIFont.systemFont(ofSize: 13, weight: .medium)
|
|
||||||
let configuration = UIImage.SymbolConfiguration(font: font)
|
|
||||||
imageTextAttachment.image = UIImage(systemName: "arrow.2.squarepath", withConfiguration: configuration)?.withTintColor(Asset.Colors.Label.secondary.color)
|
|
||||||
let imageAttribute = NSAttributedString(attachment: imageTextAttachment)
|
|
||||||
attributedString.append(imageAttribute)
|
|
||||||
label.attributedText = attributedString
|
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
|
@ -31,6 +31,7 @@ extension APIService {
|
||||||
for: nil,
|
for: nil,
|
||||||
in: domain,
|
in: domain,
|
||||||
entity: account,
|
entity: account,
|
||||||
|
userCache: nil,
|
||||||
networkDate: response.networkDate,
|
networkDate: response.networkDate,
|
||||||
log: log)
|
log: log)
|
||||||
let flag = isCreated ? "+" : "-"
|
let flag = isCreated ? "+" : "-"
|
||||||
|
@ -64,6 +65,7 @@ extension APIService {
|
||||||
for: nil,
|
for: nil,
|
||||||
in: domain,
|
in: domain,
|
||||||
entity: account,
|
entity: account,
|
||||||
|
userCache: nil,
|
||||||
networkDate: response.networkDate,
|
networkDate: response.networkDate,
|
||||||
log: log)
|
log: log)
|
||||||
let flag = isCreated ? "+" : "-"
|
let flag = isCreated ? "+" : "-"
|
||||||
|
|
|
@ -141,7 +141,7 @@ extension APIService {
|
||||||
.map { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> in
|
.map { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> in
|
||||||
let log = OSLog.api
|
let log = OSLog.api
|
||||||
|
|
||||||
return APIService.Persist.persistTimeline(
|
return APIService.Persist.persistStatus(
|
||||||
managedObjectContext: self.backgroundManagedObjectContext,
|
managedObjectContext: self.backgroundManagedObjectContext,
|
||||||
domain: mastodonAuthenticationBox.domain,
|
domain: mastodonAuthenticationBox.domain,
|
||||||
query: query,
|
query: query,
|
||||||
|
|
|
@ -40,7 +40,7 @@ extension APIService {
|
||||||
authorization: authorization
|
authorization: authorization
|
||||||
)
|
)
|
||||||
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> in
|
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> in
|
||||||
return APIService.Persist.persistTimeline(
|
return APIService.Persist.persistStatus(
|
||||||
managedObjectContext: self.backgroundManagedObjectContext,
|
managedObjectContext: self.backgroundManagedObjectContext,
|
||||||
domain: domain,
|
domain: domain,
|
||||||
query: query,
|
query: query,
|
||||||
|
|
|
@ -39,7 +39,7 @@ extension APIService {
|
||||||
query: query
|
query: query
|
||||||
)
|
)
|
||||||
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> in
|
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> in
|
||||||
return APIService.Persist.persistTimeline(
|
return APIService.Persist.persistStatus(
|
||||||
managedObjectContext: self.backgroundManagedObjectContext,
|
managedObjectContext: self.backgroundManagedObjectContext,
|
||||||
domain: domain,
|
domain: domain,
|
||||||
query: query,
|
query: query,
|
||||||
|
|
|
@ -2,11 +2,15 @@
|
||||||
// APIService+Status.swift
|
// APIService+Status.swift
|
||||||
// Mastodon
|
// Mastodon
|
||||||
//
|
//
|
||||||
// Created by MainasuK Cirno on 2021-3-18.
|
// Created by MainasuK Cirno on 2021-3-10.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
|
import CommonOSLog
|
||||||
|
import DateToolsSwift
|
||||||
import MastodonSDK
|
import MastodonSDK
|
||||||
|
|
||||||
extension APIService {
|
extension APIService {
|
||||||
|
@ -24,6 +28,64 @@ extension APIService {
|
||||||
query: query,
|
query: query,
|
||||||
authorization: authorization
|
authorization: authorization
|
||||||
)
|
)
|
||||||
|
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> in
|
||||||
|
return APIService.Persist.persistStatus(
|
||||||
|
managedObjectContext: self.backgroundManagedObjectContext,
|
||||||
|
domain: domain,
|
||||||
|
query: nil,
|
||||||
|
response: response.map { [$0] },
|
||||||
|
persistType: .lookUp,
|
||||||
|
requestMastodonUserID: nil,
|
||||||
|
log: OSLog.api
|
||||||
|
)
|
||||||
|
.setFailureType(to: Error.self)
|
||||||
|
.tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Status> in
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
return response
|
||||||
|
case .failure(let error):
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
func status(
|
||||||
|
domain: String,
|
||||||
|
statusID: Mastodon.Entity.Status.ID,
|
||||||
|
authorizationBox: AuthenticationService.MastodonAuthenticationBox
|
||||||
|
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> {
|
||||||
|
let authorization = authorizationBox.userAuthorization
|
||||||
|
return Mastodon.API.Statuses.status(
|
||||||
|
session: session,
|
||||||
|
domain: domain,
|
||||||
|
statusID: statusID,
|
||||||
|
authorization: authorization
|
||||||
|
)
|
||||||
|
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> in
|
||||||
|
return APIService.Persist.persistStatus(
|
||||||
|
managedObjectContext: self.backgroundManagedObjectContext,
|
||||||
|
domain: domain,
|
||||||
|
query: nil,
|
||||||
|
response: response.map { [$0] },
|
||||||
|
persistType: .lookUp,
|
||||||
|
requestMastodonUserID: nil,
|
||||||
|
log: OSLog.api
|
||||||
|
)
|
||||||
|
.setFailureType(to: Error.self)
|
||||||
|
.tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Status> in
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
return response
|
||||||
|
case .failure(let error):
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,7 @@ extension APIService.CoreData {
|
||||||
for requestMastodonUser: MastodonUser?,
|
for requestMastodonUser: MastodonUser?,
|
||||||
in domain: String,
|
in domain: String,
|
||||||
entity: Mastodon.Entity.Account,
|
entity: Mastodon.Entity.Account,
|
||||||
|
userCache: APIService.Persist.PersistCache<MastodonUser>?,
|
||||||
networkDate: Date,
|
networkDate: Date,
|
||||||
log: OSLog
|
log: OSLog
|
||||||
) -> (user: MastodonUser, isCreated: Bool) {
|
) -> (user: MastodonUser, isCreated: Bool) {
|
||||||
|
@ -29,6 +30,9 @@ extension APIService.CoreData {
|
||||||
|
|
||||||
// fetch old mastodon user
|
// fetch old mastodon user
|
||||||
let oldMastodonUser: MastodonUser? = {
|
let oldMastodonUser: MastodonUser? = {
|
||||||
|
if let userCache = userCache {
|
||||||
|
return userCache.dictionary[entity.id]
|
||||||
|
} else {
|
||||||
let request = MastodonUser.sortedFetchRequest
|
let request = MastodonUser.sortedFetchRequest
|
||||||
request.predicate = MastodonUser.predicate(domain: domain, id: entity.id)
|
request.predicate = MastodonUser.predicate(domain: domain, id: entity.id)
|
||||||
request.fetchLimit = 1
|
request.fetchLimit = 1
|
||||||
|
@ -39,6 +43,7 @@ extension APIService.CoreData {
|
||||||
assertionFailure(error.localizedDescription)
|
assertionFailure(error.localizedDescription)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if let oldMastodonUser = oldMastodonUser {
|
if let oldMastodonUser = oldMastodonUser {
|
||||||
|
@ -57,7 +62,7 @@ extension APIService.CoreData {
|
||||||
into: managedObjectContext,
|
into: managedObjectContext,
|
||||||
property: mastodonUserProperty
|
property: mastodonUserProperty
|
||||||
)
|
)
|
||||||
|
userCache?.dictionary[entity.id] = mastodonUser
|
||||||
os_signpost(.event, log: log, name: "update database - process entity: createOrMergeMastodonUser", signpostID: processEntityTaskSignpostID, "did insert new mastodon user %{public}s: name %s", mastodonUser.identifier, mastodonUser.username)
|
os_signpost(.event, log: log, name: "update database - process entity: createOrMergeMastodonUser", signpostID: processEntityTaskSignpostID, "did insert new mastodon user %{public}s: name %s", mastodonUser.identifier, mastodonUser.username)
|
||||||
return (mastodonUser, true)
|
return (mastodonUser, true)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// APIService+CoreData+Toot.swift
|
// APIService+CoreData+Status.swift
|
||||||
// Mastodon
|
// Mastodon
|
||||||
//
|
//
|
||||||
// Created by sxiaojian on 2021/2/3.
|
// Created by sxiaojian on 2021/2/3.
|
||||||
|
@ -13,23 +13,42 @@ import MastodonSDK
|
||||||
|
|
||||||
extension APIService.CoreData {
|
extension APIService.CoreData {
|
||||||
|
|
||||||
static func createOrMergeToot(
|
static func createOrMergeStatus(
|
||||||
into managedObjectContext: NSManagedObjectContext,
|
into managedObjectContext: NSManagedObjectContext,
|
||||||
for requestMastodonUser: MastodonUser?,
|
for requestMastodonUser: MastodonUser?,
|
||||||
entity: Mastodon.Entity.Status,
|
|
||||||
domain: String,
|
domain: String,
|
||||||
|
entity: Mastodon.Entity.Status,
|
||||||
|
tootCache: APIService.Persist.PersistCache<Toot>?,
|
||||||
|
userCache: APIService.Persist.PersistCache<MastodonUser>?,
|
||||||
networkDate: Date,
|
networkDate: Date,
|
||||||
log: OSLog
|
log: OSLog
|
||||||
) -> (Toot: Toot, isTootCreated: Bool, isMastodonUserCreated: Bool) {
|
) -> (Toot: Toot, isTootCreated: Bool, isMastodonUserCreated: Bool) {
|
||||||
|
let processEntityTaskSignpostID = OSSignpostID(log: log)
|
||||||
|
os_signpost(.begin, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "process toot %{public}s", entity.id)
|
||||||
|
defer {
|
||||||
|
os_signpost(.end, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "process toot %{public}s", entity.id)
|
||||||
|
}
|
||||||
|
|
||||||
// build tree
|
// build tree
|
||||||
let reblog = entity.reblog.flatMap { entity -> Toot in
|
let reblog = entity.reblog.flatMap { entity -> Toot in
|
||||||
let (toot, _, _) = createOrMergeToot(into: managedObjectContext, for: requestMastodonUser, entity: entity, domain: domain, networkDate: networkDate, log: log)
|
let (toot, _, _) = createOrMergeStatus(
|
||||||
|
into: managedObjectContext,
|
||||||
|
for: requestMastodonUser,
|
||||||
|
domain: domain,
|
||||||
|
entity: entity,
|
||||||
|
tootCache: tootCache,
|
||||||
|
userCache: userCache,
|
||||||
|
networkDate: networkDate,
|
||||||
|
log: log
|
||||||
|
)
|
||||||
return toot
|
return toot
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetch old Toot
|
// fetch old Toot
|
||||||
let oldToot: Toot? = {
|
let oldToot: Toot? = {
|
||||||
|
if let tootCache = tootCache {
|
||||||
|
return tootCache.dictionary[entity.id]
|
||||||
|
} else {
|
||||||
let request = Toot.sortedFetchRequest
|
let request = Toot.sortedFetchRequest
|
||||||
request.predicate = Toot.predicate(domain: domain, id: entity.id)
|
request.predicate = Toot.predicate(domain: domain, id: entity.id)
|
||||||
request.fetchLimit = 1
|
request.fetchLimit = 1
|
||||||
|
@ -40,6 +59,7 @@ extension APIService.CoreData {
|
||||||
assertionFailure(error.localizedDescription)
|
assertionFailure(error.localizedDescription)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if let oldToot = oldToot {
|
if let oldToot = oldToot {
|
||||||
|
@ -47,10 +67,16 @@ extension APIService.CoreData {
|
||||||
APIService.CoreData.merge(toot: oldToot, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: networkDate)
|
APIService.CoreData.merge(toot: oldToot, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: networkDate)
|
||||||
return (oldToot, false, false)
|
return (oldToot, false, false)
|
||||||
} else {
|
} else {
|
||||||
let (mastodonUser, isMastodonUserCreated) = createOrMergeMastodonUser(into: managedObjectContext, for: requestMastodonUser,in: domain, entity: entity.account, networkDate: networkDate, log: log)
|
let (mastodonUser, isMastodonUserCreated) = createOrMergeMastodonUser(into: managedObjectContext, for: requestMastodonUser,in: domain, entity: entity.account, userCache: userCache, networkDate: networkDate, log: log)
|
||||||
let application = entity.application.flatMap { app -> Application? in
|
let application = entity.application.flatMap { app -> Application? in
|
||||||
Application.insert(into: managedObjectContext, property: Application.Property(name: app.name, website: app.website, vapidKey: app.vapidKey))
|
Application.insert(into: managedObjectContext, property: Application.Property(name: app.name, website: app.website, vapidKey: app.vapidKey))
|
||||||
}
|
}
|
||||||
|
let replyTo: Toot? = {
|
||||||
|
// could be nil if target replyTo toot's persist task in the queue
|
||||||
|
guard let inReplyToID = entity.inReplyToID,
|
||||||
|
let replyTo = tootCache?.dictionary[inReplyToID] else { return nil }
|
||||||
|
return replyTo
|
||||||
|
}()
|
||||||
let poll = entity.poll.flatMap { poll -> Poll in
|
let poll = entity.poll.flatMap { poll -> Poll in
|
||||||
let options = poll.options.enumerated().map { i, option -> PollOption in
|
let options = poll.options.enumerated().map { i, option -> PollOption in
|
||||||
let votedBy: MastodonUser? = (poll.ownVotes ?? []).contains(i) ? requestMastodonUser : nil
|
let votedBy: MastodonUser? = (poll.ownVotes ?? []).contains(i) ? requestMastodonUser : nil
|
||||||
|
@ -92,6 +118,7 @@ extension APIService.CoreData {
|
||||||
author: mastodonUser,
|
author: mastodonUser,
|
||||||
reblog: reblog,
|
reblog: reblog,
|
||||||
application: application,
|
application: application,
|
||||||
|
replyTo: replyTo,
|
||||||
poll: poll,
|
poll: poll,
|
||||||
mentions: metions,
|
mentions: metions,
|
||||||
emojis: emojis,
|
emojis: emojis,
|
||||||
|
@ -103,6 +130,8 @@ extension APIService.CoreData {
|
||||||
bookmarkedBy: (entity.bookmarked ?? false) ? requestMastodonUser : nil,
|
bookmarkedBy: (entity.bookmarked ?? false) ? requestMastodonUser : nil,
|
||||||
pinnedBy: (entity.pinned ?? false) ? requestMastodonUser : nil
|
pinnedBy: (entity.pinned ?? false) ? requestMastodonUser : nil
|
||||||
)
|
)
|
||||||
|
tootCache?.dictionary[entity.id] = toot
|
||||||
|
os_signpost(.event, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "did insert new tweet %{public}s: %s", mastodonUser.identifier, entity.id)
|
||||||
return (toot, true, isMastodonUserCreated)
|
return (toot, true, isMastodonUserCreated)
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,66 @@
|
||||||
|
//
|
||||||
|
// APIService+Persist+PersistCache.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by MainasuK Cirno on 2021-3-10.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreDataStack
|
||||||
|
import MastodonSDK
|
||||||
|
|
||||||
|
extension APIService.Persist {
|
||||||
|
|
||||||
|
class PersistCache<T> {
|
||||||
|
var dictionary: [String : T] = [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension APIService.Persist.PersistCache where T == Toot {
|
||||||
|
|
||||||
|
static func ids(for toots: [Mastodon.Entity.Status]) -> Set<Mastodon.Entity.Status.ID> {
|
||||||
|
var value = Set<String>()
|
||||||
|
for toot in toots {
|
||||||
|
value = value.union(ids(for: toot))
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
static func ids(for toot: Mastodon.Entity.Status) -> Set<Mastodon.Entity.Status.ID> {
|
||||||
|
var value = Set<String>()
|
||||||
|
value.insert(toot.id)
|
||||||
|
if let inReplyToID = toot.inReplyToID {
|
||||||
|
value.insert(inReplyToID)
|
||||||
|
}
|
||||||
|
if let reblog = toot.reblog {
|
||||||
|
value = value.union(ids(for: reblog))
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension APIService.Persist.PersistCache where T == MastodonUser {
|
||||||
|
|
||||||
|
static func ids(for toots: [Mastodon.Entity.Status]) -> Set<Mastodon.Entity.Account.ID> {
|
||||||
|
var value = Set<String>()
|
||||||
|
for toot in toots {
|
||||||
|
value = value.union(ids(for: toot))
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
static func ids(for toot: Mastodon.Entity.Status) -> Set<Mastodon.Entity.Account.ID> {
|
||||||
|
var value = Set<String>()
|
||||||
|
value.insert(toot.account.id)
|
||||||
|
if let inReplyToAccountID = toot.inReplyToAccountID {
|
||||||
|
value.insert(inReplyToAccountID)
|
||||||
|
}
|
||||||
|
if let reblog = toot.reblog {
|
||||||
|
value = value.union(ids(for: reblog))
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,226 @@
|
||||||
|
//
|
||||||
|
// APIService+Persist+PersistMemo.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by MainasuK Cirno on 2021-3-10.
|
||||||
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
|
import MastodonSDK
|
||||||
|
|
||||||
|
extension APIService.Persist {
|
||||||
|
|
||||||
|
class PersistMemo<T, U> {
|
||||||
|
|
||||||
|
let status: T
|
||||||
|
let children: [PersistMemo<T, U>]
|
||||||
|
let memoType: MemoType
|
||||||
|
let statusProcessType: ProcessType
|
||||||
|
let authorProcessType: ProcessType
|
||||||
|
|
||||||
|
enum MemoType {
|
||||||
|
case homeTimeline
|
||||||
|
case mentionTimeline
|
||||||
|
case userTimeline
|
||||||
|
case publicTimeline
|
||||||
|
case likeList
|
||||||
|
case searchList
|
||||||
|
case lookUp
|
||||||
|
|
||||||
|
case reblog
|
||||||
|
|
||||||
|
var flag: String {
|
||||||
|
switch self {
|
||||||
|
case .homeTimeline: return "H"
|
||||||
|
case .mentionTimeline: return "M"
|
||||||
|
case .userTimeline: return "U"
|
||||||
|
case .publicTimeline: return "P"
|
||||||
|
case .likeList: return "L"
|
||||||
|
case .searchList: return "S"
|
||||||
|
case .lookUp: return "LU"
|
||||||
|
case .reblog: return "R"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ProcessType {
|
||||||
|
case create
|
||||||
|
case merge
|
||||||
|
|
||||||
|
var flag: String {
|
||||||
|
switch self {
|
||||||
|
case .create: return "+"
|
||||||
|
case .merge: return "~"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
status: T,
|
||||||
|
children: [PersistMemo<T, U>],
|
||||||
|
memoType: MemoType,
|
||||||
|
statusProcessType: ProcessType,
|
||||||
|
authorProcessType: ProcessType
|
||||||
|
) {
|
||||||
|
self.status = status
|
||||||
|
self.children = children
|
||||||
|
self.memoType = memoType
|
||||||
|
self.statusProcessType = statusProcessType
|
||||||
|
self.authorProcessType = authorProcessType
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension APIService.Persist.PersistMemo {
|
||||||
|
|
||||||
|
struct Counting {
|
||||||
|
var status = Counter()
|
||||||
|
var user = Counter()
|
||||||
|
|
||||||
|
static func + (left: Counting, right: Counting) -> Counting {
|
||||||
|
return Counting(
|
||||||
|
status: left.status + right.status,
|
||||||
|
user: left.user + right.user
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Counter {
|
||||||
|
var create = 0
|
||||||
|
var merge = 0
|
||||||
|
|
||||||
|
static func + (left: Counter, right: Counter) -> Counter {
|
||||||
|
return Counter(
|
||||||
|
create: left.create + right.create,
|
||||||
|
merge: left.merge + right.merge
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func count() -> Counting {
|
||||||
|
var counting = Counting()
|
||||||
|
|
||||||
|
switch statusProcessType {
|
||||||
|
case .create: counting.status.create += 1
|
||||||
|
case .merge: counting.status.merge += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
switch authorProcessType {
|
||||||
|
case .create: counting.user.create += 1
|
||||||
|
case .merge: counting.user.merge += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for child in children {
|
||||||
|
let childCounting = child.count()
|
||||||
|
counting = counting + childCounting
|
||||||
|
}
|
||||||
|
|
||||||
|
return counting
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension APIService.Persist.PersistMemo where T == Toot, U == MastodonUser {
|
||||||
|
|
||||||
|
static func createOrMergeToot(
|
||||||
|
into managedObjectContext: NSManagedObjectContext,
|
||||||
|
for requestMastodonUser: MastodonUser?,
|
||||||
|
requestMastodonUserID: MastodonUser.ID?,
|
||||||
|
domain: String,
|
||||||
|
entity: Mastodon.Entity.Status,
|
||||||
|
memoType: MemoType,
|
||||||
|
tootCache: APIService.Persist.PersistCache<T>?,
|
||||||
|
userCache: APIService.Persist.PersistCache<U>?,
|
||||||
|
networkDate: Date,
|
||||||
|
log: OSLog
|
||||||
|
) -> APIService.Persist.PersistMemo<T, U> {
|
||||||
|
let processEntityTaskSignpostID = OSSignpostID(log: log)
|
||||||
|
os_signpost(.begin, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "process toot %{public}s", entity.id)
|
||||||
|
defer {
|
||||||
|
os_signpost(.end, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "finish process toot %{public}s", entity.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// build tree
|
||||||
|
let reblogMemo = entity.reblog.flatMap { entity -> APIService.Persist.PersistMemo<T, U> in
|
||||||
|
createOrMergeToot(
|
||||||
|
into: managedObjectContext,
|
||||||
|
for: requestMastodonUser,
|
||||||
|
requestMastodonUserID: requestMastodonUserID,
|
||||||
|
domain: domain,
|
||||||
|
entity: entity,
|
||||||
|
memoType: .reblog,
|
||||||
|
tootCache: tootCache,
|
||||||
|
userCache: userCache,
|
||||||
|
networkDate: networkDate,
|
||||||
|
log: log
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let children = [reblogMemo].compactMap { $0 }
|
||||||
|
|
||||||
|
|
||||||
|
let (toot, isTootCreated, isMastodonUserCreated) = APIService.CoreData.createOrMergeStatus(
|
||||||
|
into: managedObjectContext,
|
||||||
|
for: requestMastodonUser,
|
||||||
|
domain: domain,
|
||||||
|
entity: entity,
|
||||||
|
tootCache: tootCache,
|
||||||
|
userCache: userCache,
|
||||||
|
networkDate: networkDate,
|
||||||
|
log: log
|
||||||
|
)
|
||||||
|
let memo = APIService.Persist.PersistMemo<T, U>(
|
||||||
|
status: toot,
|
||||||
|
children: children,
|
||||||
|
memoType: memoType,
|
||||||
|
statusProcessType: isTootCreated ? .create : .merge,
|
||||||
|
authorProcessType: isMastodonUserCreated ? .create : .merge
|
||||||
|
)
|
||||||
|
|
||||||
|
switch (memo.statusProcessType, memoType) {
|
||||||
|
case (.create, .homeTimeline), (.merge, .homeTimeline):
|
||||||
|
let timelineIndex = toot.homeTimelineIndexes?
|
||||||
|
.first { $0.userID == requestMastodonUserID }
|
||||||
|
guard let requestMastodonUserID = requestMastodonUserID else {
|
||||||
|
assertionFailure()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if timelineIndex == nil {
|
||||||
|
// make it indexed
|
||||||
|
let timelineIndexProperty = HomeTimelineIndex.Property(domain: domain, userID: requestMastodonUserID)
|
||||||
|
let _ = HomeTimelineIndex.insert(into: managedObjectContext, property: timelineIndexProperty, toot: toot)
|
||||||
|
} else {
|
||||||
|
// enity already in home timeline
|
||||||
|
}
|
||||||
|
case (.create, .mentionTimeline), (.merge, .mentionTimeline):
|
||||||
|
break
|
||||||
|
// TODO:
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return memo
|
||||||
|
}
|
||||||
|
|
||||||
|
func log(indentLevel: Int = 0) -> String {
|
||||||
|
let indent = Array(repeating: " ", count: indentLevel).joined()
|
||||||
|
let preview = status.content.prefix(32).replacingOccurrences(of: "\n", with: " ")
|
||||||
|
let message = "\(indent)[\(statusProcessType.flag)\(memoType.flag)](\(status.id)) [\(authorProcessType.flag)](\(status.author.id))@\(status.author.username) ~> \(preview)"
|
||||||
|
|
||||||
|
var childrenMessages: [String] = []
|
||||||
|
for child in children {
|
||||||
|
childrenMessages.append(child.log(indentLevel: indentLevel + 1))
|
||||||
|
}
|
||||||
|
let result = [[message] + childrenMessages]
|
||||||
|
.flatMap { $0 }
|
||||||
|
.joined(separator: "\n")
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,254 @@
|
||||||
|
//
|
||||||
|
// APIService+Persist+Status.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by sxiaojian on 2021/1/27.
|
||||||
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
|
import func QuartzCore.CACurrentMediaTime
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
|
import MastodonSDK
|
||||||
|
|
||||||
|
extension APIService.Persist {
|
||||||
|
|
||||||
|
enum PersistTimelineType {
|
||||||
|
case `public`
|
||||||
|
case home
|
||||||
|
case likeList
|
||||||
|
case lookUp
|
||||||
|
}
|
||||||
|
|
||||||
|
static func persistStatus(
|
||||||
|
managedObjectContext: NSManagedObjectContext,
|
||||||
|
domain: String,
|
||||||
|
query: Mastodon.API.Timeline.TimelineQuery?,
|
||||||
|
response: Mastodon.Response.Content<[Mastodon.Entity.Status]>,
|
||||||
|
persistType: PersistTimelineType,
|
||||||
|
requestMastodonUserID: MastodonUser.ID?, // could be nil when response from public endpoint
|
||||||
|
log: OSLog
|
||||||
|
) -> AnyPublisher<Result<Void, Error>, Never> {
|
||||||
|
return managedObjectContext.performChanges {
|
||||||
|
let toots = response.value
|
||||||
|
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: persist %{public}ld toots…", ((#file as NSString).lastPathComponent), #line, #function, toots.count)
|
||||||
|
|
||||||
|
let contextTaskSignpostID = OSSignpostID(log: log)
|
||||||
|
let start = CACurrentMediaTime()
|
||||||
|
os_signpost(.begin, log: log, name: #function, signpostID: contextTaskSignpostID)
|
||||||
|
defer {
|
||||||
|
os_signpost(.end, log: .api, name: #function, signpostID: contextTaskSignpostID)
|
||||||
|
let end = CACurrentMediaTime()
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: persist cost %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start)
|
||||||
|
}
|
||||||
|
|
||||||
|
// load request mastodon user
|
||||||
|
let requestMastodonUser: MastodonUser? = {
|
||||||
|
guard let requestMastodonUserID = requestMastodonUserID else { return nil }
|
||||||
|
let request = MastodonUser.sortedFetchRequest
|
||||||
|
request.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID)
|
||||||
|
request.fetchLimit = 1
|
||||||
|
request.returnsObjectsAsFaults = false
|
||||||
|
do {
|
||||||
|
return try managedObjectContext.fetch(request).first
|
||||||
|
} catch {
|
||||||
|
assertionFailure(error.localizedDescription)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// load working set into context to avoid cache miss
|
||||||
|
let cacheTaskSignpostID = OSSignpostID(log: log)
|
||||||
|
os_signpost(.begin, log: log, name: "load toots & users into cache", signpostID: cacheTaskSignpostID)
|
||||||
|
|
||||||
|
// contains reblog
|
||||||
|
let tootCache: PersistCache<Toot> = {
|
||||||
|
let cache = PersistCache<Toot>()
|
||||||
|
let cacheIDs = PersistCache<Toot>.ids(for: toots)
|
||||||
|
let cachedToots: [Toot] = {
|
||||||
|
let request = Toot.sortedFetchRequest
|
||||||
|
let ids = Array(cacheIDs)
|
||||||
|
request.predicate = Toot.predicate(domain: domain, ids: ids)
|
||||||
|
request.returnsObjectsAsFaults = false
|
||||||
|
request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)]
|
||||||
|
do {
|
||||||
|
return try managedObjectContext.fetch(request)
|
||||||
|
} catch {
|
||||||
|
assertionFailure(error.localizedDescription)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
for toot in cachedToots {
|
||||||
|
cache.dictionary[toot.id] = toot
|
||||||
|
}
|
||||||
|
os_signpost(.event, log: log, name: "load toot into cache", signpostID: cacheTaskSignpostID, "cached %{public}ld toots", cachedToots.count)
|
||||||
|
return cache
|
||||||
|
}()
|
||||||
|
|
||||||
|
let userCache: PersistCache<MastodonUser> = {
|
||||||
|
let cache = PersistCache<MastodonUser>()
|
||||||
|
let cacheIDs = PersistCache<MastodonUser>.ids(for: toots)
|
||||||
|
let cachedMastodonUsers: [MastodonUser] = {
|
||||||
|
let request = MastodonUser.sortedFetchRequest
|
||||||
|
let ids = Array(cacheIDs)
|
||||||
|
request.predicate = MastodonUser.predicate(domain: domain, ids: ids)
|
||||||
|
//request.returnsObjectsAsFaults = false
|
||||||
|
do {
|
||||||
|
return try managedObjectContext.fetch(request)
|
||||||
|
} catch {
|
||||||
|
assertionFailure(error.localizedDescription)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
for mastodonuser in cachedMastodonUsers {
|
||||||
|
cache.dictionary[mastodonuser.id] = mastodonuser
|
||||||
|
}
|
||||||
|
os_signpost(.event, log: log, name: "load user into cache", signpostID: cacheTaskSignpostID, "cached %{public}ld users", cachedMastodonUsers.count)
|
||||||
|
return cache
|
||||||
|
}()
|
||||||
|
|
||||||
|
os_signpost(.end, log: log, name: "load toots & users into cache", signpostID: cacheTaskSignpostID)
|
||||||
|
|
||||||
|
// remote timeline merge local timeline record set
|
||||||
|
// declare it before persist
|
||||||
|
let mergedOldTootsInTimeline = tootCache.dictionary.values.filter {
|
||||||
|
return $0.homeTimelineIndexes?.contains(where: { $0.userID == requestMastodonUserID }) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
let updateDatabaseTaskSignpostID = OSSignpostID(log: log)
|
||||||
|
let memoType: PersistMemo<Toot, MastodonUser>.MemoType = {
|
||||||
|
switch persistType {
|
||||||
|
case .home: return .homeTimeline
|
||||||
|
case .public: return .publicTimeline
|
||||||
|
case .likeList: return .likeList
|
||||||
|
case .lookUp: return .lookUp
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var persistMemos: [PersistMemo<Toot, MastodonUser>] = []
|
||||||
|
os_signpost(.begin, log: log, name: "update database", signpostID: updateDatabaseTaskSignpostID)
|
||||||
|
for entity in toots {
|
||||||
|
let processEntityTaskSignpostID = OSSignpostID(log: log)
|
||||||
|
os_signpost(.begin, log: log, name: "update database - process entity", signpostID: processEntityTaskSignpostID, "process entity %{public}s", entity.id)
|
||||||
|
defer {
|
||||||
|
os_signpost(.end, log: log, name: "update database - process entity", signpostID: processEntityTaskSignpostID, "process entity %{public}s", entity.id)
|
||||||
|
}
|
||||||
|
let memo = PersistMemo.createOrMergeToot(
|
||||||
|
into: managedObjectContext,
|
||||||
|
for: requestMastodonUser,
|
||||||
|
requestMastodonUserID: requestMastodonUserID,
|
||||||
|
domain: domain,
|
||||||
|
entity: entity,
|
||||||
|
memoType: memoType,
|
||||||
|
tootCache: tootCache,
|
||||||
|
userCache: userCache,
|
||||||
|
networkDate: response.networkDate,
|
||||||
|
log: log
|
||||||
|
)
|
||||||
|
persistMemos.append(memo)
|
||||||
|
} // end for…
|
||||||
|
os_signpost(.end, log: log, name: "update database", signpostID: updateDatabaseTaskSignpostID)
|
||||||
|
|
||||||
|
// home timeline tasks
|
||||||
|
switch persistType {
|
||||||
|
case .home:
|
||||||
|
guard let query = query,
|
||||||
|
let requestMastodonUserID = requestMastodonUserID else {
|
||||||
|
assertionFailure()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Task 1: update anchor hasMore
|
||||||
|
// update maxID anchor hasMore attribute when fetching on home timeline
|
||||||
|
// do not use working records due to anchor toot is removable on the remote
|
||||||
|
var anchorToot: Toot?
|
||||||
|
if let maxID = query.maxID {
|
||||||
|
do {
|
||||||
|
// load anchor toot from database
|
||||||
|
let request = Toot.sortedFetchRequest
|
||||||
|
request.predicate = Toot.predicate(domain: domain, id: maxID)
|
||||||
|
request.returnsObjectsAsFaults = false
|
||||||
|
request.fetchLimit = 1
|
||||||
|
anchorToot = try managedObjectContext.fetch(request).first
|
||||||
|
if persistType == .home {
|
||||||
|
let timelineIndex = anchorToot.flatMap { toot in
|
||||||
|
toot.homeTimelineIndexes?.first(where: { $0.userID == requestMastodonUserID })
|
||||||
|
}
|
||||||
|
timelineIndex?.update(hasMore: false)
|
||||||
|
} else {
|
||||||
|
assertionFailure()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
assertionFailure(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task 2: set last toot hasMore when fetched toots not overlap with the timeline in the local database
|
||||||
|
let _oldestMemo = persistMemos
|
||||||
|
.sorted(by: { $0.status.createdAt < $1.status.createdAt })
|
||||||
|
.first
|
||||||
|
if let oldestMemo = _oldestMemo {
|
||||||
|
if let anchorToot = anchorToot {
|
||||||
|
// using anchor. set hasMore when (overlap itself OR no overlap) AND oldest record NOT anchor
|
||||||
|
let isNoOverlap = mergedOldTootsInTimeline.isEmpty
|
||||||
|
let isOnlyOverlapItself = mergedOldTootsInTimeline.count == 1 && mergedOldTootsInTimeline.first?.id == anchorToot.id
|
||||||
|
let isAnchorEqualOldestRecord = oldestMemo.status.id == anchorToot.id
|
||||||
|
if (isNoOverlap || isOnlyOverlapItself) && !isAnchorEqualOldestRecord {
|
||||||
|
if persistType == .home {
|
||||||
|
let timelineIndex = oldestMemo.status.homeTimelineIndexes?
|
||||||
|
.first(where: { $0.userID == requestMastodonUserID })
|
||||||
|
timelineIndex?.update(hasMore: true)
|
||||||
|
} else {
|
||||||
|
assertionFailure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if mergedOldTootsInTimeline.isEmpty {
|
||||||
|
// no anchor. set hasMore when no overlap
|
||||||
|
if persistType == .home {
|
||||||
|
let timelineIndex = oldestMemo.status.homeTimelineIndexes?
|
||||||
|
.first(where: { $0.userID == requestMastodonUserID })
|
||||||
|
timelineIndex?.update(hasMore: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// empty working record. mark anchor hasMore in the task 1
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// print working record tree map
|
||||||
|
#if DEBUG
|
||||||
|
DispatchQueue.global(qos: .utility).async {
|
||||||
|
let logs = persistMemos
|
||||||
|
.map { record in record.log() }
|
||||||
|
.joined(separator: "\n")
|
||||||
|
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: working status: \n%s", ((#file as NSString).lastPathComponent), #line, #function, logs)
|
||||||
|
let counting = persistMemos
|
||||||
|
.map { record in record.count() }
|
||||||
|
.reduce(into: PersistMemo.Counting(), { result, next in result = result + next })
|
||||||
|
let newTweetsInTimeLineCount = persistMemos.reduce(0, { result, next in
|
||||||
|
return next.statusProcessType == .create ? result + 1 : result
|
||||||
|
})
|
||||||
|
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: tweet: insert %{public}ldT(%{public}ldTRQ), merge %{public}ldT(%{public}ldTRQ)", ((#file as NSString).lastPathComponent), #line, #function, newTweetsInTimeLineCount, counting.status.create, mergedOldTootsInTimeline.count, counting.status.merge)
|
||||||
|
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: twitter user: insert %{public}ld, merge %{public}ld", ((#file as NSString).lastPathComponent), #line, #function, counting.user.create, counting.user.merge)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
.handleEvents(receiveOutput: { result in
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
break
|
||||||
|
case .failure(let error):
|
||||||
|
#if DEBUG
|
||||||
|
debugPrint(error)
|
||||||
|
#endif
|
||||||
|
assertionFailure(error.localizedDescription)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,446 +0,0 @@
|
||||||
//
|
|
||||||
// APIService+Persist+Timeline.swift
|
|
||||||
// Mastodon
|
|
||||||
//
|
|
||||||
// Created by sxiaojian on 2021/1/27.
|
|
||||||
//
|
|
||||||
|
|
||||||
import os.log
|
|
||||||
import func QuartzCore.CACurrentMediaTime
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
import CoreData
|
|
||||||
import CoreDataStack
|
|
||||||
import MastodonSDK
|
|
||||||
|
|
||||||
extension APIService.Persist {
|
|
||||||
|
|
||||||
enum PersistTimelineType {
|
|
||||||
case `public`
|
|
||||||
case home
|
|
||||||
case likeList
|
|
||||||
}
|
|
||||||
|
|
||||||
static func persistTimeline(
|
|
||||||
managedObjectContext: NSManagedObjectContext,
|
|
||||||
domain: String,
|
|
||||||
query: Mastodon.API.Timeline.TimelineQuery,
|
|
||||||
response: Mastodon.Response.Content<[Mastodon.Entity.Status]>,
|
|
||||||
persistType: PersistTimelineType,
|
|
||||||
requestMastodonUserID: MastodonUser.ID?, // could be nil when response from public endpoint
|
|
||||||
log: OSLog
|
|
||||||
) -> AnyPublisher<Result<Void, Error>, Never> {
|
|
||||||
let toots = response.value
|
|
||||||
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: persist %{public}ld toots…", ((#file as NSString).lastPathComponent), #line, #function, toots.count)
|
|
||||||
|
|
||||||
return managedObjectContext.performChanges {
|
|
||||||
let contextTaskSignpostID = OSSignpostID(log: log)
|
|
||||||
let start = CACurrentMediaTime()
|
|
||||||
os_signpost(.begin, log: log, name: #function, signpostID: contextTaskSignpostID)
|
|
||||||
defer {
|
|
||||||
os_signpost(.end, log: .api, name: #function, signpostID: contextTaskSignpostID)
|
|
||||||
let end = CACurrentMediaTime()
|
|
||||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: persist cost %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start)
|
|
||||||
}
|
|
||||||
|
|
||||||
// load request mastodon user
|
|
||||||
let requestMastodonUser: MastodonUser? = {
|
|
||||||
guard let requestMastodonUserID = requestMastodonUserID else { return nil }
|
|
||||||
let request = MastodonUser.sortedFetchRequest
|
|
||||||
request.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID)
|
|
||||||
request.fetchLimit = 1
|
|
||||||
request.returnsObjectsAsFaults = false
|
|
||||||
do {
|
|
||||||
return try managedObjectContext.fetch(request).first
|
|
||||||
} catch {
|
|
||||||
assertionFailure(error.localizedDescription)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// load working set into context to avoid cache miss
|
|
||||||
let cacheTaskSignpostID = OSSignpostID(log: log)
|
|
||||||
os_signpost(.begin, log: log, name: "load toots into cache", signpostID: cacheTaskSignpostID)
|
|
||||||
let workingIDRecord = APIService.Persist.WorkingIDRecord.workingID(entities: toots)
|
|
||||||
|
|
||||||
// contains toots and reblogs
|
|
||||||
let _tootCache: [Toot] = {
|
|
||||||
let request = Toot.sortedFetchRequest
|
|
||||||
let idSet = workingIDRecord.statusIDSet
|
|
||||||
.union(workingIDRecord.reblogIDSet)
|
|
||||||
let ids = Array(idSet)
|
|
||||||
request.predicate = Toot.predicate(domain: domain, ids: ids)
|
|
||||||
request.returnsObjectsAsFaults = false
|
|
||||||
request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)]
|
|
||||||
do {
|
|
||||||
return try managedObjectContext.fetch(request)
|
|
||||||
} catch {
|
|
||||||
assertionFailure(error.localizedDescription)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
os_signpost(.event, log: log, name: "load toots into cache", signpostID: cacheTaskSignpostID, "cached %{public}ld toots", _tootCache.count)
|
|
||||||
os_signpost(.end, log: log, name: "load toots into cache", signpostID: cacheTaskSignpostID)
|
|
||||||
|
|
||||||
// remote timeline merge local timeline record set
|
|
||||||
// declare it before do working
|
|
||||||
let mergedOldTootsInTimeline = _tootCache.filter {
|
|
||||||
return $0.homeTimelineIndexes?.contains(where: { $0.userID == requestMastodonUserID }) ?? false
|
|
||||||
}
|
|
||||||
|
|
||||||
let updateDatabaseTaskSignpostID = OSSignpostID(log: log)
|
|
||||||
let recordType: WorkingRecord.RecordType = {
|
|
||||||
switch persistType {
|
|
||||||
case .public: return .publicTimeline
|
|
||||||
case .home: return .homeTimeline
|
|
||||||
case .likeList: return .favoriteTimeline
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
var workingRecords: [WorkingRecord] = []
|
|
||||||
os_signpost(.begin, log: log, name: "update database", signpostID: updateDatabaseTaskSignpostID)
|
|
||||||
for entity in toots {
|
|
||||||
let processEntityTaskSignpostID = OSSignpostID(log: log)
|
|
||||||
os_signpost(.begin, log: log, name: "update database - process entity", signpostID: processEntityTaskSignpostID, "process entity %{public}s", entity.id)
|
|
||||||
defer {
|
|
||||||
os_signpost(.end, log: log, name: "update database - process entity", signpostID: processEntityTaskSignpostID, "process entity %{public}s", entity.id)
|
|
||||||
}
|
|
||||||
let record = WorkingRecord.createOrMergeToot(
|
|
||||||
into: managedObjectContext,
|
|
||||||
for: requestMastodonUser,
|
|
||||||
domain: domain,
|
|
||||||
entity: entity,
|
|
||||||
recordType: recordType,
|
|
||||||
networkDate: response.networkDate,
|
|
||||||
log: log
|
|
||||||
)
|
|
||||||
workingRecords.append(record)
|
|
||||||
} // end for…
|
|
||||||
os_signpost(.end, log: log, name: "update database", signpostID: updateDatabaseTaskSignpostID)
|
|
||||||
|
|
||||||
// home & mention timeline tasks
|
|
||||||
switch persistType {
|
|
||||||
case .home:
|
|
||||||
// Task 1: update anchor hasMore
|
|
||||||
// update maxID anchor hasMore attribute when fetching on timeline
|
|
||||||
// do not use working records due to anchor toot is removable on the remote
|
|
||||||
var anchorToot: Toot?
|
|
||||||
if let maxID = query.maxID {
|
|
||||||
do {
|
|
||||||
// load anchor toot from database
|
|
||||||
let request = Toot.sortedFetchRequest
|
|
||||||
request.predicate = Toot.predicate(domain: domain, id: maxID)
|
|
||||||
request.returnsObjectsAsFaults = false
|
|
||||||
request.fetchLimit = 1
|
|
||||||
anchorToot = try managedObjectContext.fetch(request).first
|
|
||||||
if persistType == .home {
|
|
||||||
let timelineIndex = anchorToot.flatMap { toot in
|
|
||||||
toot.homeTimelineIndexes?.first(where: { $0.userID == requestMastodonUserID })
|
|
||||||
}
|
|
||||||
timelineIndex?.update(hasMore: false)
|
|
||||||
} else {
|
|
||||||
assertionFailure()
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
assertionFailure(error.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Task 2: set last toot hasMore when fetched toots not overlap with the timeline in the local database
|
|
||||||
let _oldestRecord = workingRecords
|
|
||||||
.sorted(by: { $0.status.createdAt < $1.status.createdAt })
|
|
||||||
.first
|
|
||||||
if let oldestRecord = _oldestRecord {
|
|
||||||
if let anchorToot = anchorToot {
|
|
||||||
// using anchor. set hasMore when (overlap itself OR no overlap) AND oldest record NOT anchor
|
|
||||||
let isNoOverlap = mergedOldTootsInTimeline.isEmpty
|
|
||||||
let isOnlyOverlapItself = mergedOldTootsInTimeline.count == 1 && mergedOldTootsInTimeline.first?.id == anchorToot.id
|
|
||||||
let isAnchorEqualOldestRecord = oldestRecord.status.id == anchorToot.id
|
|
||||||
if (isNoOverlap || isOnlyOverlapItself) && !isAnchorEqualOldestRecord {
|
|
||||||
if persistType == .home {
|
|
||||||
let timelineIndex = oldestRecord.status.homeTimelineIndexes?
|
|
||||||
.first(where: { $0.userID == requestMastodonUserID })
|
|
||||||
timelineIndex?.update(hasMore: true)
|
|
||||||
} else {
|
|
||||||
assertionFailure()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if mergedOldTootsInTimeline.isEmpty {
|
|
||||||
// no anchor. set hasMore when no overlap
|
|
||||||
if persistType == .home {
|
|
||||||
let timelineIndex = oldestRecord.status.homeTimelineIndexes?
|
|
||||||
.first(where: { $0.userID == requestMastodonUserID })
|
|
||||||
timelineIndex?.update(hasMore: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// empty working record. mark anchor hasMore in the task 1
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// print working record tree map
|
|
||||||
#if DEBUG
|
|
||||||
DispatchQueue.global(qos: .utility).async {
|
|
||||||
let logs = workingRecords
|
|
||||||
.map { record in record.log() }
|
|
||||||
.joined(separator: "\n")
|
|
||||||
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: working status: \n%s", ((#file as NSString).lastPathComponent), #line, #function, logs)
|
|
||||||
let counting = workingRecords
|
|
||||||
.map { record in record.count() }
|
|
||||||
.reduce(into: WorkingRecord.Counting(), { result, next in result = result + next })
|
|
||||||
let newTootsInTimeLineCount = workingRecords.reduce(0, { result, next in
|
|
||||||
return next.statusProcessType == .create ? result + 1 : result
|
|
||||||
})
|
|
||||||
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: toot: insert %{public}ldT(%{public}ldTRQ), merge %{public}ldT(%{public}ldTRQ)", ((#file as NSString).lastPathComponent), #line, #function, newTootsInTimeLineCount, counting.status.create, mergedOldTootsInTimeline.count, counting.status.merge)
|
|
||||||
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: mastodon user: insert %{public}ld, merge %{public}ld", ((#file as NSString).lastPathComponent), #line, #function, counting.user.create, counting.user.merge)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
.handleEvents(receiveOutput: { result in
|
|
||||||
switch result {
|
|
||||||
case .success:
|
|
||||||
break
|
|
||||||
case .failure(let error):
|
|
||||||
#if DEBUG
|
|
||||||
debugPrint(error)
|
|
||||||
#endif
|
|
||||||
assertionFailure(error.localizedDescription)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension APIService.Persist {
|
|
||||||
|
|
||||||
struct WorkingIDRecord {
|
|
||||||
var statusIDSet: Set<String>
|
|
||||||
var reblogIDSet: Set<String>
|
|
||||||
var userIDSet: Set<String>
|
|
||||||
|
|
||||||
enum RecordType {
|
|
||||||
case timeline
|
|
||||||
case reblog
|
|
||||||
}
|
|
||||||
|
|
||||||
init(statusIDSet: Set<String> = Set(), reblogIDSet: Set<String> = Set(), userIDSet: Set<String> = Set()) {
|
|
||||||
self.statusIDSet = statusIDSet
|
|
||||||
self.reblogIDSet = reblogIDSet
|
|
||||||
self.userIDSet = userIDSet
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func union(record: WorkingIDRecord) {
|
|
||||||
statusIDSet = statusIDSet.union(record.statusIDSet)
|
|
||||||
reblogIDSet = reblogIDSet.union(record.reblogIDSet)
|
|
||||||
userIDSet = userIDSet.union(record.userIDSet)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func workingID(entities: [Mastodon.Entity.Status]) -> WorkingIDRecord {
|
|
||||||
var value = WorkingIDRecord()
|
|
||||||
for entity in entities {
|
|
||||||
let child = workingID(entity: entity, recordType: .timeline)
|
|
||||||
value.union(record: child)
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func workingID(entity: Mastodon.Entity.Status, recordType: RecordType) -> WorkingIDRecord {
|
|
||||||
var value = WorkingIDRecord()
|
|
||||||
switch recordType {
|
|
||||||
case .timeline: value.statusIDSet = Set([entity.id])
|
|
||||||
case .reblog: value.reblogIDSet = Set([entity.id])
|
|
||||||
}
|
|
||||||
value.userIDSet = Set([entity.account.id])
|
|
||||||
|
|
||||||
if let reblog = entity.reblog {
|
|
||||||
let child = workingID(entity: reblog, recordType: .reblog)
|
|
||||||
value.union(record: child)
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class WorkingRecord {
|
|
||||||
|
|
||||||
let status: Toot
|
|
||||||
let children: [WorkingRecord]
|
|
||||||
let recordType: RecordType
|
|
||||||
let statusProcessType: ProcessType
|
|
||||||
let userProcessType: ProcessType
|
|
||||||
|
|
||||||
init(
|
|
||||||
status: Toot,
|
|
||||||
children: [APIService.Persist.WorkingRecord],
|
|
||||||
recordType: APIService.Persist.WorkingRecord.RecordType,
|
|
||||||
tootProcessType: ProcessType,
|
|
||||||
userProcessType: ProcessType
|
|
||||||
) {
|
|
||||||
self.status = status
|
|
||||||
self.children = children
|
|
||||||
self.recordType = recordType
|
|
||||||
self.statusProcessType = tootProcessType
|
|
||||||
self.userProcessType = userProcessType
|
|
||||||
}
|
|
||||||
|
|
||||||
enum RecordType {
|
|
||||||
case publicTimeline
|
|
||||||
case homeTimeline
|
|
||||||
case mentionTimeline
|
|
||||||
case userTimeline
|
|
||||||
case favoriteTimeline
|
|
||||||
case searchTimeline
|
|
||||||
|
|
||||||
case reblog
|
|
||||||
|
|
||||||
var flag: String {
|
|
||||||
switch self {
|
|
||||||
case .publicTimeline: return "P"
|
|
||||||
case .homeTimeline: return "H"
|
|
||||||
case .mentionTimeline: return "M"
|
|
||||||
case .userTimeline: return "U"
|
|
||||||
case .favoriteTimeline: return "F"
|
|
||||||
case .searchTimeline: return "S"
|
|
||||||
case .reblog: return "R"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ProcessType {
|
|
||||||
case create
|
|
||||||
case merge
|
|
||||||
|
|
||||||
var flag: String {
|
|
||||||
switch self {
|
|
||||||
case .create: return "+"
|
|
||||||
case .merge: return "-"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func log(indentLevel: Int = 0) -> String {
|
|
||||||
let indent = Array(repeating: " ", count: indentLevel).joined()
|
|
||||||
let tootPreview = status.content.prefix(32).replacingOccurrences(of: "\n", with: " ")
|
|
||||||
let message = "\(indent)[\(statusProcessType.flag)\(recordType.flag)](\(status.id)) [\(userProcessType.flag)](\(status.author.id))@\(status.author.username) ~> \(tootPreview)"
|
|
||||||
|
|
||||||
var childrenMessages: [String] = []
|
|
||||||
for child in children {
|
|
||||||
childrenMessages.append(child.log(indentLevel: indentLevel + 1))
|
|
||||||
}
|
|
||||||
let result = [[message] + childrenMessages]
|
|
||||||
.flatMap { $0 }
|
|
||||||
.joined(separator: "\n")
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Counting {
|
|
||||||
var status = Counter()
|
|
||||||
var user = Counter()
|
|
||||||
|
|
||||||
static func + (left: Counting, right: Counting) -> Counting {
|
|
||||||
return Counting(
|
|
||||||
status: left.status + right.status,
|
|
||||||
user: left.user + right.user
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Counter {
|
|
||||||
var create = 0
|
|
||||||
var merge = 0
|
|
||||||
|
|
||||||
static func + (left: Counter, right: Counter) -> Counter {
|
|
||||||
return Counter(
|
|
||||||
create: left.create + right.create,
|
|
||||||
merge: left.merge + right.merge
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func count() -> Counting {
|
|
||||||
var counting = Counting()
|
|
||||||
|
|
||||||
switch statusProcessType {
|
|
||||||
case .create: counting.status.create += 1
|
|
||||||
case .merge: counting.status.merge += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
switch userProcessType {
|
|
||||||
case .create: counting.user.create += 1
|
|
||||||
case .merge: counting.user.merge += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
for child in children {
|
|
||||||
let childCounting = child.count()
|
|
||||||
counting = counting + childCounting
|
|
||||||
}
|
|
||||||
|
|
||||||
return counting
|
|
||||||
}
|
|
||||||
|
|
||||||
// handle timelineIndex insert with APIService.Persist.createOrMergeToot
|
|
||||||
static func createOrMergeToot(
|
|
||||||
into managedObjectContext: NSManagedObjectContext,
|
|
||||||
for requestMastodonUser: MastodonUser?,
|
|
||||||
domain: String,
|
|
||||||
entity: Mastodon.Entity.Status,
|
|
||||||
recordType: RecordType,
|
|
||||||
networkDate: Date,
|
|
||||||
log: OSLog
|
|
||||||
) -> WorkingRecord {
|
|
||||||
let processEntityTaskSignpostID = OSSignpostID(log: log)
|
|
||||||
os_signpost(.begin, log: log, name: "update database - process entity: createorMergeToot", signpostID: processEntityTaskSignpostID, "process toot %{public}s", entity.id)
|
|
||||||
defer {
|
|
||||||
os_signpost(.end, log: log, name: "update database - process entity: createorMergeToot", signpostID: processEntityTaskSignpostID, "finish process toot %{public}s", entity.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// build tree
|
|
||||||
let reblogRecord: WorkingRecord? = entity.reblog.flatMap { entity -> WorkingRecord in
|
|
||||||
createOrMergeToot(into: managedObjectContext, for: requestMastodonUser, domain: domain, entity: entity, recordType: .reblog, networkDate: networkDate, log: log)
|
|
||||||
}
|
|
||||||
let children = [reblogRecord].compactMap { $0 }
|
|
||||||
|
|
||||||
let (status, isTootCreated, isTootUserCreated) = APIService.CoreData.createOrMergeToot(into: managedObjectContext, for: requestMastodonUser, entity: entity, domain: domain, networkDate: networkDate, log: log)
|
|
||||||
|
|
||||||
let result = WorkingRecord(
|
|
||||||
status: status,
|
|
||||||
children: children,
|
|
||||||
recordType: recordType,
|
|
||||||
tootProcessType: isTootCreated ? .create : .merge,
|
|
||||||
userProcessType: isTootUserCreated ? .create : .merge
|
|
||||||
)
|
|
||||||
|
|
||||||
switch (result.statusProcessType, recordType) {
|
|
||||||
case (.create, .homeTimeline), (.merge, .homeTimeline):
|
|
||||||
guard let requestMastodonUserID = requestMastodonUser?.id else {
|
|
||||||
assertionFailure("Request user is required for home timeline")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
let timelineIndex = status.homeTimelineIndexes?
|
|
||||||
.first { $0.userID == requestMastodonUserID }
|
|
||||||
if timelineIndex == nil {
|
|
||||||
let timelineIndexProperty = HomeTimelineIndex.Property(domain: domain, userID: requestMastodonUserID)
|
|
||||||
|
|
||||||
let _ = HomeTimelineIndex.insert(
|
|
||||||
into: managedObjectContext,
|
|
||||||
property: timelineIndexProperty,
|
|
||||||
toot: status
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// enity already in home timeline
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,84 @@
|
||||||
|
//
|
||||||
|
// StatusPrefetchingService.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by MainasuK Cirno on 2021-3-10.
|
||||||
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
|
import MastodonSDK
|
||||||
|
|
||||||
|
final class StatusPrefetchingService {
|
||||||
|
|
||||||
|
typealias TaskID = String
|
||||||
|
|
||||||
|
let workingQueue = DispatchQueue(label: "status-prefetching-service-working-queue")
|
||||||
|
|
||||||
|
var disposeBag = Set<AnyCancellable>()
|
||||||
|
private(set) var statusPrefetchingDisposeBagDict: [TaskID: AnyCancellable] = [:]
|
||||||
|
|
||||||
|
weak var apiService: APIService?
|
||||||
|
|
||||||
|
init(apiService: APIService) {
|
||||||
|
self.apiService = apiService
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension StatusPrefetchingService {
|
||||||
|
|
||||||
|
func prefetchReplyTo(
|
||||||
|
domain: String,
|
||||||
|
statusObjectID: NSManagedObjectID,
|
||||||
|
statusID: Mastodon.Entity.Status.ID,
|
||||||
|
replyToStatusID: Mastodon.Entity.Status.ID,
|
||||||
|
authorizationBox: AuthenticationService.MastodonAuthenticationBox
|
||||||
|
) {
|
||||||
|
workingQueue.async { [weak self] in
|
||||||
|
guard let self = self, let apiService = self.apiService else { return }
|
||||||
|
let taskID = domain + "@" + statusID + "->" + replyToStatusID
|
||||||
|
guard self.statusPrefetchingDisposeBagDict[taskID] == nil else { return }
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: prefetching replyTo: %s", ((#file as NSString).lastPathComponent), #line, #function, taskID)
|
||||||
|
|
||||||
|
self.statusPrefetchingDisposeBagDict[taskID] = apiService.status(
|
||||||
|
domain: domain,
|
||||||
|
statusID: replyToStatusID,
|
||||||
|
authorizationBox: authorizationBox
|
||||||
|
)
|
||||||
|
.sink(receiveCompletion: { [weak self] completion in
|
||||||
|
// remove task when completed
|
||||||
|
guard let self = self else { return }
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: prefeched replyTo: %s", ((#file as NSString).lastPathComponent), #line, #function, taskID)
|
||||||
|
self.statusPrefetchingDisposeBagDict[taskID] = nil
|
||||||
|
}, receiveValue: { [weak self] _ in
|
||||||
|
guard let self = self else { return }
|
||||||
|
let backgroundManagedObjectContext = apiService.backgroundManagedObjectContext
|
||||||
|
backgroundManagedObjectContext.performChanges {
|
||||||
|
guard let status = backgroundManagedObjectContext.object(with: statusObjectID) as? Toot else { return }
|
||||||
|
do {
|
||||||
|
let predicate = Toot.predicate(domain: domain, id: replyToStatusID)
|
||||||
|
let request = Toot.sortedFetchRequest
|
||||||
|
request.predicate = predicate
|
||||||
|
request.returnsObjectsAsFaults = false
|
||||||
|
request.fetchLimit = 1
|
||||||
|
guard let replyTo = try backgroundManagedObjectContext.fetch(request).first else { return }
|
||||||
|
status.update(replyTo: replyTo)
|
||||||
|
} catch {
|
||||||
|
assertionFailure(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sink { _ in
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update status replyTo: %s", ((#file as NSString).lastPathComponent), #line, #function, taskID)
|
||||||
|
} receiveValue: { _ in
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
.store(in: &self.disposeBag)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -26,6 +26,7 @@ class AppContext: ObservableObject {
|
||||||
let emojiService: EmojiService
|
let emojiService: EmojiService
|
||||||
let audioPlaybackService = AudioPlaybackService()
|
let audioPlaybackService = AudioPlaybackService()
|
||||||
let videoPlaybackService = VideoPlaybackService()
|
let videoPlaybackService = VideoPlaybackService()
|
||||||
|
let statusPrefetchingService: StatusPrefetchingService
|
||||||
|
|
||||||
let documentStore: DocumentStore
|
let documentStore: DocumentStore
|
||||||
private var documentStoreSubscription: AnyCancellable!
|
private var documentStoreSubscription: AnyCancellable!
|
||||||
|
@ -52,6 +53,9 @@ class AppContext: ObservableObject {
|
||||||
emojiService = EmojiService(
|
emojiService = EmojiService(
|
||||||
apiService: apiService
|
apiService: apiService
|
||||||
)
|
)
|
||||||
|
statusPrefetchingService = StatusPrefetchingService(
|
||||||
|
apiService: _apiService
|
||||||
|
)
|
||||||
|
|
||||||
documentStore = DocumentStore()
|
documentStore = DocumentStore()
|
||||||
documentStoreSubscription = documentStore.objectWillChange
|
documentStoreSubscription = documentStore.objectWillChange
|
||||||
|
|
|
@ -2,12 +2,56 @@
|
||||||
// Mastodon+API+Statuses.swift
|
// Mastodon+API+Statuses.swift
|
||||||
//
|
//
|
||||||
//
|
//
|
||||||
// Created by MainasuK Cirno on 2021-3-12.
|
// Created by MainasuK Cirno on 2021-3-10.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
extension Mastodon.API.Statuses {
|
||||||
|
|
||||||
|
static func viewStatusEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL {
|
||||||
|
let pathComponent = "statuses/" + statusID
|
||||||
|
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// View specific status
|
||||||
|
///
|
||||||
|
/// View information about a status
|
||||||
|
///
|
||||||
|
/// - Since: 0.0.0
|
||||||
|
/// - Version: 3.3.0
|
||||||
|
/// # Last Update
|
||||||
|
/// 2021/3/10
|
||||||
|
/// # Reference
|
||||||
|
/// [Document](https://docs.joinmastodon.org/methods/statuses/)
|
||||||
|
/// - Parameters:
|
||||||
|
/// - session: `URLSession`
|
||||||
|
/// - domain: Mastodon instance domain. e.g. "example.com"
|
||||||
|
/// - statusID: id for status
|
||||||
|
/// - authorization: User token. Could be nil if status is public
|
||||||
|
/// - Returns: `AnyPublisher` contains `Status` nested in the response
|
||||||
|
public static func status(
|
||||||
|
session: URLSession,
|
||||||
|
domain: String,
|
||||||
|
statusID: Mastodon.Entity.Poll.ID,
|
||||||
|
authorization: Mastodon.API.OAuth.Authorization?
|
||||||
|
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> {
|
||||||
|
let request = Mastodon.API.get(
|
||||||
|
url: viewStatusEndpointURL(domain: domain, statusID: statusID),
|
||||||
|
query: nil,
|
||||||
|
authorization: authorization
|
||||||
|
)
|
||||||
|
return session.dataTaskPublisher(for: request)
|
||||||
|
.tryMap { data, response in
|
||||||
|
let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response)
|
||||||
|
return Mastodon.Response.Content(value: value, response: response)
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
extension Mastodon.API.Statuses {
|
extension Mastodon.API.Statuses {
|
||||||
|
|
||||||
static func publishNewStatusEndpointURL(domain: String) -> URL {
|
static func publishNewStatusEndpointURL(domain: String) -> URL {
|
||||||
|
|
|
@ -98,8 +98,8 @@ extension Mastodon.API {
|
||||||
public enum OAuth { }
|
public enum OAuth { }
|
||||||
public enum Onboarding { }
|
public enum Onboarding { }
|
||||||
public enum Polls { }
|
public enum Polls { }
|
||||||
public enum Statuses { }
|
|
||||||
public enum Reblog { }
|
public enum Reblog { }
|
||||||
|
public enum Statuses { }
|
||||||
public enum Timeline { }
|
public enum Timeline { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// Mastodon+Entity+Toot.swift
|
// Mastodon+Entity+Status.swift
|
||||||
//
|
//
|
||||||
//
|
//
|
||||||
// Created by MainasuK Cirno on 2021/1/27.
|
// Created by MainasuK Cirno on 2021/1/27.
|
||||||
|
|
Loading…
Reference in New Issue