Timeline filtering!

This commit is contained in:
Shadowfacts 2022-12-03 22:16:43 -05:00
parent 7e5d8675c2
commit 81abcfcf7b
9 changed files with 268 additions and 42 deletions

View File

@ -8,7 +8,7 @@
import Foundation
public enum Timeline {
public enum Timeline: Equatable {
case home
case `public`(local: Bool)
case tag(hashtag: String)

View File

@ -64,6 +64,8 @@
D61F75B1293BD85300C0B37F /* CreateFilterService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75B0293BD85300C0B37F /* CreateFilterService.swift */; };
D61F75B3293BD89C00C0B37F /* UpdateFilterService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75B2293BD89C00C0B37F /* UpdateFilterService.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 */; };
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.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>"; };
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>"; };
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>"; };
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>"; };
@ -1347,6 +1351,7 @@
D620483523D38075008A63EF /* ContentTextView.swift */,
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
D6969E9F240C8384002843CE /* EmojiLabel.swift */,
D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */,
D67C1794266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift */,
D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */,
D6DD2A44273D6C5700386A6C /* GIFImageView.swift */,
@ -1454,6 +1459,7 @@
D6D4DDDB212518A200E1C4BB /* Info.plist */,
D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */,
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */,
D61F75B6293C119700C0B37F /* Filterer.swift */,
D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */,
D61F75BA293C183100C0B37F /* HTMLConverter.swift */,
D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */,
@ -1992,6 +1998,7 @@
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */,
D62E9985279CA23900C26176 /* URLSession+Development.swift in Sources */,
D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */,
D61F75B7293C119700C0B37F /* Filterer.swift in Sources */,
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */,
D6895DE928D962C2006341DA /* TimelineLikeController.swift in Sources */,
D6420AEE26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.swift in Sources */,
@ -2082,6 +2089,7 @@
D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */,
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */,
D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */,
D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */,
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */,
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
D6F6A5592920676800F496A8 /* ComposeToolbar.swift in Sources */,

View File

@ -148,14 +148,25 @@ class MastodonController: ObservableObject {
})
}
func initialize() async throws {
async let ownAccount = try getOwnAccount()
async let ownInstance = try getOwnInstance()
@MainActor
func initialize() {
// 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)
loadLists()
async let _ = await loadFilters()
Task {
do {
async let ownAccount = try getOwnAccount()
async let ownInstance = try getOwnInstance()
_ = try await (ownAccount, ownInstance)
loadLists()
async let _ = await loadFilters()
} catch {
Logging.general.error("MastodonController initialization failed: \(String(describing: error))")
}
}
}
func getOwnAccount(completion: ((Result<Account, Client.Error>) -> Void)? = nil) {
@ -354,8 +365,6 @@ class MastodonController: ObservableObject {
@MainActor
func loadFilters() async {
filters = (try? persistentContainer.viewContext.fetch(FilterMO.fetchRequest())) ?? []
var apiFilters: [AnyFilter]?
if instanceFeatures.filtersV2 {
let req = Client.getFiltersV2()
@ -377,4 +386,9 @@ class MastodonController: ObservableObject {
}
}
@MainActor
private func loadCachedFilters() {
filters = (try? persistentContainer.viewContext.fetch(FilterMO.fetchRequest())) ?? []
}
}

159
Tusker/Filterer.swift Normal file
View File

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

View File

@ -42,9 +42,7 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate {
let controller = MastodonController.getForAccount(account)
session.mastodonController = controller
Task {
try? await controller.initialize()
}
controller.initialize()
guard let rootVC = viewController(for: activity, mastodonController: controller) else {
UIApplication.shared.requestSceneSessionDestruction(session, options: nil, errorHandler: nil)

View File

@ -50,9 +50,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate {
}
session.mastodonController = controller
Task {
try? await controller.initialize()
}
controller.initialize()
let composeVC = ComposeHostingController(draft: draft, mastodonController: controller)
composeVC.delegate = self

View File

@ -212,9 +212,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
func createAppUI() -> TuskerRootViewController {
let mastodonController = window!.windowScene!.session.mastodonController!
Task {
try? await mastodonController.initialize()
}
mastodonController.initialize()
let split = MainSplitViewController(mastodonController: mastodonController)
if UIDevice.current.userInterfaceIdiom == .phone,

View File

@ -13,6 +13,7 @@ import Combine
class TimelineViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController, RefreshableViewController {
let timeline: Timeline
weak var mastodonController: MastodonController!
let filterer: Filterer
private(set) var controller: TimelineLikeController<TimelineItem>!
let confirmLoadMore = PassthroughSubject<Void, Never>()
@ -28,6 +29,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
init(for timeline: Timeline, mastodonController: MastodonController!) {
self.timeline = timeline
self.mastodonController = mastodonController
self.filterer = Filterer(mastodonController: mastodonController, context: timeline == .home ? .home : .public)
super.init(nibName: nil, bundle: nil)
@ -59,6 +61,10 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
if item.hideSeparators {
config.topSeparatorVisibility = .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 {
config.topSeparatorInsets = 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
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
cell.showsIndicator = false
}
@ -128,8 +141,16 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
}
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
switch itemIdentifier {
case .status(id: let id, state: let state):
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state))
case .status(id: let id, collapseState: let state, filterState: let filterState):
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:
return collectionView.dequeueConfiguredReusableCell(using: gapCell, for: indexPath, item: ())
case .loadingIndicator:
@ -229,14 +250,14 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
centerVisibleItem = allItems[centerVisible.row]
}
let ids = items.map {
if case .status(id: let id, state: _) = $0 {
if case .status(id: let id, _, _) = $0 {
return id
} else {
fatalError()
}
}
let centerVisibleID: String
if case .status(id: let id, state: _) = centerVisibleItem {
if case .status(id: let id, _, _) = centerVisibleItem {
centerVisibleID = id
} else {
fatalError()
@ -269,7 +290,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
controller.restoreInitial {
var snapshot = dataSource.snapshot()
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)
dataSource.apply(snapshot, animatingDifferences: false) {
if let centerID = activity.userInfo?["centerID"] as? String ?? activity.userInfo?["topID"] as? String,
@ -353,14 +374,14 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
return
}
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
!presentItems.contains(firstID) {
// create a new snapshot to reset the timeline to the "present" state
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
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")
config.edge = .top
@ -463,19 +484,19 @@ extension TimelineViewController {
enum Item: TimelineLikeCollectionViewItem {
typealias TimelineItem = String // status ID
case status(id: String, state: CollapseState)
case status(id: String, collapseState: CollapseState, filterState: FilterState)
case gap
case loadingIndicator
case confirmLoadMore
case publicTimelineDescription
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 {
switch (lhs, rhs) {
case let (.status(id: a, state: _), .status(id: b, state: _)):
case let (.status(id: a, _, _), .status(id: b, _, _)):
return a == b
case (.gap, .gap):
return true
@ -492,7 +513,7 @@ extension TimelineViewController {
func hash(into hasher: inout Hasher) {
switch self {
case .status(id: let id, state: _):
case .status(id: let id, _, _):
hasher.combine(0)
hasher.combine(id)
case .gap:
@ -517,7 +538,7 @@ extension TimelineViewController {
var isSelectable: Bool {
switch self {
case .publicTimelineDescription, .gap, .status(id: _, state: _):
case .publicTimelineDescription, .gap, .status(_, _, _):
return true
default:
return false
@ -547,7 +568,7 @@ extension TimelineViewController {
func loadNewer() async throws -> [TimelineItem] {
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
}
let newer = RequestRange.after(id: id, count: nil)
@ -571,7 +592,7 @@ extension TimelineViewController {
func loadOlder() async throws -> [TimelineItem] {
let snapshot = dataSource.snapshot()
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
}
let older = RequestRange.before(id: id, count: nil)
@ -601,14 +622,14 @@ extension TimelineViewController {
switch direction {
case .above:
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
throw Error.noGap
}
range = .before(id: id, count: nil)
case .below:
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
}
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
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)
}
// the end index of the range of timelineItems that don't yet exist in the data source
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 {
addedItems = false
} else {
@ -686,7 +707,7 @@ extension TimelineViewController {
// if there's any overlap, last overlapping item will be the last item below the gap
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)
}
@ -698,7 +719,7 @@ extension TimelineViewController {
} else {
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 {
addedItems = false
} else {
@ -762,10 +783,19 @@ extension TimelineViewController: UICollectionViewDelegate {
switch item {
case .publicTimelineDescription:
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)!
// 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: state.copy())
if filterState.isWarning {
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:
let cell = collectionView.cellForItem(at: indexPath) as! TimelineGapCollectionViewCell
cell.showsIndicator = true

View File

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