From 2f5307bfc7303f55c1dc084f64d5837589ae963d Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Thu, 11 Jan 2024 18:55:35 +0100 Subject: [PATCH] Add timeline content filter --- IceCubesApp/App/AppRegistry.swift | 2 + .../Tabs/Settings/ContentSettingsView.swift | 32 +- .../App/Tabs/Timeline/TimelineTab.swift | 14 +- .../Localization/Localizable.xcstrings | 598 +++++++++++++++++- Packages/Env/Sources/Env/Router.swift | 3 + .../Env/Sources/Env/UserPreferences.swift | 9 - .../Sources/StatusKit/List/ReblogCache.swift | 120 ---- .../Sources/StatusKit/Row/StatusRowView.swift | 1 - .../StatusKit/Row/StatusRowViewModel.swift | 13 - .../Timeline/TimelineContentFilter.swift | 46 ++ .../View/TimelineContentFilterView.swift | 38 ++ .../Sources/Timeline/View/TimelineView.swift | 13 + .../Timeline/View/TimelineViewModel.swift | 13 +- .../Timeline/actors/TimelineDatasource.swift | 19 +- 14 files changed, 757 insertions(+), 164 deletions(-) delete mode 100644 Packages/StatusKit/Sources/StatusKit/List/ReblogCache.swift create mode 100644 Packages/Timeline/Sources/Timeline/TimelineContentFilter.swift create mode 100644 Packages/Timeline/Sources/Timeline/View/TimelineContentFilterView.swift diff --git a/IceCubesApp/App/AppRegistry.swift b/IceCubesApp/App/AppRegistry.swift index d5e05b33..1d1a9122 100644 --- a/IceCubesApp/App/AppRegistry.swift +++ b/IceCubesApp/App/AppRegistry.swift @@ -114,6 +114,8 @@ extension View { ActivityView(image: image, status: status) case let .editTagGroup(tagGroup, onSaved): EditTagGroupView(tagGroup: tagGroup, onSaved: onSaved) + case .timelineContentFilter: + NavigationSheet { TimelineContentFilterView() } } } .withEnvironments() diff --git a/IceCubesApp/App/Tabs/Settings/ContentSettingsView.swift b/IceCubesApp/App/Tabs/Settings/ContentSettingsView.swift index d4a7599e..d9fdedf6 100644 --- a/IceCubesApp/App/Tabs/Settings/ContentSettingsView.swift +++ b/IceCubesApp/App/Tabs/Settings/ContentSettingsView.swift @@ -6,24 +6,18 @@ import Network import NukeUI import SwiftUI import UserNotifications +import Timeline @MainActor struct ContentSettingsView: View { @Environment(UserPreferences.self) private var userPreferences @Environment(Theme.self) private var theme + + @State private var contentFilter = TimelineContentFilter.shared var body: some View { @Bindable var userPreferences = userPreferences Form { - Section("settings.content.boosts") { - Toggle(isOn: $userPreferences.suppressDupeReblogs) { - Text("settings.content.hide-repeated-boosts") - } - } - #if !os(visionOS) - .listRowBackground(theme.primaryBackgroundColor) - #endif - Section("settings.content.media") { Toggle(isOn: $userPreferences.autoPlayVideo) { Text("settings.other.autoplay-video") @@ -89,7 +83,7 @@ struct ContentSettingsView: View { } footer: { Text("settings.content.collapse-long-posts-hint") } - #if !os(visionOS) + #if !os(visionOS) .listRowBackground(theme.primaryBackgroundColor) #endif @@ -122,6 +116,24 @@ struct ContentSettingsView: View { #if !os(visionOS) .listRowBackground(theme.primaryBackgroundColor) #endif + + Section("timeline.content-filter.title") { + Toggle(isOn: $contentFilter.showBoosts) { + Label("timeline.filter.show-boosts", image: "Rocket") + } + Toggle(isOn: $contentFilter.showReplies) { + Label("timeline.filter.show-replies", systemImage: "bubble.left.and.bubble.right") + } + Toggle(isOn: $contentFilter.showThreads) { + Label("timeline.filter.show-threads", systemImage: "bubble.left.and.text.bubble.right") + } + Toggle(isOn: $contentFilter.showQuotePosts) { + Label("timeline.filter.show-quote", systemImage: "quote.bubble") + } + } + #if !os(visionOS) + .listRowBackground(theme.primaryBackgroundColor) + #endif } .navigationTitle("settings.content.navigation-title") #if !os(visionOS) diff --git a/IceCubesApp/App/Tabs/Timeline/TimelineTab.swift b/IceCubesApp/App/Tabs/Timeline/TimelineTab.swift index 6883504c..8f703750 100644 --- a/IceCubesApp/App/Tabs/Timeline/TimelineTab.swift +++ b/IceCubesApp/App/Tabs/Timeline/TimelineTab.swift @@ -124,7 +124,10 @@ struct TimelineTab: View { @ViewBuilder private var timelineFilterButton: some View { latestOrResumeButtons + contentFilterButton + Divider() pinMenuButton + Divider() timelineFiltersButtons listsFiltersButons tagsFiltersButtons @@ -203,7 +206,6 @@ struct TimelineTab: View { } } } - Divider() } } @@ -225,8 +227,6 @@ struct TimelineTab: View { Label("status.action.pin", systemImage: "pin") } } - - Divider() } private var timelineFiltersButtons: some View { @@ -311,6 +311,14 @@ struct TimelineTab: View { } } } + + private var contentFilterButton: some View { + Button(action: { + routerPath.presentedSheet = .timelineContentFilter + }, label: { + Label("timeline.content-filter.title", systemSymbol: .line3HorizontalDecrease) + }) + } private func resetTimelineFilter() { if client.isAuth, canFilterTimeline { diff --git a/IceCubesApp/Resources/Localization/Localizable.xcstrings b/IceCubesApp/Resources/Localization/Localizable.xcstrings index fca10dc1..bc534dba 100644 --- a/IceCubesApp/Resources/Localization/Localizable.xcstrings +++ b/IceCubesApp/Resources/Localization/Localizable.xcstrings @@ -40122,6 +40122,7 @@ } }, "settings.content.boosts" : { + "extractionState" : "stale", "localizations" : { "be" : { "stringUnit" : { @@ -41066,6 +41067,7 @@ } }, "settings.content.hide-repeated-boosts" : { + "extractionState" : "stale", "localizations" : { "be" : { "stringUnit" : { @@ -73453,6 +73455,124 @@ } } }, + "timeline.content-filter.title" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Content Filter" + } + }, + "ca" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Content Filter" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Content Filter" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Content Filter" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Content Filter" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Content Filter" + } + }, + "eu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Content Filter" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Content Filter" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Content Filter" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Content Filter" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Content Filter" + } + }, + "nb" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Content Filter" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Content Filter" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Content Filter" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Content Filter" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Content Filter" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Content Filter" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Content Filter" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Content Filter" + } + } + } + }, "timeline.federated" : { "comment" : "MARK: Package: Timeline", "extractionState" : "manual", @@ -74163,6 +74283,482 @@ } } }, + "timeline.filter.show-boosts" : { + "extractionState" : "manual", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Boosts" + } + }, + "ca" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Boosts" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Boosts" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show Boosts" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show Boosts" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Boosts" + } + }, + "eu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Boosts" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Boosts" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Boosts" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Boosts" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Boosts" + } + }, + "nb" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Boosts" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Boosts" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Boosts" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Boosts" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Boosts" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Boosts" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Boosts" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Boosts" + } + } + } + }, + "timeline.filter.show-quote" : { + "extractionState" : "manual", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Quotes" + } + }, + "ca" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Quotes" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Quotes" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show Quotes" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show Quotes" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Quotes" + } + }, + "eu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Quotes" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Quotes" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Quotes" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Quotes" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Quotes" + } + }, + "nb" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Quotes" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Quotes" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Quotes" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Quotes" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Quotes" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Quotes" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Quotes" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Quotes" + } + } + } + }, + "timeline.filter.show-replies" : { + "extractionState" : "manual", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Replies" + } + }, + "ca" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Replies" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Replies" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show Replies" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show Replies" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Replies" + } + }, + "eu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Replies" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Replies" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Replies" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Replies" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Replies" + } + }, + "nb" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Replies" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Replies" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Replies" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Replies" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Replies" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Replies" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Replies" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Replies" + } + } + } + }, + "timeline.filter.show-threads" : { + "extractionState" : "manual", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Threads" + } + }, + "ca" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Threads" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Threads" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show Threads" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show Threads" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Threads" + } + }, + "eu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Threads" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Threads" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Threads" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Threads" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Threads" + } + }, + "nb" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Threads" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Threads" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Threads" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Threads" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Threads" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Threads" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Threads" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show Threads" + } + } + } + }, "timeline.filter.tag-groups" : { "localizations" : { "be" : { @@ -75243,4 +75839,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/Packages/Env/Sources/Env/Router.swift b/Packages/Env/Sources/Env/Router.swift index ceba64f2..648d7d44 100644 --- a/Packages/Env/Sources/Env/Router.swift +++ b/Packages/Env/Sources/Env/Router.swift @@ -57,6 +57,7 @@ public enum SheetDestination: Identifiable { case report(status: Status) case shareImage(image: UIImage, status: Status) case editTagGroup(tagGroup: TagGroup, onSaved: ((TagGroup) -> Void)?) + case timelineContentFilter public var id: String { switch self { @@ -85,6 +86,8 @@ public enum SheetDestination: Identifiable { "editTagGroup" case .settings, .support, .about, .accountPushNotficationsSettings: "settings" + case .timelineContentFilter: + "timelineContentFilter" } } } diff --git a/Packages/Env/Sources/Env/UserPreferences.swift b/Packages/Env/Sources/Env/UserPreferences.swift index 48ce6a23..3c879b9c 100644 --- a/Packages/Env/Sources/Env/UserPreferences.swift +++ b/Packages/Env/Sources/Env/UserPreferences.swift @@ -28,8 +28,6 @@ import SwiftUI @AppStorage("user_deepl_api_free") public var userDeeplAPIFree = true @AppStorage("auto_detect_post_language") public var autoDetectPostLanguage = true - @AppStorage("suppress_dupe_reblogs") public var suppressDupeReblogs: Bool = false - @AppStorage("inAppBrowserReaderView") public var inAppBrowserReaderView = false @AppStorage("haptic_tab") public var hapticTabSelectionEnabled = true @@ -197,12 +195,6 @@ import SwiftUI } } - public var suppressDupeReblogs: Bool { - didSet { - storage.suppressDupeReblogs = suppressDupeReblogs - } - } - public var inAppBrowserReaderView: Bool { didSet { storage.inAppBrowserReaderView = inAppBrowserReaderView @@ -478,7 +470,6 @@ import SwiftUI alwaysUseDeepl = storage.alwaysUseDeepl userDeeplAPIFree = storage.userDeeplAPIFree autoDetectPostLanguage = storage.autoDetectPostLanguage - suppressDupeReblogs = storage.suppressDupeReblogs inAppBrowserReaderView = storage.inAppBrowserReaderView hapticTabSelectionEnabled = storage.hapticTabSelectionEnabled hapticTimelineEnabled = storage.hapticTimelineEnabled diff --git a/Packages/StatusKit/Sources/StatusKit/List/ReblogCache.swift b/Packages/StatusKit/Sources/StatusKit/List/ReblogCache.swift deleted file mode 100644 index 9b16ea5c..00000000 --- a/Packages/StatusKit/Sources/StatusKit/List/ReblogCache.swift +++ /dev/null @@ -1,120 +0,0 @@ -import Env -import Foundation -import LRUCache -import Models -import SwiftUI - -public class ReblogCache: @unchecked Sendable { - struct CacheEntry: Codable { - var reblogId: String - var postId: String - var seen: Bool - } - - public static let shared = ReblogCache() - var statusCache = LRUCache() - private var needsWrite = false - - init() { - statusCache.countLimit = 300 // can tune the cache here, 100 is super conservative - - // read any existing cache from disk - if FileManager.default.fileExists(atPath: cacheFile.path()) { - do { - let data = try Data(contentsOf: cacheFile) - let cacheData = try JSONDecoder().decode([CacheEntry].self, from: data) - for entry in cacheData { - statusCache.setValue(entry, forKey: entry.reblogId) - } - } catch { - print("Error reading cache from disc") - } - print("Starting cache has \(statusCache.count) items") - } - DispatchQueue.main.asyncAfter(deadline: .now() + 30.0) { [weak self] in - self?.saveCache() - } - } - - private func saveCache() { - if needsWrite { - do { - let data = try JSONEncoder().encode(statusCache.allValues) - try data.write(to: cacheFile) - } catch { - print("Error writing cache to disc") - } - needsWrite = false - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 30.0) { [weak self] in - self?.saveCache() - } - } - - private var cacheFile: URL { - let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) - let documentsDirectory = paths[0] - - return URL(fileURLWithPath: documentsDirectory.path()).appendingPathComponent("reblog.json") - } - - @MainActor public func removeDuplicateReblogs(_ statuses: inout [Status]) { - if !UserPreferences.shared.suppressDupeReblogs { - return - } - - var i = statuses.count - - for status in statuses.reversed() { - // go backwards through the status list - // so that we can remove items without - // borking the array - - i -= 1 - if let reblog = status.reblog { - if let cached = statusCache.value(forKey: reblog.id) { - // this is already cached - if cached.postId != status.id, cached.seen { - // This was posted by someone other than the person we have in the cache - // and we have seen the items at some point, so we might want to suppress it - - if status.account.id != CurrentAccount.shared.account?.id { - // just a quick check to makes sure that this wasn't boosted by the current - // user. Hiding that would be confusing - // But assuming it isn't then we can suppress this boost - print("suppressing: \(reblog.id)/ \(String(describing: reblog.account.displayName)) by \(String(describing: status.account.displayName))") - statuses.remove(at: i) - // assert(statuses.count == (ct-1)) - } - } - } - cache(status, seen: false) - } - } - } - - public func cache(_ status: Status, seen: Bool) { - var wasSeen = false - var postToCache = status.id - - if let reblog = status.reblog { - // only caching boosts at the moment. - - if let cached = statusCache.value(forKey: reblog.id) { - // every time we see it, we refresh it in the list - // so poplular things are kept in the cache - - wasSeen = cached.seen - - if wasSeen { - postToCache = cached.postId - // if we have seen a particular version of the post - // that's the one we keep - } - } - statusCache.setValue(CacheEntry(reblogId: reblog.id, postId: postToCache, seen: seen || wasSeen), forKey: reblog.id) - needsWrite = true - } - } -} diff --git a/Packages/StatusKit/Sources/StatusKit/Row/StatusRowView.swift b/Packages/StatusKit/Sources/StatusKit/Row/StatusRowView.swift index d89fd58d..7f8fb62a 100644 --- a/Packages/StatusKit/Sources/StatusKit/Row/StatusRowView.swift +++ b/Packages/StatusKit/Sources/StatusKit/Row/StatusRowView.swift @@ -106,7 +106,6 @@ public struct StatusRowView: View { } } .onAppear { - viewModel.markSeen() if !reasons.contains(.placeholder) { if !isCompact, viewModel.embeddedStatus == nil { Task { diff --git a/Packages/StatusKit/Sources/StatusKit/Row/StatusRowViewModel.swift b/Packages/StatusKit/Sources/StatusKit/Row/StatusRowViewModel.swift index 32e91b1b..7f19f5e2 100644 --- a/Packages/StatusKit/Sources/StatusKit/Row/StatusRowViewModel.swift +++ b/Packages/StatusKit/Sources/StatusKit/Row/StatusRowViewModel.swift @@ -151,19 +151,6 @@ import SwiftUI recalcCollapse() } - func markSeen() { - // called in on appear so we can cache that the status has been seen. - if UserPreferences.shared.suppressDupeReblogs, !seen { - DispatchQueue.global().async { [weak self] in - guard let self else { return } - ReblogCache.shared.cache(status, seen: true) - Task { @MainActor in - self.seen = true - } - } - } - } - func navigateToDetail() { if isRemote, let url = URL(string: finalStatus.url ?? "") { routerPath.navigate(to: .remoteStatusDetail(url: url)) diff --git a/Packages/Timeline/Sources/Timeline/TimelineContentFilter.swift b/Packages/Timeline/Sources/Timeline/TimelineContentFilter.swift new file mode 100644 index 00000000..5af4d4e2 --- /dev/null +++ b/Packages/Timeline/Sources/Timeline/TimelineContentFilter.swift @@ -0,0 +1,46 @@ +import Foundation +import SwiftUI + +@MainActor +@Observable public class TimelineContentFilter { + class Storage { + @AppStorage("timeline_show_boosts") var showBoosts: Bool = true + @AppStorage("timeline_show_replies") var showReplies: Bool = true + @AppStorage("timeline_show_threads") var showThreads: Bool = true + @AppStorage("timeline_quote_posts") var showQuotePosts: Bool = true + } + + public static let shared = TimelineContentFilter() + private let storage = Storage() + + public var showBoosts: Bool { + didSet { + storage.showBoosts = showBoosts + } + } + + public var showReplies: Bool { + didSet { + storage.showReplies = showReplies + } + } + + public var showThreads: Bool { + didSet { + storage.showThreads = showThreads + } + } + + public var showQuotePosts: Bool { + didSet { + storage.showQuotePosts = showQuotePosts + } + } + + private init() { + showBoosts = storage.showBoosts + showReplies = storage.showReplies + showThreads = storage.showThreads + showQuotePosts = storage.showQuotePosts + } +} diff --git a/Packages/Timeline/Sources/Timeline/View/TimelineContentFilterView.swift b/Packages/Timeline/Sources/Timeline/View/TimelineContentFilterView.swift new file mode 100644 index 00000000..cf2a5396 --- /dev/null +++ b/Packages/Timeline/Sources/Timeline/View/TimelineContentFilterView.swift @@ -0,0 +1,38 @@ +import SwiftUI +import DesignSystem + +public struct TimelineContentFilterView: View { + @Environment(Theme.self) private var theme + + @State private var contentFilter = TimelineContentFilter.shared + + public init() {} + + public var body: some View { + Form { + Section { + Toggle(isOn: $contentFilter.showBoosts) { + Label("timeline.filter.show-boosts", image: "Rocket") + } + Toggle(isOn: $contentFilter.showReplies) { + Label("timeline.filter.show-replies", systemImage: "bubble.left.and.bubble.right") + } + Toggle(isOn: $contentFilter.showThreads) { + Label("timeline.filter.show-threads", systemImage: "bubble.left.and.text.bubble.right") + } + Toggle(isOn: $contentFilter.showQuotePosts) { + Label("timeline.filter.show-quote", systemImage: "quote.bubble") + } + } + #if !os(visionOS) + .listRowBackground(theme.primaryBackgroundColor) + #endif + } + .navigationTitle("timeline.content-filter.title") + .navigationBarTitleDisplayMode(.inline) + #if !os(visionOS) + .scrollContentBackground(.hidden) + .background(theme.secondaryBackgroundColor) + #endif + } +} diff --git a/Packages/Timeline/Sources/Timeline/View/TimelineView.swift b/Packages/Timeline/Sources/Timeline/View/TimelineView.swift index 0e468710..72fdfe61 100644 --- a/Packages/Timeline/Sources/Timeline/View/TimelineView.swift +++ b/Packages/Timeline/Sources/Timeline/View/TimelineView.swift @@ -20,6 +20,7 @@ public struct TimelineView: View { @State private var viewModel = TimelineViewModel() @State private var prefetcher = TimelineMediaPrefetcher() + @State private var contentFilter = TimelineContentFilter.shared @State private var wasBackgrounded: Bool = false @State private var collectionView: UICollectionView? @@ -166,6 +167,18 @@ public struct TimelineView: View { .onChange(of: viewModel.timeline) { _, newValue in timeline = newValue } + .onChange(of: contentFilter.showReplies) { _, _ in + Task { await viewModel.refreshTimelineContentFilter() } + } + .onChange(of: contentFilter.showBoosts) { _, _ in + Task { await viewModel.refreshTimelineContentFilter() } + } + .onChange(of: contentFilter.showThreads) { _, _ in + Task { await viewModel.refreshTimelineContentFilter() } + } + .onChange(of: contentFilter.showQuotePosts) { _, _ in + Task { await viewModel.refreshTimelineContentFilter() } + } .onChange(of: scenePhase) { _, newValue in switch newValue { case .active: diff --git a/Packages/Timeline/Sources/Timeline/View/TimelineViewModel.swift b/Packages/Timeline/Sources/Timeline/View/TimelineViewModel.swift index 43722761..407f687f 100644 --- a/Packages/Timeline/Sources/Timeline/View/TimelineViewModel.swift +++ b/Packages/Timeline/Sources/Timeline/View/TimelineViewModel.swift @@ -205,6 +205,14 @@ extension TimelineViewModel: StatusesFetcher { } } + func refreshTimelineContentFilter() async { + timelineTask?.cancel() + let statuses = await datasource.getFiltered() + withAnimation { + statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) + } + } + func fetchStatuses(from: Marker.Content) async throws { guard let client else { return } statusesState = .loading @@ -213,7 +221,6 @@ extension TimelineViewModel: StatusesFetcher { minId: nil, offset: 0)) - ReblogCache.shared.removeDuplicateReblogs(&statuses) StatusDataControllerProvider.shared.updateDataControllers(for: statuses, client: client) await datasource.set(statuses) @@ -284,7 +291,6 @@ extension TimelineViewModel: StatusesFetcher { minId: nil, offset: 0)) - ReblogCache.shared.removeDuplicateReblogs(&statuses) StatusDataControllerProvider.shared.updateDataControllers(for: statuses, client: client) await datasource.set(statuses) @@ -309,7 +315,6 @@ extension TimelineViewModel: StatusesFetcher { !ids.contains(where: { $0 == status.id }) } - ReblogCache.shared.removeDuplicateReblogs(&newStatuses) StatusDataControllerProvider.shared.updateDataControllers(for: newStatuses, client: client) // If no new statuses, resume streaming and exit. @@ -401,7 +406,6 @@ extension TimelineViewModel: StatusesFetcher { { pagesLoaded += 1 - ReblogCache.shared.removeDuplicateReblogs(&newStatuses) StatusDataControllerProvider.shared.updateDataControllers(for: newStatuses, client: client) allStatuses.insert(contentsOf: newStatuses, at: 0) @@ -424,7 +428,6 @@ extension TimelineViewModel: StatusesFetcher { minId: nil, offset: statuses.count)) - ReblogCache.shared.removeDuplicateReblogs(&newStatuses) await datasource.append(contentOf: newStatuses) StatusDataControllerProvider.shared.updateDataControllers(for: newStatuses, client: client) diff --git a/Packages/Timeline/Sources/Timeline/actors/TimelineDatasource.swift b/Packages/Timeline/Sources/Timeline/actors/TimelineDatasource.swift index a099ac9f..6c663d29 100644 --- a/Packages/Timeline/Sources/Timeline/actors/TimelineDatasource.swift +++ b/Packages/Timeline/Sources/Timeline/actors/TimelineDatasource.swift @@ -1,5 +1,6 @@ import Foundation import Models +import Env actor TimelineDatasource { private var statuses: [Status] = [] @@ -12,8 +13,22 @@ actor TimelineDatasource { statuses } - func getFiltered() -> [Status] { - statuses.filter{ !$0.isHidden } + func getFiltered() async -> [Status] { + let contentFilter = TimelineContentFilter.shared + let showReplies = await contentFilter.showReplies + let showBoosts = await contentFilter.showBoosts + let showThreads = await contentFilter.showThreads + let showQuotePosts = await contentFilter.showQuotePosts + return statuses.filter { status in + if status.isHidden || + !showReplies && status.inReplyToId != nil && status.inReplyToAccountId != status.account.id || + !showBoosts && status.reblog != nil || + !showThreads && status.inReplyToAccountId == status.account.id || + !showQuotePosts && !status.content.statusesURLs.isEmpty { + return false + } + return true + } } func count() -> Int {