Store followed hashtags
The followed hashtags may not load until after the timeline request completes, and we want to be able to show the hashtag indicator (or at least make a best effort attempt) immediately.
This commit is contained in:
parent
80f9800fd6
commit
97d5b955a0
|
@ -261,6 +261,10 @@ public class Client {
|
||||||
return Request<Account>(method: .post, path: "/api/v1/follows", body: ParametersBody(["uri" => acct]))
|
return Request<Account>(method: .post, path: "/api/v1/follows", body: ParametersBody(["uri" => acct]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static func getFollowedHashtags() -> Request<[Hashtag]> {
|
||||||
|
return Request(method: .get, path: "/api/v1/followed_tags")
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Lists
|
// MARK: - Lists
|
||||||
public static func getLists() -> Request<[List]> {
|
public static func getLists() -> Request<[List]> {
|
||||||
return Request<[List]>(method: .get, path: "/api/v1/lists")
|
return Request<[List]>(method: .get, path: "/api/v1/lists")
|
||||||
|
|
|
@ -15,11 +15,14 @@ public class Hashtag: Codable {
|
||||||
public let url: WebURL
|
public let url: WebURL
|
||||||
/// Only present when returned from the trending hashtags endpoint
|
/// Only present when returned from the trending hashtags endpoint
|
||||||
public let history: [History]?
|
public let history: [History]?
|
||||||
|
/// Only present on Mastodon >= 4 and when logged in
|
||||||
|
public let following: Bool?
|
||||||
|
|
||||||
public init(name: String, url: URL) {
|
public init(name: String, url: URL) {
|
||||||
self.name = name
|
self.name = name
|
||||||
self.url = WebURL(url)!
|
self.url = WebURL(url)!
|
||||||
self.history = nil
|
self.history = nil
|
||||||
|
self.following = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
public required init(from decoder: Decoder) throws {
|
public required init(from decoder: Decoder) throws {
|
||||||
|
@ -28,6 +31,7 @@ public class Hashtag: Codable {
|
||||||
// pixelfed (possibly others) don't fully escape special characters in the hashtag url
|
// pixelfed (possibly others) don't fully escape special characters in the hashtag url
|
||||||
self.url = try container.decode(WebURL.self, forKey: .url)
|
self.url = try container.decode(WebURL.self, forKey: .url)
|
||||||
self.history = try container.decodeIfPresent([History].self, forKey: .history)
|
self.history = try container.decodeIfPresent([History].self, forKey: .history)
|
||||||
|
self.following = try container.decodeIfPresent(Bool.self, forKey: .following)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func encode(to encoder: Encoder) throws {
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
@ -35,12 +39,14 @@ public class Hashtag: Codable {
|
||||||
try container.encode(name, forKey: .name)
|
try container.encode(name, forKey: .name)
|
||||||
try container.encode(url, forKey: .url)
|
try container.encode(url, forKey: .url)
|
||||||
try container.encodeIfPresent(history, forKey: .history)
|
try container.encodeIfPresent(history, forKey: .history)
|
||||||
|
try container.encodeIfPresent(following, forKey: .following)
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case name
|
case name
|
||||||
case url
|
case url
|
||||||
case history
|
case history
|
||||||
|
case following
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -50,6 +50,7 @@
|
||||||
D61F758E2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61F758C2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib */; };
|
D61F758E2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61F758C2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib */; };
|
||||||
D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F758F29353B4300C0B37F /* FileManager+Size.swift */; };
|
D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F758F29353B4300C0B37F /* FileManager+Size.swift */; };
|
||||||
D61F759229365C6C00C0B37F /* CollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759129365C6C00C0B37F /* CollectionViewController.swift */; };
|
D61F759229365C6C00C0B37F /* CollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759129365C6C00C0B37F /* CollectionViewController.swift */; };
|
||||||
|
D61F75942936F0DA00C0B37F /* FollowedHashtag.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */; };
|
||||||
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; };
|
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; };
|
||||||
D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; };
|
D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; };
|
||||||
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; };
|
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; };
|
||||||
|
@ -416,6 +417,7 @@
|
||||||
D61F758C2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = StatusUpdatedNotificationTableViewCell.xib; sourceTree = "<group>"; };
|
D61F758C2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = StatusUpdatedNotificationTableViewCell.xib; sourceTree = "<group>"; };
|
||||||
D61F758F29353B4300C0B37F /* FileManager+Size.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Size.swift"; sourceTree = "<group>"; };
|
D61F758F29353B4300C0B37F /* FileManager+Size.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Size.swift"; sourceTree = "<group>"; };
|
||||||
D61F759129365C6C00C0B37F /* CollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewController.swift; sourceTree = "<group>"; };
|
D61F759129365C6C00C0B37F /* CollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewController.swift; sourceTree = "<group>"; };
|
||||||
|
D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedHashtag.swift; sourceTree = "<group>"; };
|
||||||
D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = "<group>"; };
|
D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = "<group>"; };
|
||||||
D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = "<group>"; };
|
D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = "<group>"; };
|
||||||
D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = "<group>"; };
|
D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = "<group>"; };
|
||||||
|
@ -881,6 +883,7 @@
|
||||||
D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */,
|
D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */,
|
||||||
D6B9366E2828452F00237D0E /* SavedHashtag.swift */,
|
D6B9366E2828452F00237D0E /* SavedHashtag.swift */,
|
||||||
D6B9366C2828444F00237D0E /* SavedInstance.swift */,
|
D6B9366C2828444F00237D0E /* SavedInstance.swift */,
|
||||||
|
D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */,
|
||||||
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
|
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
|
||||||
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
|
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
|
||||||
);
|
);
|
||||||
|
@ -1824,6 +1827,7 @@
|
||||||
0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */,
|
0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */,
|
||||||
D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */,
|
D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */,
|
||||||
D6C7D27D22B6EBF800071952 /* AttachmentsContainerView.swift in Sources */,
|
D6C7D27D22B6EBF800071952 /* AttachmentsContainerView.swift in Sources */,
|
||||||
|
D61F75942936F0DA00C0B37F /* FollowedHashtag.swift in Sources */,
|
||||||
D63CC7122911F57C000E19DE /* StatusBarTappableViewController.swift in Sources */,
|
D63CC7122911F57C000E19DE /* StatusBarTappableViewController.swift in Sources */,
|
||||||
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */,
|
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */,
|
||||||
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */,
|
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */,
|
||||||
|
|
|
@ -84,6 +84,14 @@ struct InstanceFeatures {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var canFollowHashtags: Bool {
|
||||||
|
if case .mastodon(_, .some(let version)) = instanceType {
|
||||||
|
return version >= Version(4, 0, 0)
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
mutating func update(instance: Instance, nodeInfo: NodeInfo?) {
|
mutating func update(instance: Instance, nodeInfo: NodeInfo?) {
|
||||||
let ver = instance.version.lowercased()
|
let ver = instance.version.lowercased()
|
||||||
if ver.contains("glitch") {
|
if ver.contains("glitch") {
|
||||||
|
|
|
@ -49,6 +49,7 @@ class MastodonController: ObservableObject {
|
||||||
@Published private(set) var instanceFeatures = InstanceFeatures()
|
@Published private(set) var instanceFeatures = InstanceFeatures()
|
||||||
@Published private(set) var lists: [List] = []
|
@Published private(set) var lists: [List] = []
|
||||||
@Published private(set) var customEmojis: [Emoji]?
|
@Published private(set) var customEmojis: [Emoji]?
|
||||||
|
@Published private(set) var followedHashtags: [FollowedHashtag] = []
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
@ -78,6 +79,15 @@ class MastodonController: ObservableObject {
|
||||||
self.instanceFeatures.update(instance: instance, nodeInfo: nodeInfo)
|
self.instanceFeatures.update(instance: instance, nodeInfo: nodeInfo)
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
$instanceFeatures
|
||||||
|
.filter { [unowned self] in $0.canFollowHashtags && self.followedHashtags.isEmpty }
|
||||||
|
.sink { [unowned self] _ in
|
||||||
|
Task {
|
||||||
|
await self.loadFollowedHashtags()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
|
@ -321,6 +331,20 @@ class MastodonController: ObservableObject {
|
||||||
self.lists = new
|
self.lists = new
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func loadFollowedHashtags() async {
|
||||||
|
followedHashtags = (try? persistentContainer.viewContext.fetch(FollowedHashtag.fetchRequest())) ?? []
|
||||||
|
|
||||||
|
let req = Client.getFollowedHashtags()
|
||||||
|
if let (hashtags, _) = try? await run(req) {
|
||||||
|
self.persistentContainer.updateFollowedHashtags(hashtags) {
|
||||||
|
if case .success(let hashtags) = $0 {
|
||||||
|
self.followedHashtags = hashtags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ListComparator: SortComparator {
|
private struct ListComparator: SortComparator {
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
//
|
||||||
|
// FollowedHashtag.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/29/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
import Pachyderm
|
||||||
|
import WebURLFoundationExtras
|
||||||
|
|
||||||
|
@objc(FollowedHashtag)
|
||||||
|
public final class FollowedHashtag: NSManagedObject {
|
||||||
|
|
||||||
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<FollowedHashtag> {
|
||||||
|
return NSFetchRequest<FollowedHashtag>(entityName: "FollowedHashtag")
|
||||||
|
}
|
||||||
|
|
||||||
|
@nonobjc public class func fetchRequest(name: String) -> NSFetchRequest<FollowedHashtag> {
|
||||||
|
let req = NSFetchRequest<FollowedHashtag>(entityName: "FollowedHashtag")
|
||||||
|
req.predicate = NSPredicate(format: "name = %@", name)
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged public var name: String
|
||||||
|
@NSManaged public var url: URL
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FollowedHashtag {
|
||||||
|
convenience init(hashtag: Hashtag, context: NSManagedObjectContext) {
|
||||||
|
self.init(context: context)
|
||||||
|
self.name = hashtag.name
|
||||||
|
self.url = URL(hashtag.url)!
|
||||||
|
}
|
||||||
|
}
|
|
@ -267,6 +267,38 @@ class MastodonCachePersistentStore: NSPersistentContainer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateFollowedHashtags(_ hashtags: [Hashtag], completion: @escaping (Result<[FollowedHashtag], Error>) -> Void) {
|
||||||
|
viewContext.perform {
|
||||||
|
do {
|
||||||
|
var all = try self.viewContext.fetch(FollowedHashtag.fetchRequest())
|
||||||
|
|
||||||
|
let toDelete = all.filter { existing in !hashtags.contains(where: { $0.name == existing.name }) }.map(\.objectID)
|
||||||
|
if !toDelete.isEmpty {
|
||||||
|
try self.viewContext.execute(NSBatchDeleteRequest(objectIDs: toDelete))
|
||||||
|
}
|
||||||
|
|
||||||
|
for hashtag in hashtags where !all.contains(where: { $0.name == hashtag.name}) {
|
||||||
|
let mo = FollowedHashtag(hashtag: hashtag, context: self.viewContext)
|
||||||
|
all.append(mo)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.save(context: self.viewContext)
|
||||||
|
completion(.success(all))
|
||||||
|
} catch {
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasFollowedHashtag(_ hashtag: Hashtag) -> Bool {
|
||||||
|
do {
|
||||||
|
let req = FollowedHashtag.fetchRequest(name: name)
|
||||||
|
return try viewContext.count(for: req) > 0
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func managedObjectsDidChange(_ notification: Foundation.Notification) {
|
@objc private func managedObjectsDidChange(_ notification: Foundation.Notification) {
|
||||||
let changes = hasChangedSavedHashtagsOrInstances(notification)
|
let changes = hasChangedSavedHashtagsOrInstances(notification)
|
||||||
if changes.hashtags {
|
if changes.hashtags {
|
||||||
|
|
|
@ -28,6 +28,10 @@
|
||||||
</uniquenessConstraint>
|
</uniquenessConstraint>
|
||||||
</uniquenessConstraints>
|
</uniquenessConstraints>
|
||||||
</entity>
|
</entity>
|
||||||
|
<entity name="FollowedHashtag" representedClassName="FollowedHashtag" syncable="YES">
|
||||||
|
<attribute name="name" attributeType="String"/>
|
||||||
|
<attribute name="url" attributeType="URI"/>
|
||||||
|
</entity>
|
||||||
<entity name="Relationship" representedClassName="RelationshipMO" syncable="YES">
|
<entity name="Relationship" representedClassName="RelationshipMO" syncable="YES">
|
||||||
<attribute name="accountID" optional="YES" attributeType="String"/>
|
<attribute name="accountID" optional="YES" attributeType="String"/>
|
||||||
<attribute name="blocking" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
<attribute name="blocking" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
|
|
Loading…
Reference in New Issue