forked from shadowfacts/Tusker
Timeline filtering!
This commit is contained in:
parent
7e5d8675c2
commit
81abcfcf7b
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public enum Timeline {
|
public enum Timeline: Equatable {
|
||||||
case home
|
case home
|
||||||
case `public`(local: Bool)
|
case `public`(local: Bool)
|
||||||
case tag(hashtag: String)
|
case tag(hashtag: String)
|
||||||
|
|
|
@ -64,6 +64,8 @@
|
||||||
D61F75B1293BD85300C0B37F /* CreateFilterService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75B0293BD85300C0B37F /* CreateFilterService.swift */; };
|
D61F75B1293BD85300C0B37F /* CreateFilterService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75B0293BD85300C0B37F /* CreateFilterService.swift */; };
|
||||||
D61F75B3293BD89C00C0B37F /* UpdateFilterService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75B2293BD89C00C0B37F /* UpdateFilterService.swift */; };
|
D61F75B3293BD89C00C0B37F /* UpdateFilterService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75B2293BD89C00C0B37F /* UpdateFilterService.swift */; };
|
||||||
D61F75B5293BD97400C0B37F /* DeleteFilterService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75B4293BD97400C0B37F /* DeleteFilterService.swift */; };
|
D61F75B5293BD97400C0B37F /* DeleteFilterService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75B4293BD97400C0B37F /* DeleteFilterService.swift */; };
|
||||||
|
D61F75B7293C119700C0B37F /* Filterer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75B6293C119700C0B37F /* Filterer.swift */; };
|
||||||
|
D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */; };
|
||||||
D61F75BB293C183100C0B37F /* HTMLConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75BA293C183100C0B37F /* HTMLConverter.swift */; };
|
D61F75BB293C183100C0B37F /* HTMLConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75BA293C183100C0B37F /* HTMLConverter.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 */; };
|
||||||
|
@ -445,6 +447,8 @@
|
||||||
D61F75B0293BD85300C0B37F /* CreateFilterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateFilterService.swift; sourceTree = "<group>"; };
|
D61F75B0293BD85300C0B37F /* CreateFilterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateFilterService.swift; sourceTree = "<group>"; };
|
||||||
D61F75B2293BD89C00C0B37F /* UpdateFilterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateFilterService.swift; sourceTree = "<group>"; };
|
D61F75B2293BD89C00C0B37F /* UpdateFilterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateFilterService.swift; sourceTree = "<group>"; };
|
||||||
D61F75B4293BD97400C0B37F /* DeleteFilterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteFilterService.swift; sourceTree = "<group>"; };
|
D61F75B4293BD97400C0B37F /* DeleteFilterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteFilterService.swift; sourceTree = "<group>"; };
|
||||||
|
D61F75B6293C119700C0B37F /* Filterer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Filterer.swift; sourceTree = "<group>"; };
|
||||||
|
D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZeroHeightCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
D61F75BA293C183100C0B37F /* HTMLConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLConverter.swift; sourceTree = "<group>"; };
|
D61F75BA293C183100C0B37F /* HTMLConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLConverter.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>"; };
|
||||||
|
@ -1347,6 +1351,7 @@
|
||||||
D620483523D38075008A63EF /* ContentTextView.swift */,
|
D620483523D38075008A63EF /* ContentTextView.swift */,
|
||||||
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
|
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
|
||||||
D6969E9F240C8384002843CE /* EmojiLabel.swift */,
|
D6969E9F240C8384002843CE /* EmojiLabel.swift */,
|
||||||
|
D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */,
|
||||||
D67C1794266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift */,
|
D67C1794266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift */,
|
||||||
D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */,
|
D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */,
|
||||||
D6DD2A44273D6C5700386A6C /* GIFImageView.swift */,
|
D6DD2A44273D6C5700386A6C /* GIFImageView.swift */,
|
||||||
|
@ -1454,6 +1459,7 @@
|
||||||
D6D4DDDB212518A200E1C4BB /* Info.plist */,
|
D6D4DDDB212518A200E1C4BB /* Info.plist */,
|
||||||
D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */,
|
D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */,
|
||||||
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */,
|
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */,
|
||||||
|
D61F75B6293C119700C0B37F /* Filterer.swift */,
|
||||||
D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */,
|
D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */,
|
||||||
D61F75BA293C183100C0B37F /* HTMLConverter.swift */,
|
D61F75BA293C183100C0B37F /* HTMLConverter.swift */,
|
||||||
D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */,
|
D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */,
|
||||||
|
@ -1992,6 +1998,7 @@
|
||||||
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */,
|
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */,
|
||||||
D62E9985279CA23900C26176 /* URLSession+Development.swift in Sources */,
|
D62E9985279CA23900C26176 /* URLSession+Development.swift in Sources */,
|
||||||
D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */,
|
D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */,
|
||||||
|
D61F75B7293C119700C0B37F /* Filterer.swift in Sources */,
|
||||||
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */,
|
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */,
|
||||||
D6895DE928D962C2006341DA /* TimelineLikeController.swift in Sources */,
|
D6895DE928D962C2006341DA /* TimelineLikeController.swift in Sources */,
|
||||||
D6420AEE26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.swift in Sources */,
|
D6420AEE26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.swift in Sources */,
|
||||||
|
@ -2082,6 +2089,7 @@
|
||||||
D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */,
|
D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */,
|
||||||
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */,
|
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */,
|
||||||
D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */,
|
D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */,
|
||||||
|
D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */,
|
||||||
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */,
|
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */,
|
||||||
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
|
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
|
||||||
D6F6A5592920676800F496A8 /* ComposeToolbar.swift in Sources */,
|
D6F6A5592920676800F496A8 /* ComposeToolbar.swift in Sources */,
|
||||||
|
|
|
@ -148,14 +148,25 @@ class MastodonController: ObservableObject {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func initialize() async throws {
|
@MainActor
|
||||||
async let ownAccount = try getOwnAccount()
|
func initialize() {
|
||||||
async let ownInstance = try getOwnInstance()
|
// we want this to happen immediately, and synchronously so that the filters (which don't change that often)
|
||||||
|
// are available when Filterers are constructed
|
||||||
|
loadCachedFilters()
|
||||||
|
|
||||||
_ = try await (ownAccount, ownInstance)
|
Task {
|
||||||
|
do {
|
||||||
|
async let ownAccount = try getOwnAccount()
|
||||||
|
async let ownInstance = try getOwnInstance()
|
||||||
|
|
||||||
loadLists()
|
_ = try await (ownAccount, ownInstance)
|
||||||
async let _ = await loadFilters()
|
|
||||||
|
loadLists()
|
||||||
|
async let _ = await loadFilters()
|
||||||
|
} catch {
|
||||||
|
Logging.general.error("MastodonController initialization failed: \(String(describing: error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getOwnAccount(completion: ((Result<Account, Client.Error>) -> Void)? = nil) {
|
func getOwnAccount(completion: ((Result<Account, Client.Error>) -> Void)? = nil) {
|
||||||
|
@ -354,8 +365,6 @@ class MastodonController: ObservableObject {
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func loadFilters() async {
|
func loadFilters() async {
|
||||||
filters = (try? persistentContainer.viewContext.fetch(FilterMO.fetchRequest())) ?? []
|
|
||||||
|
|
||||||
var apiFilters: [AnyFilter]?
|
var apiFilters: [AnyFilter]?
|
||||||
if instanceFeatures.filtersV2 {
|
if instanceFeatures.filtersV2 {
|
||||||
let req = Client.getFiltersV2()
|
let req = Client.getFiltersV2()
|
||||||
|
@ -377,4 +386,9 @@ class MastodonController: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func loadCachedFilters() {
|
||||||
|
filters = (try? persistentContainer.viewContext.fetch(FilterMO.fetchRequest())) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,159 @@
|
||||||
|
//
|
||||||
|
// Filterer.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/3/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Pachyderm
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
class FilterState {
|
||||||
|
static var unknown: FilterState { FilterState(state: .unknown) }
|
||||||
|
|
||||||
|
private var state: State
|
||||||
|
|
||||||
|
var isWarning: Bool {
|
||||||
|
switch state {
|
||||||
|
case .known(.warn(_)):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private init(state: State) {
|
||||||
|
self.state = state
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a closure for the status in case the result is cached and we don't need to look it up
|
||||||
|
@MainActor
|
||||||
|
func resolveFor(status: () -> StatusMO, resolver: Filterer) -> Filterer.Result {
|
||||||
|
switch state {
|
||||||
|
case .unknown:
|
||||||
|
let result = resolver.resolve(status: status())
|
||||||
|
state = .known(result)
|
||||||
|
return result
|
||||||
|
case .known(let result):
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setResult(_ result: Filterer.Result) {
|
||||||
|
self.state = .known(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum State {
|
||||||
|
case unknown
|
||||||
|
case known(Filterer.Result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class Filterer {
|
||||||
|
let mastodonController: MastodonController
|
||||||
|
let context: FilterV1.Context
|
||||||
|
|
||||||
|
private let htmlConverter = HTMLConverter()
|
||||||
|
private var needsSetupFilters = true
|
||||||
|
private var matchers = [(NSRegularExpression, Result)]()
|
||||||
|
private var filtersChanged: AnyCancellable?
|
||||||
|
private var filterObservers = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(mastodonController: MastodonController, context: FilterV1.Context) {
|
||||||
|
self.mastodonController = mastodonController
|
||||||
|
self.context = context
|
||||||
|
|
||||||
|
filtersChanged = mastodonController.$filters
|
||||||
|
.sink { [unowned self] _ in
|
||||||
|
self.needsSetupFilters = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupFilters(filters: [FilterMO]) {
|
||||||
|
print("setting up filters")
|
||||||
|
matchers = []
|
||||||
|
filterObservers = []
|
||||||
|
for filter in filters where filter.contexts.contains(context) {
|
||||||
|
guard let matcher = filter.createMatcher() else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matchers.append(matcher)
|
||||||
|
|
||||||
|
filter.objectWillChange
|
||||||
|
.sink { [unowned self] _ in
|
||||||
|
self.needsSetupFilters = true
|
||||||
|
}
|
||||||
|
.store(in: &filterObservers)
|
||||||
|
}
|
||||||
|
needsSetupFilters = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolve(status: StatusMO) -> Result {
|
||||||
|
if needsSetupFilters {
|
||||||
|
setupFilters(filters: mastodonController.filters)
|
||||||
|
}
|
||||||
|
if matchers.isEmpty {
|
||||||
|
return .allow
|
||||||
|
}
|
||||||
|
lazy var text = htmlConverter.convert(status.content).string
|
||||||
|
for (regex, result) in matchers {
|
||||||
|
if (!status.spoilerText.isEmpty && regex.numberOfMatches(in: status.spoilerText, range: NSRange(location: 0, length: status.spoilerText.utf16.count)) > 0)
|
||||||
|
|| regex.numberOfMatches(in: text, range: NSRange(location: 0, length: text.utf16.count)) > 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return .allow
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Result {
|
||||||
|
case allow
|
||||||
|
case hide
|
||||||
|
case warn(String)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension FilterMO {
|
||||||
|
func createMatcher() -> (NSRegularExpression, Filterer.Result)? {
|
||||||
|
guard keywords.count > 0 else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: it would be cool to use the Regex builder stuff for this, but it's iOS 16 only
|
||||||
|
var pattern = ""
|
||||||
|
|
||||||
|
var isFirst = true
|
||||||
|
for keyword in keywordMOs {
|
||||||
|
if isFirst {
|
||||||
|
isFirst = false
|
||||||
|
} else {
|
||||||
|
pattern += "|"
|
||||||
|
}
|
||||||
|
pattern += "("
|
||||||
|
if keyword.wholeWord {
|
||||||
|
pattern += "\\b"
|
||||||
|
}
|
||||||
|
pattern += NSRegularExpression.escapedPattern(for: keyword.keyword)
|
||||||
|
if keyword.wholeWord {
|
||||||
|
pattern += "\\b"
|
||||||
|
}
|
||||||
|
pattern += ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.useUnicodeWordBoundaries, .caseInsensitive]) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: Filterer.Result
|
||||||
|
switch filterAction {
|
||||||
|
case .hide:
|
||||||
|
result = .hide
|
||||||
|
case .warn:
|
||||||
|
result = .warn(titleOrKeyword)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (regex, result)
|
||||||
|
}
|
||||||
|
}
|
|
@ -42,9 +42,7 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
|
|
||||||
let controller = MastodonController.getForAccount(account)
|
let controller = MastodonController.getForAccount(account)
|
||||||
session.mastodonController = controller
|
session.mastodonController = controller
|
||||||
Task {
|
controller.initialize()
|
||||||
try? await controller.initialize()
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let rootVC = viewController(for: activity, mastodonController: controller) else {
|
guard let rootVC = viewController(for: activity, mastodonController: controller) else {
|
||||||
UIApplication.shared.requestSceneSessionDestruction(session, options: nil, errorHandler: nil)
|
UIApplication.shared.requestSceneSessionDestruction(session, options: nil, errorHandler: nil)
|
||||||
|
|
|
@ -50,9 +50,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
session.mastodonController = controller
|
session.mastodonController = controller
|
||||||
Task {
|
controller.initialize()
|
||||||
try? await controller.initialize()
|
|
||||||
}
|
|
||||||
|
|
||||||
let composeVC = ComposeHostingController(draft: draft, mastodonController: controller)
|
let composeVC = ComposeHostingController(draft: draft, mastodonController: controller)
|
||||||
composeVC.delegate = self
|
composeVC.delegate = self
|
||||||
|
|
|
@ -212,9 +212,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
||||||
|
|
||||||
func createAppUI() -> TuskerRootViewController {
|
func createAppUI() -> TuskerRootViewController {
|
||||||
let mastodonController = window!.windowScene!.session.mastodonController!
|
let mastodonController = window!.windowScene!.session.mastodonController!
|
||||||
Task {
|
mastodonController.initialize()
|
||||||
try? await mastodonController.initialize()
|
|
||||||
}
|
|
||||||
|
|
||||||
let split = MainSplitViewController(mastodonController: mastodonController)
|
let split = MainSplitViewController(mastodonController: mastodonController)
|
||||||
if UIDevice.current.userInterfaceIdiom == .phone,
|
if UIDevice.current.userInterfaceIdiom == .phone,
|
||||||
|
|
|
@ -13,6 +13,7 @@ import Combine
|
||||||
class TimelineViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController, RefreshableViewController {
|
class TimelineViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController, RefreshableViewController {
|
||||||
let timeline: Timeline
|
let timeline: Timeline
|
||||||
weak var mastodonController: MastodonController!
|
weak var mastodonController: MastodonController!
|
||||||
|
let filterer: Filterer
|
||||||
|
|
||||||
private(set) var controller: TimelineLikeController<TimelineItem>!
|
private(set) var controller: TimelineLikeController<TimelineItem>!
|
||||||
let confirmLoadMore = PassthroughSubject<Void, Never>()
|
let confirmLoadMore = PassthroughSubject<Void, Never>()
|
||||||
|
@ -28,6 +29,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
init(for timeline: Timeline, mastodonController: MastodonController!) {
|
init(for timeline: Timeline, mastodonController: MastodonController!) {
|
||||||
self.timeline = timeline
|
self.timeline = timeline
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
|
self.filterer = Filterer(mastodonController: mastodonController, context: timeline == .home ? .home : .public)
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
|
@ -59,6 +61,10 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
if item.hideSeparators {
|
if item.hideSeparators {
|
||||||
config.topSeparatorVisibility = .hidden
|
config.topSeparatorVisibility = .hidden
|
||||||
config.bottomSeparatorVisibility = .hidden
|
config.bottomSeparatorVisibility = .hidden
|
||||||
|
} else if case .status(id: let id, collapseState: _, filterState: let filterState) = item,
|
||||||
|
case .hide = filterState.resolveFor(status: { mastodonController.persistentContainer.status(for: id)! }, resolver: filterer) {
|
||||||
|
config.topSeparatorVisibility = .hidden
|
||||||
|
config.bottomSeparatorVisibility = .hidden
|
||||||
} else {
|
} else {
|
||||||
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
||||||
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
||||||
|
@ -113,6 +119,13 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, CollapseState)> { [unowned self] cell, indexPath, item in
|
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, CollapseState)> { [unowned self] cell, indexPath, item in
|
||||||
self.configureStatusCell(cell, id: item.0, state: item.1)
|
self.configureStatusCell(cell, id: item.0, state: item.1)
|
||||||
}
|
}
|
||||||
|
let zeroHeightCell = UICollectionView.CellRegistration<ZeroHeightCollectionViewCell, Void> { _, _, _ in
|
||||||
|
}
|
||||||
|
let filterWarningCell = UICollectionView.CellRegistration<UICollectionViewListCell, String> { cell, indexPath, item in
|
||||||
|
var config = cell.defaultContentConfiguration()
|
||||||
|
config.text = "Filtered: \(item)"
|
||||||
|
cell.contentConfiguration = config
|
||||||
|
}
|
||||||
let gapCell = UICollectionView.CellRegistration<TimelineGapCollectionViewCell, Void> { cell, indexPath, _ in
|
let gapCell = UICollectionView.CellRegistration<TimelineGapCollectionViewCell, Void> { cell, indexPath, _ in
|
||||||
cell.showsIndicator = false
|
cell.showsIndicator = false
|
||||||
}
|
}
|
||||||
|
@ -128,8 +141,16 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
}
|
}
|
||||||
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
|
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
|
||||||
switch itemIdentifier {
|
switch itemIdentifier {
|
||||||
case .status(id: let id, state: let state):
|
case .status(id: let id, collapseState: let state, filterState: let filterState):
|
||||||
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state))
|
let status = { self.mastodonController.persistentContainer.status(for: id)! }
|
||||||
|
switch filterState.resolveFor(status: status, resolver: filterer) {
|
||||||
|
case .allow:
|
||||||
|
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state))
|
||||||
|
case .hide:
|
||||||
|
return collectionView.dequeueConfiguredReusableCell(using: zeroHeightCell, for: indexPath, item: ())
|
||||||
|
case .warn(let filterTitle):
|
||||||
|
return collectionView.dequeueConfiguredReusableCell(using: filterWarningCell, for: indexPath, item: filterTitle)
|
||||||
|
}
|
||||||
case .gap:
|
case .gap:
|
||||||
return collectionView.dequeueConfiguredReusableCell(using: gapCell, for: indexPath, item: ())
|
return collectionView.dequeueConfiguredReusableCell(using: gapCell, for: indexPath, item: ())
|
||||||
case .loadingIndicator:
|
case .loadingIndicator:
|
||||||
|
@ -229,14 +250,14 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
centerVisibleItem = allItems[centerVisible.row]
|
centerVisibleItem = allItems[centerVisible.row]
|
||||||
}
|
}
|
||||||
let ids = items.map {
|
let ids = items.map {
|
||||||
if case .status(id: let id, state: _) = $0 {
|
if case .status(id: let id, _, _) = $0 {
|
||||||
return id
|
return id
|
||||||
} else {
|
} else {
|
||||||
fatalError()
|
fatalError()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let centerVisibleID: String
|
let centerVisibleID: String
|
||||||
if case .status(id: let id, state: _) = centerVisibleItem {
|
if case .status(id: let id, _, _) = centerVisibleItem {
|
||||||
centerVisibleID = id
|
centerVisibleID = id
|
||||||
} else {
|
} else {
|
||||||
fatalError()
|
fatalError()
|
||||||
|
@ -269,7 +290,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
controller.restoreInitial {
|
controller.restoreInitial {
|
||||||
var snapshot = dataSource.snapshot()
|
var snapshot = dataSource.snapshot()
|
||||||
snapshot.appendSections([.statuses])
|
snapshot.appendSections([.statuses])
|
||||||
let items = statusIDs.map { Item.status(id: $0, state: .unknown) }
|
let items = statusIDs.map { Item.status(id: $0, collapseState: .unknown, filterState: .unknown) }
|
||||||
snapshot.appendItems(items, toSection: .statuses)
|
snapshot.appendItems(items, toSection: .statuses)
|
||||||
dataSource.apply(snapshot, animatingDifferences: false) {
|
dataSource.apply(snapshot, animatingDifferences: false) {
|
||||||
if let centerID = activity.userInfo?["centerID"] as? String ?? activity.userInfo?["topID"] as? String,
|
if let centerID = activity.userInfo?["centerID"] as? String ?? activity.userInfo?["topID"] as? String,
|
||||||
|
@ -353,14 +374,14 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let currentItems = snapshot.itemIdentifiers(inSection: .statuses)
|
let currentItems = snapshot.itemIdentifiers(inSection: .statuses)
|
||||||
if case .status(id: let firstID, state: _) = currentItems.first,
|
if case .status(id: let firstID, _, _) = currentItems.first,
|
||||||
// if there's no overlap between presentItems and the existing items in the data source, prompt the user
|
// if there's no overlap between presentItems and the existing items in the data source, prompt the user
|
||||||
!presentItems.contains(firstID) {
|
!presentItems.contains(firstID) {
|
||||||
|
|
||||||
// create a new snapshot to reset the timeline to the "present" state
|
// create a new snapshot to reset the timeline to the "present" state
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
snapshot.appendSections([.statuses])
|
snapshot.appendSections([.statuses])
|
||||||
snapshot.appendItems(presentItems.map { .status(id: $0, state: .unknown) }, toSection: .statuses)
|
snapshot.appendItems(presentItems.map { .status(id: $0, collapseState: .unknown, filterState: .unknown) }, toSection: .statuses)
|
||||||
|
|
||||||
var config = ToastConfiguration(title: "Jump to present")
|
var config = ToastConfiguration(title: "Jump to present")
|
||||||
config.edge = .top
|
config.edge = .top
|
||||||
|
@ -463,19 +484,19 @@ extension TimelineViewController {
|
||||||
enum Item: TimelineLikeCollectionViewItem {
|
enum Item: TimelineLikeCollectionViewItem {
|
||||||
typealias TimelineItem = String // status ID
|
typealias TimelineItem = String // status ID
|
||||||
|
|
||||||
case status(id: String, state: CollapseState)
|
case status(id: String, collapseState: CollapseState, filterState: FilterState)
|
||||||
case gap
|
case gap
|
||||||
case loadingIndicator
|
case loadingIndicator
|
||||||
case confirmLoadMore
|
case confirmLoadMore
|
||||||
case publicTimelineDescription
|
case publicTimelineDescription
|
||||||
|
|
||||||
static func fromTimelineItem(_ id: String) -> Self {
|
static func fromTimelineItem(_ id: String) -> Self {
|
||||||
return .status(id: id, state: .unknown)
|
return .status(id: id, collapseState: .unknown, filterState: .unknown)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func ==(lhs: Item, rhs: Item) -> Bool {
|
static func ==(lhs: Item, rhs: Item) -> Bool {
|
||||||
switch (lhs, rhs) {
|
switch (lhs, rhs) {
|
||||||
case let (.status(id: a, state: _), .status(id: b, state: _)):
|
case let (.status(id: a, _, _), .status(id: b, _, _)):
|
||||||
return a == b
|
return a == b
|
||||||
case (.gap, .gap):
|
case (.gap, .gap):
|
||||||
return true
|
return true
|
||||||
|
@ -492,7 +513,7 @@ extension TimelineViewController {
|
||||||
|
|
||||||
func hash(into hasher: inout Hasher) {
|
func hash(into hasher: inout Hasher) {
|
||||||
switch self {
|
switch self {
|
||||||
case .status(id: let id, state: _):
|
case .status(id: let id, _, _):
|
||||||
hasher.combine(0)
|
hasher.combine(0)
|
||||||
hasher.combine(id)
|
hasher.combine(id)
|
||||||
case .gap:
|
case .gap:
|
||||||
|
@ -517,7 +538,7 @@ extension TimelineViewController {
|
||||||
|
|
||||||
var isSelectable: Bool {
|
var isSelectable: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .publicTimelineDescription, .gap, .status(id: _, state: _):
|
case .publicTimelineDescription, .gap, .status(_, _, _):
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
@ -547,7 +568,7 @@ extension TimelineViewController {
|
||||||
|
|
||||||
func loadNewer() async throws -> [TimelineItem] {
|
func loadNewer() async throws -> [TimelineItem] {
|
||||||
let statusesSection = dataSource.snapshot().indexOfSection(.statuses)!
|
let statusesSection = dataSource.snapshot().indexOfSection(.statuses)!
|
||||||
guard case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: 0, section: statusesSection)) else {
|
guard case .status(id: let id, _, _) = dataSource.itemIdentifier(for: IndexPath(row: 0, section: statusesSection)) else {
|
||||||
throw Error.noNewer
|
throw Error.noNewer
|
||||||
}
|
}
|
||||||
let newer = RequestRange.after(id: id, count: nil)
|
let newer = RequestRange.after(id: id, count: nil)
|
||||||
|
@ -571,7 +592,7 @@ extension TimelineViewController {
|
||||||
func loadOlder() async throws -> [TimelineItem] {
|
func loadOlder() async throws -> [TimelineItem] {
|
||||||
let snapshot = dataSource.snapshot()
|
let snapshot = dataSource.snapshot()
|
||||||
let statusesSection = snapshot.indexOfSection(.statuses)!
|
let statusesSection = snapshot.indexOfSection(.statuses)!
|
||||||
guard case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: snapshot.numberOfItems(inSection: .statuses) - 1, section: statusesSection)) else {
|
guard case .status(id: let id, _, _) = dataSource.itemIdentifier(for: IndexPath(row: snapshot.numberOfItems(inSection: .statuses) - 1, section: statusesSection)) else {
|
||||||
throw Error.noNewer
|
throw Error.noNewer
|
||||||
}
|
}
|
||||||
let older = RequestRange.before(id: id, count: nil)
|
let older = RequestRange.before(id: id, count: nil)
|
||||||
|
@ -601,14 +622,14 @@ extension TimelineViewController {
|
||||||
switch direction {
|
switch direction {
|
||||||
case .above:
|
case .above:
|
||||||
guard gapIndexPath.row > 0,
|
guard gapIndexPath.row > 0,
|
||||||
case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: gapIndexPath.row - 1, section: gapIndexPath.section)) else {
|
case .status(id: let id, _, _) = dataSource.itemIdentifier(for: IndexPath(row: gapIndexPath.row - 1, section: gapIndexPath.section)) else {
|
||||||
// not really the right error but w/e
|
// not really the right error but w/e
|
||||||
throw Error.noGap
|
throw Error.noGap
|
||||||
}
|
}
|
||||||
range = .before(id: id, count: nil)
|
range = .before(id: id, count: nil)
|
||||||
case .below:
|
case .below:
|
||||||
guard gapIndexPath.row < statusItemsCount - 1,
|
guard gapIndexPath.row < statusItemsCount - 1,
|
||||||
case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: gapIndexPath.row + 1, section: gapIndexPath.section)) else {
|
case .status(id: let id, _, _) = dataSource.itemIdentifier(for: IndexPath(row: gapIndexPath.row + 1, section: gapIndexPath.section)) else {
|
||||||
throw Error.noGap
|
throw Error.noGap
|
||||||
}
|
}
|
||||||
range = .after(id: id, count: nil)
|
range = .after(id: id, count: nil)
|
||||||
|
@ -658,13 +679,13 @@ extension TimelineViewController {
|
||||||
|
|
||||||
// if there is any overlap, the first overlapping item will be the first item below the gap
|
// if there is any overlap, the first overlapping item will be the first item below the gap
|
||||||
var indexOfFirstTimelineItemExistingBelowGap: Int?
|
var indexOfFirstTimelineItemExistingBelowGap: Int?
|
||||||
if case .status(id: let id, state: _) = afterGap.first {
|
if case .status(id: let id, _, _) = afterGap.first {
|
||||||
indexOfFirstTimelineItemExistingBelowGap = timelineItems.firstIndex(of: id)
|
indexOfFirstTimelineItemExistingBelowGap = timelineItems.firstIndex(of: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// the end index of the range of timelineItems that don't yet exist in the data source
|
// the end index of the range of timelineItems that don't yet exist in the data source
|
||||||
let endIndex = indexOfFirstTimelineItemExistingBelowGap ?? timelineItems.endIndex
|
let endIndex = indexOfFirstTimelineItemExistingBelowGap ?? timelineItems.endIndex
|
||||||
let toInsert = timelineItems[..<endIndex].map { Item.status(id: $0, state: .unknown) }
|
let toInsert = timelineItems[..<endIndex].map { Item.status(id: $0, collapseState: .unknown, filterState: .unknown) }
|
||||||
if toInsert.isEmpty {
|
if toInsert.isEmpty {
|
||||||
addedItems = false
|
addedItems = false
|
||||||
} else {
|
} else {
|
||||||
|
@ -686,7 +707,7 @@ extension TimelineViewController {
|
||||||
|
|
||||||
// if there's any overlap, last overlapping item will be the last item below the gap
|
// if there's any overlap, last overlapping item will be the last item below the gap
|
||||||
var indexOfLastTimelineItemExistingAboveGap: Int?
|
var indexOfLastTimelineItemExistingAboveGap: Int?
|
||||||
if case .status(id: let id, state: _) = beforeGap.last {
|
if case .status(id: let id, _, _) = beforeGap.last {
|
||||||
indexOfLastTimelineItemExistingAboveGap = timelineItems.lastIndex(of: id)
|
indexOfLastTimelineItemExistingAboveGap = timelineItems.lastIndex(of: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -698,7 +719,7 @@ extension TimelineViewController {
|
||||||
} else {
|
} else {
|
||||||
startIndex = timelineItems.startIndex
|
startIndex = timelineItems.startIndex
|
||||||
}
|
}
|
||||||
let toInsert = timelineItems[startIndex...].map { Item.status(id: $0, state: .unknown) }
|
let toInsert = timelineItems[startIndex...].map { Item.status(id: $0, collapseState: .unknown, filterState: .unknown) }
|
||||||
if toInsert.isEmpty {
|
if toInsert.isEmpty {
|
||||||
addedItems = false
|
addedItems = false
|
||||||
} else {
|
} else {
|
||||||
|
@ -762,10 +783,19 @@ extension TimelineViewController: UICollectionViewDelegate {
|
||||||
switch item {
|
switch item {
|
||||||
case .publicTimelineDescription:
|
case .publicTimelineDescription:
|
||||||
removeTimelineDescriptionCell()
|
removeTimelineDescriptionCell()
|
||||||
case .status(id: let id, state: let state):
|
case .status(id: let id, collapseState: let collapseState, filterState: let filterState):
|
||||||
let status = mastodonController.persistentContainer.status(for: id)!
|
let status = mastodonController.persistentContainer.status(for: id)!
|
||||||
// if the status in the timeline is a reblog, show the status that it is a reblog of
|
if filterState.isWarning {
|
||||||
selected(status: status.reblog?.id ?? id, state: state.copy())
|
filterState.setResult(.allow)
|
||||||
|
var snapshot = dataSource.snapshot()
|
||||||
|
snapshot.reloadItems([item])
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: true) {
|
||||||
|
collectionView.deselectItem(at: indexPath, animated: true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// if the status in the timeline is a reblog, show the status that it is a reblog of
|
||||||
|
selected(status: status.reblog?.id ?? id, state: collapseState.copy())
|
||||||
|
}
|
||||||
case .gap:
|
case .gap:
|
||||||
let cell = collectionView.cellForItem(at: indexPath) as! TimelineGapCollectionViewCell
|
let cell = collectionView.cellForItem(at: indexPath) as! TimelineGapCollectionViewCell
|
||||||
cell.showsIndicator = true
|
cell.showsIndicator = true
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
//
|
||||||
|
// ZeroHeightCollectionViewCell.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/3/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class ZeroHeightCollectionViewCell: UICollectionViewCell {
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
heightAnchor.constraint(equalToConstant: 0).isActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue