forked from zelo72/mastodon-ios
feat: restore post filter supports
This commit is contained in:
parent
d80b8d718a
commit
792208aebb
|
@ -24,6 +24,8 @@ extension NotificationSection {
|
||||||
|
|
||||||
struct Configuration {
|
struct Configuration {
|
||||||
weak var notificationTableViewCellDelegate: NotificationTableViewCellDelegate?
|
weak var notificationTableViewCellDelegate: NotificationTableViewCellDelegate?
|
||||||
|
let filterContext: Mastodon.Entity.Filter.Context?
|
||||||
|
let activeFilters: Published<[Mastodon.Entity.Filter]>.Publisher?
|
||||||
}
|
}
|
||||||
|
|
||||||
static func diffableDataSource(
|
static func diffableDataSource(
|
||||||
|
@ -58,57 +60,6 @@ extension NotificationSection {
|
||||||
cell.activityIndicatorView.startAnimating()
|
cell.activityIndicatorView.startAnimating()
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
// switch notificationItem {
|
|
||||||
// case .notification(let objectID, let attribute):
|
|
||||||
// guard let notification = try? managedObjectContext.existingObject(with: objectID) as? MastodonNotification,
|
|
||||||
// !notification.isDeleted
|
|
||||||
// else { return UITableViewCell() }
|
|
||||||
//
|
|
||||||
// let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationStatusTableViewCell.self), for: indexPath) as! NotificationStatusTableViewCell
|
|
||||||
// configure(
|
|
||||||
// tableView: tableView,
|
|
||||||
// cell: cell,
|
|
||||||
// notification: notification,
|
|
||||||
// dependency: dependency,
|
|
||||||
// attribute: attribute
|
|
||||||
// )
|
|
||||||
// cell.delegate = delegate
|
|
||||||
// cell.isAccessibilityElement = true
|
|
||||||
// NotificationSection.configureStatusAccessibilityLabel(cell: cell)
|
|
||||||
// return cell
|
|
||||||
//
|
|
||||||
// case .notificationStatus(objectID: let objectID, attribute: let attribute):
|
|
||||||
// guard let notification = try? managedObjectContext.existingObject(with: objectID) as? MastodonNotification,
|
|
||||||
// !notification.isDeleted,
|
|
||||||
// let status = notification.status,
|
|
||||||
// let requestUserID = dependency.context.authenticationService.activeMastodonAuthenticationBox.value?.userID
|
|
||||||
// else { return UITableViewCell() }
|
|
||||||
// let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell
|
|
||||||
//
|
|
||||||
// // configure cell
|
|
||||||
// StatusSection.configureStatusTableViewCell(
|
|
||||||
// cell: cell,
|
|
||||||
// tableView: tableView,
|
|
||||||
// timelineContext: .notifications,
|
|
||||||
// dependency: dependency,
|
|
||||||
// readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
|
|
||||||
// status: status,
|
|
||||||
// requestUserID: requestUserID,
|
|
||||||
// statusItemAttribute: attribute
|
|
||||||
// )
|
|
||||||
// cell.statusView.headerContainerView.isHidden = true // set header hide
|
|
||||||
// cell.statusView.actionToolbarContainer.isHidden = true // set toolbar hide
|
|
||||||
// cell.statusView.actionToolbarPlaceholderPaddingView.isHidden = false
|
|
||||||
// cell.delegate = statusTableViewCellDelegate
|
|
||||||
// cell.isAccessibilityElement = true
|
|
||||||
// StatusSection.configureStatusAccessibilityLabel(cell: cell)
|
|
||||||
// return cell
|
|
||||||
//
|
|
||||||
// case .bottomLoader:
|
|
||||||
// let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) as! TimelineBottomLoaderTableViewCell
|
|
||||||
// cell.startAnimating()
|
|
||||||
// return cell
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -142,163 +93,17 @@ extension NotificationSection {
|
||||||
viewModel: viewModel,
|
viewModel: viewModel,
|
||||||
delegate: configuration.notificationTableViewCellDelegate
|
delegate: configuration.notificationTableViewCellDelegate
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cell.notificationView.statusView.viewModel.filterContext = configuration.filterContext
|
||||||
|
cell.notificationView.quoteStatusView.viewModel.filterContext = configuration.filterContext
|
||||||
|
|
||||||
|
configuration.activeFilters?
|
||||||
|
.assign(to: \.activeFilters, on: cell.notificationView.statusView.viewModel)
|
||||||
|
.store(in: &cell.disposeBag)
|
||||||
|
configuration.activeFilters?
|
||||||
|
.assign(to: \.activeFilters, on: cell.notificationView.quoteStatusView.viewModel)
|
||||||
|
.store(in: &cell.disposeBag)
|
||||||
}
|
}
|
||||||
|
|
||||||
// static func configure(
|
|
||||||
// tableView: UITableView,
|
|
||||||
// cell: NotificationStatusTableViewCell,
|
|
||||||
// notification: MastodonNotification,
|
|
||||||
// dependency: NeedsDependency,
|
|
||||||
// attribute: Item.StatusAttribute
|
|
||||||
// ) {
|
|
||||||
// // configure author
|
|
||||||
// cell.configure(
|
|
||||||
// with: AvatarConfigurableViewConfiguration(
|
|
||||||
// avatarImageURL: notification.account.avatarImageURL()
|
|
||||||
// )
|
|
||||||
// )
|
|
||||||
//
|
|
||||||
// func createActionImage() -> UIImage? {
|
|
||||||
// return UIImage(
|
|
||||||
// systemName: notification.notificationType.actionImageName,
|
|
||||||
// withConfiguration: UIImage.SymbolConfiguration(
|
|
||||||
// pointSize: 12, weight: .semibold
|
|
||||||
// )
|
|
||||||
// )?
|
|
||||||
// .withTintColor(.systemBackground)
|
|
||||||
// .af.imageAspectScaled(toFit: CGSize(width: 14, height: 14))
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// cell.avatarButton.badgeImageView.backgroundColor = notification.notificationType.color
|
|
||||||
// cell.avatarButton.badgeImageView.image = createActionImage()
|
|
||||||
// cell.traitCollectionDidChange
|
|
||||||
// .receive(on: DispatchQueue.main)
|
|
||||||
// .sink { [weak cell] in
|
|
||||||
// guard let cell = cell else { return }
|
|
||||||
// cell.avatarButton.badgeImageView.image = createActionImage()
|
|
||||||
// }
|
|
||||||
// .store(in: &cell.disposeBag)
|
|
||||||
//
|
|
||||||
// // configure author name, notification description, timestamp
|
|
||||||
// let nameText = notification.account.displayNameWithFallback
|
|
||||||
// let titleLabelText: String = {
|
|
||||||
// switch notification.notificationType {
|
|
||||||
// case .favourite: return L10n.Scene.Notification.userFavoritedYourPost(nameText)
|
|
||||||
// case .follow: return L10n.Scene.Notification.userFollowedYou(nameText)
|
|
||||||
// case .followRequest: return L10n.Scene.Notification.userRequestedToFollowYou(nameText)
|
|
||||||
// case .mention: return L10n.Scene.Notification.userMentionedYou(nameText)
|
|
||||||
// case .poll: return L10n.Scene.Notification.userYourPollHasEnded(nameText)
|
|
||||||
// case .reblog: return L10n.Scene.Notification.userRebloggedYourPost(nameText)
|
|
||||||
// default: return ""
|
|
||||||
// }
|
|
||||||
// }()
|
|
||||||
//
|
|
||||||
// do {
|
|
||||||
// let nameContent = MastodonContent(content: nameText, emojis: notification.account.emojiMeta)
|
|
||||||
// let nameMetaContent = try MastodonMetaContent.convert(document: nameContent)
|
|
||||||
//
|
|
||||||
// let mastodonContent = MastodonContent(content: titleLabelText, emojis: notification.account.emojiMeta)
|
|
||||||
// let metaContent = try MastodonMetaContent.convert(document: mastodonContent)
|
|
||||||
//
|
|
||||||
// cell.titleLabel.configure(content: metaContent)
|
|
||||||
//
|
|
||||||
// if let nameRange = metaContent.string.range(of: nameMetaContent.string) {
|
|
||||||
// let nsRange = NSRange(nameRange, in: metaContent.string)
|
|
||||||
// cell.titleLabel.textStorage.addAttributes([
|
|
||||||
// .font: UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold), maximumPointSize: 20),
|
|
||||||
// .foregroundColor: Asset.Colors.brandBlue.color,
|
|
||||||
// ], range: nsRange)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// } catch {
|
|
||||||
// let metaContent = PlaintextMetaContent(string: titleLabelText)
|
|
||||||
// cell.titleLabel.configure(content: metaContent)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// let createAt = notification.createAt
|
|
||||||
// cell.timestampLabel.text = createAt.localizedSlowedTimeAgoSinceNow
|
|
||||||
// AppContext.shared.timestampUpdatePublisher
|
|
||||||
// .receive(on: DispatchQueue.main)
|
|
||||||
// .sink { [weak cell] _ in
|
|
||||||
// guard let cell = cell else { return }
|
|
||||||
// cell.timestampLabel.text = createAt.localizedSlowedTimeAgoSinceNow
|
|
||||||
// }
|
|
||||||
// .store(in: &cell.disposeBag)
|
|
||||||
//
|
|
||||||
// // configure follow request (if exist)
|
|
||||||
// if case .followRequest = notification.notificationType {
|
|
||||||
// cell.acceptButton.publisher(for: .touchUpInside)
|
|
||||||
// .sink { [weak cell] _ in
|
|
||||||
// guard let cell = cell else { return }
|
|
||||||
// cell.delegate?.notificationTableViewCell(cell, notification: notification, acceptButtonDidPressed: cell.acceptButton)
|
|
||||||
// }
|
|
||||||
// .store(in: &cell.disposeBag)
|
|
||||||
// cell.rejectButton.publisher(for: .touchUpInside)
|
|
||||||
// .sink { [weak cell] _ in
|
|
||||||
// guard let cell = cell else { return }
|
|
||||||
// cell.delegate?.notificationTableViewCell(cell, notification: notification, rejectButtonDidPressed: cell.rejectButton)
|
|
||||||
// }
|
|
||||||
// .store(in: &cell.disposeBag)
|
|
||||||
// cell.buttonStackView.isHidden = false
|
|
||||||
// } else {
|
|
||||||
// cell.buttonStackView.isHidden = true
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // configure status (if exist)
|
|
||||||
// if let status = notification.status {
|
|
||||||
// let frame = CGRect(
|
|
||||||
// x: 0,
|
|
||||||
// y: 0,
|
|
||||||
// width: tableView.readableContentGuide.layoutFrame.width - NotificationStatusTableViewCell.statusPadding.left - NotificationStatusTableViewCell.statusPadding.right,
|
|
||||||
// height: tableView.readableContentGuide.layoutFrame.height
|
|
||||||
// )
|
|
||||||
// StatusSection.configure(
|
|
||||||
// cell: cell,
|
|
||||||
// tableView: tableView,
|
|
||||||
// timelineContext: .notifications,
|
|
||||||
// dependency: dependency,
|
|
||||||
// readableLayoutFrame: frame,
|
|
||||||
// status: status,
|
|
||||||
// requestUserID: notification.userID,
|
|
||||||
// statusItemAttribute: attribute
|
|
||||||
// )
|
|
||||||
// cell.statusContainerView.isHidden = false
|
|
||||||
// cell.containerStackView.alignment = .top
|
|
||||||
// cell.containerStackViewBottomLayoutConstraint.constant = 0
|
|
||||||
// } else {
|
|
||||||
// if case .followRequest = notification.notificationType {
|
|
||||||
// cell.containerStackView.alignment = .top
|
|
||||||
// } else {
|
|
||||||
// cell.containerStackView.alignment = .center
|
|
||||||
// }
|
|
||||||
// cell.statusContainerView.isHidden = true
|
|
||||||
// cell.containerStackViewBottomLayoutConstraint.constant = 5 // 5pt margin when no status view
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// static func configureStatusAccessibilityLabel(cell: NotificationStatusTableViewCell) {
|
|
||||||
// // FIXME:
|
|
||||||
// cell.accessibilityLabel = {
|
|
||||||
// var accessibilityViews: [UIView?] = []
|
|
||||||
// accessibilityViews.append(contentsOf: [
|
|
||||||
// cell.titleLabel,
|
|
||||||
// cell.timestampLabel,
|
|
||||||
// cell.statusView
|
|
||||||
// ])
|
|
||||||
// if !cell.statusContainerView.isHidden {
|
|
||||||
// if !cell.statusView.headerContainerView.isHidden {
|
|
||||||
// accessibilityViews.append(cell.statusView.headerInfoLabel)
|
|
||||||
// }
|
|
||||||
// accessibilityViews.append(contentsOf: [
|
|
||||||
// cell.statusView.nameMetaLabel,
|
|
||||||
// cell.statusView.dateLabel,
|
|
||||||
// cell.statusView.contentMetaText.textView,
|
|
||||||
// ])
|
|
||||||
// }
|
|
||||||
// return accessibilityViews
|
|
||||||
// .compactMap { $0?.accessibilityLabel }
|
|
||||||
// .joined(separator: " ")
|
|
||||||
// }()
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -28,6 +28,8 @@ extension StatusSection {
|
||||||
struct Configuration {
|
struct Configuration {
|
||||||
weak var statusTableViewCellDelegate: StatusTableViewCellDelegate?
|
weak var statusTableViewCellDelegate: StatusTableViewCellDelegate?
|
||||||
weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?
|
weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?
|
||||||
|
let filterContext: Mastodon.Entity.Filter.Context?
|
||||||
|
let activeFilters: Published<[Mastodon.Entity.Filter]>.Publisher?
|
||||||
}
|
}
|
||||||
|
|
||||||
static func diffableDataSource(
|
static func diffableDataSource(
|
||||||
|
@ -258,6 +260,11 @@ extension StatusSection {
|
||||||
viewModel: viewModel,
|
viewModel: viewModel,
|
||||||
delegate: configuration.statusTableViewCellDelegate
|
delegate: configuration.statusTableViewCellDelegate
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cell.statusView.viewModel.filterContext = configuration.filterContext
|
||||||
|
configuration.activeFilters?
|
||||||
|
.assign(to: \.activeFilters, on: cell.statusView.viewModel)
|
||||||
|
.store(in: &cell.disposeBag)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func configure(
|
static func configure(
|
||||||
|
@ -282,6 +289,11 @@ extension StatusSection {
|
||||||
viewModel: viewModel,
|
viewModel: viewModel,
|
||||||
delegate: configuration.statusTableViewCellDelegate
|
delegate: configuration.statusTableViewCellDelegate
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cell.statusView.viewModel.filterContext = configuration.filterContext
|
||||||
|
configuration.activeFilters?
|
||||||
|
.assign(to: \.activeFilters, on: cell.statusView.viewModel)
|
||||||
|
.store(in: &cell.disposeBag)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func configure(
|
static func configure(
|
||||||
|
@ -296,133 +308,3 @@ extension StatusSection {
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension StatusSection {
|
|
||||||
|
|
||||||
enum TimelineContext {
|
|
||||||
case home
|
|
||||||
case notifications
|
|
||||||
case `public`
|
|
||||||
case thread
|
|
||||||
case account
|
|
||||||
|
|
||||||
case favorite
|
|
||||||
case hashtag
|
|
||||||
case report
|
|
||||||
case search
|
|
||||||
|
|
||||||
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,
|
|
||||||
!filters.isEmpty 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()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
class StatusContentOperation: Operation {
|
|
||||||
|
|
||||||
let logger = Logger(subsystem: "StatusContentOperation", category: "logic")
|
|
||||||
|
|
||||||
// input
|
|
||||||
let statusObjectID: NSManagedObjectID
|
|
||||||
let mastodonContent: MastodonContent
|
|
||||||
|
|
||||||
// output
|
|
||||||
var result: Result<MastodonMetaContent, Error>?
|
|
||||||
|
|
||||||
init(
|
|
||||||
statusObjectID: NSManagedObjectID,
|
|
||||||
mastodonContent: MastodonContent
|
|
||||||
) {
|
|
||||||
self.statusObjectID = statusObjectID
|
|
||||||
self.mastodonContent = mastodonContent
|
|
||||||
super.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func main() {
|
|
||||||
guard !isCancelled else { return }
|
|
||||||
// logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): prcoess \(self.statusObjectID)…")
|
|
||||||
|
|
||||||
do {
|
|
||||||
let content = try MastodonMetaContent.convert(document: mastodonContent)
|
|
||||||
result = .success(content)
|
|
||||||
// logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): process success \(self.statusObjectID)")
|
|
||||||
} catch {
|
|
||||||
result = .failure(error)
|
|
||||||
// logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): process fail \(self.statusObjectID)")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
override func cancel() {
|
|
||||||
// logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): cancel \(self.statusObjectID.debugDescription)")
|
|
||||||
super.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -21,7 +21,9 @@ extension HashtagTimelineViewModel {
|
||||||
context: context,
|
context: context,
|
||||||
configuration: StatusSection.Configuration(
|
configuration: StatusSection.Configuration(
|
||||||
statusTableViewCellDelegate: statusTableViewCellDelegate,
|
statusTableViewCellDelegate: statusTableViewCellDelegate,
|
||||||
timelineMiddleLoaderTableViewCellDelegate: nil
|
timelineMiddleLoaderTableViewCellDelegate: nil,
|
||||||
|
filterContext: .none,
|
||||||
|
activeFilters: nil
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -22,7 +22,9 @@ extension HomeTimelineViewModel {
|
||||||
context: context,
|
context: context,
|
||||||
configuration: StatusSection.Configuration(
|
configuration: StatusSection.Configuration(
|
||||||
statusTableViewCellDelegate: statusTableViewCellDelegate,
|
statusTableViewCellDelegate: statusTableViewCellDelegate,
|
||||||
timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate
|
timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate,
|
||||||
|
filterContext: .home,
|
||||||
|
activeFilters: context.statusFilterService.$activeFilters
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,9 @@ extension NotificationTimelineViewModel {
|
||||||
tableView: tableView,
|
tableView: tableView,
|
||||||
context: context,
|
context: context,
|
||||||
configuration: NotificationSection.Configuration(
|
configuration: NotificationSection.Configuration(
|
||||||
notificationTableViewCellDelegate: notificationTableViewCellDelegate
|
notificationTableViewCellDelegate: notificationTableViewCellDelegate,
|
||||||
|
filterContext: .notifications,
|
||||||
|
activeFilters: context.statusFilterService.$activeFilters
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -18,7 +18,9 @@ extension FavoriteViewModel {
|
||||||
context: context,
|
context: context,
|
||||||
configuration: StatusSection.Configuration(
|
configuration: StatusSection.Configuration(
|
||||||
statusTableViewCellDelegate: statusTableViewCellDelegate,
|
statusTableViewCellDelegate: statusTableViewCellDelegate,
|
||||||
timelineMiddleLoaderTableViewCellDelegate: nil
|
timelineMiddleLoaderTableViewCellDelegate: nil,
|
||||||
|
filterContext: .none,
|
||||||
|
activeFilters: nil
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
// set empty section to make update animation top-to-bottom style
|
// set empty section to make update animation top-to-bottom style
|
||||||
|
|
|
@ -19,7 +19,9 @@ extension UserTimelineViewModel {
|
||||||
context: context,
|
context: context,
|
||||||
configuration: StatusSection.Configuration(
|
configuration: StatusSection.Configuration(
|
||||||
statusTableViewCellDelegate: statusTableViewCellDelegate,
|
statusTableViewCellDelegate: statusTableViewCellDelegate,
|
||||||
timelineMiddleLoaderTableViewCellDelegate: nil
|
timelineMiddleLoaderTableViewCellDelegate: nil,
|
||||||
|
filterContext: .none,
|
||||||
|
activeFilters: nil
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -9,11 +9,16 @@ import UIKit
|
||||||
import Combine
|
import Combine
|
||||||
import MastodonUI
|
import MastodonUI
|
||||||
import CoreDataStack
|
import CoreDataStack
|
||||||
|
import MastodonSDK
|
||||||
import MastodonLocalization
|
import MastodonLocalization
|
||||||
import MastodonMeta
|
import MastodonMeta
|
||||||
import Meta
|
import Meta
|
||||||
|
import NaturalLanguage
|
||||||
|
|
||||||
extension StatusView {
|
extension StatusView {
|
||||||
|
|
||||||
|
static let statusFilterWorkingQueue = DispatchQueue(label: "StatusFilterWorkingQueue")
|
||||||
|
|
||||||
public func configure(feed: Feed) {
|
public func configure(feed: Feed) {
|
||||||
switch feed.kind {
|
switch feed.kind {
|
||||||
case .home:
|
case .home:
|
||||||
|
@ -48,7 +53,8 @@ extension StatusView {
|
||||||
configureContent(status: status)
|
configureContent(status: status)
|
||||||
configureMedia(status: status)
|
configureMedia(status: status)
|
||||||
configurePoll(status: status)
|
configurePoll(status: status)
|
||||||
configureToolbar(status: status)
|
configureToolbar(status: status)
|
||||||
|
configureFilter(status: status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -397,5 +403,58 @@ extension StatusView {
|
||||||
.assign(to: \.isFavorite, on: viewModel)
|
.assign(to: \.isFavorite, on: viewModel)
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func configureFilter(status: Status) {
|
||||||
|
let status = status.reblog ?? status
|
||||||
|
|
||||||
|
let content = status.content.lowercased()
|
||||||
|
|
||||||
|
Publishers.CombineLatest(
|
||||||
|
viewModel.$activeFilters,
|
||||||
|
viewModel.$filterContext
|
||||||
|
)
|
||||||
|
.receive(on: StatusView.statusFilterWorkingQueue)
|
||||||
|
.map { filters, filterContext in
|
||||||
|
var wordFilters: [Mastodon.Entity.Filter] = []
|
||||||
|
var nonWordFilters: [Mastodon.Entity.Filter] = []
|
||||||
|
for filter in filters {
|
||||||
|
guard filter.context.contains(where: { $0 == filterContext }) else { continue }
|
||||||
|
if filter.wholeWord {
|
||||||
|
wordFilters.append(filter)
|
||||||
|
} else {
|
||||||
|
nonWordFilters.append(filter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var needsFilter = false
|
||||||
|
for filter in nonWordFilters {
|
||||||
|
guard content.contains(filter.phrase.lowercased()) else { continue }
|
||||||
|
needsFilter = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if needsFilter {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let tokenizer = NLTokenizer(unit: .word)
|
||||||
|
tokenizer.string = content
|
||||||
|
let phraseWords = wordFilters.map { $0.phrase.lowercased() }
|
||||||
|
tokenizer.enumerateTokens(in: content.startIndex..<content.endIndex) { range, _ in
|
||||||
|
let word = String(content[range])
|
||||||
|
if phraseWords.contains(word) {
|
||||||
|
needsFilter = true
|
||||||
|
return false
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return needsFilter
|
||||||
|
}
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: \.isFiltered, on: viewModel)
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,7 +23,9 @@ extension ThreadViewModel {
|
||||||
context: context,
|
context: context,
|
||||||
configuration: StatusSection.Configuration(
|
configuration: StatusSection.Configuration(
|
||||||
statusTableViewCellDelegate: statusTableViewCellDelegate,
|
statusTableViewCellDelegate: statusTableViewCellDelegate,
|
||||||
timelineMiddleLoaderTableViewCellDelegate: nil
|
timelineMiddleLoaderTableViewCellDelegate: nil,
|
||||||
|
filterContext: .thread,
|
||||||
|
activeFilters: context.statusFilterService.$activeFilters
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -23,7 +23,7 @@ final class StatusFilterService {
|
||||||
let filterUpdatePublisher = PassthroughSubject<Void, Never>()
|
let filterUpdatePublisher = PassthroughSubject<Void, Never>()
|
||||||
|
|
||||||
// output
|
// output
|
||||||
let activeFilters = CurrentValueSubject<[Mastodon.Entity.Filter], Never>([])
|
@Published var activeFilters: [Mastodon.Entity.Filter] = []
|
||||||
|
|
||||||
init(
|
init(
|
||||||
apiService: APIService,
|
apiService: APIService,
|
||||||
|
@ -57,7 +57,14 @@ final class StatusFilterService {
|
||||||
.map { response in
|
.map { response in
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let newResponse = response.map { filters in
|
let newResponse = response.map { filters in
|
||||||
return filters.filter { $0.expiresAt > now } // filter out expired rules
|
return filters.filter { filter in
|
||||||
|
if let expiresAt = filter.expiresAt {
|
||||||
|
// filter out expired rules
|
||||||
|
return expiresAt > now
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Result<Mastodon.Response.Content<[Mastodon.Entity.Filter]>, Error>.success(newResponse)
|
return Result<Mastodon.Response.Content<[Mastodon.Entity.Filter]>, Error>.success(newResponse)
|
||||||
}
|
}
|
||||||
|
@ -70,7 +77,7 @@ final class StatusFilterService {
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let response):
|
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)
|
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
|
self.activeFilters = response.value
|
||||||
case .failure(let error):
|
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)
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch account filters fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="19574" systemVersion="21D49" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="19574" systemVersion="21D62" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||||
<entity name="Application" representedClassName="CoreDataStack.Application" syncable="YES">
|
<entity name="Application" representedClassName="CoreDataStack.Application" syncable="YES">
|
||||||
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
<attribute name="name" attributeType="String"/>
|
<attribute name="name" attributeType="String"/>
|
||||||
|
|
|
@ -22,7 +22,7 @@ extension Mastodon.Entity {
|
||||||
public let id: ID
|
public let id: ID
|
||||||
public let phrase: String
|
public let phrase: String
|
||||||
public let context: [Context]
|
public let context: [Context]
|
||||||
public let expiresAt: Date
|
public let expiresAt: Date?
|
||||||
public let irreversible: Bool
|
public let irreversible: Bool
|
||||||
public let wholeWord: Bool
|
public let wholeWord: Bool
|
||||||
|
|
||||||
|
@ -38,7 +38,7 @@ extension Mastodon.Entity {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Mastodon.Entity.Filter {
|
extension Mastodon.Entity.Filter {
|
||||||
public enum Context: RawRepresentable, Codable {
|
public enum Context: RawRepresentable, Codable, Hashable {
|
||||||
case home
|
case home
|
||||||
case notifications
|
case notifications
|
||||||
case `public`
|
case `public`
|
||||||
|
|
|
@ -89,6 +89,11 @@ extension StatusView {
|
||||||
@Published public var replyCount: Int = 0
|
@Published public var replyCount: Int = 0
|
||||||
@Published public var reblogCount: Int = 0
|
@Published public var reblogCount: Int = 0
|
||||||
@Published public var favoriteCount: Int = 0
|
@Published public var favoriteCount: Int = 0
|
||||||
|
|
||||||
|
// Filter
|
||||||
|
@Published public var activeFilters: [Mastodon.Entity.Filter] = []
|
||||||
|
@Published public var filterContext: Mastodon.Entity.Filter.Context?
|
||||||
|
@Published public var isFiltered = false
|
||||||
|
|
||||||
@Published public var groupedAccessibilityLabel = ""
|
@Published public var groupedAccessibilityLabel = ""
|
||||||
|
|
||||||
|
@ -128,9 +133,8 @@ extension StatusView {
|
||||||
isMediaSensitive = false
|
isMediaSensitive = false
|
||||||
isMediaSensitiveToggled = false
|
isMediaSensitiveToggled = false
|
||||||
|
|
||||||
// isSensitive = false
|
activeFilters = []
|
||||||
// isContentReveal = false
|
filterContext = nil
|
||||||
// isMediaReveal = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
@ -192,6 +196,7 @@ extension StatusView.ViewModel {
|
||||||
bindToolbar(statusView: statusView)
|
bindToolbar(statusView: statusView)
|
||||||
bindMetric(statusView: statusView)
|
bindMetric(statusView: statusView)
|
||||||
bindMenu(statusView: statusView)
|
bindMenu(statusView: statusView)
|
||||||
|
bindFilter(statusView: statusView)
|
||||||
bindAccessibility(statusView: statusView)
|
bindAccessibility(statusView: statusView)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -611,6 +616,17 @@ extension StatusView.ViewModel {
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func bindFilter(statusView: StatusView) {
|
||||||
|
$isFiltered
|
||||||
|
.sink { isFiltered in
|
||||||
|
statusView.containerStackView.isHidden = isFiltered
|
||||||
|
if isFiltered {
|
||||||
|
statusView.setFilterHintLabelDisplay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
}
|
||||||
|
|
||||||
private func bindAccessibility(statusView: StatusView) {
|
private func bindAccessibility(statusView: StatusView) {
|
||||||
let authorAccessibilityLabel = Publishers.CombineLatest3(
|
let authorAccessibilityLabel = Publishers.CombineLatest3(
|
||||||
$header,
|
$header,
|
||||||
|
|
|
@ -226,6 +226,15 @@ public final class StatusView: UIView {
|
||||||
// metric
|
// metric
|
||||||
public let statusMetricView = StatusMetricView()
|
public let statusMetricView = StatusMetricView()
|
||||||
|
|
||||||
|
// filter hint
|
||||||
|
public let filterHintLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.textColor = Asset.Colors.Label.secondary.color
|
||||||
|
label.text = L10n.Common.Controls.Timeline.filtered
|
||||||
|
label.font = .systemFont(ofSize: 17, weight: .regular)
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
public func prepareForReuse() {
|
public func prepareForReuse() {
|
||||||
disposeBag.removeAll()
|
disposeBag.removeAll()
|
||||||
|
|
||||||
|
@ -249,7 +258,7 @@ public final class StatusView: UIView {
|
||||||
mediaContainerView.isHidden = true
|
mediaContainerView.isHidden = true
|
||||||
pollContainerView.isHidden = true
|
pollContainerView.isHidden = true
|
||||||
statusVisibilityView.isHidden = true
|
statusVisibilityView.isHidden = true
|
||||||
// setSpoilerBannerViewHidden(isHidden: true)
|
filterHintLabel.isHidden = true
|
||||||
}
|
}
|
||||||
|
|
||||||
public override init(frame: CGRect) {
|
public override init(frame: CGRect) {
|
||||||
|
@ -570,6 +579,14 @@ extension StatusView.Style {
|
||||||
statusView.actionToolbarContainer.configure(for: .inline)
|
statusView.actionToolbarContainer.configure(for: .inline)
|
||||||
statusView.actionToolbarContainer.preservesSuperviewLayoutMargins = true
|
statusView.actionToolbarContainer.preservesSuperviewLayoutMargins = true
|
||||||
statusView.containerStackView.addArrangedSubview(statusView.actionToolbarContainer)
|
statusView.containerStackView.addArrangedSubview(statusView.actionToolbarContainer)
|
||||||
|
|
||||||
|
// filterHintLabel
|
||||||
|
statusView.filterHintLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
statusView.addSubview(statusView.filterHintLabel)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
statusView.filterHintLabel.centerXAnchor.constraint(equalTo: statusView.containerStackView.centerXAnchor),
|
||||||
|
statusView.filterHintLabel.centerYAnchor.constraint(equalTo: statusView.containerStackView.centerYAnchor),
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
func inline(statusView: StatusView) {
|
func inline(statusView: StatusView) {
|
||||||
|
@ -673,9 +690,9 @@ extension StatusView {
|
||||||
statusVisibilityView.isHidden = false
|
statusVisibilityView.isHidden = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// func setSpoilerBannerViewHidden(isHidden: Bool) {
|
func setFilterHintLabelDisplay() {
|
||||||
// spoilerBannerView.isHidden = isHidden
|
filterHintLabel.isHidden = false
|
||||||
// }
|
}
|
||||||
|
|
||||||
// content text Width
|
// content text Width
|
||||||
public var contentMaxLayoutWidth: CGFloat {
|
public var contentMaxLayoutWidth: CGFloat {
|
||||||
|
|
Loading…
Reference in New Issue