diff --git a/Pachyderm/Sources/Pachyderm/Model/Timeline.swift b/Pachyderm/Sources/Pachyderm/Model/Timeline.swift index 56cc6060..27787654 100644 --- a/Pachyderm/Sources/Pachyderm/Model/Timeline.swift +++ b/Pachyderm/Sources/Pachyderm/Model/Timeline.swift @@ -8,7 +8,7 @@ import Foundation -public enum Timeline { +public enum Timeline: Equatable { case home case `public`(local: Bool) case tag(hashtag: String) diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 6d07da57..08b1126e 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -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 = ""; }; D61F75B2293BD89C00C0B37F /* UpdateFilterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateFilterService.swift; sourceTree = ""; }; D61F75B4293BD97400C0B37F /* DeleteFilterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteFilterService.swift; sourceTree = ""; }; + D61F75B6293C119700C0B37F /* Filterer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Filterer.swift; sourceTree = ""; }; + D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZeroHeightCollectionViewCell.swift; sourceTree = ""; }; D61F75BA293C183100C0B37F /* HTMLConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLConverter.swift; sourceTree = ""; }; D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = ""; }; D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = ""; }; @@ -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 */, diff --git a/Tusker/API/MastodonController.swift b/Tusker/API/MastodonController.swift index be4d22aa..4e4b10d7 100644 --- a/Tusker/API/MastodonController.swift +++ b/Tusker/API/MastodonController.swift @@ -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) -> 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())) ?? [] + } + } diff --git a/Tusker/Filterer.swift b/Tusker/Filterer.swift new file mode 100644 index 00000000..41e894f2 --- /dev/null +++ b/Tusker/Filterer.swift @@ -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() + + 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) + } +} diff --git a/Tusker/Scenes/AuxiliarySceneDelegate.swift b/Tusker/Scenes/AuxiliarySceneDelegate.swift index e49e6be1..b017c7b4 100644 --- a/Tusker/Scenes/AuxiliarySceneDelegate.swift +++ b/Tusker/Scenes/AuxiliarySceneDelegate.swift @@ -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) diff --git a/Tusker/Scenes/ComposeSceneDelegate.swift b/Tusker/Scenes/ComposeSceneDelegate.swift index a23dbe87..9ee0b645 100644 --- a/Tusker/Scenes/ComposeSceneDelegate.swift +++ b/Tusker/Scenes/ComposeSceneDelegate.swift @@ -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 diff --git a/Tusker/Scenes/MainSceneDelegate.swift b/Tusker/Scenes/MainSceneDelegate.swift index 33e2173a..d7387950 100644 --- a/Tusker/Scenes/MainSceneDelegate.swift +++ b/Tusker/Scenes/MainSceneDelegate.swift @@ -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, diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index 8a2a2521..a104ea5f 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -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! let confirmLoadMore = PassthroughSubject() @@ -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 { [unowned self] cell, indexPath, item in self.configureStatusCell(cell, id: item.0, state: item.1) } + let zeroHeightCell = UICollectionView.CellRegistration { _, _, _ in + } + let filterWarningCell = UICollectionView.CellRegistration { cell, indexPath, item in + var config = cell.defaultContentConfiguration() + config.text = "Filtered: \(item)" + cell.contentConfiguration = config + } let gapCell = UICollectionView.CellRegistration { 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() 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[..