Merge pull request #213 from tootsuite/feature/filter
Add timeline status filter
This commit is contained in:
commit
593c0835af
|
@ -169,6 +169,7 @@
|
|||
"edit_info": "Edit Info"
|
||||
},
|
||||
"timeline": {
|
||||
"filtered": "Filtered",
|
||||
"timestamp": {
|
||||
"now": "Now",
|
||||
"time_ago": "%s ago"
|
||||
|
|
|
@ -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 */,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -97,6 +97,7 @@ extension NotificationSection {
|
|||
StatusSection.configure(
|
||||
cell: cell,
|
||||
tableView: tableView,
|
||||
timelineContext: .notifications,
|
||||
dependency: dependency,
|
||||
readableLayoutFrame: frame,
|
||||
status: status,
|
||||
|
|
|
@ -42,6 +42,7 @@ extension ReportSection {
|
|||
StatusSection.configure(
|
||||
cell: cell,
|
||||
tableView: tableView,
|
||||
timelineContext: .report,
|
||||
dependency: dependency,
|
||||
readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
|
||||
status: status,
|
||||
|
|
|
@ -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,
|
||||
|
@ -255,6 +351,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)
|
||||
// set author: name + username + avatar
|
||||
|
@ -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()
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 |
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -24,6 +24,7 @@ extension HashtagTimelineViewModel {
|
|||
|
||||
diffableDataSource = StatusSection.tableViewDiffableDataSource(
|
||||
for: tableView,
|
||||
timelineContext: .hashtag,
|
||||
dependency: dependency,
|
||||
managedObjectContext: context.managedObjectContext,
|
||||
timestampUpdatePublisher: timestampUpdatePublisher,
|
||||
|
|
|
@ -25,11 +25,17 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency, Media
|
|||
|
||||
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 {
|
||||
|
@ -273,10 +280,28 @@ extension HomeTimelineViewController {
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ extension HomeTimelineViewModel {
|
|||
|
||||
diffableDataSource = StatusSection.tableViewDiffableDataSource(
|
||||
for: tableView,
|
||||
timelineContext: .home,
|
||||
dependency: dependency,
|
||||
managedObjectContext: fetchedResultsController.managedObjectContext,
|
||||
timestampUpdatePublisher: timestampUpdatePublisher,
|
||||
|
@ -33,13 +34,10 @@ extension HomeTimelineViewModel {
|
|||
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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -148,8 +148,10 @@ extension NotificationViewController {
|
|||
|
||||
tableView.deselectRow(with: transitionCoordinator, animated: animated)
|
||||
|
||||
// fetch latest notification when will appear
|
||||
// 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
|
||||
|
|
|
@ -131,8 +131,23 @@ 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 {
|
||||
|
|
|
@ -21,6 +21,7 @@ extension FavoriteViewModel {
|
|||
|
||||
diffableDataSource = StatusSection.tableViewDiffableDataSource(
|
||||
for: tableView,
|
||||
timelineContext: .favorite,
|
||||
dependency: dependency,
|
||||
managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext,
|
||||
timestampUpdatePublisher: timestampUpdatePublisher,
|
||||
|
|
|
@ -21,6 +21,7 @@ extension UserTimelineViewModel {
|
|||
|
||||
diffableDataSource = StatusSection.tableViewDiffableDataSource(
|
||||
for: tableView,
|
||||
timelineContext: .account,
|
||||
dependency: dependency,
|
||||
managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext,
|
||||
timestampUpdatePublisher: timestampUpdatePublisher,
|
||||
|
|
|
@ -24,6 +24,7 @@ extension PublicTimelineViewModel {
|
|||
|
||||
diffableDataSource = StatusSection.tableViewDiffableDataSource(
|
||||
for: tableView,
|
||||
timelineContext: .public,
|
||||
dependency: dependency,
|
||||
managedObjectContext: fetchedResultsController.managedObjectContext,
|
||||
timestampUpdatePublisher: timestampUpdatePublisher,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -26,6 +26,7 @@ extension ThreadViewModel {
|
|||
|
||||
diffableDataSource = StatusSection.tableViewDiffableDataSource(
|
||||
for: tableView,
|
||||
timelineContext: .thread,
|
||||
dependency: dependency,
|
||||
managedObjectContext: context.managedObjectContext,
|
||||
timestampUpdatePublisher: timestampUpdatePublisher,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue