Merge pull request #213 from tootsuite/feature/filter

Add timeline status filter
This commit is contained in:
CMK 2021-07-09 19:22:21 +08:00 committed by GitHub
commit 593c0835af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 400 additions and 31 deletions

View File

@ -169,6 +169,7 @@
"edit_info": "Edit Info"
},
"timeline": {
"filtered": "Filtered",
"timestamp": {
"now": "Now",
"time_ago": "%s ago"

View File

@ -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 = "<group>"; };
DB9D6C2D25E504AC0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = "<group>"; };
DB9D6C3725E508BE0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = "<group>"; };
DB9D7C20269824B80054B3DF /* APIService+Filter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Filter.swift"; sourceTree = "<group>"; };
DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIInterpolatingMotionEffect.swift; sourceTree = "<group>"; };
DBA088DE26958164003EB4B2 /* UserFetchedResultsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserFetchedResultsController.swift; sourceTree = "<group>"; };
DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedEdgesButton.swift; sourceTree = "<group>"; };
@ -1957,6 +1959,7 @@
DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */,
DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */,
5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */,
DB9D7C20269824B80054B3DF /* APIService+Filter.swift */,
);
path = APIService;
sourceTree = "<group>";
@ -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 */,

View File

@ -12,7 +12,7 @@
<key>CoreDataStack.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>21</integer>
<integer>20</integer>
</dict>
<key>Mastodon - ASDK.xcscheme_^#shared#^_</key>
<dict>
@ -37,7 +37,7 @@
<key>NotificationService.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>20</integer>
<integer>21</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>

View File

@ -97,6 +97,7 @@ extension NotificationSection {
StatusSection.configure(
cell: cell,
tableView: tableView,
timelineContext: .notifications,
dependency: dependency,
readableLayoutFrame: frame,
status: status,

View File

@ -42,6 +42,7 @@ extension ReportSection {
StatusSection.configure(
cell: cell,
tableView: tableView,
timelineContext: .report,
dependency: dependency,
readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
status: status,

View File

@ -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<Date, Never>,
@ -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<Bool, Never> {
guard let content = content,
let currentFilterContext = timelineContext.filterContext else {
return Just(false).eraseToAnyPublisher()
}
return Future<Bool, Never> { 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..<text.endIndex) { range, _ in
let word = String(text[range])
if phraseWords.contains(word) {
needsFilter = true
return false
} else {
return true
}
}
DispatchQueue.main.async {
promise(.success(needsFilter))
}
}
}
.eraseToAnyPublisher()
}
}
extension StatusSection {
static func configureStatusTableViewCell(
cell: StatusTableViewCell,
tableView: UITableView,
timelineContext: TimelineContext,
dependency: NeedsDependency,
readableLayoutFrame: CGRect?,
status: Status,
@ -224,6 +318,7 @@ extension StatusSection {
configure(
cell: cell,
tableView: tableView,
timelineContext: timelineContext,
dependency: dependency,
readableLayoutFrame: readableLayoutFrame,
status: status,
@ -235,6 +330,7 @@ extension StatusSection {
static func configure(
cell: StatusCell,
tableView: UITableView,
timelineContext: TimelineContext,
dependency: NeedsDependency,
readableLayoutFrame: CGRect?,
status: Status,
@ -254,6 +350,32 @@ extension StatusSection {
}
}
.store(in: &cell.disposeBag)
let document = MastodonContent(
content: (status.reblog ?? status).content,
emojis: (status.reblog ?? status).emojiMeta
)
let content = try? MastodonMetaContent.convert(document: document)
if status.author.id == requestUserID || status.reblog?.author.id == requestUserID {
// do not filter myself
} else {
let needsFilter = StatusSection.needsFilterStatus(
content: content,
filters: AppContext.shared.authenticationService.activeFilters.value,
timelineContext: timelineContext
)
needsFilter
.receive(on: DispatchQueue.main)
.sink { [weak cell] needsFilter in
guard let cell = cell else { return }
cell.isFiltered = needsFilter
if needsFilter {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: filter out status: %s", ((#file as NSString).lastPathComponent), #line, #function, content?.original ?? "<nil>")
}
}
.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()

View File

@ -24,6 +24,7 @@ internal enum Asset {
internal static let accentColor = ColorAsset(name: "AccentColor")
internal enum Asset {
internal static let email = ImageAsset(name: "Asset/email")
internal static let friends = ImageAsset(name: "Asset/friends")
internal static let mastodonTextLogo = ImageAsset(name: "Asset/mastodon.text.logo")
}
internal enum Circles {

View File

@ -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 {

View File

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "friends 3.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "friends 2.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "friends 1.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 585 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

View File

@ -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 cant view thiss profile
until they unblock you.";
"Common.Controls.Timeline.Header.BlockingWarning" = "You cant view this profile

View File

@ -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 cant view thiss profile
until they unblock you.";
"Common.Controls.Timeline.Header.BlockingWarning" = "You cant view this profile

View File

@ -24,6 +24,7 @@ extension HashtagTimelineViewModel {
diffableDataSource = StatusSection.tableViewDiffableDataSource(
for: tableView,
timelineContext: .hashtag,
dependency: dependency,
managedObjectContext: context.managedObjectContext,
timestampUpdatePublisher: timestampUpdatePublisher,

View File

@ -24,12 +24,18 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency, Media
private(set) lazy var viewModel = HomeTimelineViewModel(context: context)
let mediaPreviewTransitionController = MediaPreviewTransitionController()
let friendsAssetImageView: UIImageView = {
let imageView = UIImageView()
imageView.image = Asset.Asset.friends.image
imageView.contentMode = .scaleAspectFill
return imageView
}()
lazy var emptyView: UIStackView = {
let emptyView = UIStackView()
emptyView.axis = .vertical
emptyView.distribution = .fill
emptyView.layoutMargins = UIEdgeInsets(top: 0, left: 20, bottom: 54, right: 20)
emptyView.isLayoutMarginsRelativeArrangement = true
return emptyView
}()
@ -246,9 +252,10 @@ extension HomeTimelineViewController {
view.addSubview(emptyView)
emptyView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
emptyView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor),
emptyView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor),
emptyView.bottomAnchor.constraint(equalTo: view.readableContentGuide.bottomAnchor)
emptyView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
emptyView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
emptyView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
emptyView.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor)
])
if emptyView.arrangedSubviews.count > 0 {
@ -272,11 +279,29 @@ extension HomeTimelineViewController {
button.addTarget(self, action: #selector(HomeTimelineViewController.manuallySearchButtonPressed(_:)), for: .touchUpInside)
return button
}()
emptyView.addArrangedSubview(findPeopleButton)
emptyView.setCustomSpacing(17, after: findPeopleButton)
emptyView.addArrangedSubview(manuallySearchButton)
let topPaddingView = UIView()
let bottomPaddingView = UIView()
emptyView.addArrangedSubview(topPaddingView)
emptyView.addArrangedSubview(friendsAssetImageView)
emptyView.addArrangedSubview(bottomPaddingView)
topPaddingView.translatesAutoresizingMaskIntoConstraints = false
bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
topPaddingView.heightAnchor.constraint(equalTo: bottomPaddingView.heightAnchor, multiplier: 0.8),
])
let buttonContainerStackView = UIStackView()
emptyView.addArrangedSubview(buttonContainerStackView)
buttonContainerStackView.isLayoutMarginsRelativeArrangement = true
buttonContainerStackView.layoutMargins = UIEdgeInsets(top: 0, left: 32, bottom: 22, right: 32)
buttonContainerStackView.axis = .vertical
buttonContainerStackView.spacing = 17
buttonContainerStackView.addArrangedSubview(findPeopleButton)
buttonContainerStackView.addArrangedSubview(manuallySearchButton)
}
}

View File

@ -25,6 +25,7 @@ extension HomeTimelineViewModel {
diffableDataSource = StatusSection.tableViewDiffableDataSource(
for: tableView,
timelineContext: .home,
dependency: dependency,
managedObjectContext: fetchedResultsController.managedObjectContext,
timestampUpdatePublisher: timestampUpdatePublisher,
@ -32,14 +33,11 @@ extension HomeTimelineViewModel {
timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate,
threadReplyLoaderTableViewCellDelegate: nil
)
// make initial snapshot animation smooth
var snapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>()
snapshot.appendSections([.main])
diffableDataSource?.apply(snapshot)
// workaround to append loader wrong animation issue
snapshot.appendItems([.bottomLoader], toSection: .main)
diffableDataSource?.apply(snapshot)
}
}

View File

@ -148,8 +148,10 @@ extension NotificationViewController {
tableView.deselectRow(with: transitionCoordinator, animated: animated)
// fetch latest notification when will appear
viewModel.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self)
// fetch latest notification when scroll position is within half screen height to prevent list reload
if tableView.contentOffset.y < view.frame.height * 0.5 {
viewModel.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self)
}
// needs trigger manually after onboarding dismiss

View File

@ -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 {

View File

@ -21,6 +21,7 @@ extension FavoriteViewModel {
diffableDataSource = StatusSection.tableViewDiffableDataSource(
for: tableView,
timelineContext: .favorite,
dependency: dependency,
managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext,
timestampUpdatePublisher: timestampUpdatePublisher,

View File

@ -21,6 +21,7 @@ extension UserTimelineViewModel {
diffableDataSource = StatusSection.tableViewDiffableDataSource(
for: tableView,
timelineContext: .account,
dependency: dependency,
managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext,
timestampUpdatePublisher: timestampUpdatePublisher,

View File

@ -24,6 +24,7 @@ extension PublicTimelineViewModel {
diffableDataSource = StatusSection.tableViewDiffableDataSource(
for: tableView,
timelineContext: .public,
dependency: dependency,
managedObjectContext: fetchedResultsController.managedObjectContext,
timestampUpdatePublisher: timestampUpdatePublisher,

View File

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

View File

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

View File

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

View File

@ -26,6 +26,7 @@ extension ThreadViewModel {
diffableDataSource = StatusSection.tableViewDiffableDataSource(
for: tableView,
timelineContext: .thread,
dependency: dependency,
managedObjectContext: context.managedObjectContext,
timestampUpdatePublisher: timestampUpdatePublisher,

View File

@ -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<Mastodon.Response.Content<[Mastodon.Entity.Filter]>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
let domain = mastodonAuthenticationBox.domain
return Mastodon.API.Account.filters(session: session, domain: domain, authorization: authorization)
}
}

View File

@ -27,6 +27,7 @@ final class AuthenticationService: NSObject {
let mastodonAuthenticationBoxes = CurrentValueSubject<[AuthenticationService.MastodonAuthenticationBox], Never>([])
let activeMastodonAuthentication = CurrentValueSubject<MastodonAuthentication?, Never>(nil)
let activeMastodonAuthenticationBox = CurrentValueSubject<AuthenticationService.MastodonAuthenticationBox?, Never>(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<Void, Never>()
filterUpdateTimerPublisher
.map { _ in }
.subscribe(filterUpdatePublisher)
.store(in: &disposeBag)
Publishers.CombineLatest(
activeMastodonAuthenticationBox,
filterUpdatePublisher
)
.flatMap { box, _ -> AnyPublisher<Result<Mastodon.Response.Content<[Mastodon.Entity.Filter]>, 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<Mastodon.Response.Content<[Mastodon.Entity.Filter]>, Error>.success(newResponse)
}
.catch { error in
Just(Result<Mastodon.Response.Content<[Mastodon.Entity.Filter]>, 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()
}
}

View File

@ -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<Mastodon.Response.Content<[Mastodon.Entity.Filter]>, 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()
}
}

View File

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