feat: update the notification tab "Mentions" segment table UI

This commit is contained in:
CMK 2021-11-02 14:28:21 +08:00
parent 86d475fe56
commit 0d39d061a1
8 changed files with 319 additions and 184 deletions

View File

@ -10,7 +10,7 @@ import Foundation
enum NotificationItem { enum NotificationItem {
case notification(objectID: NSManagedObjectID, attribute: Item.StatusAttribute) case notification(objectID: NSManagedObjectID, attribute: Item.StatusAttribute)
case notificationStatus(objectID: NSManagedObjectID, attribute: Item.StatusAttribute) // display notification status without card wrapper
case bottomLoader case bottomLoader
} }
@ -19,6 +19,8 @@ extension NotificationItem: Equatable {
switch (lhs, rhs) { switch (lhs, rhs) {
case (.notification(let idLeft, _), .notification(let idRight, _)): case (.notification(let idLeft, _), .notification(let idRight, _)):
return idLeft == idRight return idLeft == idRight
case (.notificationStatus(let idLeft, _), .notificationStatus(let idRight, _)):
return idLeft == idRight
case (.bottomLoader, .bottomLoader): case (.bottomLoader, .bottomLoader):
return true return true
default: default:
@ -32,6 +34,8 @@ extension NotificationItem: Hashable {
switch self { switch self {
case .notification(let id, _): case .notification(let id, _):
hasher.combine(id) hasher.combine(id)
case .notificationStatus(let id, _):
hasher.combine(id)
case .bottomLoader: case .bottomLoader:
hasher.combine(String(describing: NotificationItem.bottomLoader.self)) hasher.combine(String(describing: NotificationItem.bottomLoader.self))
} }
@ -43,6 +47,8 @@ extension NotificationItem {
switch self { switch self {
case .notification(let objectID, _): case .notification(let objectID, _):
return .mastodonNotification(objectID: objectID) return .mastodonNotification(objectID: objectID)
case .notificationStatus(let objectID, _):
return .mastodonNotification(objectID: objectID)
case .bottomLoader: case .bottomLoader:
return nil return nil
} }

View File

@ -21,9 +21,10 @@ enum NotificationSection: Equatable, Hashable {
extension NotificationSection { extension NotificationSection {
static func tableViewDiffableDataSource( static func tableViewDiffableDataSource(
for tableView: UITableView, for tableView: UITableView,
dependency: NeedsDependency,
managedObjectContext: NSManagedObjectContext, managedObjectContext: NSManagedObjectContext,
delegate: NotificationTableViewCellDelegate, delegate: NotificationTableViewCellDelegate,
dependency: NeedsDependency statusTableViewCellDelegate: StatusTableViewCellDelegate
) -> UITableViewDiffableDataSource<NotificationSection, NotificationItem> { ) -> UITableViewDiffableDataSource<NotificationSection, NotificationItem> {
UITableViewDiffableDataSource(tableView: tableView) { UITableViewDiffableDataSource(tableView: tableView) {
[weak delegate, weak dependency] [weak delegate, weak dependency]
@ -32,137 +33,45 @@ extension NotificationSection {
switch notificationItem { switch notificationItem {
case .notification(let objectID, let attribute): case .notification(let objectID, let attribute):
guard let notification = try? managedObjectContext.existingObject(with: objectID) as? MastodonNotification, guard let notification = try? managedObjectContext.existingObject(with: objectID) as? MastodonNotification,
!notification.isDeleted else { !notification.isDeleted
return UITableViewCell() else { return UITableViewCell() }
}
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationStatusTableViewCell.self), for: indexPath) as! NotificationStatusTableViewCell let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationStatusTableViewCell.self), for: indexPath) as! NotificationStatusTableViewCell
cell.delegate = delegate configure(
tableView: tableView,
// configure author cell: cell,
cell.configure( notification: notification,
with: AvatarConfigurableViewConfiguration( dependency: dependency,
avatarImageURL: notification.account.avatarImageURL() attribute: attribute
)
) )
cell.delegate = delegate
return cell
func createActionImage() -> UIImage? { case .notificationStatus(objectID: let objectID, attribute: let attribute):
return UIImage( guard let notification = try? managedObjectContext.existingObject(with: objectID) as? MastodonNotification,
systemName: notification.notificationType.actionImageName, !notification.isDeleted,
withConfiguration: UIImage.SymbolConfiguration( let status = notification.status,
pointSize: 12, weight: .semibold let requestUserID = dependency.context.authenticationService.activeMastodonAuthenticationBox.value?.userID
) else { return UITableViewCell() }
)? let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell
.withTintColor(.systemBackground)
.af.imageAspectScaled(toFit: CGSize(width: 14, height: 14))
}
cell.avatarButton.badgeImageView.backgroundColor = notification.notificationType.color // configure cell
cell.avatarButton.badgeImageView.image = createActionImage() StatusSection.configureStatusTableViewCell(
cell.traitCollectionDidChange cell: cell,
.receive(on: DispatchQueue.main) tableView: tableView,
.sink { [weak cell] in timelineContext: .notifications,
guard let cell = cell else { return } dependency: dependency,
cell.avatarButton.badgeImageView.image = createActionImage() readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
} status: status,
.store(in: &cell.disposeBag) requestUserID: requestUserID,
statusItemAttribute: attribute
// configure author name, notification description, timestamp )
let nameText = notification.account.displayNameWithFallback cell.statusView.headerContainerView.isHidden = true // set header hide
let titleLabelText: String = { cell.statusView.actionToolbarContainer.isHidden = true // set toolbar hide
switch notification.notificationType { cell.statusView.actionToolbarPlaceholderPaddingView.isHidden = false
case .favourite: return L10n.Scene.Notification.userFavoritedYourPost(nameText) cell.delegate = statusTableViewCellDelegate
case .follow: return L10n.Scene.Notification.userFollowedYou(nameText) cell.isAccessibilityElement = true
case .followRequest: return L10n.Scene.Notification.userRequestedToFollowYou(nameText) StatusSection.configureStatusAccessibilityLabel(cell: cell)
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
}
return cell return cell
case .bottomLoader: case .bottomLoader:
@ -174,3 +83,136 @@ extension NotificationSection {
} }
} }
extension NotificationSection {
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
}
}
}

View File

@ -19,21 +19,25 @@ extension NotificationViewController: StatusProvider {
func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future<Status?, Never> { func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future<Status?, Never> {
return Future<Status?, Never> { promise in return Future<Status?, Never> { promise in
guard let cell = cell, guard let diffableDataSource = self.viewModel.diffableDataSource else {
let diffableDataSource = self.viewModel.diffableDataSource, assertionFailure()
let indexPath = self.tableView.indexPath(for: cell), promise(.success(nil))
return
}
guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }),
let item = diffableDataSource.itemIdentifier(for: indexPath) else { let item = diffableDataSource.itemIdentifier(for: indexPath) else {
promise(.success(nil)) promise(.success(nil))
return return
} }
switch item { switch item {
case .notification(let objectID, _): case .notification(let objectID, _),
.notificationStatus(let objectID, _):
self.viewModel.fetchedResultsController.managedObjectContext.perform { self.viewModel.fetchedResultsController.managedObjectContext.perform {
let notification = self.viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! MastodonNotification let notification = self.viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! MastodonNotification
promise(.success(notification.status)) promise(.success(notification.status))
} }
default: case .bottomLoader:
promise(.success(nil)) promise(.success(nil))
} }
} }
@ -68,3 +72,6 @@ extension NotificationViewController: StatusProvider {
} }
} }
// MARK: - UserProvider
extension NotificationViewController: UserProvider { }

View File

@ -14,8 +14,10 @@ import OSLog
import UIKit import UIKit
import Meta import Meta
import MetaTextKit import MetaTextKit
import AVKit
final class NotificationViewController: UIViewController, NeedsDependency { final class NotificationViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
@ -23,15 +25,18 @@ final class NotificationViewController: UIViewController, NeedsDependency {
var observations = Set<NSKeyValueObservation>() var observations = Set<NSKeyValueObservation>()
private(set) lazy var viewModel = NotificationViewModel(context: context) private(set) lazy var viewModel = NotificationViewModel(context: context)
let mediaPreviewTransitionController = MediaPreviewTransitionController()
let segmentControl: UISegmentedControl = { let segmentControl: UISegmentedControl = {
let control = UISegmentedControl(items: [L10n.Scene.Notification.Title.everything, L10n.Scene.Notification.Title.mentions]) let control = UISegmentedControl(items: [L10n.Scene.Notification.Title.everything, L10n.Scene.Notification.Title.mentions])
control.selectedSegmentIndex = NotificationViewModel.NotificationSegment.EveryThing.rawValue control.selectedSegmentIndex = NotificationViewModel.NotificationSegment.everyThing.rawValue
return control return control
}() }()
let tableView: UITableView = { let tableView: UITableView = {
let tableView = ControlContainableTableView() let tableView = ControlContainableTableView()
tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self))
tableView.register(NotificationStatusTableViewCell.self, forCellReuseIdentifier: String(describing: NotificationStatusTableViewCell.self)) tableView.register(NotificationStatusTableViewCell.self, forCellReuseIdentifier: String(describing: NotificationStatusTableViewCell.self))
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
tableView.estimatedRowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = UITableView.automaticDimension
@ -82,7 +87,12 @@ extension NotificationViewController {
tableView.delegate = self tableView.delegate = self
viewModel.tableView = tableView viewModel.tableView = tableView
viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self
viewModel.setupDiffableDataSource(for: tableView, delegate: self, dependency: self) viewModel.setupDiffableDataSource(
for: tableView,
dependency: self,
delegate: self,
statusTableViewCellDelegate: self
)
viewModel.viewDidLoad.send() viewModel.viewDidLoad.send()
// bind refresh control // bind refresh control
@ -128,9 +138,9 @@ extension NotificationViewController {
self.viewModel.needsScrollToTopAfterDataSourceUpdate = true self.viewModel.needsScrollToTopAfterDataSourceUpdate = true
switch segment { switch segment {
case .EveryThing: case .everyThing:
self.viewModel.notificationPredicate.value = MastodonNotification.predicate(domain: domain, userID: userID) self.viewModel.notificationPredicate.value = MastodonNotification.predicate(domain: domain, userID: userID)
case .Mentions: case .mentions:
self.viewModel.notificationPredicate.value = MastodonNotification.predicate(domain: domain, userID: userID, typeRaw: Mastodon.Entity.Notification.NotificationType.mention.rawValue) self.viewModel.notificationPredicate.value = MastodonNotification.predicate(domain: domain, userID: userID, typeRaw: Mastodon.Entity.Notification.NotificationType.mention.rawValue)
} }
} }
@ -148,8 +158,8 @@ extension NotificationViewController {
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
tableView.deselectRow(with: transitionCoordinator, animated: animated) aspectViewWillAppear(animated)
// fetch latest notification when scroll position is within half screen height to prevent list reload // fetch latest notification when scroll position is within half screen height to prevent list reload
if tableView.contentOffset.y < view.frame.height * 0.5 { if tableView.contentOffset.y < view.frame.height * 0.5 {
@ -181,6 +191,12 @@ extension NotificationViewController {
// reset notification count // reset notification count
context.notificationService.clearNotificationCountForActiveUser() context.notificationService.clearNotificationCountForActiveUser()
} }
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
aspectViewDidDisappear(animated)
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator) super.viewWillTransition(to: size, with: coordinator)
@ -208,33 +224,34 @@ extension NotificationViewController {
} }
} }
// MARK: - StatusTableViewControllerAspect // MARK: - TableViewCellHeightCacheableContainer
extension NotificationViewController: StatusTableViewControllerAspect { } extension NotificationViewController: TableViewCellHeightCacheableContainer {
var cellFrameCache: NSCache<NSNumber, NSValue> { return viewModel.cellFrameCache }
extension NotificationViewController {
func cacheTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { func cacheTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard let diffableDataSource = viewModel.diffableDataSource else { return } guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
switch item { switch item {
case .notification(let objectID, _): case .notification(let objectID, _),
.notificationStatus(let objectID, _):
guard let object = try? viewModel.fetchedResultsController.managedObjectContext.existingObject(with: objectID) as? MastodonNotification else { return } guard let object = try? viewModel.fetchedResultsController.managedObjectContext.existingObject(with: objectID) as? MastodonNotification else { return }
let key = object.id as NSString let key = object.objectID.hashValue
let frame = cell.frame let frame = cell.frame
viewModel.cellFrameCache.setObject(NSValue(cgRect: frame), forKey: key) viewModel.cellFrameCache.setObject(NSValue(cgRect: frame), forKey: NSNumber(value: key))
case .bottomLoader: case .bottomLoader:
break break
} }
} }
func handleTableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { func handleTableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
guard let diffableDataSource = viewModel.diffableDataSource else { return UITableView.automaticDimension } guard let diffableDataSource = viewModel.diffableDataSource else { return UITableView.automaticDimension }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return UITableView.automaticDimension } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return UITableView.automaticDimension }
switch item { switch item {
case .notification(let objectID, _): case .notification(let objectID, _),
.notificationStatus(let objectID, _):
guard let object = try? viewModel.fetchedResultsController.managedObjectContext.existingObject(with: objectID) as? MastodonNotification else { return UITableView.automaticDimension } guard let object = try? viewModel.fetchedResultsController.managedObjectContext.existingObject(with: objectID) as? MastodonNotification else { return UITableView.automaticDimension }
let key = object.id as NSString let key = object.objectID.hashValue
guard let frame = viewModel.cellFrameCache.object(forKey: key)?.cgRectValue else { return UITableView.automaticDimension } guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: key))?.cgRectValue else { return UITableView.automaticDimension }
return frame.height return frame.height
case .bottomLoader: case .bottomLoader:
return TimelineLoaderTableViewCell.cellHeight return TimelineLoaderTableViewCell.cellHeight
@ -242,22 +259,55 @@ extension NotificationViewController {
} }
} }
// MARK: - StatusTableViewControllerAspect
extension NotificationViewController: StatusTableViewControllerAspect { }
// MARK: - UITableViewDelegate // MARK: - UITableViewDelegate
extension NotificationViewController: UITableViewDelegate { extension NotificationViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
aspectTableView(tableView, estimatedHeightForRowAt: indexPath)
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard let diffableDataSource = viewModel.diffableDataSource else { return } guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
open(item: item) switch item {
case .notificationStatus:
aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath)
case .bottomLoader:
if !tableView.isDragging, !tableView.isDecelerating {
viewModel.loadOldestStateMachine.enter(NotificationViewModel.LoadOldestState.Loading.self)
}
default:
break
}
} }
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
cacheTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
aspectTableView(tableView, didSelectRowAt: indexPath)
} }
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
return handleTableView(tableView, estimatedHeightForRowAt: indexPath) return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point)
}
func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration)
}
func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration)
}
func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator)
} }
} }
@ -278,19 +328,6 @@ extension NotificationViewController {
break break
} }
} }
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
switch item {
case .bottomLoader:
if !tableView.isDragging, !tableView.isDecelerating {
viewModel.loadOldestStateMachine.enter(NotificationViewModel.LoadOldestState.Loading.self)
}
default:
break
}
}
} }
// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate // MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate
@ -388,6 +425,7 @@ extension NotificationViewController: ScrollViewContainer {
} }
} }
// MARK: - LoadMoreConfigurableTableViewContainer
extension NotificationViewController: LoadMoreConfigurableTableViewContainer { extension NotificationViewController: LoadMoreConfigurableTableViewContainer {
typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
typealias LoadingState = NotificationViewModel.LoadOldestState.Loading typealias LoadingState = NotificationViewModel.LoadOldestState.Loading
@ -395,6 +433,24 @@ extension NotificationViewController: LoadMoreConfigurableTableViewContainer {
var loadMoreConfigurableStateMachine: GKStateMachine { viewModel.loadOldestStateMachine } var loadMoreConfigurableStateMachine: GKStateMachine { viewModel.loadOldestStateMachine }
} }
// MARK: - AVPlayerViewControllerDelegate
extension NotificationViewController: AVPlayerViewControllerDelegate {
func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
}
func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
}
}
// MARK: - statusTableViewCellDelegate
extension NotificationViewController: StatusTableViewCellDelegate {
var playerViewControllerDelegate: AVPlayerViewControllerDelegate? {
return self
}
}
extension NotificationViewController { extension NotificationViewController {
enum CategorySwitch: String, CaseIterable { enum CategorySwitch: String, CaseIterable {
@ -452,9 +508,9 @@ extension NotificationViewController {
switch category { switch category {
case .showEverything: case .showEverything:
viewModel.selectedIndex.value = .EveryThing viewModel.selectedIndex.value = .everyThing
case .showMentions: case .showMentions:
viewModel.selectedIndex.value = .Mentions viewModel.selectedIndex.value = .mentions
} }
} }

View File

@ -14,14 +14,16 @@ import MastodonSDK
extension NotificationViewModel { extension NotificationViewModel {
func setupDiffableDataSource( func setupDiffableDataSource(
for tableView: UITableView, for tableView: UITableView,
dependency: NeedsDependency,
delegate: NotificationTableViewCellDelegate, delegate: NotificationTableViewCellDelegate,
dependency: NeedsDependency statusTableViewCellDelegate: StatusTableViewCellDelegate
) { ) {
diffableDataSource = NotificationSection.tableViewDiffableDataSource( diffableDataSource = NotificationSection.tableViewDiffableDataSource(
for: tableView, for: tableView,
dependency: dependency,
managedObjectContext: fetchedResultsController.managedObjectContext, managedObjectContext: fetchedResultsController.managedObjectContext,
delegate: delegate, delegate: delegate,
dependency: dependency statusTableViewCellDelegate: statusTableViewCellDelegate
) )
var snapshot = NSDiffableDataSourceSnapshot<NotificationSection, NotificationItem>() var snapshot = NSDiffableDataSourceSnapshot<NotificationSection, NotificationItem>()
@ -81,11 +83,23 @@ extension NotificationViewModel: NSFetchedResultsControllerDelegate {
} }
var newSnapshot = NSDiffableDataSourceSnapshot<NotificationSection, NotificationItem>() var newSnapshot = NSDiffableDataSourceSnapshot<NotificationSection, NotificationItem>()
newSnapshot.appendSections([.main]) newSnapshot.appendSections([.main])
let items: [NotificationItem] = notifications.map { notification in
let attribute: Item.StatusAttribute = oldSnapshotAttributeDict[notification.objectID] ?? Item.StatusAttribute() let segment = self.selectedIndex.value
return NotificationItem.notification(objectID: notification.objectID, attribute: attribute) switch segment {
case .everyThing:
let items: [NotificationItem] = notifications.map { notification in
let attribute: Item.StatusAttribute = oldSnapshotAttributeDict[notification.objectID] ?? Item.StatusAttribute()
return NotificationItem.notification(objectID: notification.objectID, attribute: attribute)
}
newSnapshot.appendItems(items, toSection: .main)
case .mentions:
let items: [NotificationItem] = notifications.map { notification in
let attribute: Item.StatusAttribute = oldSnapshotAttributeDict[notification.objectID] ?? Item.StatusAttribute()
return NotificationItem.notificationStatus(objectID: notification.objectID, attribute: attribute)
}
newSnapshot.appendItems(items, toSection: .main)
} }
newSnapshot.appendItems(items, toSection: .main)
if !notifications.isEmpty, self.noMoreNotification.value == false { if !notifications.isEmpty, self.noMoreNotification.value == false {
newSnapshot.appendItems([.bottomLoader], toSection: .main) newSnapshot.appendItems([.bottomLoader], toSection: .main)
} }

View File

@ -92,13 +92,13 @@ extension NotificationViewModel.LoadOldestState {
} receiveValue: { [weak viewModel] response in } receiveValue: { [weak viewModel] response in
guard let viewModel = viewModel else { return } guard let viewModel = viewModel else { return }
switch viewModel.selectedIndex.value { switch viewModel.selectedIndex.value {
case .EveryThing: case .everyThing:
if response.value.isEmpty { if response.value.isEmpty {
stateMachine.enter(NoMore.self) stateMachine.enter(NoMore.self)
} else { } else {
stateMachine.enter(Idle.self) stateMachine.enter(Idle.self)
} }
case .Mentions: case .mentions:
viewModel.noMoreNotification.value = response.value.isEmpty viewModel.noMoreNotification.value = response.value.isEmpty
let list = response.value.filter { $0.type == Mastodon.Entity.Notification.NotificationType.mention } let list = response.value.filter { $0.type == Mastodon.Entity.Notification.NotificationType.mention }
if list.isEmpty { if list.isEmpty {

View File

@ -23,13 +23,13 @@ final class NotificationViewModel: NSObject {
weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate?
let viewDidLoad = PassthroughSubject<Void, Never>() let viewDidLoad = PassthroughSubject<Void, Never>()
let selectedIndex = CurrentValueSubject<NotificationSegment, Never>(.EveryThing) let selectedIndex = CurrentValueSubject<NotificationSegment, Never>(.everyThing)
let noMoreNotification = CurrentValueSubject<Bool, Never>(false) let noMoreNotification = CurrentValueSubject<Bool, Never>(false)
let activeMastodonAuthenticationBox: CurrentValueSubject<MastodonAuthenticationBox?, Never> let activeMastodonAuthenticationBox: CurrentValueSubject<MastodonAuthenticationBox?, Never>
let fetchedResultsController: NSFetchedResultsController<MastodonNotification>! let fetchedResultsController: NSFetchedResultsController<MastodonNotification>!
let notificationPredicate = CurrentValueSubject<NSPredicate?, Never>(nil) let notificationPredicate = CurrentValueSubject<NSPredicate?, Never>(nil)
let cellFrameCache = NSCache<NSString, NSValue>() let cellFrameCache = NSCache<NSNumber, NSValue>()
var needsScrollToTopAfterDataSourceUpdate = false var needsScrollToTopAfterDataSourceUpdate = false
let dataSourceDidUpdated = PassthroughSubject<Void, Never>() let dataSourceDidUpdated = PassthroughSubject<Void, Never>()
@ -161,7 +161,7 @@ final class NotificationViewModel: NSObject {
extension NotificationViewModel { extension NotificationViewModel {
enum NotificationSegment: Int { enum NotificationSegment: Int {
case EveryThing case everyThing
case Mentions case mentions
} }
} }

View File

@ -203,6 +203,9 @@ final class StatusView: UIView {
return actionToolbarContainer return actionToolbarContainer
}() }()
// set display when needs bottom padding
let actionToolbarPlaceholderPaddingView = UIView()
let contentMetaText: MetaText = { let contentMetaText: MetaText = {
let metaText = MetaText() let metaText = MetaText()
metaText.textView.backgroundColor = .clear metaText.textView.backgroundColor = .clear
@ -451,6 +454,13 @@ extension StatusView {
containerStackView.sendSubviewToBack(actionToolbarContainer) containerStackView.sendSubviewToBack(actionToolbarContainer)
actionToolbarContainer.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) actionToolbarContainer.setContentCompressionResistancePriority(.defaultHigh, for: .vertical)
actionToolbarContainer.setContentHuggingPriority(.required - 1, for: .vertical) actionToolbarContainer.setContentHuggingPriority(.required - 1, for: .vertical)
actionToolbarPlaceholderPaddingView.translatesAutoresizingMaskIntoConstraints = false
containerStackView.addArrangedSubview(actionToolbarPlaceholderPaddingView)
NSLayoutConstraint.activate([
actionToolbarPlaceholderPaddingView.heightAnchor.constraint(equalToConstant: 12).priority(.required - 1),
])
actionToolbarPlaceholderPaddingView.isHidden = true
headerContainerView.isHidden = true headerContainerView.isHidden = true
statusMosaicImageViewContainer.isHidden = true statusMosaicImageViewContainer.isHidden = true