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"
|
"edit_info": "Edit Info"
|
||||||
},
|
},
|
||||||
"timeline": {
|
"timeline": {
|
||||||
|
"filtered": "Filtered",
|
||||||
"timestamp": {
|
"timestamp": {
|
||||||
"now": "Now",
|
"now": "Now",
|
||||||
"time_ago": "%s ago"
|
"time_ago": "%s ago"
|
||||||
|
|
|
@ -410,6 +410,7 @@
|
||||||
DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */; };
|
DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */; };
|
||||||
DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2D25E504AC0051B173 /* Attachment.swift */; };
|
DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2D25E504AC0051B173 /* Attachment.swift */; };
|
||||||
DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C3725E508BE0051B173 /* 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 */; };
|
DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */; };
|
||||||
DBA088DF26958164003EB4B2 /* UserFetchedResultsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA088DE26958164003EB4B2 /* UserFetchedResultsController.swift */; };
|
DBA088DF26958164003EB4B2 /* UserFetchedResultsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA088DE26958164003EB4B2 /* UserFetchedResultsController.swift */; };
|
||||||
DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedEdgesButton.swift; sourceTree = "<group>"; };
|
||||||
|
@ -1957,6 +1959,7 @@
|
||||||
DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */,
|
DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */,
|
||||||
DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */,
|
DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */,
|
||||||
5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */,
|
5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */,
|
||||||
|
DB9D7C20269824B80054B3DF /* APIService+Filter.swift */,
|
||||||
);
|
);
|
||||||
path = APIService;
|
path = APIService;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -3345,6 +3348,7 @@
|
||||||
DBA9443A265CC0FC00C537E1 /* Fields.swift in Sources */,
|
DBA9443A265CC0FC00C537E1 /* Fields.swift in Sources */,
|
||||||
2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */,
|
2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */,
|
||||||
DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */,
|
DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */,
|
||||||
|
DB9D7C21269824B80054B3DF /* APIService+Filter.swift in Sources */,
|
||||||
2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */,
|
2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */,
|
||||||
DBCBCC012680AF2A000F5B51 /* AsyncHomeTimelineViewModel+Diffable.swift in Sources */,
|
DBCBCC012680AF2A000F5B51 /* AsyncHomeTimelineViewModel+Diffable.swift in Sources */,
|
||||||
DBCC3B36261440BA0045B23D /* UINavigationController.swift in Sources */,
|
DBCC3B36261440BA0045B23D /* UINavigationController.swift in Sources */,
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>21</integer>
|
<integer>20</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>Mastodon - ASDK.xcscheme_^#shared#^_</key>
|
<key>Mastodon - ASDK.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
@ -37,7 +37,7 @@
|
||||||
<key>NotificationService.xcscheme_^#shared#^_</key>
|
<key>NotificationService.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>20</integer>
|
<integer>21</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
<key>SuppressBuildableAutocreation</key>
|
<key>SuppressBuildableAutocreation</key>
|
||||||
|
|
|
@ -97,6 +97,7 @@ extension NotificationSection {
|
||||||
StatusSection.configure(
|
StatusSection.configure(
|
||||||
cell: cell,
|
cell: cell,
|
||||||
tableView: tableView,
|
tableView: tableView,
|
||||||
|
timelineContext: .notifications,
|
||||||
dependency: dependency,
|
dependency: dependency,
|
||||||
readableLayoutFrame: frame,
|
readableLayoutFrame: frame,
|
||||||
status: status,
|
status: status,
|
||||||
|
|
|
@ -42,6 +42,7 @@ extension ReportSection {
|
||||||
StatusSection.configure(
|
StatusSection.configure(
|
||||||
cell: cell,
|
cell: cell,
|
||||||
tableView: tableView,
|
tableView: tableView,
|
||||||
|
timelineContext: .report,
|
||||||
dependency: dependency,
|
dependency: dependency,
|
||||||
readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
|
readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
|
||||||
status: status,
|
status: status,
|
||||||
|
|
|
@ -13,6 +13,8 @@ import UIKit
|
||||||
import AVKit
|
import AVKit
|
||||||
import AlamofireImage
|
import AlamofireImage
|
||||||
import MastodonMeta
|
import MastodonMeta
|
||||||
|
import MastodonSDK
|
||||||
|
import NaturalLanguage
|
||||||
|
|
||||||
// import LinkPresentation
|
// import LinkPresentation
|
||||||
|
|
||||||
|
@ -22,6 +24,7 @@ import AsyncDisplayKit
|
||||||
|
|
||||||
protocol StatusCell: DisposeBagCollectable {
|
protocol StatusCell: DisposeBagCollectable {
|
||||||
var statusView: StatusView { get }
|
var statusView: StatusView { get }
|
||||||
|
var isFiltered: Bool { get set }
|
||||||
}
|
}
|
||||||
|
|
||||||
enum StatusSection: Equatable, Hashable {
|
enum StatusSection: Equatable, Hashable {
|
||||||
|
@ -59,6 +62,7 @@ extension StatusSection {
|
||||||
|
|
||||||
static func tableViewDiffableDataSource(
|
static func tableViewDiffableDataSource(
|
||||||
for tableView: UITableView,
|
for tableView: UITableView,
|
||||||
|
timelineContext: TimelineContext,
|
||||||
dependency: NeedsDependency,
|
dependency: NeedsDependency,
|
||||||
managedObjectContext: NSManagedObjectContext,
|
managedObjectContext: NSManagedObjectContext,
|
||||||
timestampUpdatePublisher: AnyPublisher<Date, Never>,
|
timestampUpdatePublisher: AnyPublisher<Date, Never>,
|
||||||
|
@ -91,6 +95,7 @@ extension StatusSection {
|
||||||
configureStatusTableViewCell(
|
configureStatusTableViewCell(
|
||||||
cell: cell,
|
cell: cell,
|
||||||
tableView: tableView,
|
tableView: tableView,
|
||||||
|
timelineContext: timelineContext,
|
||||||
dependency: dependency,
|
dependency: dependency,
|
||||||
readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
|
readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
|
||||||
status: status,
|
status: status,
|
||||||
|
@ -128,6 +133,7 @@ extension StatusSection {
|
||||||
StatusSection.configure(
|
StatusSection.configure(
|
||||||
cell: cell,
|
cell: cell,
|
||||||
tableView: tableView,
|
tableView: tableView,
|
||||||
|
timelineContext: timelineContext,
|
||||||
dependency: dependency,
|
dependency: dependency,
|
||||||
readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
|
readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
|
||||||
status: status,
|
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 {
|
extension StatusSection {
|
||||||
|
|
||||||
static func configureStatusTableViewCell(
|
static func configureStatusTableViewCell(
|
||||||
cell: StatusTableViewCell,
|
cell: StatusTableViewCell,
|
||||||
tableView: UITableView,
|
tableView: UITableView,
|
||||||
|
timelineContext: TimelineContext,
|
||||||
dependency: NeedsDependency,
|
dependency: NeedsDependency,
|
||||||
readableLayoutFrame: CGRect?,
|
readableLayoutFrame: CGRect?,
|
||||||
status: Status,
|
status: Status,
|
||||||
|
@ -224,6 +318,7 @@ extension StatusSection {
|
||||||
configure(
|
configure(
|
||||||
cell: cell,
|
cell: cell,
|
||||||
tableView: tableView,
|
tableView: tableView,
|
||||||
|
timelineContext: timelineContext,
|
||||||
dependency: dependency,
|
dependency: dependency,
|
||||||
readableLayoutFrame: readableLayoutFrame,
|
readableLayoutFrame: readableLayoutFrame,
|
||||||
status: status,
|
status: status,
|
||||||
|
@ -235,6 +330,7 @@ extension StatusSection {
|
||||||
static func configure(
|
static func configure(
|
||||||
cell: StatusCell,
|
cell: StatusCell,
|
||||||
tableView: UITableView,
|
tableView: UITableView,
|
||||||
|
timelineContext: TimelineContext,
|
||||||
dependency: NeedsDependency,
|
dependency: NeedsDependency,
|
||||||
readableLayoutFrame: CGRect?,
|
readableLayoutFrame: CGRect?,
|
||||||
status: Status,
|
status: Status,
|
||||||
|
@ -255,6 +351,32 @@ extension StatusSection {
|
||||||
}
|
}
|
||||||
.store(in: &cell.disposeBag)
|
.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
|
// set header
|
||||||
StatusSection.configureStatusViewHeader(cell: cell, status: status)
|
StatusSection.configureStatusViewHeader(cell: cell, status: status)
|
||||||
// set author: name + username + avatar
|
// set author: name + username + avatar
|
||||||
|
@ -275,6 +397,7 @@ extension StatusSection {
|
||||||
StatusSection.configureStatusContent(
|
StatusSection.configureStatusContent(
|
||||||
cell: cell,
|
cell: cell,
|
||||||
status: status,
|
status: status,
|
||||||
|
content: content,
|
||||||
readableLayoutFrame: readableLayoutFrame,
|
readableLayoutFrame: readableLayoutFrame,
|
||||||
statusItemAttribute: statusItemAttribute
|
statusItemAttribute: statusItemAttribute
|
||||||
)
|
)
|
||||||
|
@ -558,20 +681,15 @@ extension StatusSection {
|
||||||
static func configureStatusContent(
|
static func configureStatusContent(
|
||||||
cell: StatusCell,
|
cell: StatusCell,
|
||||||
status: Status,
|
status: Status,
|
||||||
|
content: MastodonMetaContent?,
|
||||||
readableLayoutFrame: CGRect?,
|
readableLayoutFrame: CGRect?,
|
||||||
statusItemAttribute: Item.StatusAttribute
|
statusItemAttribute: Item.StatusAttribute
|
||||||
) {
|
) {
|
||||||
// set content
|
// set content
|
||||||
do {
|
if let content = content {
|
||||||
let status = status.reblog ?? status
|
cell.statusView.contentMetaText.configure(content: content)
|
||||||
let content = MastodonContent(
|
cell.statusView.contentMetaText.textView.accessibilityLabel = content.trimmed
|
||||||
content: status.content,
|
} else {
|
||||||
emojis: status.emojiMeta
|
|
||||||
)
|
|
||||||
let metaContent = try MastodonMetaContent.convert(document: content)
|
|
||||||
cell.statusView.contentMetaText.configure(content: metaContent)
|
|
||||||
cell.statusView.contentMetaText.textView.accessibilityLabel = metaContent.trimmed
|
|
||||||
} catch {
|
|
||||||
cell.statusView.contentMetaText.textView.text = " "
|
cell.statusView.contentMetaText.textView.text = " "
|
||||||
cell.statusView.contentMetaText.textView.accessibilityLabel = ""
|
cell.statusView.contentMetaText.textView.accessibilityLabel = ""
|
||||||
assertionFailure()
|
assertionFailure()
|
||||||
|
|
|
@ -24,6 +24,7 @@ internal enum Asset {
|
||||||
internal static let accentColor = ColorAsset(name: "AccentColor")
|
internal static let accentColor = ColorAsset(name: "AccentColor")
|
||||||
internal enum Asset {
|
internal enum Asset {
|
||||||
internal static let email = ImageAsset(name: "Asset/email")
|
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 static let mastodonTextLogo = ImageAsset(name: "Asset/mastodon.text.logo")
|
||||||
}
|
}
|
||||||
internal enum Circles {
|
internal enum Circles {
|
||||||
|
|
|
@ -328,6 +328,8 @@ internal enum L10n {
|
||||||
internal static let search = L10n.tr("Localizable", "Common.Controls.Tabs.Search")
|
internal static let search = L10n.tr("Localizable", "Common.Controls.Tabs.Search")
|
||||||
}
|
}
|
||||||
internal enum Timeline {
|
internal enum Timeline {
|
||||||
|
/// Filtered
|
||||||
|
internal static let filtered = L10n.tr("Localizable", "Common.Controls.Timeline.Filtered")
|
||||||
internal enum Accessibility {
|
internal enum Accessibility {
|
||||||
/// %@ favorites
|
/// %@ favorites
|
||||||
internal static func countFavorites(_ p1: Any) -> String {
|
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.CountFavorites" = "%@ favorites";
|
||||||
"Common.Controls.Timeline.Accessibility.CountReblogs" = "%@ reblogs";
|
"Common.Controls.Timeline.Accessibility.CountReblogs" = "%@ reblogs";
|
||||||
"Common.Controls.Timeline.Accessibility.CountReplies" = "%@ replies";
|
"Common.Controls.Timeline.Accessibility.CountReplies" = "%@ replies";
|
||||||
|
"Common.Controls.Timeline.Filtered" = "Filtered";
|
||||||
"Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view this’s profile
|
"Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view this’s profile
|
||||||
until they unblock you.";
|
until they unblock you.";
|
||||||
"Common.Controls.Timeline.Header.BlockingWarning" = "You can’t view this profile
|
"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.CountFavorites" = "%@ favorites";
|
||||||
"Common.Controls.Timeline.Accessibility.CountReblogs" = "%@ reblogs";
|
"Common.Controls.Timeline.Accessibility.CountReblogs" = "%@ reblogs";
|
||||||
"Common.Controls.Timeline.Accessibility.CountReplies" = "%@ replies";
|
"Common.Controls.Timeline.Accessibility.CountReplies" = "%@ replies";
|
||||||
|
"Common.Controls.Timeline.Filtered" = "Filtered";
|
||||||
"Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view this’s profile
|
"Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view this’s profile
|
||||||
until they unblock you.";
|
until they unblock you.";
|
||||||
"Common.Controls.Timeline.Header.BlockingWarning" = "You can’t view this profile
|
"Common.Controls.Timeline.Header.BlockingWarning" = "You can’t view this profile
|
||||||
|
|
|
@ -24,6 +24,7 @@ extension HashtagTimelineViewModel {
|
||||||
|
|
||||||
diffableDataSource = StatusSection.tableViewDiffableDataSource(
|
diffableDataSource = StatusSection.tableViewDiffableDataSource(
|
||||||
for: tableView,
|
for: tableView,
|
||||||
|
timelineContext: .hashtag,
|
||||||
dependency: dependency,
|
dependency: dependency,
|
||||||
managedObjectContext: context.managedObjectContext,
|
managedObjectContext: context.managedObjectContext,
|
||||||
timestampUpdatePublisher: timestampUpdatePublisher,
|
timestampUpdatePublisher: timestampUpdatePublisher,
|
||||||
|
|
|
@ -25,11 +25,17 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency, Media
|
||||||
|
|
||||||
let mediaPreviewTransitionController = MediaPreviewTransitionController()
|
let mediaPreviewTransitionController = MediaPreviewTransitionController()
|
||||||
|
|
||||||
|
let friendsAssetImageView: UIImageView = {
|
||||||
|
let imageView = UIImageView()
|
||||||
|
imageView.image = Asset.Asset.friends.image
|
||||||
|
imageView.contentMode = .scaleAspectFill
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
lazy var emptyView: UIStackView = {
|
lazy var emptyView: UIStackView = {
|
||||||
let emptyView = UIStackView()
|
let emptyView = UIStackView()
|
||||||
emptyView.axis = .vertical
|
emptyView.axis = .vertical
|
||||||
emptyView.distribution = .fill
|
emptyView.distribution = .fill
|
||||||
emptyView.layoutMargins = UIEdgeInsets(top: 0, left: 20, bottom: 54, right: 20)
|
|
||||||
emptyView.isLayoutMarginsRelativeArrangement = true
|
emptyView.isLayoutMarginsRelativeArrangement = true
|
||||||
return emptyView
|
return emptyView
|
||||||
}()
|
}()
|
||||||
|
@ -246,9 +252,10 @@ extension HomeTimelineViewController {
|
||||||
view.addSubview(emptyView)
|
view.addSubview(emptyView)
|
||||||
emptyView.translatesAutoresizingMaskIntoConstraints = false
|
emptyView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
emptyView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor),
|
emptyView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
|
||||||
emptyView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor),
|
emptyView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
emptyView.bottomAnchor.constraint(equalTo: view.readableContentGuide.bottomAnchor)
|
emptyView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
emptyView.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor)
|
||||||
])
|
])
|
||||||
|
|
||||||
if emptyView.arrangedSubviews.count > 0 {
|
if emptyView.arrangedSubviews.count > 0 {
|
||||||
|
@ -273,10 +280,28 @@ extension HomeTimelineViewController {
|
||||||
return button
|
return button
|
||||||
}()
|
}()
|
||||||
|
|
||||||
emptyView.addArrangedSubview(findPeopleButton)
|
let topPaddingView = UIView()
|
||||||
emptyView.setCustomSpacing(17, after: findPeopleButton)
|
let bottomPaddingView = UIView()
|
||||||
emptyView.addArrangedSubview(manuallySearchButton)
|
|
||||||
|
|
||||||
|
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(
|
diffableDataSource = StatusSection.tableViewDiffableDataSource(
|
||||||
for: tableView,
|
for: tableView,
|
||||||
|
timelineContext: .home,
|
||||||
dependency: dependency,
|
dependency: dependency,
|
||||||
managedObjectContext: fetchedResultsController.managedObjectContext,
|
managedObjectContext: fetchedResultsController.managedObjectContext,
|
||||||
timestampUpdatePublisher: timestampUpdatePublisher,
|
timestampUpdatePublisher: timestampUpdatePublisher,
|
||||||
|
@ -33,13 +34,10 @@ extension HomeTimelineViewModel {
|
||||||
threadReplyLoaderTableViewCellDelegate: nil
|
threadReplyLoaderTableViewCellDelegate: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// make initial snapshot animation smooth
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>()
|
var snapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>()
|
||||||
snapshot.appendSections([.main])
|
snapshot.appendSections([.main])
|
||||||
diffableDataSource?.apply(snapshot)
|
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)
|
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)
|
viewModel.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// needs trigger manually after onboarding dismiss
|
// needs trigger manually after onboarding dismiss
|
||||||
|
|
|
@ -131,8 +131,23 @@ final class NotificationStatusTableViewCell: UITableViewCell, StatusCell {
|
||||||
var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint!
|
var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint!
|
||||||
var separatorLineToMarginTrailingLayoutConstraint: 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() {
|
override func prepareForReuse() {
|
||||||
super.prepareForReuse()
|
super.prepareForReuse()
|
||||||
|
isFiltered = false
|
||||||
avatarImageViewTask?.cancel()
|
avatarImageViewTask?.cancel()
|
||||||
avatarImageViewTask = nil
|
avatarImageViewTask = nil
|
||||||
statusView.updateContentWarningDisplay(isHidden: true, animated: false)
|
statusView.updateContentWarningDisplay(isHidden: true, animated: false)
|
||||||
|
@ -263,6 +278,14 @@ extension NotificationStatusTableViewCell {
|
||||||
separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)),
|
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
|
statusView.delegate = self
|
||||||
|
|
||||||
let avatarImageViewTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
|
let avatarImageViewTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
|
||||||
|
@ -283,6 +306,12 @@ extension NotificationStatusTableViewCell {
|
||||||
statusContainerView.layer.borderColor = Asset.Colors.Border.notificationStatus.color.cgColor
|
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 {
|
extension NotificationStatusTableViewCell {
|
||||||
|
|
|
@ -21,6 +21,7 @@ extension FavoriteViewModel {
|
||||||
|
|
||||||
diffableDataSource = StatusSection.tableViewDiffableDataSource(
|
diffableDataSource = StatusSection.tableViewDiffableDataSource(
|
||||||
for: tableView,
|
for: tableView,
|
||||||
|
timelineContext: .favorite,
|
||||||
dependency: dependency,
|
dependency: dependency,
|
||||||
managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext,
|
managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext,
|
||||||
timestampUpdatePublisher: timestampUpdatePublisher,
|
timestampUpdatePublisher: timestampUpdatePublisher,
|
||||||
|
|
|
@ -21,6 +21,7 @@ extension UserTimelineViewModel {
|
||||||
|
|
||||||
diffableDataSource = StatusSection.tableViewDiffableDataSource(
|
diffableDataSource = StatusSection.tableViewDiffableDataSource(
|
||||||
for: tableView,
|
for: tableView,
|
||||||
|
timelineContext: .account,
|
||||||
dependency: dependency,
|
dependency: dependency,
|
||||||
managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext,
|
managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext,
|
||||||
timestampUpdatePublisher: timestampUpdatePublisher,
|
timestampUpdatePublisher: timestampUpdatePublisher,
|
||||||
|
|
|
@ -24,6 +24,7 @@ extension PublicTimelineViewModel {
|
||||||
|
|
||||||
diffableDataSource = StatusSection.tableViewDiffableDataSource(
|
diffableDataSource = StatusSection.tableViewDiffableDataSource(
|
||||||
for: tableView,
|
for: tableView,
|
||||||
|
timelineContext: .public,
|
||||||
dependency: dependency,
|
dependency: dependency,
|
||||||
managedObjectContext: fetchedResultsController.managedObjectContext,
|
managedObjectContext: fetchedResultsController.managedObjectContext,
|
||||||
timestampUpdatePublisher: timestampUpdatePublisher,
|
timestampUpdatePublisher: timestampUpdatePublisher,
|
||||||
|
|
|
@ -41,6 +41,9 @@ final class ReportedStatusTableViewCell: UITableViewCell, StatusCell {
|
||||||
var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint!
|
var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint!
|
||||||
var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint!
|
var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint!
|
||||||
|
|
||||||
|
// not support filter
|
||||||
|
var isFiltered: Bool = false
|
||||||
|
|
||||||
override func prepareForReuse() {
|
override func prepareForReuse() {
|
||||||
super.prepareForReuse()
|
super.prepareForReuse()
|
||||||
statusView.updateContentWarningDisplay(isHidden: true, animated: false)
|
statusView.updateContentWarningDisplay(isHidden: true, animated: false)
|
||||||
|
|
|
@ -479,9 +479,6 @@ extension StatusView {
|
||||||
avatarStackedContainerButton.addTarget(self, action: #selector(StatusView.avatarStackedContainerButtonDidPressed(_:)), for: .touchUpInside)
|
avatarStackedContainerButton.addTarget(self, action: #selector(StatusView.avatarStackedContainerButtonDidPressed(_:)), for: .touchUpInside)
|
||||||
revealContentWarningButton.addTarget(self, action: #selector(StatusView.revealContentWarningButtonDidPressed(_:)), for: .touchUpInside)
|
revealContentWarningButton.addTarget(self, action: #selector(StatusView.revealContentWarningButtonDidPressed(_:)), for: .touchUpInside)
|
||||||
pollVoteButton.addTarget(self, action: #selector(StatusView.pollVoteButtonPressed(_:)), for: .touchUpInside)
|
pollVoteButton.addTarget(self, action: #selector(StatusView.pollVoteButtonPressed(_:)), for: .touchUpInside)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -71,9 +71,24 @@ final class StatusTableViewCell: UITableViewCell, StatusCell {
|
||||||
var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint!
|
var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint!
|
||||||
var separatorLineToMarginTrailingLayoutConstraint: 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() {
|
override func prepareForReuse() {
|
||||||
super.prepareForReuse()
|
super.prepareForReuse()
|
||||||
selectionStyle = .default
|
selectionStyle = .default
|
||||||
|
isFiltered = false
|
||||||
statusView.statusMosaicImageViewContainer.resetImageTask()
|
statusView.statusMosaicImageViewContainer.resetImageTask()
|
||||||
statusView.contentMetaText.textView.isSelectable = false
|
statusView.contentMetaText.textView.isSelectable = false
|
||||||
statusView.updateContentWarningDisplay(isHidden: true, animated: false)
|
statusView.updateContentWarningDisplay(isHidden: true, animated: false)
|
||||||
|
@ -133,6 +148,14 @@ extension StatusTableViewCell {
|
||||||
])
|
])
|
||||||
resetSeparatorLineLayout()
|
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.delegate = self
|
||||||
statusView.pollTableView.delegate = self
|
statusView.pollTableView.delegate = self
|
||||||
statusView.statusMosaicImageViewContainer.delegate = self
|
statusView.statusMosaicImageViewContainer.delegate = self
|
||||||
|
@ -148,6 +171,12 @@ extension StatusTableViewCell {
|
||||||
resetSeparatorLineLayout()
|
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(
|
diffableDataSource = StatusSection.tableViewDiffableDataSource(
|
||||||
for: tableView,
|
for: tableView,
|
||||||
|
timelineContext: .thread,
|
||||||
dependency: dependency,
|
dependency: dependency,
|
||||||
managedObjectContext: context.managedObjectContext,
|
managedObjectContext: context.managedObjectContext,
|
||||||
timestampUpdatePublisher: timestampUpdatePublisher,
|
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 mastodonAuthenticationBoxes = CurrentValueSubject<[AuthenticationService.MastodonAuthenticationBox], Never>([])
|
||||||
let activeMastodonAuthentication = CurrentValueSubject<MastodonAuthentication?, Never>(nil)
|
let activeMastodonAuthentication = CurrentValueSubject<MastodonAuthentication?, Never>(nil)
|
||||||
let activeMastodonAuthenticationBox = CurrentValueSubject<AuthenticationService.MastodonAuthenticationBox?, Never>(nil)
|
let activeMastodonAuthenticationBox = CurrentValueSubject<AuthenticationService.MastodonAuthenticationBox?, Never>(nil)
|
||||||
|
let activeFilters = CurrentValueSubject<[Mastodon.Entity.Filter], Never>([])
|
||||||
|
|
||||||
init(
|
init(
|
||||||
managedObjectContext: NSManagedObjectContext,
|
managedObjectContext: NSManagedObjectContext,
|
||||||
|
@ -87,6 +88,53 @@ final class AuthenticationService: NSObject {
|
||||||
} catch {
|
} catch {
|
||||||
assertionFailure(error.localizedDescription)
|
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 notifications
|
||||||
case `public`
|
case `public`
|
||||||
case thread
|
case thread
|
||||||
|
case account
|
||||||
|
|
||||||
case _other(String)
|
case _other(String)
|
||||||
|
|
||||||
|
@ -52,6 +53,7 @@ extension Mastodon.Entity.Filter {
|
||||||
case "notifications": self = .notifications
|
case "notifications": self = .notifications
|
||||||
case "public": self = .`public`
|
case "public": self = .`public`
|
||||||
case "thread": self = .thread
|
case "thread": self = .thread
|
||||||
|
case "account": self = .account
|
||||||
default: self = ._other(rawValue)
|
default: self = ._other(rawValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -62,6 +64,7 @@ extension Mastodon.Entity.Filter {
|
||||||
case .notifications: return "notifications"
|
case .notifications: return "notifications"
|
||||||
case .public: return "public"
|
case .public: return "public"
|
||||||
case .thread: return "thread"
|
case .thread: return "thread"
|
||||||
|
case .account: return "account"
|
||||||
case ._other(let value): return value
|
case ._other(let value): return value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue