From 0dacea632656791c96b5c0371a41117a70557624 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 9 Jul 2021 19:07:12 +0800 Subject: [PATCH] feat: add filter for status --- Localization/app.json | 1 + Mastodon.xcodeproj/project.pbxproj | 4 + .../Section/NotificationSection.swift | 1 + .../Diffiable/Section/ReportSection.swift | 1 + .../Diffiable/Section/StatusSection.swift | 138 ++++++++++++++++-- Mastodon/Generated/Strings.swift | 2 + .../Resources/ar.lproj/Localizable.strings | 1 + .../Resources/en.lproj/Localizable.strings | 1 + .../HashtagTimelineViewModel+Diffable.swift | 1 + .../HomeTimelineViewModel+Diffable.swift | 1 + .../NotificationStatusTableViewCell.swift | 29 ++++ .../Favorite/FavoriteViewModel+Diffable.swift | 1 + .../UserTimelineViewModel+Diffable.swift | 1 + .../PublicTimelineViewModel+Diffable.swift | 1 + .../Report/ReportedStatusTableviewCell.swift | 3 + .../Scene/Share/View/Content/StatusView.swift | 3 - .../TableviewCell/StatusTableViewCell.swift | 29 ++++ .../Thread/ThreadViewModel+Diffable.swift | 1 + .../APIService/APIService+Filter.swift | 25 ++++ Mastodon/Service/AuthenticationService.swift | 48 ++++++ .../API/Mastodon+API+Account+Filter.swift | 52 +++++++ .../Entity/Mastodon+Entity+Filter.swift | 3 + 22 files changed, 334 insertions(+), 13 deletions(-) create mode 100644 Mastodon/Service/APIService/APIService+Filter.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Filter.swift diff --git a/Localization/app.json b/Localization/app.json index 50abb54b8..ffa074ec3 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -169,6 +169,7 @@ "edit_info": "Edit Info" }, "timeline": { + "filtered": "Filtered", "timestamp": { "now": "Now", "time_ago": "%s ago" diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index e6a687da0..9707f3d44 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -410,6 +410,7 @@ DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */; }; DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2D25E504AC0051B173 /* Attachment.swift */; }; DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C3725E508BE0051B173 /* Attachment.swift */; }; + DB9D7C21269824B80054B3DF /* APIService+Filter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D7C20269824B80054B3DF /* APIService+Filter.swift */; }; DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */; }; DBA088DF26958164003EB4B2 /* UserFetchedResultsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA088DE26958164003EB4B2 /* UserFetchedResultsController.swift */; }; DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */; }; @@ -1041,6 +1042,7 @@ DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MosaicImageViewModel.swift; sourceTree = ""; }; DB9D6C2D25E504AC0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; DB9D6C3725E508BE0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; + DB9D7C20269824B80054B3DF /* APIService+Filter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Filter.swift"; sourceTree = ""; }; DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIInterpolatingMotionEffect.swift; sourceTree = ""; }; DBA088DE26958164003EB4B2 /* UserFetchedResultsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserFetchedResultsController.swift; sourceTree = ""; }; DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedEdgesButton.swift; sourceTree = ""; }; @@ -1957,6 +1959,7 @@ DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */, DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */, 5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */, + DB9D7C20269824B80054B3DF /* APIService+Filter.swift */, ); path = APIService; sourceTree = ""; @@ -3345,6 +3348,7 @@ DBA9443A265CC0FC00C537E1 /* Fields.swift in Sources */, 2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */, DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */, + DB9D7C21269824B80054B3DF /* APIService+Filter.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, DBCBCC012680AF2A000F5B51 /* AsyncHomeTimelineViewModel+Diffable.swift in Sources */, DBCC3B36261440BA0045B23D /* UINavigationController.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/NotificationSection.swift b/Mastodon/Diffiable/Section/NotificationSection.swift index 77098a935..9728c75be 100644 --- a/Mastodon/Diffiable/Section/NotificationSection.swift +++ b/Mastodon/Diffiable/Section/NotificationSection.swift @@ -97,6 +97,7 @@ extension NotificationSection { StatusSection.configure( cell: cell, tableView: tableView, + timelineContext: .notifications, dependency: dependency, readableLayoutFrame: frame, status: status, diff --git a/Mastodon/Diffiable/Section/ReportSection.swift b/Mastodon/Diffiable/Section/ReportSection.swift index 8a73aee0e..5da10c399 100644 --- a/Mastodon/Diffiable/Section/ReportSection.swift +++ b/Mastodon/Diffiable/Section/ReportSection.swift @@ -42,6 +42,7 @@ extension ReportSection { StatusSection.configure( cell: cell, tableView: tableView, + timelineContext: .report, dependency: dependency, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, status: status, diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 7b613dfd3..15c3f7896 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -13,6 +13,8 @@ import UIKit import AVKit import AlamofireImage import MastodonMeta +import MastodonSDK +import NaturalLanguage // import LinkPresentation @@ -22,6 +24,7 @@ import AsyncDisplayKit protocol StatusCell: DisposeBagCollectable { var statusView: StatusView { get } + var isFiltered: Bool { get set } } enum StatusSection: Equatable, Hashable { @@ -59,6 +62,7 @@ extension StatusSection { static func tableViewDiffableDataSource( for tableView: UITableView, + timelineContext: TimelineContext, dependency: NeedsDependency, managedObjectContext: NSManagedObjectContext, timestampUpdatePublisher: AnyPublisher, @@ -91,6 +95,7 @@ extension StatusSection { configureStatusTableViewCell( cell: cell, tableView: tableView, + timelineContext: timelineContext, dependency: dependency, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, status: status, @@ -128,6 +133,7 @@ extension StatusSection { StatusSection.configure( cell: cell, tableView: tableView, + timelineContext: timelineContext, dependency: dependency, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, status: status, @@ -210,11 +216,99 @@ extension StatusSection { } } +extension StatusSection { + + enum TimelineContext { + case home + case notifications + case `public` + case thread + case account + + case favorite + case hashtag + case report + + var filterContext: Mastodon.Entity.Filter.Context? { + switch self { + case .home: return .home + case .notifications: return .notifications + case .public: return .public + case .thread: return .thread + case .account: return .account + default: return nil + } + } + } + + private static func needsFilterStatus( + content: MastodonMetaContent?, + filters: [Mastodon.Entity.Filter], + timelineContext: TimelineContext + ) -> AnyPublisher { + guard let content = content, + let currentFilterContext = timelineContext.filterContext else { + return Just(false).eraseToAnyPublisher() + } + + return Future { promise in + DispatchQueue.global(qos: .userInteractive).async { + var wordFilters: [Mastodon.Entity.Filter] = [] + var nonWordFilters: [Mastodon.Entity.Filter] = [] + for filter in filters { + guard filter.context.contains(where: { $0 == currentFilterContext }) else { continue } + if filter.wholeWord { + wordFilters.append(filter) + } else { + nonWordFilters.append(filter) + } + } + + let text = content.original.lowercased() + + var needsFilter = false + for filter in nonWordFilters { + guard text.contains(filter.phrase.lowercased()) else { continue } + needsFilter = true + break + } + + if needsFilter { + DispatchQueue.main.async { + promise(.success(true)) + } + return + } + + let tokenizer = NLTokenizer(unit: .word) + tokenizer.string = text + let phraseWords = wordFilters.map { $0.phrase.lowercased() } + tokenizer.enumerateTokens(in: text.startIndex..") + } + } + .store(in: &cell.disposeBag) + } // set header StatusSection.configureStatusViewHeader(cell: cell, status: status) @@ -275,6 +397,7 @@ extension StatusSection { StatusSection.configureStatusContent( cell: cell, status: status, + content: content, readableLayoutFrame: readableLayoutFrame, statusItemAttribute: statusItemAttribute ) @@ -558,20 +681,15 @@ extension StatusSection { static func configureStatusContent( cell: StatusCell, status: Status, + content: MastodonMetaContent?, readableLayoutFrame: CGRect?, statusItemAttribute: Item.StatusAttribute ) { // set content - do { - let status = status.reblog ?? status - let content = MastodonContent( - content: status.content, - emojis: status.emojiMeta - ) - let metaContent = try MastodonMetaContent.convert(document: content) - cell.statusView.contentMetaText.configure(content: metaContent) - cell.statusView.contentMetaText.textView.accessibilityLabel = metaContent.trimmed - } catch { + if let content = content { + cell.statusView.contentMetaText.configure(content: content) + cell.statusView.contentMetaText.textView.accessibilityLabel = content.trimmed + } else { cell.statusView.contentMetaText.textView.text = " " cell.statusView.contentMetaText.textView.accessibilityLabel = "" assertionFailure() diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 6ea2504a4..5fa285e57 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -328,6 +328,8 @@ internal enum L10n { internal static let search = L10n.tr("Localizable", "Common.Controls.Tabs.Search") } internal enum Timeline { + /// Filtered + internal static let filtered = L10n.tr("Localizable", "Common.Controls.Timeline.Filtered") internal enum Accessibility { /// %@ favorites internal static func countFavorites(_ p1: Any) -> String { diff --git a/Mastodon/Resources/ar.lproj/Localizable.strings b/Mastodon/Resources/ar.lproj/Localizable.strings index 82663ba28..63a389a9e 100644 --- a/Mastodon/Resources/ar.lproj/Localizable.strings +++ b/Mastodon/Resources/ar.lproj/Localizable.strings @@ -119,6 +119,7 @@ Please check your internet connection."; "Common.Controls.Timeline.Accessibility.CountFavorites" = "%@ favorites"; "Common.Controls.Timeline.Accessibility.CountReblogs" = "%@ reblogs"; "Common.Controls.Timeline.Accessibility.CountReplies" = "%@ replies"; +"Common.Controls.Timeline.Filtered" = "Filtered"; "Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view this’s profile until they unblock you."; "Common.Controls.Timeline.Header.BlockingWarning" = "You can’t view this profile diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 82663ba28..63a389a9e 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -119,6 +119,7 @@ Please check your internet connection."; "Common.Controls.Timeline.Accessibility.CountFavorites" = "%@ favorites"; "Common.Controls.Timeline.Accessibility.CountReblogs" = "%@ reblogs"; "Common.Controls.Timeline.Accessibility.CountReplies" = "%@ replies"; +"Common.Controls.Timeline.Filtered" = "Filtered"; "Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view this’s profile until they unblock you."; "Common.Controls.Timeline.Header.BlockingWarning" = "You can’t view this profile diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift index 41b4d9210..a90afdad0 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift @@ -24,6 +24,7 @@ extension HashtagTimelineViewModel { diffableDataSource = StatusSection.tableViewDiffableDataSource( for: tableView, + timelineContext: .hashtag, dependency: dependency, managedObjectContext: context.managedObjectContext, timestampUpdatePublisher: timestampUpdatePublisher, diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift index 48f417449..b2ea2035c 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift @@ -25,6 +25,7 @@ extension HomeTimelineViewModel { diffableDataSource = StatusSection.tableViewDiffableDataSource( for: tableView, + timelineContext: .home, dependency: dependency, managedObjectContext: fetchedResultsController.managedObjectContext, timestampUpdatePublisher: timestampUpdatePublisher, diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift index 75342b6c0..ad6092ad5 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift @@ -130,9 +130,24 @@ final class NotificationStatusTableViewCell: UITableViewCell, StatusCell { var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint! var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint! + + var isFiltered: Bool = false { + didSet { + configure(isFiltered: isFiltered) + } + } + + let filteredLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.secondary.color + label.text = L10n.Common.Controls.Timeline.filtered + label.font = .preferredFont(forTextStyle: .body) + return label + }() override func prepareForReuse() { super.prepareForReuse() + isFiltered = false avatarImageViewTask?.cancel() avatarImageViewTask = nil statusView.updateContentWarningDisplay(isHidden: true, animated: false) @@ -263,6 +278,14 @@ extension NotificationStatusTableViewCell { separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), ]) + filteredLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(filteredLabel) + NSLayoutConstraint.activate([ + filteredLabel.centerXAnchor.constraint(equalTo: statusContainerView.centerXAnchor), + filteredLabel.centerYAnchor.constraint(equalTo: statusContainerView.centerYAnchor), + ]) + filteredLabel.isHidden = true + statusView.delegate = self let avatarImageViewTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer @@ -283,6 +306,12 @@ extension NotificationStatusTableViewCell { statusContainerView.layer.borderColor = Asset.Colors.Border.notificationStatus.color.cgColor } + private func configure(isFiltered: Bool) { + statusView.alpha = isFiltered ? 0 : 1 + filteredLabel.isHidden = !isFiltered + isUserInteractionEnabled = !isFiltered + } + } extension NotificationStatusTableViewCell { diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift index 85928e852..f21498aaf 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift @@ -21,6 +21,7 @@ extension FavoriteViewModel { diffableDataSource = StatusSection.tableViewDiffableDataSource( for: tableView, + timelineContext: .favorite, dependency: dependency, managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext, timestampUpdatePublisher: timestampUpdatePublisher, diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift index 276c3566f..5ccc1441f 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift @@ -21,6 +21,7 @@ extension UserTimelineViewModel { diffableDataSource = StatusSection.tableViewDiffableDataSource( for: tableView, + timelineContext: .account, dependency: dependency, managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext, timestampUpdatePublisher: timestampUpdatePublisher, diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift index 3270302bb..7e6eb30f0 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift @@ -24,6 +24,7 @@ extension PublicTimelineViewModel { diffableDataSource = StatusSection.tableViewDiffableDataSource( for: tableView, + timelineContext: .public, dependency: dependency, managedObjectContext: fetchedResultsController.managedObjectContext, timestampUpdatePublisher: timestampUpdatePublisher, diff --git a/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift index 0db0e79ab..639167684 100644 --- a/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift +++ b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift @@ -41,6 +41,9 @@ final class ReportedStatusTableViewCell: UITableViewCell, StatusCell { var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint! var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint! + // not support filter + var isFiltered: Bool = false + override func prepareForReuse() { super.prepareForReuse() statusView.updateContentWarningDisplay(isHidden: true, animated: false) diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index d62fba0df..63bc573c6 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -479,9 +479,6 @@ extension StatusView { avatarStackedContainerButton.addTarget(self, action: #selector(StatusView.avatarStackedContainerButtonDidPressed(_:)), for: .touchUpInside) revealContentWarningButton.addTarget(self, action: #selector(StatusView.revealContentWarningButtonDidPressed(_:)), for: .touchUpInside) pollVoteButton.addTarget(self, action: #selector(StatusView.pollVoteButtonPressed(_:)), for: .touchUpInside) - - - } } diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 0b3d3fddb..508d928e1 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -71,9 +71,24 @@ final class StatusTableViewCell: UITableViewCell, StatusCell { var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint! var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint! + var isFiltered: Bool = false { + didSet { + configure(isFiltered: isFiltered) + } + } + + let filteredLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.secondary.color + label.text = L10n.Common.Controls.Timeline.filtered + label.font = .preferredFont(forTextStyle: .body) + return label + }() + override func prepareForReuse() { super.prepareForReuse() selectionStyle = .default + isFiltered = false statusView.statusMosaicImageViewContainer.resetImageTask() statusView.contentMetaText.textView.isSelectable = false statusView.updateContentWarningDisplay(isHidden: true, animated: false) @@ -133,6 +148,14 @@ extension StatusTableViewCell { ]) resetSeparatorLineLayout() + filteredLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(filteredLabel) + NSLayoutConstraint.activate([ + filteredLabel.centerXAnchor.constraint(equalTo: centerXAnchor), + filteredLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + filteredLabel.isHidden = true + statusView.delegate = self statusView.pollTableView.delegate = self statusView.statusMosaicImageViewContainer.delegate = self @@ -148,6 +171,12 @@ extension StatusTableViewCell { resetSeparatorLineLayout() } + private func configure(isFiltered: Bool) { + statusView.alpha = isFiltered ? 0 : 1 + threadMetaView.alpha = isFiltered ? 0 : 1 + filteredLabel.isHidden = !isFiltered + isUserInteractionEnabled = !isFiltered + } } diff --git a/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift b/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift index 58e618f89..e09d6acc1 100644 --- a/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift +++ b/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift @@ -26,6 +26,7 @@ extension ThreadViewModel { diffableDataSource = StatusSection.tableViewDiffableDataSource( for: tableView, + timelineContext: .thread, dependency: dependency, managedObjectContext: context.managedObjectContext, timestampUpdatePublisher: timestampUpdatePublisher, diff --git a/Mastodon/Service/APIService/APIService+Filter.swift b/Mastodon/Service/APIService/APIService+Filter.swift new file mode 100644 index 000000000..5ecd10774 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Filter.swift @@ -0,0 +1,25 @@ +// +// APIService+Filter.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-7-9. +// + +import os.log +import Combine +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK + +extension APIService { + + func filters( + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + let domain = mastodonAuthenticationBox.domain + + return Mastodon.API.Account.filters(session: session, domain: domain, authorization: authorization) + } +} diff --git a/Mastodon/Service/AuthenticationService.swift b/Mastodon/Service/AuthenticationService.swift index a0bbca57a..b72f281fe 100644 --- a/Mastodon/Service/AuthenticationService.swift +++ b/Mastodon/Service/AuthenticationService.swift @@ -27,6 +27,7 @@ final class AuthenticationService: NSObject { let mastodonAuthenticationBoxes = CurrentValueSubject<[AuthenticationService.MastodonAuthenticationBox], Never>([]) let activeMastodonAuthentication = CurrentValueSubject(nil) let activeMastodonAuthenticationBox = CurrentValueSubject(nil) + let activeFilters = CurrentValueSubject<[Mastodon.Entity.Filter], Never>([]) init( managedObjectContext: NSManagedObjectContext, @@ -87,6 +88,53 @@ final class AuthenticationService: NSObject { } catch { assertionFailure(error.localizedDescription) } + + // fetch account filters every 60s and filter out expired items + let filterUpdateTimerPublisher = Timer.publish(every: 60.0, on: .main, in: .common) + .autoconnect() + .share() + .eraseToAnyPublisher() + let filterUpdatePublisher = PassthroughSubject() + + filterUpdateTimerPublisher + .map { _ in } + .subscribe(filterUpdatePublisher) + .store(in: &disposeBag) + + Publishers.CombineLatest( + activeMastodonAuthenticationBox, + filterUpdatePublisher + ) + .flatMap { box, _ -> AnyPublisher, Error>, Never> in + guard let box = box else { + return Just(Result { throw APIService.APIError.implicit(.authenticationMissing) }).eraseToAnyPublisher() + } + return apiService.filters(mastodonAuthenticationBox: box) + .map { response in + let now = Date() + let newResponse = response.map { filters in + return filters.filter { $0.expiresAt > now } + } + return Result, Error>.success(newResponse) + } + .catch { error in + Just(Result, Error>.failure(error)) + } + .eraseToAnyPublisher() + } + .sink { result in + switch result { + case .success(let response): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch account filters success. %ld items", ((#file as NSString).lastPathComponent), #line, #function, response.value.count) + self.activeFilters.value = response.value + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch account filters fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + + break + } + } + .store(in: &disposeBag) + filterUpdatePublisher.send() } } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Filter.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Filter.swift new file mode 100644 index 000000000..5bded31ba --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Filter.swift @@ -0,0 +1,52 @@ +// +// Mastodon+API+Account+Filter.swift +// +// +// Created by MainasuK Cirno on 2021-7-9. +// + +import Foundation +import Combine + +// MARK: - Account credentials +extension Mastodon.API.Account { + + static func filtersEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("filters") + } + + /// View all filters + /// + /// Creates a user and account records. + /// + /// - Since: 2.4.3 + /// - Version: 3.3.1 + /// # Last Update + /// 2021/7/9 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/filters/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `[Filter]` nested in the response + public static func filters( + session: URLSession, + domain: String, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: filtersEndpointURL(domain: domain), + query: nil, + authorization: authorization + ) + + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Filter].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Filter.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Filter.swift index 6374c0ab0..e52dd36b0 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Filter.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Filter.swift @@ -43,6 +43,7 @@ extension Mastodon.Entity.Filter { case notifications case `public` case thread + case account case _other(String) @@ -52,6 +53,7 @@ extension Mastodon.Entity.Filter { case "notifications": self = .notifications case "public": self = .`public` case "thread": self = .thread + case "account": self = .account default: self = ._other(rawValue) } } @@ -62,6 +64,7 @@ extension Mastodon.Entity.Filter { case .notifications: return "notifications" case .public: return "public" case .thread: return "thread" + case .account: return "account" case ._other(let value): return value } }