Timeline filtering!
This commit is contained in:
parent
7e5d8675c2
commit
81abcfcf7b
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public enum Timeline {
|
||||
public enum Timeline: Equatable {
|
||||
case home
|
||||
case `public`(local: Bool)
|
||||
case tag(hashtag: String)
|
||||
|
|
|
@ -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 */,
|
||||
|
|
|
@ -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())) ?? []
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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