Quellcodebibliothek Statistik Leitseite products/Sources/formale Sprachen/C/Threema/Threema/Chats/ChatView/   (Social Medium ©)  Datei vom 26.9.2026 mit Größe 140 kB image not shown  

Quelle  ChatViewController.swift   Sprache: JAVA

 

import CocoaLumberjackSwift
import Combine
import FileUtility
import GroupCalls
import OSLog
import PDFKit
import SwiftUI
import ThreemaEssentials
import ThreemaFramework
import ThreemaMacros
import UIKit
import VisionKit
import WebRTC

/// The chat view!
///
/// TODO: Describe "architecture/dependencies"
///
/// For the view hierarchy see `configureLayout()`
final class ChatViewController: UIViewController {
    
    var willMoveToNonNilWindow = false {
        didSet {
            if willMoveToNonNilWindow {
                _ = unreadMessagesSnapshot.tick(willStayAtBottomOfView: isAtBottomOfView)
            }
        }
    }
    
    // MARK: - Internal
    
    /// Used to determine whether ChatTextView is currently resetting the keyboard
    /// Resetting the keyboard may cause animations to be cancelled which is not great when new messages are added
    var isResettingKeyboard = false
    
    /// True if the last time `willEnterForeground` was called the window of `view` was nil and we have not yet executed
    /// the checks for the unread message line.
    /// False otherwise; will be set to false after executing the checks for the unread message line.
    /// Part of the workaround for the passcode lock screen. See `ChatViewTableView` for additional info
    var didEnterForegroundWithNilWindow = false
    
    private var cancellables = Set<AnyCancellable>()
    
    private var chatViewShowsEmojiLongPressInfoTip = false
    
    // MARK: - Model
    
    /// Conversation shown in this chat view
    ///
    /// This should not be accessed outside of this class. It will be marked as private as soon as
    /// ChatViewControllerActions have been refactor to incorporate this.
    @available(
        iOS,
        deprecated: 0.0,
        message: "Must not be used outside of ChatViewController, except in the PPAssetsActionHelperDelegate extension and in MainTabBarController."
    )
    let conversation: ConversationEntity
    
    private let businessInjector: BusinessInjectorProtocol
    private let entityManager: EntityManager
    private var observerConversation: NSKeyValueObservation?
    
    private let contextMenuController = CustomContextMenuController()
    
    /// Set when this chat started closing because its conversation is being deleted.
    /// Prevents double-calls (KVO + `didDismissModalNavigationController`).
    private var isClosingDeletedConversation = false
    
    /// Called once when this chat's conversation is being deleted. The delegate
    /// (ConversationListCoordinator) is responsible for all UI cleanup.
    weak var conversationDeletedDelegate: ChatViewControllerConversationDeletedDelegate?
    
    // MARK: - Debug
    
    private let initTime = CACurrentMediaTime()
    
    private var scrollPositionRestoreFinished = false
    
    // MARK: - UI
    
    /// A serial queue used for queueing scrollToRow when initiating scroll to bottom from `didApplySnapshot`.
    /// The queue is needed since we're need to wait until snapshot apply is done before actually programmatically
    /// scrolling.
    private let scrollToQueue = DispatchQueue(label: "ch.threema.chatView.scrollQueue", qos: .userInteractive)
    
    /// Scroll state used to update contentOffset between `willApplySnapshot(currentDoesIncludeNewestMessage:)` and
    /// `didApplySnapshot(delegateScrollCompletion:)`
    private var currentScrollState: ChatViewScrollState?
    
    /// Mode the chat view user interface is in
    enum UserInterfaceMode {
        /// Default chat view
        case `default`
        
        /// Chat search
        case search
        
        /// Multiselect
        case multiselect
        
        /// Preview of chat with no chat bar
        ///
        /// This is intended for non-interactive use. E.g. long-press previews
        case preview
    }
    
    var userInterfaceMode: UserInterfaceMode = .default {
        didSet {
            updateNavigationItem()
            
            switch userInterfaceMode {
            case .default:
                hideToolbar(animated: true)
                showScrollToBottomButtonAndChatBar()
                
                tableView.setEditing(false, animated: true)
                dataSource.deselectAllMessages()
                
                updateContentInsets()
                enableKeyboardDismissGestureRecognizer()
                
                NSLayoutConstraint.deactivate(multiselectScrollToBottomButtonConstraints)
                NSLayoutConstraint.activate(defaultScrollToBottomButtonConstraints)
                
                // When we start in `preview` mode the messages are not marked as read
                // so we should retry now
                if oldValue == .preview {
                    DispatchQueue.global(qos: .userInteractive).async {
                        self.unreadMessagesSnapshot.synchronousReconfiguration()
                    }
                    chatBarCoordinator.loadDraft()
                }
                
            case .search:
                // The toolbar hiding and showing will be handled by the search functionality
                hideScrollToBottomButtonAndChatBar()
                
                tableView.setEditing(false, animated: true)
                dataSource.deselectAllMessages()
                
                updateContentInsets()
                enableKeyboardDismissGestureRecognizer()
                
                NSLayoutConstraint.deactivate(defaultScrollToBottomButtonConstraints)
                NSLayoutConstraint.activate(multiselectScrollToBottomButtonConstraints)
                
            case .multiselect:
                hideToolbar(animated: true)
                hideScrollToBottomButtonAndChatBar()
                
                tableView.setEditing(true, animated: true)
                dataSource.deselectAllMessages()
                
                updateContentInsets()
                disableKeyboardDismissGestureRecognizer()
                
                NSLayoutConstraint.deactivate(defaultScrollToBottomButtonConstraints)
                NSLayoutConstraint.activate(multiselectScrollToBottomButtonConstraints)
                
            case .preview:
                hideToolbar(animated: true)
                hideScrollToBottomButtonAndChatBar()
                
                tableView.setEditing(false, animated: true)
                dataSource.deselectAllMessages()
                
                updateContentInsets()
                disableKeyboardDismissGestureRecognizer()
                
                NSLayoutConstraint.deactivate(defaultScrollToBottomButtonConstraints)
                NSLayoutConstraint.activate(multiselectScrollToBottomButtonConstraints)
            }
        }
    }
    
    // While we would be able to access the size class in the view controller's
    // trait collection, that value would not include the whole picture, only
    // the current context. In order to correctly assess it, we inject it,
    // letting the caller decide from where this information will be fetched.
    private let isRegularSizeClass: () -> Bool
    
    var cellInteractionEnabled: Bool {
        userInterfaceMode == .default
    }
    
    /// Provides the background image for the chat view
    private lazy var backgroundView: UIImageView = {
        let backgroundView = UIImageView(frame: .infinite)
        
        backgroundView.contentMode = .scaleAspectFill
        backgroundView.clipsToBounds = true
        
        return backgroundView
    }()
    
    weak var selectedTextView: MessageTextView?
    
    var contextMenuActionsQueue = [() -> Void]()
    
    // MARK: Header
    
    private let initialUnreadCount: Int
    
    private lazy var chatProfileView = ChatProfileView(
        for: conversation,
        entityManager: entityManager,
        initialUnreadCount: initialUnreadCount,
        isRegularSizeClass: isRegularSizeClass
    ) { [weak self] in
        self?.chatProfileViewTapped()
    }
    
    private lazy var deleteBarButton: UIBarButtonItem = {
        let button = UIBarButtonItem(
            title: #localize("messages_delete_all_button"),
            style: .plain,
            target: self,
            action: #selector(showDeleteMessagesAlert)
        )
        button.tintColor = .systemRed
        return button
    }()
    
    private lazy var cancelBarButton = UIBarButtonItem.cancelButton(target: self, selector: #selector(endMultiselect))
    
    private lazy var callBarButtonItem: UIBarButtonItem = {
        let button = UIBarButtonItem(
            image: BundleUtil.imageNamed(ChatViewConfiguration.Profile.callSymbolName),
            style: .plain,
            target: self,
            action: #selector(startOneToOneCall)
        )
        
        button.accessibilityLabel = #localize("call")
        button.accessibilityIdentifier = "ChatViewControllerCallBarButtonItem"
        return button
    }()
    
    private lazy var groupCallBarButtonItem: UIBarButtonItem = {
        let button = UIBarButtonItem(
            image: BundleUtil.imageNamed(ChatViewConfiguration.Profile.groupCallSymbolName),
            style: .plain,
            target: self,
            action: #selector(startGroupCall)
        )
        
        button.accessibilityLabel = #localize("call")
        button.accessibilityIdentifier = "ChatViewControllerGroupCallBarButtonItem"
        return button
    }()
    
    private lazy var old_ballotBarButton = Old_BallotWithOpenCountButton { [weak self] _ in
        self?.showBallots()
    }
    
    private lazy var ballotBarButton = UIBarButtonItem(
        image: UIImage(systemName: "chart.pie"),
        style: .plain,
        target: self,
        action: #selector(showBallots)
    )
    
    // Banner container that holds all chat banners
    private lazy var bannerContainerView: ChatViewBannerContainerView = {
        let container = ChatViewBannerContainerView()
        container.translatesAutoresizingMaskIntoConstraints = false
        return container
    }()
    
    private lazy var groupCallBannerView: GroupCallBannerView = {
        let bannerView = GroupCallBannerView(delegate: self)
        
        bannerView.isHidden = true
        bannerView.translatesAutoresizingMaskIntoConstraints = false
        
        return bannerView
    }()
    
    private lazy var workStatusBannerView: ChatViewWorkStatusBannerView? = {
        guard TargetManager.isWork,
              !conversation.isGroup,
              let contactEntity = conversation.contact else {
            return nil
        }
        
        let banner = ChatViewWorkStatusBannerView(contact: Contact(contactEntity: contactEntity), bounds: view.bounds)
        banner.translatesAutoresizingMaskIntoConstraints = false
        return banner
    }()
    
    // The hosting view controller needs to be retrained such that a confirmation dialog can be shown
    private lazy var spamReportingBannerViewController: UIHostingController<ChatViewSpamReportingBanner> = {
        let conversationType: ChatViewSpamReportingBanner.ConversationType =
            if conversation.isGroup {
                .group(displayName: conversation.displayName)
            }
            else {
                .oneToOne(displayName: conversation.contact?.displayName)
            }
        
        let bannerView = ChatViewSpamReportingBanner(
            conversationType: conversationType
        ) { [weak self] action in
            self?.reportSpam(with: action)
        } notSpamAction: { [weak self] in
            self?.markAsNotSpam()
        }
        
        return UIHostingController(rootView: bannerView)
    }()
    
    private var spamReportingBannerView: UIView? {
        let view = spamReportingBannerViewController.view
        
        // Needed to make the background to shine through and the corner radius to be correctly clipped
        view?.backgroundColor = .clear
        
        // Is unhidden if potential spam is detected
        view?.isHidden = true
        
        return view
    }
    
    // MARK: Table view
    
    private lazy var tableView: ChatViewTableView = {
        let tableView = ChatViewTableView(frame: .zero, style: .plain)
        
        tableView.backgroundColor = .clear
        tableView.keyboardDismissMode = .interactive
        
        // Remove any section header padding
        tableView.sectionHeaderTopPadding = 0
        tableView.alpha = 0.0
        
        // This fixes an issue, where voice over selected a random cell after scrolling to bottom when opening the chat
        // view the first time.
        // We set the animations to true again, after we set the alpha back to 1.0
        if UIAccessibility.isVoiceOverRunning {
            UIView.setAnimationsEnabled(false)
        }
        
        // This also enables programmatic selection
        // Manual selection only allowed in multiselect mode which is ensured by implementing
        // `tableView(_:willSelectRowAt:)`
        tableView.allowsSelection = true
        
        tableView.allowsMultipleSelectionDuringEditing = true
        
        tableView.separatorStyle = .none
        
        tableView.allowsMultipleSelectionDuringEditing = true
        
        tableView.delegate = self
        
        tableView.chatViewDelegate = self
        
        return tableView
    }()
    
    private lazy var dataSource = ChatViewDataSource(
        for: conversation,
        in: tableView,
        delegate: self,
        chatViewTableViewCellDelegate: chatViewTableViewCellDelegate,
        chatViewTableViewVoiceMessageCellDelegate: chatViewTableViewVoiceMessageCellDelegate,
        entityManager: entityManager,
        loadAround: { [weak self] in
            self?.chatViewDataSourceLoadAround()
        },
        afterFirstSnapshotApply: { [weak self] in
            self?.chatViewDataSourceAfterFirstSnapshotApply()
        },
        unreadMessagesSnapshot: unreadMessagesSnapshot
    )
    
    /// Coordinates the number of unread messages that should be displayed and whether the unread message line should be
    /// shown at all
    /// The chatViewDataSource uses this through its delegate to check whether a new snapshot should include the unread
    /// message line or not.
    ///
    /// The unread message line is displayed if the chat is opened and there are unread messages in it or
    /// if the scroll position is not at the very bottom and new messages are received.
    /// It is hidden if the user manually scrolls to the bottom of the chat view either through the scroll to bottom
    /// button or by dragging the scrollView.
    private lazy var unreadMessagesSnapshot = UnreadMessagesStateManager(
        conversation: self.conversation,
        businessInjector: self.businessInjector,
        unreadMessagesStateManagerDelegate: self
    )
    
    private let chatScrollPositionProvider: ChatScrollPositionProvider
    
    private lazy var chatSearchController = ChatSearchController(for: conversation, delegate: self)
    
    private lazy var chatViewTableViewCellDelegate = ChatViewTableViewCellDelegate(
        chatViewController: self,
        tableView: tableView,
        entityManager: entityManager
    )
    
    private lazy var chatViewTableViewVoiceMessageCellDelegate =
        ChatViewTableViewVoiceMessageCellDelegate(chatViewController: self)
    
    // Cell height caching
    private let cellHeightCache = CellHeightCache()
    
    private lazy var scrollToTopHelperView: UIScrollView = {
        let scrollView = UIScrollView()
        scrollView.translatesAutoresizingMaskIntoConstraints = false
        
        scrollView.contentOffset.y = 1
        scrollView.contentSize.height = view.bounds.height + 1
        
        scrollView.delegate = self
        
        return scrollView
    }()
    
    // MARK: Compose bar
    
    lazy var chatBarCoordinator = ChatBarCoordinator(
        conversation: conversation,
        chatViewController: self,
        chatBarCoordinatorDelegate: self,
        chatViewTableViewVoiceMessageCellDelegate: chatViewTableViewVoiceMessageCellDelegate,
        showConversationInformation: showConversationInformation
    )
    
    private var showConversationInformation: ShowConversationInformation?
    
    private lazy var scrollToBottomButton: UIView =
        if #available(iOS 26.0, *) {
            ScrollToBottomButton(
                unreadMessagesSnapshot: unreadMessagesSnapshot
            ) { [weak self] in
                guard let self else {
                    return
                }
            
                self.scrollToBottomButtonAction()
            }
        }
        else {
            // We keep the init outside to be able to call `updateColors()`
            old_ScrollToBottomButton
        }
    
    private lazy var old_ScrollToBottomButton = Old_ScrollToBottomView(
        unreadMessagesSnapshot: unreadMessagesSnapshot
    ) { [weak self] in
        guard let self else {
            return
        }
        
        scrollToBottomButtonAction()
    }
    
    private func scrollToBottomButtonAction() {
        // We cannot do a layout pass during scrolling (otherwise scrolling might be stopped when updating content
        // insets). Thus we do one now
        view.layoutIfNeeded()
        
        // If the unread message line is currently visible, we scroll to the very bottom of the view
        let unreadMessageLineVisible = !tableView.visibleCells
            .filter { $0 is ChatViewUnreadMessageLineCell }
            .isEmpty
        if unreadMessageLineVisible {
            jumpToBottom()
            return
        }
        
        let snapshot = dataSource.snapshot()
        
        guard let newestUnreadMessageObjectID = unreadMessagesSnapshot.unreadMessagesState?
            .oldestConsecutiveUnreadMessage else {
            jumpToBottom()
            return
        }
        
        let itemIdentifiersInScreenOrder = snapshot.itemIdentifiers
        
        // We either scroll to the previously newest unread message or the unread message line
        for item in itemIdentifiersInScreenOrder {
            if case let ChatViewDataSource.CellType.message(objectID: objectID) = item,
               objectID == newestUnreadMessageObjectID {
                jumpToBottom()
                break
            }
            if case let ChatViewDataSource.CellType.unreadLine(state: state) = item {
                guard let newestUnreadMessageObjectID = state.oldestConsecutiveUnreadMessage else {
                    
                    jumpToBottom()
                    return
                }
                guard let message = entityManager.entityFetcher
                    .existingObject(with: newestUnreadMessageObjectID) as? BaseMessageEntity else {
                    
                    jumpToBottom()
                    return
                }
                
                jump(toUnreadMessage: message.id) { _ in
                    if self.isAtBottomOfView {
                        self.userIsAtBottomOfTableView = true
                    }
                }
                return
            }
        }
        
        if !userIsAtBottomOfTableView {
            DDLogVerbose(
                "\(#function) newestUnreadMessageObjectID was not found in loaded messages and view is not at bottom"
            )
            jumpToBottom()
        }
    }
    
    private lazy var bottomComposeConstraint = view.keyboardLayoutGuide.topAnchor
    
    /// Bottom constraint of the chat bar. Swapped between `safeAreaLayoutGuide.bottomAnchor` (keyboard hidden)
    /// and `keyboardLayoutGuide.topAnchor` (keyboard visible). We can't pin to `keyboardLayoutGuide.topAnchor`
    /// permanently because the guide reports a stale y-coordinate when the device orientation changes while
    /// the app is backgrounded, leaving the chat bar floating above the bottom of the view on return.
    private var chatBarBottomConstraint: NSLayoutConstraint!
    
    /// True while a `viewWillTransition` (a screen rotation) is in flight. During this window we
    /// avoid swapping `chatBarBottomConstraint`, because iOS fires `keyboardWillHide`+`keyboardWillShow`
    /// in quick succession on rotation with the keyboard up, without this guard the chat bar would
    /// briefly snap to the safe area bottom and back.
    private var isInViewTransition = false

    /// True while `saveDraftIfChatWasLeft()` waits to decide whether a voice draft must be saved
    private var isVoiceDraftSaveScheduled = false

    private lazy var defaultScrollToBottomButtonConstraints: [NSLayoutConstraint] =
        if #available(iOS 26.0, *) {
            [
                scrollToBottomButton.trailingAnchor.constraint(
                    equalTo: view.trailingAnchor,
                    constant: -ChatViewConfiguration.ScrollToBottomButton.leftRightInsets
                ),
                scrollToBottomButton.bottomAnchor.constraint(
                    equalTo: chatBarCoordinator.chatBarContainerView.topAnchor,
                    constant: -ChatViewConfiguration.ScrollToBottomButton.distanceToChatBar
                ),
            ]
        }
        else {
            [
                scrollToBottomButton.trailingAnchor.constraint(
                    equalTo: view.trailingAnchor
                ),
                scrollToBottomButton.bottomAnchor.constraint(
                    equalTo: chatBarCoordinator.chatBarContainerView.topAnchor,
                    constant: -ChatViewConfiguration.ScrollToBottomButton.distanceToChatBar
                ),
            ]
        }
    
    private lazy var multiselectScrollToBottomButtonConstraints: [NSLayoutConstraint] = [
        scrollToBottomButton.trailingAnchor.constraint(equalTo: view.trailingAnchor),
        scrollToBottomButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
    ]
    
    // MARK: Loading messages
    
    // TODO: (IOS-2014) Tweak these variables
    /// Threshold when to load more data at the top
    private let topOffsetThreshold: CGFloat = 1500
    /// Threshold when to load more data at the bottom
    private let bottomOffsetThreshold: CGFloat = 1500
    
    /// Threshold when `isAtBottomOfView` should report that we scrolled all the way to the bottom of the view
    private let isAtBottomOfViewThreshold: CGFloat = 25 + ChatViewConfiguration.bottomInset
    
    /// Completion handler to be called after scrolling did end. Updated by `didApplySnapshot`
    private var scrollCompletion: (() -> Void)?
    
    /// Will cancel `willEnterForegroundCompletion` if it isn't executed before the timer finishes
    private var willEnterForeGroundCompletionTimer: Timer?
    
    /// Upon reentering foreground we might want to scroll the unread message line but it might not be loaded
    ///
    /// This allows us to add a task which is then executed either immediately if the unread message line is already
    /// showing or on next `didApplySnapshot`.
    private var willEnterForegroundCompletion: (((() -> Void)?) -> Void)? {
        didSet {
            if willEnterForegroundCompletion == nil {
                willEnterForeGroundCompletionTimer?.invalidate()
                willEnterForeGroundCompletionTimer = nil
            }
            else {
                willEnterForeGroundCompletionTimer = Timer.scheduledTimer(
                    withTimeInterval: ChatViewConfiguration.UnreadMessageLine.completionTimeout,
                    repeats: false
                ) { [weak self] _ in
                    self?.willEnterForegroundCompletion = nil
                }
            }
        }
    }
    
    /// Are we scrolled to the newest message at the bottom and should we scroll down when the next snapshot is applied?
    private var shouldScrollToBottomAfterNextSnapshotApply = false
    
    /// Are we jumping to a message (this includes the bottom)
    ///
    /// Use `completeJumping()` to reset this value to `false`.
    private var isJumping = false
    
    private var isScrolling = false
    /// This is true while we are programmatically scrolling to bottom or when the scrolling was cancelled and we are
    /// not scrolling anymore
    /// Before setting `isProgrammaticallyScrollingToBottom` `isScrolling` must always been set to true
    private var isProgrammaticallyScrollingToBottom = false {
        didSet {
            if ChatViewConfiguration.strictMode, isProgrammaticallyScrollingToBottom, !isScrolling {
                fatalError("Consistency error for scrolling variables")
            }
        }
    }
    
    /// When the keyboard was shown before the ContextMenu opened, we need to block inset updates. Otherwise the layout
    /// will break when we enter the multi select mode through it.
    private var insetUpdatesBlockedByContextMenu = false
    
    /// True between `willApplySnapshot` and `didApplySnapshot`
    private var isApplyingSnapshot = false
    
    /// These are used to determine whether it is safe to load new messages.
    /// During dragging adding new table view cells might throw of the scroll position.
    private var isDragging = false
    /// During an in-progress load request new load requests should not be issued
    private var isLoading = false
    
    private var isUserInteractiveScroll = false
    
    private(set) var isApplicationInForeground = true
    var willDisappear = false {
        didSet {
            DDLogVerbose("\(#function) willDisappear \(willDisappear)")
        }
    }
    
    private var userIsAtBottomOfTableView = false {
        didSet {
            unreadMessagesSnapshot.userIsAtBottomOfTableView = userIsAtBottomOfTableView
            DDLogVerbose("userIsAtBottomOfTableView \(userIsAtBottomOfTableView)")
        }
    }
    
    // MARK: - Gesture recognizer
    
    lazy var keyboardDismissGesture: UITapGestureRecognizer = {
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapOnChatView))
        tapGesture.cancelsTouchesInView = true
        return tapGesture
    }()
    
    // MARK: Experimenting and measuring
    
    // TODO: (IOS-2014) remove
    
    private var firstCellShown = false
    
    // MARK: - GroupCalls
    
    private lazy var groupCallGroupModel: GroupCallThreemaGroupModel? = {
        guard let group = businessInjector.groupManager.getGroup(conversation: conversation) else {
            return nil
        }
        
        return GroupCallThreemaGroupModel(
            groupIdentity: group.groupIdentity,
            groupName: group.name ?? ""
        )
    }()
    
    // MARK: - Spam detection
    
    private lazy var spamDetection: (any SpamDetecting)? = {
        if conversation.isGroup {
            guard ThreemaEnvironment.enableGroupSpamReporting else {
                return nil
            }
            
            return GroupSpamDetector(businessInjector: businessInjector)
        }
        else {
            guard ThreemaEnvironment.enableSpamReporting else {
                return nil
            }
            
            return SpamDetector(businessInjector: businessInjector)
        }
    }()
    
    // MARK: - Lifecycle
    
    /// Create a new chat view
    /// - Parameters:
    ///   - conversation: ConversationEntity to display in chat view
    ///   - businessInjector: Business injector to load messages
    ///   - chatScrollPositionProvider: Handle scroll position restoration
    ///   - isRegularSizeClass: Inject info about size class
    ///   - initialUnreadCount: Initial unread count for all messages from all chats (needed for correct chat profile
    ///                         sizing)
    ///   - showConversationInformation: Information when chat is opened through a notification
    init(
        for conversation: ConversationEntity,
        businessInjector: BusinessInjectorProtocol = BusinessInjector(),
        chatScrollPositionProvider: ChatScrollPositionProvider = ChatScrollPosition.shared,
        isRegularSizeClass: @escaping () -> Bool,
        initialUnreadCount: Int = 0,
        showConversationInformation: ShowConversationInformation? = nil
    ) {
        self.conversation = conversation
        self.businessInjector = businessInjector
        self.entityManager = businessInjector.entityManager
        self.chatScrollPositionProvider = chatScrollPositionProvider
        self.isRegularSizeClass = isRegularSizeClass
        self.initialUnreadCount = initialUnreadCount
        self.showConversationInformation = showConversationInformation
        
        super.init(nibName: nil, bundle: nil)
        
        // Tab bar
        if isRegularSizeClass() {
            hidesBottomBarWhenPushed = false
        }
        else {
            hidesBottomBarWhenPushed = true
        }
        
        // Configure tableView as early as possible to allow background fetching to take full effect
        configureTableView()
    }
    
    @available(*, unavailable)
    override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
        fatalError("Not supported")
    }
    
    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // TODO: See `configureLayout`
    //    override func loadView() {
    //        // Do NOT call `super`!
    //        view = tableView
    //    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        DDLogVerbose("\(#function)")
        
        addObservers()
        tableView.addGestureRecognizer(keyboardDismissGesture)
        
        configureLayout()
        configureNavigationItem()
        configureToolbar()
        
        // No need to call `updateColors()` here as `ThemedViewController.viewWillAppear()` will call it, too.
        
        // We apply a blur behind the chat bar from iOS 26 onwards
        if #available(iOS 26.0, *) {
            let interaction = UIScrollEdgeElementContainerInteraction()
            
            interaction.scrollView = tableView
            interaction.edge = .bottom
            
            chatBarCoordinator.chatBarContainerView.addInteraction(interaction)
        }
        
        // From iOS 27 onwards the top edge effect extends below pinned section headers and uses the hard style while a
        // header is pinned. There is no public way to exclude the headers, so we at least use the
        // soft style, which fades out instead of covering the header with a solid band.
        if #available(iOS 27.0, *) {
            tableView.topEdgeEffect.style = .soft
        }
        
        checkForSpam()
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        DDLogVerbose("\(#function)")
        
        hidesBottomBarWhenPushed = true
        
        chatBarCoordinator.updateSettings()
        
        // Send notification (e.g. for hiding toasts that apply to this conversation)
        if let groupID = conversation.groupID {
            NotificationCenter.default.post(
                name: NSNotification.Name(rawValue: kNotificationOpenedConversation),
                object: groupID.hexEncodedString()
            )
        }
        else if let contact = conversation.contact {
            NotificationCenter.default.post(
                name: NSNotification.Name(rawValue: kNotificationOpenedConversation),
                object: contact.identity
            )
        }
        
        // This and the opposite in `viewWillDisappear` is needed to make a search controller work that is added in a
        // child view controller using the same navigation bar. See ChatSearchController for details.
        definesPresentationContext = true
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        DDLogVerbose("\(#function)")
        
        willDisappear = false
        
        tableView.flashScrollIndicators()
        
        let endTime = CACurrentMediaTime()
        DDLogVerbose("ChatViewController duration init to viewDidAppear \(endTime - initTime) s")
        
        if let workStatusBannerView {
            workStatusBannerView.showTipIfNeeded(in: view)
        }
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        
        DDLogVerbose("\(#function)")
        
        // Hide keyboard to prevent glitches
        hideKeyboard()
        
        willDisappear = true
        scrollCompletion?()
        scrollCompletion = nil
        
        chatBarCoordinator.sendTypingIndicator(startTyping: false)
        // Stop timers to prevent leaks
        chatBarCoordinator.stopTypingTimers()
        
        // This and the opposite in `viewWillAppear` is needed to make a search controller work that is added in a
        // child view controller using the same navigation bar. See ChatSearchController for details.
        definesPresentationContext = false
    }
    
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        
        DDLogVerbose("\(#function)")
        
        guard
            !conversation.willBeDeleted,
            conversation.managedObjectContext != nil
        else {
            return
        }
        
        chatViewTableViewVoiceMessageCellDelegate.pausePlaying()
        
        saveCurrentScrollPosition()
        
        if shouldMarkMessagesAsRead {
            unreadMessagesSnapshot.resetState()
        }
        
        saveDraftIfChatWasLeft()
    }
    
    override func didMove(toParent parent: UIViewController?) {
        super.didMove(toParent: parent)
        
        // stop playing and do a cleanup if user switch to overview
        if parent == nil {
            chatViewTableViewVoiceMessageCellDelegate.didDisappear = true
            chatViewTableViewVoiceMessageCellDelegate.stopPlayingAndDoCleanup(cancel: true)
        }
    }
    
    deinit {
        DDLogVerbose("\(#function)")
        
        observerConversation?.invalidate()
        
        NotificationCenter.default.removeObserver(self)
        
        DispatchQueue.global().async {
            /// Clean up temporary files that might be leftover from playing voice messages
            FileUtility.shared.cleanTemporaryDirectory()
        }
    }
    
    // MARK: - Configuration
    
    private func addObservers() {
        
        // Observe `Conversation.willBeDeleted` to close this view
        observerConversation = conversation.observe(\.willBeDeleted) { [weak self] conversation, _ in
            guard conversation.willBeDeleted else {
                return
            }
            
            // `prepareForDeletion` is invoked inside NSManagedObjectContext.save().
            // Never drive UIKit navigation synchronously from there, and the deleting
            // context is not guaranteed to be the main-queue context.
            DispatchQueue.main.async {
                self?.closeBecauseConversationWasDeleted()
            }
        }
        
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(applicationDidEnterBackground),
            name: UIApplication.didEnterBackgroundNotification,
            object: nil
        )
        
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(applicationWillEnterForeground),
            name: UIApplication.willEnterForegroundNotification,
            object: nil
        )
        
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(keyboardWillChange(_:)),
            name: UIResponder.keyboardWillShowNotification,
            object: nil
        )
        
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(keyboardWillChange(_:)),
            name: UIResponder.keyboardWillHideNotification,
            object: nil
        )
        
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(preferredContentSizeCategoryDidChange),
            name: UIContentSizeCategory.didChangeNotification,
            object: nil
        )
        
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(wallpaperChanged),
            name: NSNotification.Name(rawValue: kNotificationWallpaperChanged),
            object: nil
        )
        
        if businessInjector.settingsStore.enableThreemaGroupCalls {
            // This will be automatically removed on deinit
            startGroupCallObserver()
        }
    }
    
    private func startGroupCallObserver() {
        guard businessInjector.settingsStore.enableThreemaGroupCalls else {
            return
        }
        
        Task {
            if let lastUpdate = await GlobalGroupCallManagerSingleton.shared
                .globalGroupCallObserver.getCurrentItem(), conversation.isEqualTo(
                    groupIdentity: lastUpdate.groupIdentity, myIdentity: self.businessInjector.myIdentityStore.identity
                ) {
                groupCallBannerView.updateBannerState(lastUpdate)
            }
        }
        
        GlobalGroupCallManagerSingleton.shared
            .globalGroupCallObserver.publisher.pub
            .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
            .filter { [weak self] update in
                guard let self else {
                    return false
                }
                
                return businessInjector.runInBackgroundAndWait { [weak self] backgroundBusinessInjector in
                    backgroundBusinessInjector.entityManager.performAndWait { [backgroundBusinessInjector] in
                        let entityFetcher =
                            backgroundBusinessInjector.entityManager.entityFetcher
                        guard
                            let objectID = self?.conversation.objectID,
                            let tempConversation = entityFetcher.existingObject(
                                with: objectID
                            ) as? ConversationEntity
                        else {
                            return false
                        }
                        return tempConversation.isEqualTo(
                            groupIdentity: update.groupIdentity,
                            myIdentity: backgroundBusinessInjector.myIdentityStore.identity
                        )
                    }
                }
            }
            .sink(receiveValue: { [weak self] update in
                DDLogVerbose("[GroupCall] Update ChatView banner for Call")
                self?.groupCallBannerView.updateBannerState(update)
            }).store(in: &cancellables)
    }
    
    private func enableKeyboardDismissGestureRecognizer() {
        keyboardDismissGesture.isEnabled = true
    }
    
    private func disableKeyboardDismissGestureRecognizer() {
        keyboardDismissGesture.isEnabled = false
    }
    
    private func configureLayout() {
        // # View hierarchy
        //
        // The main `view` of the VC hosts all views. From back to front:
        // - Background view *
        // - Table view
        // - Chat bar
        // - Scroll to bottom button
        //
        // This is chosen on purpose, because the loading & layout (with scrolling to the correct position) of the
        // table view normally takes longer than the start of the transition to the VC. This allows us to load and show
        // all other views before the table view is ready. Thus a user could already start writing a message after the
        // transition completes, but before the table view is ready for interaction. If the table view would be the
        // main view of the VC the animation would be choppy.
        //
        // * this is preferred over `tableView.backgroundView` as this would not show the background until the table
        //   view is loaded and setting it the scroll position to infinity leads to weird background layout.
        
        view.addSubview(backgroundView)
        view.addSubview(tableView)
        view.addSubview(chatBarCoordinator.chatBarContainerView)
        view.addSubview(scrollToBottomButton)
        view.addSubview(bannerContainerView)
        
        // Add banners to container
        if ThreemaEnvironment.enableSpamReporting || ThreemaEnvironment.enableGroupSpamReporting, let spamReportingBannerView {
            bannerContainerView.addBanner(spamReportingBannerView)
        }
        bannerContainerView.addBanner(groupCallBannerView)
        if let workStatusBannerView {
            bannerContainerView.addBanner(workStatusBannerView)
        }

        backgroundView.translatesAutoresizingMaskIntoConstraints = false
        tableView.translatesAutoresizingMaskIntoConstraints = false
        chatBarCoordinator.chatBarContainerView.translatesAutoresizingMaskIntoConstraints = false
        chatBarCoordinator.chatBarContainerView.keyboardLayoutGuide.followsUndockedKeyboard = false
        scrollToBottomButton.translatesAutoresizingMaskIntoConstraints = false

        chatBarBottomConstraint = chatBarCoordinator.chatBarContainerView.bottomAnchor.constraint(
            equalTo: view.safeAreaLayoutGuide.bottomAnchor
        )

        NSLayoutConstraint.activate([
            backgroundView.topAnchor.constraint(equalTo: view.topAnchor),
            backgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            backgroundView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            backgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            
            tableView.topAnchor.constraint(equalTo: view.topAnchor),
            tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            
            chatBarCoordinator.chatBarContainerView.leadingAnchor.constraint(equalTo: tableView.leadingAnchor),
            chatBarCoordinator.chatBarContainerView.trailingAnchor.constraint(equalTo: tableView.trailingAnchor),
            chatBarBottomConstraint,
            
            view.safeAreaLayoutGuide.topAnchor.constraint(equalTo: bannerContainerView.topAnchor, constant: -10),
            view.safeAreaLayoutGuide.leadingAnchor.constraint(
                equalTo: bannerContainerView.leadingAnchor,
                constant: -10
            ),
            view.safeAreaLayoutGuide.trailingAnchor.constraint(
                equalTo: bannerContainerView.trailingAnchor,
                constant: 10
            ),
        ])

        if #unavailable(iOS 26.0) {
            NSLayoutConstraint.activate([
                chatBarCoordinator.chatBarContainerView.topAnchor.constraint(
                    greaterThanOrEqualTo: tableView.topAnchor,
                    constant: ChatBarConfiguration.tableViewChatBarMinSpacing
                ),
            ])
        }
        
        if #unavailable(iOS 26.0), isRegularSizeClass() {
            // This removes a small gap that would open up between the chat bar and the keyboard accessory view if an
            // iPad is used with an external keyboard
            NSLayoutConstraint.activate([
                chatBarCoordinator.chatBarContainerView.blurEffectView.bottomAnchor
                    .constraint(equalTo: view.bottomAnchor),
            ])
        }
        
        NSLayoutConstraint.activate(defaultScrollToBottomButtonConstraints)
                
        wallpaperChanged()
    }
    
    // MARK: - Public functions
    
    func isRecording() -> Bool {
        chatBarCoordinator.chatBar.recordingState == .recording
    }
    
    func isPlayingAudioMessage() -> Bool {
        chatViewTableViewVoiceMessageCellDelegate.isMessageCurrentlyPlaying(nil) || chatBarCoordinator.chatBar
            .recordingState == .playing
    }
    
    func isChat(for contact: ContactEntity) -> Bool {
        contact == conversation.contact
    }
    
    func isDisplayingChat(for conv: ConversationEntity) -> Bool {
        conversation.objectID == conv.objectID
    }
    
    // MARK: - Overrides
    
    override func viewWillLayoutSubviews() {
        DDLogVerbose("\(#function)")
        super.viewWillLayoutSubviews()
        updateChatProfileViewSettings()
    }
    
    override func viewDidLayoutSubviews() {
        DDLogVerbose("\(#function)")
        super.viewDidLayoutSubviews()
        
        // Scrolling gets stopped if we update the insets during it
        if !isScrolling {
            updateContentInsets()
        }
        
        // Scrolling might get stopped during layout updates
        // If we were programmatically scrolling to bottom before the update continue or start scrolling again
        if isProgrammaticallyScrollingToBottom {
            scrollToBottom(animated: true)
        }
        
        /// This immediately starts loading cells at the very bottom even before we call `didApplySnapshot`.
        /// As a nice side-effect to preloading the cells at the bottom of the view; we also get the effect that cells
        /// only appear after the scroll position has been correctly set.
        ///
        /// Unfortunately this loads cells at the bottom even when restoring the scroll position later somewhere else in
        /// the chat view, these cells are never displayed and thus go to waste.
        /// Calling `setContentOffset` and setting the content offset immediately to the approximately correct value of
        /// the scroll position still gives a jumpy animation on slow devices.
        /// We thus accept this limitation and waste a few cells on each scroll position restore.
        ///
        /// This workaround is also present in `updateContentInsets(force:retry:)`
        if !dataSource.initialSetupCompleted, tableView.contentSize.height > 0 {
            tableView.setContentOffset(
                CGPoint(x: 0, y: CGFLOAT_MAX),
                animated: false
            )
        }
        
        updateColors()
    }
    
    private func updateColors() {
        view.backgroundColor = Colors.backgroundViewController
                
        for cell in tableView.visibleCells {
            if let chatViewBaseCell = cell as? ChatViewBaseTableViewCell {
                chatViewBaseCell.updateColors()
                continue
            }
            else if let systemMessageCell = cell as? ChatViewSystemMessageTableViewCell {
                systemMessageCell.updateColors()
            }
            else if let workConsumerSystemMessageCell = cell as? ChatViewWorkConsumerInfoSystemMessageTableViewCell {
                workConsumerSystemMessageCell.updateColors()
            }
            else if let unreadMessageCell = cell as? ChatViewUnreadMessageLineCell {
                unreadMessageCell.updateColors()
            }
        }
        
        chatBarCoordinator.updateColors()
        
        if #unavailable(iOS 26.0) {
            old_ScrollToBottomButton.updateColors()
        }
        
        Colors.update(searchBar: chatSearchController.searchBar as! UISearchBar)
        
        // We also only need this pre iOS 26. If we show a prompt, the background gets handled in
        // `StatusNavigationController`.
        if #unavailable(iOS 26.0),
           let statusNavigationController = navigationController as? StatusNavigationController {
            statusNavigationController.updateNavigationBarColor(forceOpaque: true)
        }
    }
    
    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        cellHeightCache.clear()
        super.viewWillTransition(to: size, with: coordinator)
        wallpaperChanged()

        isInViewTransition = true
        coordinator.animate(alongsideTransition: nil) { [weak self] _ in
            self?.isInViewTransition = false
        }
    }
    
    /// Saves the draft, unless the chat only disappeared for a view transition.
    ///
    /// A split view collapse or expand (rotating an iPhone Plus/Pro Max, resizing on iPad) moves this chat into
    /// another navigation controller: it disappears and appears again although the user never left it. Saving a voice
    /// draft stops the recorder, so during such a transition we wait for it to end and only save if the chat did not
    /// come back on screen. In every other case (leaving the chat, pushing the profile) the draft is saved right away,
    /// so this controller is not kept alive after it was popped.
    private func saveDraftIfChatWasLeft() {
        guard chatBarCoordinator.isRecording, isInViewTransition else {
            let wasRecording = isRecording()
            chatBarCoordinator.saveDraft()
            if wasRecording {
                DispatchQueue.main.async {
                    UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
                }
            }
            return
        }

        guard !isVoiceDraftSaveScheduled else {
            return
        }
        isVoiceDraftSaveScheduled = true

        let deadline = Date.now.addingTimeInterval(ChatViewConfiguration.VoiceDraft.transitionTimeout)

        // `self` is captured strongly: if the transition ends with the chat off screen, the draft must still be saved.
        // During a split view collapse or expand the navigation router keeps this controller alive anyway.
        func checkWhenSettled() {
            DispatchQueue.main.asyncAfter(deadline: .now() + ChatViewConfiguration.VoiceDraft.checkInterval) {
                if self.isInViewTransition, Date.now < deadline {
                    return checkWhenSettled()
                }

                self.isVoiceDraftSaveScheduled = false

                // Back on screen (rotated or resized)
                guard self.viewIfLoaded?.window == nil else {
                    return
                }

                let wasRecording = self.isRecording()
                self.chatBarCoordinator.saveDraft()
                if wasRecording {
                    UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
                }
            }
        }
        checkWhenSettled()
    }

    // MARK: - Notifications
    
    @objc private func applicationDidEnterBackground() {
        DDLogVerbose("\(#function)")
        
        saveCurrentScrollPosition()
        
        if shouldMarkMessagesAsRead {
            unreadMessagesSnapshot.resetState()
            dataSource.removeUnreadMessageLine()
        }
        
        isApplicationInForeground = false
        
        chatBarCoordinator.saveDraft()
    }
    
    @objc private func applicationWillEnterForeground() {
        isApplicationInForeground = true
        /// Part of the workaround for the passcode lock screen. See `ChatViewTableView` for additional info
        didEnterForegroundWithNilWindow = view.window == nil
        
        userIsAtBottomOfTableView = isAtBottomOfView
        
        /// Part of the workaround for the passcode lock screen. See `ChatViewTableView` for additional info
        if !didEnterForegroundWithNilWindow {
            jumpToUnreadMessage()
        }
    }
    
    @objc func didTapOnChatView() {
        hideKeyboard()
        
        selectedTextView?.resetTextSelection()
    }
    
    func hideKeyboard() {
        chatBarCoordinator.endEditing()
    }
    
    @objc private func preferredContentSizeCategoryDidChange() {
        cellHeightCache.clear()
    }

    @objc private func wallpaperChanged() {
        let wallpaperStore = businessInjector.settingsStore.wallpaperStore
        // If we use the default item, we have to create the pattern and apply it as color
        if !wallpaperStore.hasCustomWallpaper(for: conversation.objectID),
           wallpaperStore.wallpaperType() == .threema {
            backgroundView.image = nil
            backgroundView.backgroundColor = UIColor(
                patternImage: businessInjector.settingsStore.wallpaperStore.defaultWallPaper
            )
        }
        else {
            backgroundView.backgroundColor = nil
            if let image = wallpaperStore.wallpaper(for: conversation.objectID) {
                backgroundView.image = MediaConverter.downscaleImage(
                    image: image,
                    toSize: UIScreen.main.bounds.size,
                    scale: UIScreen.main.scale
                )
            }
            else {
                backgroundView.image = nil
            }
        }
    }
}

// MARK: - Navigation Item

extension ChatViewController {
    
    private func configureNavigationItem() {

        // Note: The back button is set in `ConversationListViewController` and cannot be overridden here

        // Configure chat profile view
        // See `ChatProfileView` why we choose this solution

        navigationItem.largeTitleDisplayMode = .never

        // In the regular size class the chat view is shown as a split view detail, so
        // `updateNavigationBarColor(forceOpaque:)` may not be called reliably. Setting the
        // appearance on `navigationItem` (per-VC) keeps the bar opaque regardless of any
        // notification-driven resets on the shared `navigationBar` appearance.
        if #unavailable(iOS 26.0) {
            let opaqueAppearance = UINavigationBarAppearance()
            opaqueAppearance.configureWithOpaqueBackground()
            navigationItem.standardAppearance = opaqueAppearance
            navigationItem.scrollEdgeAppearance = opaqueAppearance
        }

        updateNavigationItem()
    }
    
    private func updateNavigationItem() {
        switch userInterfaceMode {
        case .default:
            updateChatProfileViewSettings()
            
            navigationItem.titleView = chatProfileView
            
            navigationItem.hidesBackButton = false
            
            navigationItem.leftBarButtonItem = nil
            
            navigationItem.rightBarButtonItems = nil
            
            if conversation.isGroup {
                if let group = businessInjector.groupManager.getGroup(conversation: conversation),
                   businessInjector.settingsStore.enableThreemaGroupCalls, group.isSelfMember, !group.isNoteGroup {
                    navigationItem.rightBarButtonItem = groupCallBarButtonItem
                }
                else {
                    updateOpenBallotsButton()
                }
            }
            // Only show call icon if Threema calls are enabled and contact supports them
            else if UserSettings.shared()?.enableThreemaCall == true,
                    let identity = conversation.contact?.identity {
                let identities = Set<String>([identity])
                FeatureMask.check(identities: identities, for: Int(FEATURE_MASK_VOIP)) { unsupportedContacts in
                    if unsupportedContacts.isEmpty == true ||
                        ProcessInfoHelper.isRunningForScreenshots {
                        self.navigationItem.rightBarButtonItem = self.callBarButtonItem
                    }
                }
            }
            
        case .search:
            navigationItem.titleView = chatSearchController.searchBar
            
            navigationItem.hidesBackButton = true
            
            navigationItem.leftBarButtonItem = nil

            navigationItem.rightBarButtonItems = nil
            
        case .multiselect:
            navigationItem.titleView = nil
            
            navigationItem.hidesBackButton = true
            
            navigationItem.leftBarButtonItem = deleteBarButton
            updateSelectionTitle()
            
            navigationItem.rightBarButtonItems = nil
            navigationItem.rightBarButtonItem = cancelBarButton
            
        case .preview:
            navigationItem.titleView = nil
            
            navigationItem.hidesBackButton = true
            
            navigationItem.leftBarButtonItems = nil

            navigationItem.rightBarButtonItems = nil
        }
    }
    
    private func updateChatProfileViewSettings() {
        guard let navigationBar = navigationController?.navigationBar else {
            return
        }
        
        let safeAreaAdjustedBounds = navigationBar.bounds.inset(by: navigationBar.safeAreaInsets)
        chatProfileView.safeAreaAdjustedNavigationBarWidth = safeAreaAdjustedBounds.width
    }
    
    /// Update open ballots button
    ///
    /// If count goes to 0 it disappears or if goes above 0 it (re)appears
    ///
    /// As it is complicated to observe ballot changes we just call this function on every instance creation of the
    /// view, when a new (ballot) message received or when the ballot list gets dismissed.
    private func updateOpenBallotsButton() {
        
        guard conversation.isGroup, userInterfaceMode == .default else {
            return
        }
        
        let numberOfOpenBallots = entityManager.entityFetcher.openBallotEntitiesCount(for: conversation)
        
        // Only show ballots icon if we have open polls
        if numberOfOpenBallots > 0 {
            if #available(iOS 26.0, *) {
                if navigationItem.rightBarButtonItems == nil {
                    navigationItem.rightBarButtonItem = ballotBarButton
                }
                ballotBarButton.badge = .count(numberOfOpenBallots)
                ballotBarButton.accessibilityLabel = String.localizedStringWithFormat(
                    #localize("ballots_with_open_count_accessibility"),
                    numberOfOpenBallots
                )
            }
            else {
                old_ballotBarButton.openBallotsCount = UInt(numberOfOpenBallots)
                
                if navigationItem.rightBarButtonItems == nil {
                    navigationItem.rightBarButtonItems = old_ballotBarButton.rightBarButtonItems
                }
            }
        }
        else if !businessInjector.userSettings.enableThreemaGroupCalls {
            navigationItem.rightBarButtonItems = nil
        }
    }
    
    private func closeBecauseConversationWasDeleted() {
        guard !isClosingDeletedConversation else {
            return
        }
        isClosingDeletedConversation = true

        conversationDeletedDelegate?.chatViewControllerConversationWasDeleted(self)
    }

    func chatProfileViewTapped() {
        let detailsViewController: UIViewController
        
        if conversation.isGroup {
            guard let group = businessInjector.groupManager.getGroup(conversation: conversation) else {
                fatalError("No group conversation found for this conversation")
            }
            
            detailsViewController = GroupDetailsViewController(for: group, displayMode: .conversation, delegate: self)
        }
        else if let distributionList = businessInjector.distributionListManager.distributionList(for: conversation) {
            detailsViewController = DistributionListDetailsViewController(
                for: distributionList,
                displayMode: .conversation,
                delegate: self
            )
        }
        else {
            detailsViewController = SingleDetailsViewController(for: conversation, delegate: self)
        }
        
        let navigationController = ModalNavigationController(
            navigationBarClass: StatusNavigationBar.self,
            toolbarClass: nil
        )
        navigationController.setViewControllers([detailsViewController], animated: false)
        navigationController.modalPresentationStyle = .formSheet
        navigationController.modalDelegate = self
        
        present(navigationController, animated: true)
    }
    
    @objc private func startGroupCall() {
        guard let group = businessInjector.groupManager.getGroup(conversation: conversation) else {
            return
        }
        
        // When running for screenshots we fake a call.
        guard !ProcessInfoHelper.isRunningForScreenshots else {
            GlobalGroupCallManagerSingleton.shared.startGroupCallForScreenshots(group: group)
            return
        }
        
        chatViewTableViewVoiceMessageCellDelegate.pausePlaying()
        CallProvider.shared.startGroupCall(in: group, intent: .createOrJoin)
    }
    
    /// Start 1:1 call
    ///
    /// - Note: Only call this if you previously checked if `conversation.contact` supports calls
    @objc func startOneToOneCall() {
        chatViewTableViewVoiceMessageCellDelegate.pausePlaying()
        
        guard let contact = conversation.contact else {
            DDLogError("Conversation.Contact is unexpectedly nil")
            return
        }
        // We assume that the contact supports calls
        CallProvider.shared.startOutgoingCall(with: contact.identity)
    }
    
    @objc private func showBallots() {
        let pollListController = UIHostingController(
            rootView: ListPollView(
                entityManager: entityManager,
                conversation: conversation
            ) { [weak self] in
                self?.updateOpenBallotsButton()
            } onDelete: { [weak self] deletedMessagesObjectIDs in
                self?.willDeleteMessages(with: deletedMessagesObjectIDs)
            }
        )
        present(pollListController, animated: true)
    }
    
    @objc private func showDeleteMessagesAlert() {
        let alertTitle: String
        var alertMessage: String?
        
        if dataSource.hasSelectedObjectIDs() {
            alertTitle = #localize("messages_delete_selected_confirm")
        }
        else {
            alertTitle = #localize("messages_delete_all_confirm_title")
            alertMessage = #localize("messages_delete_all_confirm_message")
        }
        
        UIAlertTemplate.showDestructiveAlert(
            owner: self,
            title: alertTitle,
            message: alertMessage,
            titleDestructive: #localize("delete")
        ) { _ in
            
            if self.dataSource.hasSelectedObjectIDs() {
                self.dataSource.deleteSelectedMessages()
            }
            else {
                self.dataSource.deleteAllMessages()
            }
            self.endMultiselect()
        }
    }
    
    func playNextMessageIfPossible(from message: NSManagedObjectID) {
        guard let indexPath = dataSource.indexPath(for: .message(objectID: message)) else {
            DDLogError("\(#function) could not find indexPath for message")
            return
        }
            
        var nextMessageIndexPath: IndexPath?
            
        let nextMessageOffset = 1
            
        if indexPath.section < tableView.numberOfSections,
           indexPath.row + nextMessageOffset < tableView.numberOfRows(inSection: indexPath.section) {
            nextMessageIndexPath = IndexPath(row: indexPath.row + nextMessageOffset, section: indexPath.section)
        }
        else if indexPath.section + 1 < tableView.numberOfSections,
                tableView.numberOfRows(inSection: indexPath.section + nextMessageOffset) > 0 {
            nextMessageIndexPath = IndexPath(row: 0, section: indexPath.section + nextMessageOffset)
        }
            
        guard let nextMessageIndexPath,
              let cell = tableView.cellForRow(at: nextMessageIndexPath) as? ChatViewVoiceMessageTableViewCell else {
            return
        }
            
        let scrollPosition: UITableView.ScrollPosition = .top
            
        DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(750)) {
            self.contentSizeChangeSafeScrollToRow(at: nextMessageIndexPath, at: scrollPosition, animated: true)
            // Loading will be handled by `UIScrollViewDelegate`
                
            cell.downloadAndPlay()
        }
    }
    
    func showReactionAlert(for result: ReactionsManager.ReactionSendingResult) {
        Task { @MainActor in
            tableView.contextMenuInteraction?.dismissMenu()
            
            if result == .partialSupportRemoteGroup, UserSettings.shared().partialReactionSupportAlertShown {
                NotificationPresenterWrapper.shared.present(type: .emojiPartialGroup)
            }
            else {
                UIAlertTemplate.showOKAlert(owner: self, title: result.alertTitle, message: result.alertMessage)
                if result == .partialSupportRemoteGroup {
                    UserSettings.shared().partialReactionSupportAlertShown = true
                }
            }
        }
    }
    
    override func accessibilityPerformMagicTap() -> Bool {
        chatViewTableViewVoiceMessageCellDelegate.stopPlayingAndDoCleanup(cancel: true)
        return true
    }
}

// MARK: - Table View

extension ChatViewController {
    private func configureTableView() {
        // This needs to be done here and not in the lazy `tableView` initializer. Otherwise we get an infinite loop.
        tableView.dataSource = dataSource
        
        // Add reusable section header
        tableView.registerHeaderFooter(ChatViewSectionTableViewHeaderView.self)
    }
    
    // MARK: Scroll
    
    /// Scrolls to the bottom of the tableView
    ///
    /// *The animation and the scroll may be cancelled if the tableView is updated with new data, the view layouts its
    /// subviews in an unfortunate way or the contentOffset is changed. The caller must make sure that the animation
    /// isn't cancelled by such updates.*
    ///
    /// Use `jumpToBottom` if the messages at the bottom are not guaranteed to be already loaded.
    ///
    /// See `dataSource.apply` for more information about the current implementation of locking updates.
    /// - Parameter animated: Whether scrolling is animated or not
    private func scrollToBottom(animated: Bool, force: Bool = false) {
        DDLogVerbose("\(#function)")
        
        // Do not cancel user scroll
        guard ((!isUserInteractiveScroll && !isAtBottomOfView) || !dataSource.initialSetupCompleted) || force else {
            DDLogVerbose("\(#function) do not cancel user interactive scroll")
            return
        }
        
        guard let bottomIndexPath = dataSource.bottomIndexPath else {
            // Use scroll view API to go to bottom instead of table view if now index path is available
            DDLogVerbose("Fallback for scroll to bottom")
            let bottomContentOffset = tableView.contentSize.height
                - tableView.frame.height
                + tableView.adjustedContentInset.bottom
            
            tableView.setContentOffset(
                CGPoint(x: tableView.contentOffset.x, y: bottomContentOffset),
                animated: animated
            )
            
            return
        }
        
        DDLogVerbose("Scroll to bottom index path \(bottomIndexPath) ...")
        
        let scrollPosition: UITableView.ScrollPosition = .bottom
        if ChatViewConfiguration.ScrollCompletionBehavior.useCustomViewAnimationBlock {
            if animated {
                scrollToBottomWithCustomAnimation(bottomIndexPath)
            }
            else {
                contentSizeChangeSafeScrollToRow(at: bottomIndexPath, at: scrollPosition, animated: false)
            }
        }
        else {
            contentSizeChangeSafeScrollToRow(at: bottomIndexPath, at: scrollPosition, animated: animated)
        }
    }
    
    /// Helper function for `scrollToBottom(animated: Bool)`
    /// - Parameter bottomIndexPath: indexPath to scroll to
    private func scrollToBottomWithCustomAnimation(_ bottomIndexPath: IndexPath) {
        UIView.animate(
            withDuration: ChatViewConfiguration.ScrollCompletionBehavior.animationDuration,
            delay: 0.0,
            options: [.curveEaseInOut, .allowUserInteraction]
        ) {
            let scrollPosition: UITableView.ScrollPosition = .bottom
            self.contentSizeChangeSafeScrollToRow(at: bottomIndexPath, at: scrollPosition, animated: false)
        } completion: { _ in
            defer {
                self.scrollViewDidEndScrolling()
            }
            
            if !self.isUserInteractiveScroll, !self.isAtBottomOfView,
               bottomIndexPath == self.dataSource.bottomIndexPath {
                let msg = "After scrolling to bottom we are not at the bottom of the view"
                if ChatViewConfiguration.strictMode {
                    fatalError(msg)
                }
                else {
                    DDLogError("\(msg)")
                    assertionFailure(msg)
                }
            }
        }
    }
    
    /// Calls `tableView.scrollToRow(at:at:animated:)` but protects against scrolling being not successful due to
    /// changing cell sizes after scrolling
    ///
    /// In general you will not need this. Use `jump(to:animated:highlight:))` or `scrollToBottom(animated:)` instead.
    ///
    /// For more information see comment below
    ///
    /// - Parameters:
    ///   - indexPath: Identifies the row to scroll to
    ///   - scrollPosition: final scroll position
    ///   - animated: whether to animate the scrolling or not
    private func contentSizeChangeSafeScrollToRow(
        at indexPath: IndexPath,
        at scrollPosition: UITableView.ScrollPosition,
        animated: Bool
    ) {
        guard tableView.numberOfSections > indexPath.section,
              tableView.numberOfRows(inSection: indexPath.section) > indexPath.row else {
            DDLogError("You may not scroll to this row")
            return
        }
        
        let scrollToIndexPathIsVisible = tableView.indexPathsForVisibleRows?.contains(indexPath) ?? false
        
        // This is not ideal since we might still jump a bit after the first scroll (see below)
        // But if the first scroll is successful the second one might not do anything at all and
        // we'll miss the chance to update our alpha.
        scrollPositionRestoreFinished = true
        
        tableView.scrollToRow(at: indexPath, at: scrollPosition, animated: animated)
        
        // At this point we are at the correct contentOffset for showing the cell at the very bottom
        // however the tableView might change its contentSize due to newly rendered cells whose exact height
        // (which was previously determined as automatic) is only now known causing the new contentOffset to be
        // incorrect
        //
        // To alleviate this issue we do another layout pass on tableView (which in general should do nothing) and
        // scroll to bottom again.
        //
        // This is especially noticeable because when opening chat view at the very bottom, the
        // bottommost cell will be slightly cut
        // off if this layout pass isn't present.
        tableView.layoutIfNeeded()
        
        // If we are already approximately at `indexPath` (i.e. it is visible on screen) we might loose the correct
        // scroll position for unknown reasons.
        // `setContentOffset` called from UITableView `scrollToRow` is incorrect on the second invocation of
        // `scrollToRow`. It is unclear why but causes the scroll position to jump up by ca. 400 points. This avoids
        // this issue.
        guard scrollToIndexPathIsVisible else {
            tableView.scrollToRow(at: indexPath, at: scrollPosition, animated: animated)
            return
        }
    }

    // MARK: Jump
    
    /// Jumps to the given messageID, the jump can either scroll in an animated way or be abrupt and highlight the
    /// jumped to cell for a short amount of time (will set highlight and unset highlight after a short time interval).
    /// - Parameters:
    ///   - messageID: A valid messageID
    ///   - animated: whether the jump is scrolled/animated
    ///   - highlight: whether the cell should be highlighted for a short amount of time after jumping has been
    ///                approximately completed
    func jump(to messageID: Data, animated: Bool, highlight: Bool = false) {
        jump(to: messageID, animated: animated) { message in
            guard highlight else {
                return
            }
            
            guard let message else {
                DDLogError("Message was unexpectedly nil")
                return
            }
            
            guard !self.dataSource.isSelected(objectID: message.objectID) else {
                DDLogWarn("Message is already selected so we don't highlight")
                return
            }
            
            DispatchQueue.main.asyncAfter(
                deadline: .now() + ChatViewConfiguration.ChatBubble.HighlightedAnimation.highlightDelayAfterScroll
            ) { [weak self] in
                
                guard let indexPath = self?.dataSource
                    .indexPath(for: ChatViewDataSource.CellType.message(objectID: message.objectID)) else {
                    DDLogWarn("Couldn't get indexPath")
                    return
                }
                
                guard let cell = self?.tableView.cellForRow(
                    at: indexPath
                ) as? ChatViewBaseTableViewCell else {
                    DDLogWarn("Couldn't get cell")
                    return
                }
                cell.blinkCell(
                    duration: ChatViewConfiguration.ChatBubble.HighlightedAnimation
                        .highlightedDurationLong
                )
            }
        }
    }
    
    func jumpToAndSelect(_ messageObjectID: NSManagedObjectID) {
        isJumping = true
        
        guard let message = entityManager.entityFetcher.existingObject(
            with: messageObjectID
        ) as? BaseMessageEntity else {
            DDLogWarn(
                "Unable to load message (\(messageObjectID.uriRepresentation())) to jump to."
            )
            return
        }
        
        guard let messageDate = message.date else {
            DDLogWarn("Unable to load date for message (\(messageObjectID.uriRepresentation())) to jump to.")
            return
        }
        
        dataSource.loadMessages(around: messageDate).done { [weak self] in
            guard let indexPath = self?.dataSource.indexPath(
                for: ChatViewDataSource.CellType.message(objectID: message.objectID)
            ) else {
                DDLogWarn("Couldn't get indexPath")
                return
            }
            // This was copied from jump(to messageID:)
            // TODO: This is a workaround that will be resolved with IOS-2720
            DispatchQueue.main.async {
                self?.dataSource.deselectAllMessages()
                self?.dataSource.didSelectRow(at: indexPath)
                
                // If scrollPosition is set to `.none` it will not scroll
                self?.tableView.selectRow(at: indexPath, animated: true, scrollPosition: .top)
                
                if UIAccessibility.isVoiceOverRunning, let cell = self?.tableView.cellForRow(at: indexPath) {
                    UIAccessibility.post(notification: .layoutChanged, argument: cell)
                }
            }
        }
        .ensure {
            self.completeJumping()
        }
        .catch { error in
            DDLogWarn("Unable to load messages for jumpToAndSelect: \(error)")
        }
    }
    
    private func completeJumping(
        to message: BaseMessageEntity? = nil,
        completion: ((BaseMessageEntity?) -> Void)? = nil
    ) {
        // Wait a bit until scrolling should be somewhat completed
        DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(400)) {
            self.isJumping = false
            completion?(message)
        }
    }
    
    private func jumpToTop() {
        isJumping = true
        dataSource.loadOldestMessages().ensure {
            self.view.layoutIfNeeded()

            if self.tableView.numberOfSections > 0,
               self.tableView.numberOfRows(inSection: 0) > 0 {
                self.tableView.scrollToRow(
                    at: IndexPath(row: 0, section: 0),
                    at: .top,
                    animated: true
                )
            }

            self.completeJumping { _ in
                self.userIsAtBottomOfTableView = self.isAtBottomOfView
            }
        }
        .catch { error in
            DDLogWarn("Unable to load messages for jumpToTop: \(error)")
        }
    }
    
    private func jumpToBottom() {
        isJumping = true
        dataSource.loadNewestMessages().ensure {
            self.view.layoutIfNeeded()
            self.isScrolling = true
            self.scrollToBottom(animated: true, force: true)
            self.completeJumping { _ in
                self.userIsAtBottomOfTableView = true
            }
        }
        .catch { error in
            DDLogWarn("Unable to load messages for jumpToBottom: \(error)")
        }
    }
    
    private func jump(toUnreadMessage unreadMessageMessageID: Data, completion: ((BaseMessageEntity?) -> Void)? = nil) {
        dataSource.initialSetupCompleted = true
        let position: UITableView.ScrollPosition = .top
        jump(to: unreadMessageMessageID, at: position, completion: { baseMessage in
            completion?(baseMessage)
            self.userIsAtBottomOfTableView = self.isAtBottomOfView
        })
    }
    
    private func jumpContentLoadUnsafe(to unreadMessageMessageObjectID: NSManagedObjectID, animated: Bool = false) {
        isJumping = true
        defer { completeJumping() }
        
        let scrollPosition: UITableView.ScrollPosition = .top
        
        if let indexPath = dataSource.indexPath(
            for: ChatViewDataSource.CellType.message(objectID: unreadMessageMessageObjectID)
        ) {
            contentSizeChangeSafeScrollToRow(at: indexPath, at: scrollPosition, animated: animated)
        }
        else {
            DDLogError("\(#function): Timing Error. Message was not yet applied")
        }
    }
    
    private func jumpContentLoadUnsafeAndSelect(to searchedMessageObjectID: NSManagedObjectID, animated: Bool = false) {
        jumpContentLoadUnsafe(to: searchedMessageObjectID)
        dataSource.initialSetupCompleted = true
        
        guard let indexPath = dataSource.indexPath(
            for: ChatViewDataSource.CellType.message(objectID: searchedMessageObjectID)
        ) else {
            DDLogWarn("Couldn't get indexPath")
            isJumping = false
            dataSource.initialSetupCompleted = true
            
            tableView.alpha = 1.0
            if UIAccessibility.isVoiceOverRunning {
                UIView.setAnimationsEnabled(true)
            }
            return
        }
        
        DispatchQueue.main.async {
            self.dataSource.deselectAllMessages()
            self.dataSource.didSelectRow(at: indexPath)
            
            // If scrollPosition is set to `.none` it will not scroll
            self.tableView.selectRow(at: indexPath, animated: true, scrollPosition: .top)
            
            self.isJumping = false
            self.dataSource.initialSetupCompleted = true
            
            self.tableView.alpha = 1.0
            if UIAccessibility.isVoiceOverRunning {
                UIView.setAnimationsEnabled(true)
                if let cell = self.tableView.cellForRow(at: indexPath) {
                    UIAccessibility.post(notification: .layoutChanged, argument: cell)
                }
            }
        }
    }
    
    private func jump(
        to messageID: Data,
        at scrollPosition: UITableView.ScrollPosition = .top,
        animated: Bool = false,
        completion: ((BaseMessageEntity?) -> Void)? = nil,
        onError: (() -> Void)? = nil
    ) {
        guard let message = entityManager.entityFetcher.message(
            with: messageID,
            in: conversation
        ) else {
            DDLogWarn(
                "Unable to load message (\(messageID.hexString)) to jump to. It doesn't exist or is not in this conversation."
            )
            return
        }
        
        jump(to: message, at: scrollPosition, animated: animated, completion: completion, onError: onError)
    }
    
    private func jump(
        to message: BaseMessageEntity,
        at scrollPosition: UITableView.ScrollPosition = .top,
        animated: Bool,
        completion: ((BaseMessageEntity?) -> Void)?,
        onError: (() -> Void)? = nil
    ) {
        isJumping = true
        
        guard let messageDate = message.date else {
            DDLogWarn("Unable to load date for message (\(message.objectID.uriRepresentation())) to jump to.")
            return
        }
        
        dataSource.loadMessages(around: messageDate).done {
            // TODO: This is a workaround that will be resolved with IOS-2720
            // With a one second delay we aim to be more sure that the loaded messages have been actually applied in
            // the tableView
            DispatchQueue.main.async {
                if let indexPath = self.dataSource
                    .indexPath(for: ChatViewDataSource.CellType.message(objectID: message.objectID)) {
                    self.contentSizeChangeSafeScrollToRow(at: indexPath, at: scrollPosition, animated: animated)
                    self.completeJumping(to: message, completion: completion)
                }
                else {
                    DDLogError("Timing Error: Message was not yet applied")
                    self.isJumping = false
                    onError?()
                }
            }
        }.catch { _ in
            DDLogError("Initial loading was cancelled")
        }
    }
    
    private func jumpToUnreadMessage() {
        DispatchQueue.global(qos: .userInteractive).async { [self] in
            // `synchronousReconfiguration` may not run on the main thread
            unreadMessagesSnapshot.synchronousReconfiguration()
            
            entityManager.perform {
                if let newestUnreadMessage = self.unreadMessagesSnapshot.unreadMessagesState?
                    .oldestConsecutiveUnreadMessage,
                    let message = self.entityManager.entityFetcher
                    .managedObject(with: newestUnreadMessage) as? BaseMessageEntity {
                    // We either succeed right away or do this on the next snapshot apply
                    let messageID = message.id
                    DDLogVerbose("willEnterForegroundCompletion setup")
                    self.willEnterForegroundCompletion = { [weak self] completion in
                        self?.jump(to: messageID, animated: true, completion: { _ in
                            completion?()
                        })
                    }
                    
                    self.jump(to: messageID, animated: true) { _ in
                        self.willEnterForegroundCompletion = nil
                    }
                }
            }
        }
    }
    
    // MARK: Bottom information
    
    /// We scrolled all the way to the bottom of the view
    ///
    /// - Note: This does not mean there are more messages that can be loaded at the bottom.
    private var isAtBottomOfView: Bool {
        let bottomOffset = insetAdjustedBottomOffset(for: tableView)
        
        // Add some threshold if we are really close to bottom
        if bottomOffset < isAtBottomOfViewThreshold {
            return true
        }
        
        return false
    }
    
    private func insetAdjustedBottomOffset(for scrollView: UIScrollView) -> CGFloat {
        #if DEBUG
            if !dataSource.initialSetupCompleted {
                DDLogWarn(
                    "Initial setup was not yet completed `insetAdjustedBottomOffset` might return unreliable results."
                )
            }
        #endif
        
        let currentContentOffsetY = scrollView.contentOffset.y
        // Content offset is below the top adjusted content inset so we only need to adjust for the bottom inset
        let currentAdjustedHeight = scrollView.frame.height - scrollView.adjustedContentInset.bottom
        let currentContentHeight = scrollView.contentSize.height

        return currentContentHeight - (currentContentOffsetY + currentAdjustedHeight)
    }
    
    // MARK: Scroll to bottom button & chat bar and Toolbar
    
    private func showScrollToBottomButtonAndChatBar() {
        
        chatBarCoordinator.showChatBar()
        updateContentInsets()
        
        if !isAtBottomOfView {
            scrollToBottomButton.isHidden = false
        }
    }
    
    private func hideScrollToBottomButtonAndChatBar() {
        updateContentInsets(force: true)
        chatBarCoordinator.hideChatBar()
        scrollToBottomButton.isHidden = true
    }
    
    private func configureToolbar() {
        // This is needed for search results. Otherwise the toolbar has no background
        let defaultAppearance = UIToolbarAppearance()
        defaultAppearance.configureWithDefaultBackground()
        navigationController?.toolbar.scrollEdgeAppearance = defaultAppearance
    }
    
    private func showToolbar(animated: Bool) {
        navigationController?.setToolbarHidden(false, animated: animated)
    }
    
    private func hideToolbar(animated: Bool) {
        navigationController?.setToolbarHidden(true, animated: animated)
    }
}

// MARK: - UITableViewDelegate

extension ChatViewController: UITableViewDelegate {
    
    // MARK: Section headers
    
    func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
        UITableView.automaticDimension
    }
    
    func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
        guard dataSource.snapshot().sectionIdentifiers.count > section else {
            return nil
        }
        
        let headerView: ChatViewSectionTableViewHeaderView? = tableView.dequeueHeaderFooter()
        headerView?.title = dataSource.snapshot().sectionIdentifiers[section]
        
        return headerView
    }
    
    // MARK: Cell height caching
    
    func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        // Cache height calculated by the system, select if table view is editing and cell was selected before
        if let cellType = dataSource.itemIdentifier(for: indexPath),
           case let ChatViewDataSource.CellType.message(objectID: objectID) = cellType {
            cellHeightCache.storeCellHeight(cell.frame.height, for: objectID)
            
            if userInterfaceMode == .multiselect,
               dataSource.isSelected(objectID: objectID) {
                tableView.selectRow(at: indexPath, animated: true, scrollPosition: .none)
            }
            
            dataSource.startObservingMessage(with: objectID)
        }
        
        if !firstCellShown {
            firstCellShown = true
            let endTime = CACurrentMediaTime()
            DDLogVerbose("ChatViewController duration init to first cell shown \(endTime - initTime) s")
        }
        
        if dataSource.initialSetupCompleted,
           let baseCell = cell as? ChatViewBaseTableViewCell,
           !chatViewShowsEmojiLongPressInfoTip,
           let reactionsManager = baseCell.reactionsManager,
           !reactionsManager.currentReactions.isEmpty {
            chatViewShowsEmojiLongPressInfoTip = true
            baseCell.showReactionsTip()
        }
    }
    
    func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        if let cellType = dataSource.itemIdentifier(for: indexPath),
           case let ChatViewDataSource.CellType.message(objectID: objectID) = cellType {
            dataSource.stopObservingMessage(with: objectID)
        }
    }
    
    func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
        guard let cellType = dataSource.itemIdentifier(for: indexPath),
              case let ChatViewDataSource.CellType.message(objectID: objectID) = cellType else {
            return UITableView.automaticDimension
        }
        
        // Try caches
        
        if let height = cellHeightCache.cellHeight(for: objectID) {
            return height
        }
        
        guard ChatViewConfiguration.enableEstimatedCellHeightCaching else {
            return UITableView.automaticDimension
        }
        
        guard dataSource.initialSetupCompleted else {
            return UITableView.automaticDimension
        }
        
        if let estimatedHeight = cellHeightCache.estimatedCellHeight(for: objectID) {
            return estimatedHeight
        }
        
        // Calculate estimated height
        
        guard let message = dataSource.message(for: objectID) else {
            return UITableView.automaticDimension
        }
        
        let tableViewWidth = tableView.frame.width
        
        // Used to solve breaking constraints before table view is visible
        if tableViewWidth == 0 {
            return UITableView.automaticDimension
        }
        
        let neighbors = ChatViewDataSource.neighbors(of: objectID, in: tableView)
        
        let estimatedHeight = ChatViewCellSizeProvider.estimatedCellHeight(
            for: message,
            with: neighbors,
            and: tableViewWidth
        )
        cellHeightCache.storeEstimatedCellHeight(estimatedHeight, for: objectID)
        
        return estimatedHeight
    }
    
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        guard ChatViewConfiguration.enableCellHeightCaching else {
            return UITableView.automaticDimension
        }
        
        guard let cell = dataSource.itemIdentifier(for: indexPath),
              case let ChatViewDataSource.CellType.message(objectID: objectID) = cell else {
            return UITableView.automaticDimension
        }
        
        if let size = cellHeightCache.cellHeight(for: objectID) {
            return size
        }
        
        // With no cached value we let the system do the correct calculation
        return UITableView.automaticDimension
    }
    
    // MARK: - Multi-Select
    
    public func startMultiselect(with messageObjectID: NSManagedObjectID) {
        userInterfaceMode = .multiselect
        
        guard let indexPath = dataSource.indexPath(for: .message(objectID: messageObjectID)) else {
            DDLogWarn("No index path found for selected cell")
            return
        }
        
        dataSource.didSelectRow(at: indexPath)
        tableView.selectRow(at: indexPath, animated: true, scrollPosition: .none)
        updateSelectionTitle()

        hideKeyboard()
    }
    
    @objc private func endMultiselect() {
        userInterfaceMode = .default
    }
    
    func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
        // This is needed so a cell cannot be accidentally selected by tapping in another mode than multiselect as long
        // as `tableView.allowsSelection` is enabled.
        
        guard userInterfaceMode == .multiselect else {
            return nil
        }
        
        return indexPath
    }
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        dataSource.didSelectRow(at: indexPath)
        updateSelectionTitle()
    }
    
    func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
        dataSource.didDeselectRow(at: indexPath)
        updateSelectionTitle()
    }
    
    private func updateSelectionTitle() {
        
        guard dataSource.hasSelectedObjectIDs() else {
            deleteBarButton.title = #localize("messages_delete_all_button")
            return
        }
        
        let count = dataSource.selectedObjectIDsCount()
        deleteBarButton.title = String.localizedStringWithFormat(
            #localize("delete_n"),
            count
        )
    }
    
    // MARK: ContextMenu
    
    func tableView(
        _ tableView: UITableView,
        contextMenuConfigurationForRowAt indexPath: IndexPath,
        point: CGPoint
    ) -> UIContextMenuConfiguration? {

        guard userInterfaceMode == .default else {
            return nil
        }

        // We only show the iOS ContextMenu for `ChatViewSystemMessageTableViewCell`s, for others we show our custom
        // implementations. See `presentContextMenu()` below.
        guard let cell = tableView.cellForRow(at: indexPath) as? ChatViewMessageActions,
              cell is ChatViewSystemMessageTableViewCell else {
            return nil
        }

        return cell.buildContextMenu(at: indexPath)
    }
    
    func tableView(
        _ tableView: UITableView,
        willDisplayContextMenu configuration: UIContextMenuConfiguration,
        animator: UIContextMenuInteractionAnimating?
    ) {
        willShowContextMenu()
    }
    
    func tableView(
        _ tableView: UITableView,
        willEndContextMenuInteraction configuration: UIContextMenuConfiguration,
        animator: UIContextMenuInteractionAnimating?
    ) {
        willHideContextMenu(animator: animator)
    }
    
    func tableView(
        _ tableView: UITableView,
        previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration
    ) -> UITargetedPreview? {
        
        guard let indexPath = configuration.identifier as? IndexPath,
              let cell = tableView.cellForRow(at: indexPath),
              let systemMessageCell = cell as? ChatViewSystemMessageTableViewCell else {
            return nil
        }
        
        let parameters = UIPreviewParameters()
        parameters.backgroundColor = .clear
        parameters.visiblePath = systemMessageCell.backgroundBorderPath
        
        return UITargetedPreview(view: systemMessageCell.contentView, parameters: parameters)
    }
    
    func tableView(
        _ tableView: UITableView,
        previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration
    ) -> UITargetedPreview? {
        guard let indexPath = configuration.identifier as? IndexPath,
              let cell = tableView.cellForRow(at: indexPath),
              let systemMessageCell = cell as? ChatViewSystemMessageTableViewCell else {
            return nil
        }
        
        let parameters = UIPreviewParameters()
        parameters.backgroundColor = .clear
        parameters.visiblePath = systemMessageCell.backgroundBorderPath
        
        return UITargetedPreview(view: systemMessageCell.contentView, parameters: parameters)
    }
    
    func presentContextMenu(cell: ChatViewBaseTableViewCell) {
        willShowContextMenu()
        
        guard let snapshot = cell.chatBubbleView.resizableSnapshotView(
            from: cell.chatBubbleView.bounds,
            afterScreenUpdates: false,
            withCapInsets: .zero
        ) else {
            return
        }
        guard let actionsCell = cell as? ChatViewMessageActions else {
            return
        }
        
        contextMenuController.presentContextMenu(
            chatViewController: self,
            for: cell,
            on: view,
            with: snapshot,
            snapshotBounds: cell.chatBubbleView.convert(cell.chatBubbleView.bounds, to: view.window),
            chatViewBounds: view.safeAreaLayoutGuide.layoutFrame,
            isOwnMessage: cell.messageIsOwnMessage,
            showEmojiPicker: cell.showEmojiPickerContextMenu,
            reactionsManager: cell.reactionsManager,
            actions: actionsCell.messageActionsSections()
        )
    }
    
    func dismissContextMenu(showEmojiPicker: Bool = false, for reactionsManager: ReactionsManager) {
        Task { @MainActor in
            contextMenuController.dismiss { [weak self] in
                if showEmojiPicker {
                    self?.present(EmojiPicker.sheet(with: reactionsManager), animated: true)
                }
            }
        }
    }
    
    func willShowContextMenu() {
        if chatBarCoordinator.chatBar.isTextViewFirstResponder {
            insetUpdatesBlockedByContextMenu = true
        }
        
        UIView.animate(
            withDuration: ChatViewConfiguration.contextMenuBackgroundShowHideAnimationDuration
        ) {
            self.hideScrollToBottomButtonAndChatBar()
            self.chatBarCoordinator.chatBar.resignFirstResponder()
        }
    }
    
    func willHideContextMenu(animator: UIContextMenuInteractionAnimating?) {
        // Show keyboard again if it was before, and if there is no modal presented
        if insetUpdatesBlockedByContextMenu {
            if presentedViewController == nil {
                chatBarCoordinator.chatBar.becomeFirstResponder()
            }
            insetUpdatesBlockedByContextMenu = false
        }
        
        animator?.addCompletion {
            while !self.contextMenuActionsQueue.isEmpty {
                self.contextMenuActionsQueue.removeFirst()()
            }
        }
        
        UIView.animate(
            withDuration: ChatViewConfiguration.contextMenuBackgroundShowHideAnimationDuration
        ) {
            if self.userInterfaceMode != .multiselect {
                self.showScrollToBottomButtonAndChatBar()
            }
            else {
                self.chatBarCoordinator.chatBar.resignFirstResponder()
                self.updateContentInsets()
            }
        }
    }
    
    func showExistingReactions(reactionsManager: ReactionsManager) {
        present(
            EmojiReactionModalViewController.sheet(reactionsManager),
            animated: true
        )
    }
    
    func showReactionsView() {
        checkToShowReactionsTip()
    }
}

// MARK: - ChatViewDataSourceDelegate

extension ChatViewController: ChatViewDataSourceDelegate {
    func willApplySnapshot(currentDoesIncludeNewestMessage: Bool) -> (() -> Void)? {
        DDLogVerbose("willApplySnapshot")
        
        isApplyingSnapshot = true
        
        // Don't scroll to bottom if we jump from the bottom
        guard !isJumping else {
            shouldScrollToBottomAfterNextSnapshotApply = false
            return nil
        }
        
        // When entering a chat after tapping on a notification we might show the passcode lock screen first. In that
        // case our own view has the correct size but the tableView below isn't correctly sized.
        // Note that despite this, just calling `layoutIfNeeded` on `tableView` is **not** enough.
        // This will cause a crash because the tableView to attempt to initialize an ordered set with capacity
        // 18446744073709551615, causing a crash with the following error:
        // -[__NSPlaceholderOrderedSet initWithCapacity:]: capacity (18446744073709551615) is ridiculous'
        //
        // In the regular case this shouldn't change anything because we're not animating at this point and the layout
        // should be stable.
        view.layoutIfNeeded()
                
        shouldScrollToBottomAfterNextSnapshotApply = currentDoesIncludeNewestMessage && isAtBottomOfView
            && dataSource.initialSetupCompleted && isApplicationInForeground
        
        let visibleAndScrolledToBottom = shouldScrollToBottomAfterNextSnapshotApply && shouldMarkMessagesAsRead
        
        _ = unreadMessagesSnapshot.tick(willStayAtBottomOfView: visibleAndScrolledToBottom)
        
        guard shouldScrollToBottomAfterNextSnapshotApply else {
            return nil
        }
        
        return { [weak self] in
            guard let self else {
                return
            }
            
            guard !isUserInteractiveScroll, !isDragging else {
                // The user is interacting with the chat, don't auto scroll
                //
                // In theory the user could have scrolled and stop between the call to
                // `willApplySnapshot(currentDoesincludeNewestMessage:)` and the closure call
                // but we assume that this doesn't happen in practice since snapshot apply
                // is reasonably fast (a few hundred ms).
                return
            }
            
            scrollToBottom(animated: true, force: true)
        }
    }
    
    func didApplySnapshot(delegateScrollCompletion: @escaping (() -> Void)) {
        DDLogVerbose("\(#function)")
        
        defer {
            isApplyingSnapshot = false
        }
        
        /// #  Scroll Behaviour
        /// ## Determining whether we can scroll after knowing whether we should
        ///
        /// `shouldScrollToBottomAfterNextSnapshotApply` cannot actually determine whether we are able to scroll here.
        /// Determining the ability to scroll is important for the `scrollCompletion` callback because it is only called
        /// if we actually scroll and not if we request to scroll with `scrollToBottom` but do not scroll because we are
        /// still at the bottom
        ///
        /// If we indeed need to scroll then the offset to the bottom has changed enough after applying the last
        /// snapshot (i.e. between `willApplySnapshot` and `didApplySnapshot`) such that we are not at the bottom
        /// anymore (as a new cell has been added).
        /// We use this to determine whether we will scroll on calling `scrollToBottom` and only scroll in that case.
        let hasNewlyAddedCellsInThisSnapshot = !isAtBottomOfView && willEnterForegroundCompletion == nil
                        
        if let willEnterForegroundCompletion {
            DDLogVerbose("\(#function) willEnterForegroundCompletion != nil")
            willEnterForegroundCompletion {
                self.willEnterForegroundCompletion = nil
            }
        }
        else {
            DDLogVerbose("\(#function) willEnterForegroundCompletion == nil")
        }
        
        if shouldScrollToBottomAfterNextSnapshotApply,
           hasNewlyAddedCellsInThisSnapshot,
           !isUserInteractiveScroll {
            DDLogVerbose("\(#function) Scrolling animated")
            
            DDLogVerbose("[ChatViewDataSourceDelegate] \(#function)  Scrolling animated")
            view.layoutIfNeeded()
            scrollToQueue.async { [self] in
                
                if dataSource.initialSetupCompleted {
                    DDLogVerbose("\(#function) dataSource.snapshotApplyLock.lock()")
                    dataSource.snapshotApplyLock.lock()
                }
                
                DispatchQueue.main.async {
                    if self.scrollCompletion != nil, ChatViewConfiguration.strictMode {
                        fatalError("\(#function) Cancelling previous scroll completion")
                    }
                    self.scrollCompletion = { [weak self] in
                        delegateScrollCompletion()
                        self?.scrollToQueue.sync {
                            self?.dataSource.snapshotApplyLock.unlock()
                        }
                    }
                    self.isScrolling = true
                    self.isProgrammaticallyScrollingToBottom = true
                    
                    self.scrollToBottom(animated: true, force: true)
                }
            }
        }
        else {
            DDLogVerbose("\(#function) DataSource hasn't added new messages we are not requesting to scroll")
            delegateScrollCompletion()
            if scrollCompletion != nil, ChatViewConfiguration.strictMode {
                fatalError("\(#function) Previous scroll is still pending")
            }
            scrollCompletion = nil
        }
        
        if conversation.isGroup {
            updateOpenBallotsButton()
        }
    }
    
    func lastMessageChanged(messageIdentifier: NSManagedObjectID) {
        DDLogVerbose("\(#function)")
        DispatchQueue.main.async {
            guard let message = self.entityManager.entityFetcher.managedObject(
                with: messageIdentifier
            ) as? BaseMessageEntity,
                message.isOwnMessage else {
                return
            }
            
            self.unreadMessagesSnapshot.resetState()
        }
    }
    
    func willDeleteMessage(with objectID: NSManagedObjectID) {
        // Keep track of deleted messages
        dataSource.deletedMessagesObjectIDs.insert(objectID)
    }
    
    func didDeleteMessages() {
        unreadMessagesSnapshot.resetState()
        
        DispatchQueue.main.asyncAfter(
            deadline: .now() + .milliseconds(ChatViewConfiguration.UnreadMessageLine.timeBeforeDisappear)
        ) {
            self.dataSource.removeUnreadMessageLine()
        }
    }
    
    func checkToShowReactionsTip() {
        guard !ProcessInfoHelper.isRunningForScreenshots,
              !chatViewShowsEmojiLongPressInfoTip else {
            return
        }

        for cell in tableView.visibleCells {
            guard let baseCell = cell as? ChatViewBaseTableViewCell,
                  let reactionsManager = baseCell.reactionsManager,
                  !reactionsManager.currentReactions.isEmpty,
                  tableView.bounds.contains(baseCell.frame) else {
                continue
            }
            
            chatViewShowsEmojiLongPressInfoTip = true
            baseCell.showReactionsTip()
            return
        }
    }
}

// MARK: - Scroll position restoration

extension ChatViewController {
    /// Save current scroll position
    ///
    /// Store the offset from top of a visible cell from the content offset (positive if the cell top is in the visible
    /// area).
    private func saveCurrentScrollPosition() {
        chatScrollPositionProvider.removeSavedPosition(for: conversation)
        
        // Check if we are at the bottom of the view
        // We expect that when we scrolled to the bottom of the view that all messages (at the bottom) are loaded until
        // this point and we are at the last message. If this assumption fails we will restore at the bottom next time
        // the chat is opened.
        guard !isAtBottomOfView else {
            DDLogVerbose("We are at the bottom of the Table View")
            return
        }
        
        // Take a random visible cell that conforms to `ChatScrollPositionDataProvider`
        let matchingCell = tableView.visibleCells.first { $0 is ChatScrollPositionDataProvider }
        
        guard let cell = matchingCell as? ChatScrollPositionDataProvider else {
            assertionFailure("No visible ChatScrollPositionDataProvider found")
            return
        }
        
        // Gather all needed data
        let offsetFromTop = cell.minY - tableView.contentOffset.y
        
        guard let messageObjectID = cell.messageObjectID,
              let messageDate = cell.messageDate
        else {
            assertionFailure("Cell has no message information")
            return
        }
        
        chatScrollPositionProvider.save(
            ChatScrollPositionInfo(
                offsetFromTop: offsetFromTop,
                messageObjectIDURL: messageObjectID.uriRepresentation(),
                messageDate: messageDate
            ),
            for: conversation
        )
    }
    
    /// Restore scroll position
    ///
    /// Restore the content offset by taking the new cell top offset and subtracting the stored offset.
    /// If no previous position is available or anything fails we go to the bottom.
    private func restoreScrollPosition() {
        // Just before the the last call in this method completes (i.e. also closures)
        // `dataSource.initialSetupCompleted` should be set to `true` to complete the setup and allow loading of more
        // messages.
        
        defer {
            // There will be no scroll completion callback if we are already at the bottom of the view
            self.tableView.alpha = 1.0
            if UIAccessibility.isVoiceOverRunning {
                UIView.setAnimationsEnabled(true)
            }
        }
        
        guard let previousScrollPosition = chatScrollPositionProvider.chatScrollPosition(
            for: conversation
        ) else {
            DDLogVerbose("No previous scroll position. Scroll to bottom.")
            
            guard tableView.numberOfSections > 0 else {
                dataSource.initialSetupCompleted = true
                return
            }
            
            // The newest messages should be loaded so we just scroll to the bottom to optimize
            // performance.
            view.layoutIfNeeded()
            isScrolling = true
            scrollToBottom(animated: false)
            dataSource.initialSetupCompleted = true
            userIsAtBottomOfTableView = true
            
            if isAtBottomOfView {
                // There will be no scroll completion callback if we are already at the bottom of the view
                tableView.alpha = 1.0
                if UIAccessibility.isVoiceOverRunning {
                    UIView.setAnimationsEnabled(true)
                }
            }
            
            return
        }
        
        // The correct set of messages should be loaded at this point
        
        // Exit function if some part of the restoration fails
        func restoreAtBottom() {
            DDLogVerbose("Restore at bottom")
            
            chatScrollPositionProvider.removeSavedPosition(for: conversation)
            
            // Ensure that we have the bottom data & then scroll to bottom
            dataSource.loadNewestMessages().done {
                self.view.layoutIfNeeded()
                self.isScrolling = true
                self.scrollToBottom(animated: false)
                self.dataSource.initialSetupCompleted = true
                self.userIsAtBottomOfTableView = true
                
                if self.isAtBottomOfView {
                    // There will be no scroll completion callback if we are already at the bottom of the view
                    self.tableView.alpha = 1.0
                    if UIAccessibility.isVoiceOverRunning {
                        UIView.setAnimationsEnabled(true)
                    }
                }
                
            }.catch { err in
                DDLogError("Could not load messages at bottom due to an error: \(err.localizedDescription)")
                assertionFailure()
            }
        }
        
        guard let messageObjectID = entityManager.managedObjectID(
            forURIRepresentation: previousScrollPosition.messageObjectIDURL
        ) else {
            DDLogWarn("Unable to restore message object ID")
            restoreAtBottom()
            userIsAtBottomOfTableView = true
            return
        }
        
        guard let indexPath = dataSource.indexPath(for: ChatViewDataSource.CellType.message(objectID: messageObjectID))
        else {
            DDLogWarn("Message not found in data source")
            restoreAtBottom()
            userIsAtBottomOfTableView = true
            return
        }
        
        // Needed to make the cell available in `cellForRow(at:)`
        view.layoutIfNeeded()
        tableView.scrollToRow(at: indexPath, at: .none, animated: false)
        
        scrollPositionRestoreFinished = true
        
        tableView.scrollToRow(at: indexPath, at: .none, animated: false)
        
        // Get cell to adjust offset accordingly
        guard let cell = tableView.cellForRow(at: indexPath) else {
            assertionFailure("Unable to find cell for scroll position restoration")
            restoreAtBottom()
            userIsAtBottomOfTableView = true
            return
        }
        
        // This leads to a little change of offset if restoration happens in another orientation.
        // TODO: (IOS-2014) Is this still a problem? Should we accommodate for that?
        // TODO: Also go through the protocol for correct behavior
        let newOffset = cell.frame.minY - previousScrollPosition.offsetFromTop
        tableView.contentOffset.y = newOffset
        
        chatScrollPositionProvider.removeSavedPosition(for: conversation)
        dataSource.initialSetupCompleted = true
        
        userIsAtBottomOfTableView = isAtBottomOfView
    }
}

// MARK: - UIScrollViewDelegate

extension ChatViewController: UIScrollViewDelegate {
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        DDLogVerbose("\(#function)")
        // Check if we should load more messages...
        
        DDLogVerbose("\(#function) \(scrollView.contentOffset.y)")
        
        if scrollPositionRestoreFinished {
            tableView.alpha = 1.0
            if UIAccessibility.isVoiceOverRunning {
                UIView.setAnimationsEnabled(true)
            }
        }
        
        // Wait for initial setup to complete
        guard dataSource.initialSetupCompleted else {
            return
        }
        
        // If we cannot load new messages just don't check if we should
        guard !dataSource.isLoadingNewMessages else {
            return
        }
        
        // Don't load more messages while were loading messages and scrolling to the jump position
        guard !isJumping else {
            return
        }
        
        // Do not reload on dragging as this might throw the scroll position off
        // `scrollViewWillEndDragging` does an additional call to us to make sure we load messages after dragging.
        guard !isDragging else {
            return
        }
        
        // Do not load new messages if our last load request was not yet fulfilled by an applied snapshot
        guard !isLoading else {
            return
        }
        
        // As we normally scroll from bottom to top we first check the top...
        
        // Not really needed, but allows for more consistency across all devices
        let insetAdjustedTopOffset: CGFloat = scrollView.contentOffset.y + scrollView.adjustedContentInset.top
        
        // If we are at the very bottom we never want to load messages at the top since we have chosen
        // `topOffsetThreshold` and the number of messages that are loaded appropriately.
        if insetAdjustedTopOffset < topOffsetThreshold, !isAtBottomOfView {
            isLoading = true
            
            // Needed for the WORKAROUND after loading is completed
            let currentHeight = scrollView.contentSize.height
            // If offset is less than 0 due to elasticity of the scroll view, assume 0
            let initialContentOffSet = max(tableView.contentOffset.y, 0)
            
            dataSource.loadMessagesAtTop().done {
                DDLogVerbose("loadMessagesAtTop completed")
            }
            .ensure {
                // `contentSize` may not be up to date immediately; we reschedule this for a later point
                DispatchQueue.main.async {
                    // WORKAROUND (IOS-2865)
                    // This resets the scroll position to something (currently 100) below the very top of the tableView
                    // before the last snapshot apply as long as there where some new messages loaded (the content size
                    // increases).
                    // This is useful if we managed to scroll to the top without loading older messages. Without this
                    // workaround the new messages would then be added at the bottom instead of the top of the exiting
                    // messages in the view and so we basically skip all the newly loaded messaged from scrolling past
                    // and just jump to the top again. This leads to loading ALL messages at the top (or a memory
                    // overflow)
                    // WARN: This most likely only works due to accidental scheduling.
                    //
                    // In fact it causes the scroll position to be way off. This might be due to a miscalculation when
                    // calculating `newOffset` but we don't change that until we need it.
                    
                    guard initialContentOffSet < 100 else {
                        // We're not high up enough for incorrect cell insertion, don't reset scroll position
                        return
                    }
                    
                    let heightDiff = self.tableView.contentSize.height - currentHeight
                    
                    // Do not add the offset when all messages are loaded otherwise we cannot scroll all the way
                    // to the top.
                    guard heightDiff > 0 else {
                        return
                    }
                    
                    let newOffset = initialContentOffSet + heightDiff - scrollView.adjustedContentInset.top
                    self.tableView.setContentOffset(CGPoint(x: 0, y: newOffset), animated: false)
                }
                
                self.isLoading = false
            }
            .catch { error in
                DDLogError("An error occurred when loading messages at top: \(error.localizedDescription)")
            }
            
            return
        }
        
        // ...and then the bottom
        
        // Not really needed, but allows for more consistency across all devices
        let insetAdjustedBottomOffset = insetAdjustedBottomOffset(for: scrollView)
        
        if insetAdjustedBottomOffset < bottomOffsetThreshold {
            isLoading = true
            
            dataSource.loadMessagesAtBottom().done { _ in
                DDLogVerbose("loadMessagesAtBottom completed")
            }.ensure {
                self.isLoading = false
            }
            .catch { error in
                DDLogError("An error occurred when loading messages at bottom: \(error.localizedDescription)")
            }
            
            return
        }
        
        // Check whether we're at the bottom of the view
        if dataSource.initialSetupCompleted {
            if isAtBottomOfView {
                userIsAtBottomOfTableView = true
            }
            else {
                userIsAtBottomOfTableView = false
            }
        }
    }
    
    // The functions below are used to detect when the scroll view is scrolling, we must not update content insets
    // during this.
    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        DDLogVerbose("[UIScrollViewDelegate] \(#function)")
        isScrolling = true
        isDragging = true
        isUserInteractiveScroll = true
        isProgrammaticallyScrollingToBottom = false
    }

    func scrollViewWillEndDragging(
        _ scrollView: UIScrollView,
        withVelocity velocity: CGPoint,
        targetContentOffset: UnsafeMutablePointer<CGPoint>
    ) {
        DDLogVerbose("[UIScrollViewDelegate] \(#function)")
        /// Do one last call to didScroll to make sure we are loading messages if we're supposed to.
        scrollViewDidScroll(scrollView)
    }
    
    func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        DDLogVerbose("[UIScrollViewDelegate] \(#function)")
        isDragging = false
        
        if !decelerate {
            scrollViewDidEndScrolling()
        }
        
        if dataSource.initialSetupCompleted {
            if isAtBottomOfView {
                userIsAtBottomOfTableView = true
            }
            else {
                userIsAtBottomOfTableView = false
            }
        }
        
        scrollViewDidScroll(scrollView)
    }
    
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        scrollViewDidEndScrolling()
    }
    
    func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
        DDLogVerbose("[UIScrollViewDelegate] \(#function)")
        scrollViewDidEndScrolling()
    }
    
    /// This isn't a delegate method
    func scrollViewDidEndScrolling() {
        DDLogVerbose("[UIScrollViewDelegate] \(#function)")
        isProgrammaticallyScrollingToBottom = false
        isScrolling = false
        
        if dataSource.initialSetupCompleted {
            if isAtBottomOfView {
                userIsAtBottomOfTableView = true
            }
            else {
                userIsAtBottomOfTableView = false
            }
        }
        
        isUserInteractiveScroll = isDragging
        
        // We do this immediately after scrolling is done to avoid cells resizing themselves in the meantime causing
        // animation glitches
        updateContentInsets()
        
        // We keep a slight delay before unlocking the lock and calling scrollCompletion
        // This isn't strictly necessary but feels like it could solve some of our issues
        let delay = ChatViewConfiguration.ScrollCompletionBehavior.completionBlockCallDelay
        let deadline: DispatchTime = .now() + .milliseconds(delay)
        DispatchQueue.main.asyncAfter(deadline: deadline) {
            self.scrollCompletion?()
            self.scrollCompletion = nil
        }
    }
    
    // TODO: (IOS-2014) Maybe store scroll position after scrolling finished for a bit (and no new scrolling started).
    //                  Maybe with a timer.
    
    // Scroll to bottom when tapping on top (& scroll view is not at top)
    func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
        jumpToTop()
        return false
    }
    
    override func viewSafeAreaInsetsDidChange() {
        DDLogVerbose("\(view.safeAreaInsets)")
        
        // This fixes an issue where the offset was reported wrongly after a half dismiss swipe causing the chat bar to
        // have incorrect height.
        DispatchQueue.main.async {
            self.updateContentInsets(force: true)
        }
    }

    func updateContentInsetsForce() {
        updateContentInsets(force: true)
    }

    @objc private func keyboardWillChange(_ notification: Notification) {
        // During a rotation the keyboardLayoutGuide is animated by UIKit and the chat bar follows
        // smoothly via its current anchor. Skip the swap as commented in `isInViewTransition`.
        if isInViewTransition {
            updateContentInsetsForce()
            return
        }

        let isHiding = notification.name == UIResponder.keyboardWillHideNotification
        let newAnchor: NSLayoutYAxisAnchor = isHiding
            ? view.safeAreaLayoutGuide.bottomAnchor
            : view.keyboardLayoutGuide.topAnchor

        chatBarBottomConstraint.isActive = false
        chatBarBottomConstraint = chatBarCoordinator.chatBarContainerView.bottomAnchor.constraint(equalTo: newAnchor)
        chatBarBottomConstraint.isActive = true

        // Match the keyboard's own animation so the chat bar slides in sync with it
        // instead of snapping when the constraint is swapped. The curve raw value lives
        // in bits 0-3 of `UIView.AnimationCurve` but `UIView.AnimationOptions` expects it
        // shifted into bits 16-19, hence the `<< 16`.
        if let userInfo = notification.userInfo,
           let duration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval,
           let curveRaw = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt {
            let options = UIView.AnimationOptions(rawValue: curveRaw << 16)
            UIView.animate(withDuration: duration, delay: 0, options: options) {
                self.view.layoutIfNeeded()
            }
        }

        updateContentInsetsForce()
    }
    
    func updateContentInsets(force: Bool = false, retry: Bool = true) {
        DDLogVerbose("\(#function)")
        guard !insetUpdatesBlockedByContextMenu else {
            DDLogVerbose("\(#function) Do not run updateContentInsets as we're showing the context menu")
            return
        }
        
        guard !isScrolling || (force && !isResettingKeyboard) else {
            DDLogVerbose("\(#function) Do not run updateContentInsets as we're scrolling")
            if retry {
                DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
                    self.updateContentInsets(retry: false)
                }
            }
            return
        }
        
        guard !isJumping || force else {
            DDLogVerbose("\(#function) Do not run updateContentInsets as we're jumping")
            return
        }
        
        if !Thread.isMainThread {
            assertionFailure("Constraints must be updated on main thread")
        }
        
        view.layoutIfNeeded()
        
        let wasAtBottomOfView = isAtBottomOfView
        let oldYOffset = tableView.contentOffset.y
        
        let oldInset = tableView.contentInset
        
        var newContentInset = oldInset
        var newScrollbarIndicatorInset = oldInset
        
        // Tweak the insets
        
        if userInterfaceMode == .default {
            let newBottomInset: CGFloat = view.frame.maxY
                - chatBarCoordinator.chatBarContainerView.frame.minY
                - tableView.safeAreaInsets.bottom
            
            // We need some extra space to the chat bar for the content/chat bubbles to compensate for the missing
            // spacing from the top of the next bubble.
            if conversation.isGroup {
                if conversation.lastMessage?.isOwnMessage ?? false {
                    newContentInset.bottom = newBottomInset + ChatViewConfiguration.bottomInset
                }
                else {
                    newContentInset.bottom = newBottomInset + ChatViewConfiguration.groupBottomInset
                }
            }
            else {
                newContentInset.bottom = newBottomInset + ChatViewConfiguration.bottomInset
            }
            
            // We just use the default inset for the scroll bars
            newScrollbarIndicatorInset.bottom = newBottomInset
        }
        else {
            if conversation.isGroup {
                newContentInset.bottom = ChatViewConfiguration.groupBottomInset
            }
            else {
                newContentInset.bottom = ChatViewConfiguration.bottomInset
            }

            newScrollbarIndicatorInset.bottom = 0.0
        }
        
        // Also (and always) tweak the top inset. This also influences the sticky section header inset, so we make sure
        // the header is never below the banner view.
        newContentInset.top = ChatViewConfiguration.topInset + bannerContainerView.frame.height
        
        guard !dataSource.initialSetupCompleted || tableView.contentInset != newContentInset else {
            DDLogVerbose("\(#function) Do not update content insets because they haven't changed")
            return
        }
        
        DDLogVerbose("\(#function) will updateContentInsets to \(newContentInset)")
        
        // Apply the insets if they changed
        if tableView.contentInset != newContentInset {
            tableView.contentInset = newContentInset
            DDLogVerbose("\(#function) did updateContentInsets to \(tableView.contentInset)")
        }
        if tableView.scrollIndicatorInsets != newScrollbarIndicatorInset {
            tableView.scrollIndicatorInsets = newScrollbarIndicatorInset
            DDLogVerbose("\(#function) did updateScrollbarIndicatorInsets")
        }
        
        if wasAtBottomOfView {
            DDLogVerbose("\(#function) wasAtBottomOfView")
            if dataSource.initialSetupCompleted {
                // Do not scroll to bottom before initial scroll position was set and `initialSetupCompleted` was set
                // in `restoreScrollPosition`.
                scrollToBottom(animated: false, force: true)
            }
            else if tableView.contentSize.height > 0 {
                // This replicates the workaround in `viewDidLayoutSubviews`
                tableView.setContentOffset(
                    CGPoint(x: 0, y: CGFLOAT_MAX),
                    animated: false
                )
            }
        }
        else {
            DDLogVerbose("\(#function) !wasAtBottomOfView")
            let insetChange = newContentInset.bottom - oldInset.bottom
            let newYOffset = (oldYOffset + insetChange)

            if tableView.contentOffset.y != newYOffset {
                tableView.setContentOffset(CGPoint(x: 0, y: newYOffset), animated: false)
                DDLogVerbose("\(#function) did updateContentInsets was at bottom.")
            }
        }
    }
}

// MARK: - ModalNavigationControllerDelegate

extension ChatViewController: ModalNavigationControllerDelegate {
    func didDismissModalNavigationController() {
        if conversation.willBeDeleted {
            closeBecauseConversationWasDeleted()
        }
        
        chatBarCoordinator.updateSettings()
    }
}

// MARK: - DetailsDelegate

extension ChatViewController: DetailsDelegate {
    func detailsDidDisappear() {
        if userInterfaceMode == .search {
            chatSearchController.activateSearch()
        }
    }
    
    func showChatSearch(forStarred: Bool) {
        if forStarred {
            chatSearchController.addStarredToken()
        }
        userInterfaceMode = .search
    }
    
    func willDeleteMessages(with objectIDs: [NSManagedObjectID]) {
        dataSource.deletedMessagesObjectIDs = dataSource.deletedMessagesObjectIDs.union(objectIDs)
    }
    
    func willDeleteAllMessages() {
        dataSource.willDeleteAllMessages()
    }
}

// MARK: - ChatSearchControllerDelegate

extension ChatViewController: ChatSearchControllerDelegate {
    func chatSearchController(
        select messageObjectID: NSManagedObjectID,
        highlighting searchText: String,
        in filteredSearchResults: [NSManagedObjectID]
    ) {
        var indexPaths = [IndexPath]()
        
        for section in 0..<tableView.numberOfSections {
            for row in 0..<tableView.numberOfRows(inSection: section) {
                indexPaths.append(IndexPath(row: row, section: section))
            }
        }
        
        chatViewTableViewCellDelegate.currentSearchText = searchText
        
        dataSource.reconfigure(filteredSearchResults)
        
        jumpToAndSelect(messageObjectID)
    }
    
    func chatSearchController(removeSelectionFrom messageObjectID: NSManagedObjectID) {
        guard let indexPath = dataSource.indexPath(for: ChatViewDataSource.CellType.message(objectID: messageObjectID))
        else {
            DDLogVerbose("No index path found to remove selection from")
            return
        }
        
        tableView.deselectRow(at: indexPath, animated: true)
    }
    
    func chatSearchController(showToolbarWith barButtonItems: [UIBarButtonItem], animated: Bool) {
        setToolbarItems(barButtonItems, animated: animated)
        showToolbar(animated: animated)
    }
    
    func chatSearchController(updateToolbarWith barButtonItems: [UIBarButtonItem]) {
        // If animated is true the text blinks when switching between results
        setToolbarItems(barButtonItems, animated: false)
    }
    
    func chatSearchControllerHideToolbar(animated: Bool) {
        hideToolbar(animated: animated)
        setToolbarItems(nil, animated: animated)
    }
    
    func chatSearchControllerHideSearch() {
        chatViewTableViewCellDelegate.currentSearchText = nil
        
        dataSource.reconfigure()
        
        userInterfaceMode = .default
    }
}

// MARK: - Helper functions for ChatViewDataSource initialization

// The compiler posts weird errors if something is wrong in the closures directly put into the initializer
// Thus we move these closures into their own functions
extension ChatViewController {
    private func chatViewDataSourceAfterFirstSnapshotApply() {
        if let messageObjectID = showConversationInformation?.messageObjectID {
            jumpContentLoadUnsafeAndSelect(to: messageObjectID)
        }
        // TODO: (IOS-2014) Maybe don't show messages until restoration completes to disable flickering if restoration
        // fails and we load messages at the bottom
        else if let newestUnreadMessage = unreadMessagesSnapshot.unreadMessagesState?.oldestConsecutiveUnreadMessage {
            jumpContentLoadUnsafe(to: newestUnreadMessage)
            dataSource.initialSetupCompleted = true
            tableView.alpha = 1.0
            if UIAccessibility.isVoiceOverRunning {
                UIView.setAnimationsEnabled(true)
            }
        }
        else {
            restoreScrollPosition()
        }
        
        let endTime = CACurrentMediaTime()
        DDLogVerbose("ChatViewController duration init to dataSource ready \(endTime - initTime)")
    }
    
    private func chatViewDataSourceLoadAround() -> Date? {
        if let globalSearchMessageID = showConversationInformation?.messageObjectID,
           let message = entityManager.entityFetcher.managedObject(with: globalSearchMessageID) as? BaseMessageEntity {
            message.date
        }
        else if let newestUnreadMessage = unreadMessagesSnapshot.unreadMessagesState?.oldestConsecutiveUnreadMessage,
                let message = entityManager.entityFetcher
                .managedObject(with: newestUnreadMessage) as? BaseMessageEntity {
            message.date
        }
        else {
            chatScrollPositionProvider.chatScrollPosition(for: conversation)?.messageDate
        }
    }
}

// MARK: - ChatBarCoordinatorDelegate

extension ChatViewController: ChatBarCoordinatorDelegate {
    func didDismissQuoteView() {
        updateContentInsets(force: true)
    }
}

// MARK: - ChatViewTableViewDelegate

extension ChatViewController: ChatViewTableViewDelegate {
    func willMove(toWindow newWindow: UIWindow?) {
        /// Part of the workaround for the passcode lock screen. See `ChatViewTableView` for additional infos
        if newWindow != nil, didEnterForegroundWithNilWindow {
            jumpToUnreadMessage()
            didEnterForegroundWithNilWindow = false
        }
    }
}

// MARK: - UnreadMessagesStateManagerDelegate

extension ChatViewController: UnreadMessagesStateManagerDelegate {
    
    var shouldMarkMessagesAsRead: Bool {
        assert(Thread.isMainThread)
        
        // Part of the workaround for the passcode lock screen. See `ChatViewTableView` for additional info
        guard view.window != nil || willMoveToNonNilWindow else {
            return false
        }
        
        guard userInterfaceMode != .preview else {
            return false
        }
        
        return true
    }
}

// MARK: - GroupCallBannerButtonViewDelegate

extension ChatViewController: GroupCallBannerButtonViewDelegate {
    func joinCall() async {
        guard let group = businessInjector.groupManager.getGroup(conversation: conversation) else {
            return
        }
        
        CallProvider.shared.startGroupCall(in: group, intent: .join)
    }
}

// MARK: - VNDocumentCameraViewControllerDelegate

extension ChatViewController: VNDocumentCameraViewControllerDelegate {
    func documentCameraViewController(
        _ controller: VNDocumentCameraViewController,
        didFinishWith scan: VNDocumentCameraScan
    ) {
        let pdfDocument = PDFDocument()
        for i in 0..<scan.pageCount {
            let img = scan.imageOfPage(at: i)
            guard let pdfPage = PDFPage(image: img) else {
                return
            }
            pdfDocument.insert(pdfPage, at: i)
        }
        guard let pdfData = pdfDocument.dataRepresentation()
        else {
            let message = "Could not create SendMediaAction in \(#function)"
            assertionFailure(message)
            return
        }
        
        controller.dismiss(animated: true)
        let sendMediaAction = SendMediaAction(chatViewController: self)
        sendMediaAction.showPreviewForAssets(assets: [PhotosAccessHelper.storePDFToTmpDir(pdfData: pdfData)])
    }
}

/// Called by ChatViewController when its conversation is being deleted.
/// The delegate handles all UI cleanup: dismissing presented VC, popping/resetting
/// navigation, and clearing coordinator state.
protocol ChatViewControllerConversationDeletedDelegate: AnyObject {
    func chatViewControllerConversationWasDeleted(_ chatViewController: ChatViewController)
}

// MARK: - Spam detection & reporting

extension ChatViewController {
    private func checkForSpam() {
        guard ThreemaEnvironment.enableSpamReporting || ThreemaEnvironment.enableGroupSpamReporting else {
            return
        }
        
        // When opening a 1:1 or group conversation on an application not built for the _OnPrem_
        // flavour, run the following steps:
        
        guard TargetManager.isOnPrem == false, TargetManager.isCustomOnPrem == false else {
            return
        }
        
        // Only try to detect spam if there is any detector and read it on the main actor to avoid a main actor hop when
        // accessing the isolated `spamDetection` property inside the detached task.
        guard let spamDetection else {
            DDLogDebug("No spam detection as there is no spam detector")
            return
        }
        
        // Loaded on the main actor such that it can be safely used in another actor
        let conversationObjectID = conversation.objectID

        // Run in a separate task to prevent blocking the loading of the chat view. The detection itself runs
        // on the `SpamDetector` actor and thus off the main actor
        Task.detached(priority: .utility) { [weak self] in
            guard let self else {
                return
            }
            
            // 1. Let `spam-potential` be the result of running the _Spam Potential
            //    Detection Steps_ for the current 1:1 conversation.
            let isPotentialSpam = await spamDetection.isPotentialSpam(conversationObjectID: conversationObjectID)

            await MainActor.run { [weak self] in
                // 2. If `spam-potential` indicates that spam potential was detected, display a
                //    method to report `contact` as a spam source.
                self?.spamReportingBannerView?.isHidden = !isPotentialSpam
                self?.updateContentInsets(force: true)
            }
        }
    }
    
    @MainActor
    private func markAsNotSpam() {
        guard ThreemaEnvironment.enableSpamReporting || ThreemaEnvironment.enableGroupSpamReporting else {
            return
        }
        
        removeSpamReportingBanner()
        
        businessInjector.entityManager.performSave {
            self.conversation.spamReportingState = .notSpam
        }
    }
    
    @MainActor
    private func reportSpam(with action: ChatViewSpamReportingBanner.ReportAction) {
        guard ThreemaEnvironment.enableSpamReporting || ThreemaEnvironment.enableGroupSpamReporting else {
            return
        }
        
        // The API call is fire-and-forget. Thus we immediately hide the view and don't need to show a progress
        removeSpamReportingBanner()
        
        businessInjector.entityManager.performSave {
            self.conversation.spamReportingState = .reported
        }
        
        if conversation.isGroup {
            reportGroupSpam(with: action)
        }
        else {
            reportContactSpam(with: action)
        }
    }
    
    @MainActor
    private func reportContactSpam(with action: ChatViewSpamReportingBanner.ReportAction) {
        guard let sender = conversation.contact else {
            DDLogError("Unable to report spam, because conversation has not contact")
            return
        }
        
        // Loaded on main actor to such that it can be safely used in another actor
        let senderIdentity = sender.identity
        let senderPublicNickname = sender.publicNickname
        
        // Send to server (fire-and-forget)
        Task.detached(priority: .utility) {
            do {
                try await ServerAPIConnector().reportSpam(
                    for: senderIdentity,
                    and: senderPublicNickname,
                    store: MyIdentityStore.shared()
                )
            }
            catch {
                // Move into separate variable to prevent deprecation warning of `DDLogError`
                let errorMessage = "Unable to report \(senderIdentity) (\(senderPublicNickname, default: "nil")) as spam: \(error)"
                DDLogError("\(errorMessage)")
            }
        }
        
        if action == .reportBlockAndDelete {
            // Block
            self.businessInjector.settingsStore.blacklist.insert(sender.identity)
            
            // Delete
            let deleteAction = DeleteContactAction(for: sender)
            // This might ask for confirmation of the deletion and thus can be canceled
            // Using `chatProfileView` instead of `spamReportingBannerView` because the latter is hidden
            deleteAction.execute(in: chatProfileView, of: self) { [weak self] in
                self?.willDeleteAllMessages()
            }
        }
    }
    
    @MainActor
    private func reportGroupSpam(with action: ChatViewSpamReportingBanner.ReportAction) {
        guard let group = businessInjector.groupManager.getGroup(conversation: conversation) else {
            DDLogError("Unable to report spam, because conversation has no group")
            return
        }
        
        guard case let .contact(creator) = group.creator else {
            DDLogError("Unable to report spam, because group creator is not a known contact")
            return
        }
        
        // Loaded on main actor to such that it can be safely used in another actor
        let groupID = group.groupID
        let numberOfMembers = group.numberOfMembers
        let creatorIdentity = creator.identity.rawValue
        let creatorPublicNickname = creator.publicNickname

        Task.detached(priority: .utility) {
            do {
                try await ServerAPIConnector().reportSpamForGroup(
                    groupID: groupID,
                    adminIdentity: creatorIdentity,
                    adminNickname: creatorPublicNickname,
                    memberCount: numberOfMembers,
                    store: MyIdentityStore.shared()
                )
            }
            catch {
                // Move into separate variable to prevent deprecation warning of `DDLogError`
                let errorMessage =
                    "Unable to report group \(groupID.hexString) created by \(creatorIdentity) (\(creatorPublicNickname, default: "nil")) as spam: \(error)"
                DDLogError("\(errorMessage)")
            }
        }

        if action == .reportBlockAndDelete {
            // Block creator
            businessInjector.settingsStore.blacklist.insert(creatorIdentity)
            
            // This might ask for confirmation of the leaving & deletion and thus can be canceled
            // Using `chatProfileView` instead of `spamReportingBannerView` because the latter is hidden
            DeleteConversationAction.execute(
                for: conversation,
                owner: self,
                anchorView: chatProfileView,
                singleFunction: .delete
            ) { _ in
                DDLogVerbose("Group reported as spam was deleted")
            }
        }
    }
    
    @MainActor
    private func removeSpamReportingBanner() {
        spamReportingBannerView?.isHidden = true
        updateContentInsets(force: true) // Needed to update top content insets
    }
}

Messung
CHZG
85959993

¤ Dauer der Verarbeitung: 0.76 Sekunden  ¤

*© Formatika GbR, Deutschland






Wurzel

Suchen

PVS Prover

Isabelle Prover


Cephes Mathematical Library

Vienna Development Method

Haftungshinweis

Die Informationen auf dieser Webseite wurden nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit, noch Qualität der bereit gestellten Informationen zugesichert.

Bemerkung:

Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.