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