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:
Shadowfacts 2022-11-29 21:43:56 -05:00
parent 80f9800fd6
commit 97d5b955a0
8 changed files with 120 additions and 0 deletions

View File

@ -261,6 +261,10 @@ public class Client {
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
public static func getLists() -> Request<[List]> {
return Request<[List]>(method: .get, path: "/api/v1/lists")

View File

@ -15,11 +15,14 @@ public class Hashtag: Codable {
public let url: WebURL
/// Only present when returned from the trending hashtags endpoint
public let history: [History]?
/// Only present on Mastodon >= 4 and when logged in
public let following: Bool?
public init(name: String, url: URL) {
self.name = name
self.url = WebURL(url)!
self.history = nil
self.following = nil
}
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
self.url = try container.decode(WebURL.self, forKey: .url)
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 {
@ -35,12 +39,14 @@ public class Hashtag: Codable {
try container.encode(name, forKey: .name)
try container.encode(url, forKey: .url)
try container.encodeIfPresent(history, forKey: .history)
try container.encodeIfPresent(following, forKey: .following)
}
private enum CodingKeys: String, CodingKey {
case name
case url
case history
case following
}
}

View File

@ -50,6 +50,7 @@
D61F758E2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61F758C2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib */; };
D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F758F29353B4300C0B37F /* FileManager+Size.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 */; };
D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.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>"; };
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>"; };
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>"; };
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>"; };
@ -881,6 +883,7 @@
D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */,
D6B9366E2828452F00237D0E /* SavedHashtag.swift */,
D6B9366C2828444F00237D0E /* SavedInstance.swift */,
D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */,
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
);
@ -1824,6 +1827,7 @@
0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */,
D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */,
D6C7D27D22B6EBF800071952 /* AttachmentsContainerView.swift in Sources */,
D61F75942936F0DA00C0B37F /* FollowedHashtag.swift in Sources */,
D63CC7122911F57C000E19DE /* StatusBarTappableViewController.swift in Sources */,
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */,
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */,

View File

@ -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?) {
let ver = instance.version.lowercased()
if ver.contains("glitch") {

View File

@ -49,6 +49,7 @@ class MastodonController: ObservableObject {
@Published private(set) var instanceFeatures = InstanceFeatures()
@Published private(set) var lists: [List] = []
@Published private(set) var customEmojis: [Emoji]?
@Published private(set) var followedHashtags: [FollowedHashtag] = []
private var cancellables = Set<AnyCancellable>()
@ -78,6 +79,15 @@ class MastodonController: ObservableObject {
self.instanceFeatures.update(instance: instance, nodeInfo: nodeInfo)
}
.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
@ -321,6 +331,20 @@ class MastodonController: ObservableObject {
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 {

View File

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

View File

@ -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) {
let changes = hasChangedSavedHashtagsOrInstances(notification)
if changes.hashtags {

View File

@ -28,6 +28,10 @@
</uniquenessConstraint>
</uniquenessConstraints>
</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">
<attribute name="accountID" optional="YES" attributeType="String"/>
<attribute name="blocking" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>