TM-SGNL-iOS/Signal/ConversationView/ConversationViewController+CVC.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

811 lines
31 KiB
Swift

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public import Foundation
public import SignalServiceKit
public import SignalUI
extension ConversationViewController {
public var isGroupConversation: Bool { thread.isGroupThread }
public static var messageSection: Int { CVLoadCoordinator.messageSection }
public var hasRenderState: Bool { !renderState.isEmptyInitialState }
public var hasAppearedAndHasAppliedFirstLoad: Bool {
(hasRenderState &&
hasViewDidAppearEverBegun &&
!loadCoordinator.shouldHideCollectionViewContent)
}
public var lastReloadDate: Date { renderState.loadDate }
public func indexPath(forInteractionUniqueId interactionUniqueId: String) -> IndexPath? {
loadCoordinator.indexPath(forInteractionUniqueId: interactionUniqueId)
}
public func indexPath(forItemViewModel itemViewModel: CVItemViewModelImpl) -> IndexPath? {
indexPath(forInteractionUniqueId: itemViewModel.interaction.uniqueId)
}
public func interaction(forIndexPath indexPath: IndexPath) -> TSInteraction? {
guard let renderItem = self.renderItem(forIndex: indexPath.row) else {
return nil
}
return renderItem.interaction
}
var indexPathOfUnreadMessagesIndicator: IndexPath? {
loadCoordinator.indexPathOfUnreadIndicator
}
public var canLoadOlderItems: Bool {
loadCoordinator.canLoadOlderItems
}
public var canLoadNewerItems: Bool {
loadCoordinator.canLoadNewerItems
}
public var currentRenderStateDebugDescription: String {
renderState.debugDescription
}
public var areCellsAnimating: Bool {
viewState.activeCellAnimations.count > 0
}
}
// MARK: -
extension ConversationViewController: CVLoadCoordinatorDelegate {
public var conversationViewController: ConversationViewController? {
self
}
func chatColorDidChange() {
viewState.chatColor = SSKEnvironment.shared.databaseStorageRef.read { tx in Self.loadChatColor(for: thread, tx: tx) }
updateConversationStyle()
}
func updateAccessibilityCustomActionsForCell(_ cell: CVItemCell) {
if let cvcell = cell as? CVCell {
updateAccessibilityCustomActionsForCell(cell: cvcell)
}
}
func willUpdateWithNewRenderState(_ renderState: CVRenderState) -> CVUpdateToken {
AssertIsOnMainThread()
// HACK to work around radar #28167779
// "UICollectionView performBatchUpdates can trigger a crash if the collection view is flagged for layout"
// more: https://github.com/PSPDFKit-labs/radar.apple.com/tree/master/28167779%20-%20CollectionViewBatchingIssue
// This was our #2 crash, and much exacerbated by the refactoring somewhere between 2.6.2.0-2.6.3.8
//
// NOTE: It's critical we do this before beginLongLivedReadTransaction.
// We want to relayout our contents using the old message mappings and
// view items before they are updated.
collectionView.layoutIfNeeded()
// ENDHACK to work around radar #28167779
// Snapshot CVC layout state before we land the load;
// we use this to ensure scroll continuity when landing the load.
let scrollContinuityToken = layout.buildScrollContinuityToken()
// CVC will often use this state to ensure scroll continuity
// when landing loads, so ensure the value is updated before
// landing loads.
let lastKnownDistanceFromBottom = self.updateLastKnownDistanceFromBottom()
return CVUpdateToken(isScrolledToBottom: self.isScrolledToBottom,
lastMessageForInboxSortId: threadViewModel.lastMessageForInbox?.sortId,
scrollContinuityToken: scrollContinuityToken,
lastKnownDistanceFromBottom: lastKnownDistanceFromBottom)
}
func updateWithNewRenderState(update: CVUpdate,
scrollAction: CVScrollAction,
updateToken: CVUpdateToken) {
AssertIsOnMainThread()
guard hasViewWillAppearEverBegun else {
// It's safe to ignore updates before viewWillAppear
// if called for the first time.
Logger.info("View is not yet loaded.")
loadDidLand()
return
}
let renderState = update.renderState
layout.update(conversationStyle: renderState.conversationStyle)
var scrollAction = scrollAction
if !viewState.hasAppliedFirstLoad {
scrollAction = CVScrollAction(action: .initialPosition, isAnimated: false)
} else if let scrollActionForSizeTransition = viewState.scrollActionForSizeTransition {
// If we're in a size transition, honor the relevant scroll action.
scrollAction = scrollActionForSizeTransition
}
// Capture old group model before we update threadViewModel.
// This will be nil for non-group threads.
let oldGroupModel = renderState.prevThreadViewModel?.threadRecord.groupModelIfGroupThread
updateNavigationBarSubtitleLabel()
updateBarButtonItems()
// This will be nil for non-group threads.
let newGroupModel = thread.groupModelIfGroupThread
if oldGroupModel != newGroupModel {
ensureBannerState()
}
// If the message has been deleted / disappeared, we need to dismiss
dismissMessageContextMenuIfNecessary()
showMessageRequestDialogIfRequiredAsync()
updateNavigationTitle()
updateShouldHideCollectionViewContent(reloadIfClearingFlag: false)
if loadCoordinator.shouldHideCollectionViewContent {
updateViewToReflectLoad(loadedRenderState: self.renderState)
loadDidLand()
} else {
if !viewState.hasAppliedFirstLoad {
// Ignore scrollAction; we need to scroll to .initialPosition.
updateWithFirstLoad(update: update)
} else {
switch update.type {
case .minor:
updateForMinorUpdate(update: update, scrollAction: scrollAction)
case .reloadAll:
updateReloadingAll(renderState: renderState, scrollAction: scrollAction)
case .diff(let items, let shouldAnimateUpdate):
updateWithDiff(
update: update,
items: items,
shouldAnimateUpdate: shouldAnimateUpdate,
scrollAction: scrollAction,
updateToken: updateToken
)
}
}
setHasAppliedFirstLoadIfNecessary()
}
}
// The more work we put into this method, the greater our
// confidence we have that CVC view state is always up-to-date.
// But that can make "minor update" updates more expensive.
private func updateViewToReflectLoad(loadedRenderState: CVRenderState) {
// We can skip some of this work
guard self.hasViewWillAppearEverBegun else {
return
}
self.updateLastKnownDistanceFromBottom()
self.updateInputToolbarLayout()
self.showMessageRequestDialogIfRequired()
self.configureScrollDownButtons()
let hasViewDidAppearEverCompleted = self.hasViewDidAppearEverCompleted
DispatchQueue.main.async {
self.reloadReactionsDetailSheetWithSneakyTransaction()
if hasViewDidAppearEverCompleted {
_ = self.autoLoadMoreIfNecessary()
}
}
}
private func loadDidLand() {
switch viewState.selectionAnimationState {
case .willAnimate:
viewState.selectionAnimationState = .animating
case .animating, .idle:
viewState.selectionAnimationState = .idle
ensureBottomViewType()
}
}
// The view's first appearance and the first load can race.
// We need to handle them completing in either order.
//
// This means performing much of the work we do when we land
// the first load.
public func viewWillAppearForLoad() {
updateShouldHideCollectionViewContent(reloadIfClearingFlag: true)
}
public func viewSafeAreaInsetsDidChangeForLoad() {
updateShouldHideCollectionViewContent(reloadIfClearingFlag: true)
}
// One of the inconveniences of iOS view presentation is that the
// safeAreaInsets are set after viewWillAppear() and before
// viewDidAppear(). We kick off our first load when view presentation
// begins, but that load will have the wrong layout.
//
// Another considerations is that the view events (viewWillAppear(),
// safeAreaInsets being set) can race with the first load(s).
//
// We use the shouldHideCollectionViewContent flag to handle these
// issues. We don't "apply" loads until this flag is set. The flag
// isn't set until:
//
// * viewWillAppear() has occurred at least once.
// * safeAreaInsets is non-zero (if appropriate).
// * At least one load has landed that has an appropriate safeAreaInsets
// value.
//
// This ensures that we don't render mis-formatted content during
// view presentation.
private func updateShouldHideCollectionViewContent(reloadIfClearingFlag: Bool) {
// We hide collection view content until the view
// appears for the first time. Once we've cleared
// the flag, never set it again.
guard loadCoordinator.shouldHideCollectionViewContent else {
return
}
let shouldHideCollectionViewContent: Bool = {
// Don't hide content for more than a couple of seconds.
let viewAge = abs(self.viewState.viewCreationDate.timeIntervalSinceNow)
let maxHideTime = kSecondInterval * 2
guard viewAge < maxHideTime else {
// This should only occur on very slow devices.
Logger.warn("View taking a long time to render content.")
return false
}
// Hide content until "viewWillAppear()" is called for the
// first time.
guard self.hasViewWillAppearEverBegun else {
return true
}
// Hide content until the first load lands.
guard self.hasRenderState else {
return true
}
guard renderState.conversationStyle.isValidStyle else {
return true
}
return false
}()
guard !shouldHideCollectionViewContent else {
return
}
loadCoordinator.shouldHideCollectionViewContent = false
// Completion of the first load can race with the
// view appearing for the first time. If the first load
// completes first, we need to update the collection view
// to reflect its contents.
if reloadIfClearingFlag, hasRenderState {
reloadCollectionViewImmediately()
scrollToInitialPosition(animated: false)
updateViewToReflectLoad(loadedRenderState: self.renderState)
loadCoordinator.enqueueReload()
setHasAppliedFirstLoadIfNecessary()
}
}
private func reloadCollectionViewImmediately() {
AssertIsOnMainThread()
self.collectionView.cvc_reloadData(animated: false, cvc: self)
}
private func updateForMinorUpdate(update: CVUpdate, scrollAction: CVScrollAction) {
// If the scroll action is not animated, perform it _before_
// updateViewToReflectLoad().
if !scrollAction.isAnimated {
self.perform(scrollAction: scrollAction)
}
updateViewToReflectLoad(loadedRenderState: self.renderState)
loadDidLand()
if scrollAction.isAnimated {
self.perform(scrollAction: scrollAction)
}
}
private func updateWithFirstLoad(update: CVUpdate) {
reloadCollectionViewImmediately()
scrollToInitialPosition(animated: false)
if self.hasViewDidAppearEverCompleted {
clearInitialScrollState()
}
updateViewToReflectLoad(loadedRenderState: self.renderState)
loadDidLand()
}
private func setHasAppliedFirstLoadIfNecessary() {
guard !viewState.hasAppliedFirstLoad else {
return
}
viewState.hasAppliedFirstLoad = true
if self.hasViewDidAppearEverCompleted {
clearInitialScrollState()
}
}
private func updateReloadingAll(renderState: CVRenderState, scrollAction: CVScrollAction) {
reloadCollectionViewImmediately()
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
// If the scroll action is not animated, perform it _before_
// updateViewToReflectLoad().
if !scrollAction.isAnimated {
self.perform(scrollAction: scrollAction)
}
self.updateViewToReflectLoad(loadedRenderState: renderState)
self.loadDidLand()
if scrollAction.isAnimated {
self.perform(scrollAction: scrollAction)
}
}
}
private func resetViewStateAfterError() {
reloadCollectionViewForReset()
// Try to update the lastKnownDistanceFromBottom; the content size may have changed.
updateLastKnownDistanceFromBottom()
}
private func updateWithDiff(
update: CVUpdate,
items: [CVUpdate.Item],
shouldAnimateUpdate: Bool,
scrollAction scrollActionParam: CVScrollAction,
updateToken: CVUpdateToken
) {
AssertIsOnMainThread()
owsAssertDebug(!items.isEmpty)
let renderState = update.renderState
let isScrolledToBottom = updateToken.isScrolledToBottom
let viewState = self.viewState
var scrollAction = scrollActionParam
// Update scroll action to auto-scroll if necessary.
if scrollAction.action == .none, !self.isUserScrolling {
for item in items {
let renderItem = item.value
switch item.updateType {
case .insert:
var wasJustInserted = false
if let lastMessageForInboxSortId = updateToken.lastMessageForInboxSortId {
if lastMessageForInboxSortId < renderItem.interaction.sortId {
wasJustInserted = true
}
} else {
// The first interaction in the thread.
wasJustInserted = true
}
// We want to auto-scroll to the bottom of the conversation
// if the user is inserting new interactions.
let isAutoScrollInteraction: Bool
switch renderItem.interactionType {
case .typingIndicator:
isAutoScrollInteraction = true
case .incomingMessage,
.outgoingMessage,
.call,
.error,
.info:
isAutoScrollInteraction = wasJustInserted
default:
isAutoScrollInteraction = false
}
if let outgoingMessage = renderItem.interaction as? TSOutgoingMessage,
!outgoingMessage.wasNotCreatedLocally,
wasJustInserted {
// Whenever we send an outgoing message from the local device,
// auto-scroll to the bottom of the conversation, regardless
// of scroll state.
scrollAction = CVScrollAction(action: .bottomForNewMessage, isAnimated: true)
break
} else if isAutoScrollInteraction,
isScrolledToBottom {
// If we're already at the bottom of the conversation and
// a freshly inserted message or typing indicator appears,
// auto-scroll to show it.
scrollAction = CVScrollAction(action: .bottomForNewMessage, isAnimated: true)
break
}
default:
break
}
}
}
if .loadOlder == renderState.loadType {
scrollAction = .none
}
viewState.scrollActionForUpdate = scrollAction
// We have two scroll continuity mechanisms:
//
// * The first is in the targetContentOffset(forProposedContentOffset:) method in CVC+Scroll.swift.
// This handles scroll continuity in most cases.
// * The second is in ConversationViewLayout.willPerformBatchUpdates().
// We manipulate the content offset using
// UICollectionViewLayoutInvalidationContext.contentOffsetAdjustment.
//
// We prefer the second mechanism and only use the first mechanism to
// handle special cases (ie. when shouldUseDelegateScrollContinuity is true).
let scrollContinuity: ScrollContinuity = {
guard let loadType = renderState.loadType else {
owsFailDebug("Missing loadType.")
return .delegateScrollContinuity
}
// TODO: We could extend the layout's invalidation-based approach
// to scroll continuity to support more of these cases.
if shouldUseDelegateScrollContinuity {
return .delegateScrollContinuity
}
let scrollContinuityToken = updateToken.scrollContinuityToken
switch loadType {
case .loadInitialMapping:
return .none
case .loadSameLocation:
return .contentRelativeToViewport(token: scrollContinuityToken,
isRelativeToTop: false)
case .loadOlder:
return .contentRelativeToViewport(token: scrollContinuityToken,
isRelativeToTop: true)
case .loadNewer, .loadNewest:
return .contentRelativeToViewport(token: scrollContinuityToken,
isRelativeToTop: false)
case .loadPageAroundInteraction:
return .contentRelativeToViewport(token: scrollContinuityToken,
isRelativeToTop: false)
}
}()
let batchUpdatesBlock = {
AssertIsOnMainThread()
let section = Self.messageSection
for item in items {
switch item.updateType {
case .delete(let oldIndex):
let indexPath = IndexPath(row: oldIndex, section: section)
self.collectionView.deleteItems(at: [indexPath])
case .insert(let newIndex):
let indexPath = IndexPath(row: newIndex, section: section)
self.collectionView.insertItems(at: [indexPath])
case .move(let oldIndex, let newIndex):
let oldIndexPath = IndexPath(row: oldIndex, section: section)
let newIndexPath = IndexPath(row: newIndex, section: section)
self.collectionView.moveItem(at: oldIndexPath, to: newIndexPath)
case .update(let oldIndex, _):
let indexPath = IndexPath(row: oldIndex, section: section)
self.collectionView.reloadItems(at: [indexPath])
}
}
}
let completion = { [weak self] (finished: Bool) in
AssertIsOnMainThread()
guard let self = self else {
return
}
// If the scroll action is not animated, perform it _before_
// updateViewToReflectLoad().
if !scrollAction.isAnimated {
self.perform(scrollAction: scrollAction)
}
self.updateViewToReflectLoad(loadedRenderState: renderState)
if shouldAnimateUpdate {
self.loadDidLand()
}
if scrollAction.isAnimated {
self.perform(scrollAction: scrollAction)
}
viewState.scrollActionForUpdate = nil
if !finished {
// If animations were interrupted, reset to get back to a known good state.
DispatchQueue.main.async { [weak self] in
self?.resetViewStateAfterError()
}
}
}
// We use an obj-c free function so that we can handle NSException.
self.collectionView.cvc_performBatchUpdates(batchUpdatesBlock,
completion: completion,
animated: shouldAnimateUpdate,
scrollContinuity: scrollContinuity,
lastKnownDistanceFromBottom: updateToken.lastKnownDistanceFromBottom,
cvc: self)
if !shouldAnimateUpdate {
self.loadDidLand()
}
}
private var scrolledToEdgeTolerancePoints: CGFloat {
let deviceFrame = CurrentAppContext().frame
// Within 1 screenful of the edge of the load window.
return max(deviceFrame.width, deviceFrame.height)
}
var isScrollNearTopOfLoadWindow: Bool {
return isScrolledToTop(tolerancePoints: scrolledToEdgeTolerancePoints)
}
var isScrollNearBottomOfLoadWindow: Bool {
return isScrolledToBottom(tolerancePoints: scrolledToEdgeTolerancePoints)
}
public func registerReuseIdentifiers() {
CVCell.registerReuseIdentifiers(collectionView: self.collectionView)
collectionView.register(LoadMoreMessagesView.self,
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
withReuseIdentifier: LoadMoreMessagesView.reuseIdentifier)
collectionView.register(LoadMoreMessagesView.self,
forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter,
withReuseIdentifier: LoadMoreMessagesView.reuseIdentifier)
}
public static func buildInitialConversationStyle(
for thread: TSThread,
chatColor: ColorOrGradientSetting,
wallpaperViewBuilder: WallpaperViewBuilder?
) -> ConversationStyle {
buildConversationStyle(
type: .initial,
thread: thread,
viewWidth: 0,
chatColor: chatColor,
wallpaperViewBuilder: wallpaperViewBuilder
)
}
private static func buildConversationStyle(
type: ConversationStyle.`Type`,
thread: TSThread,
viewWidth: CGFloat,
chatColor: ColorOrGradientSetting,
wallpaperViewBuilder: WallpaperViewBuilder?
) -> ConversationStyle {
let hasWallpaper: Bool
let isWallpaperPhoto: Bool
switch wallpaperViewBuilder {
case .customPhoto:
hasWallpaper = true
isWallpaperPhoto = true
case .colorOrGradient:
hasWallpaper = true
isWallpaperPhoto = false
case .none:
hasWallpaper = false
isWallpaperPhoto = false
}
return ConversationStyle(
type: type,
thread: thread,
viewWidth: viewWidth,
hasWallpaper: hasWallpaper,
isWallpaperPhoto: isWallpaperPhoto,
chatColor: chatColor
)
}
private func buildConversationStyle() -> ConversationStyle {
AssertIsOnMainThread()
func buildConversationStyle(type: ConversationStyle.`Type`, viewWidth: CGFloat) -> ConversationStyle {
Self.buildConversationStyle(
type: type,
thread: thread,
viewWidth: viewWidth,
chatColor: viewState.chatColor,
wallpaperViewBuilder: viewState.wallpaperViewBuilder
)
}
func buildDefaultConversationStyle(type: ConversationStyle.`Type`) -> ConversationStyle {
// Treat all styles as "initial" (not to be trusted) until
// we have a view config.
let viewWidth = floor(collectionView.width)
return buildConversationStyle(type: type, viewWidth: viewWidth)
}
guard self.conversationStyle.type != .`default` else {
// Once we built a normal style, never go back to
// building an initial or placeholder style.
owsAssertDebug(navigationController != nil || viewState.isInPreviewPlatter)
return buildDefaultConversationStyle(type: .`default`)
}
guard let navigationController = navigationController else {
if viewState.isInPreviewPlatter {
// In a preview platter, we'll never have a navigation controller
return buildDefaultConversationStyle(type: .`default`)
} else {
// Treat all styles as "initial" (not to be trusted) until
// we have a navigationController.
return buildDefaultConversationStyle(type: .initial)
}
}
let collectionViewWidth = self.collectionView.width
let rootViewWidth = self.view.width
let viewSafeAreaInsets = self.view.safeAreaInsets
let navigationViewWidth = navigationController.view.width
let navigationSafeAreaInsets = navigationController.view.safeAreaInsets
let isMissingSafeAreaInsets = (viewSafeAreaInsets == .zero &&
navigationSafeAreaInsets != .zero)
let hasInvalidWidth = (collectionViewWidth > navigationViewWidth ||
rootViewWidth > navigationViewWidth)
let hasValidStyle = !isMissingSafeAreaInsets && !hasInvalidWidth
if hasValidStyle {
// No need to rewrite style; style is already valid.
return buildDefaultConversationStyle(type: .`default`)
} else {
let viewAge = abs(self.viewState.viewCreationDate.timeIntervalSinceNow)
let maxHideTime = kSecondInterval * 2
guard viewAge < maxHideTime else {
// This should never happen, but we want to put an upper bound on
// how long we're willing to infer view state from the
// navigationController. It might not always be safe to assume that
// navigationController view and CVC view state converge.
Logger.warn("View state taking a long time to be configured.")
return buildDefaultConversationStyle(type: .placeholder)
}
// We can derive a style that reflects what the correct style will be,
// using values from the navigationController.
let viewWidth = floor(navigationViewWidth)
return buildConversationStyle(type: .placeholder, viewWidth: viewWidth)
}
}
@discardableResult
public func updateConversationStyle() -> Bool {
AssertIsOnMainThread()
let oldConversationStyle = self.conversationStyle
let newConversationStyle = buildConversationStyle()
let didChange = !newConversationStyle.isEqualForCellRendering(oldConversationStyle)
if !didChange {
return false
}
self.conversationStyle = newConversationStyle
if let inputToolbar = inputToolbar {
inputToolbar.update(conversationStyle: newConversationStyle)
}
// We need to kick off a reload cycle if conversationStyle changes.
loadCoordinator.updateConversationStyle(newConversationStyle)
return true
}
}
// MARK: -
extension ConversationViewController: CVViewStateDelegate {
public func viewStateUIModeDidChange(oldValue: ConversationUIMode) {
if oldValue != uiMode && (oldValue == .selection || uiMode == .selection) {
// Proactively update bottom bar before load lands
ensureBottomViewType()
// Block loads while things animate.
viewState.selectionAnimationState = .willAnimate
loadCoordinator.enqueueReload()
DispatchQueue.main.asyncAfter(deadline: .now() + CVComponentMessage.selectionAnimationDuration) {
self.viewState.selectionAnimationState = .idle
// Enqueue a new load after animation so the "wasShowingSelectionUI" state is updated.
self.loadCoordinator.enqueueReload()
}
} else {
loadCoordinator.enqueueReload()
}
}
}
// MARK: - Load More
extension ConversationViewController {
public func autoLoadMoreIfNecessary() -> Bool {
AssertIsOnMainThread()
guard hasAppearedAndHasAppliedFirstLoad else {
return false
}
let isMainAppAndActive = CurrentAppContext().isMainAppAndActive
guard isViewVisible, isMainAppAndActive else {
return false
}
guard showLoadOlderHeader || showLoadNewerHeader else {
return false
}
guard let navigationController = navigationController else {
return false
}
navigationController.view.layoutIfNeeded()
let navControllerSize = navigationController.view.frame.size
let loadThreshold = navControllerSize.largerAxis * 3
let distanceFromTop = collectionView.contentOffset.y
let isCloseToTop = distanceFromTop < loadThreshold
if showLoadOlderHeader, isCloseToTop {
if loadCoordinator.didLoadOlderRecently {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
_ = self?.autoLoadMoreIfNecessary()
}
return false
}
loadCoordinator.loadOlderItems()
return true
}
let distanceFromBottom = collectionView.contentSize.height - collectionView.bounds.size.height
- collectionView.contentOffset.y
let isCloseToBottom = distanceFromBottom < loadThreshold
if showLoadNewerHeader, isCloseToBottom {
if loadCoordinator.didLoadNewerRecently {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
_ = self?.autoLoadMoreIfNecessary()
}
return false
}
loadCoordinator.loadNewerItems()
return true
}
return false
}
public var showLoadOlderHeader: Bool { loadCoordinator.showLoadOlderHeader }
public var showLoadNewerHeader: Bool { loadCoordinator.showLoadNewerHeader }
}