Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/src/ViewControllers/HomeView/Stories/StoriesViewController.swift
1 views
//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation
import Photos
import SignalServiceKit
import SignalUI
import UIKit

class StoriesViewController: OWSViewController, StoryListDataSourceDelegate, HomeTabViewController {
    let tableView = UITableView()

    private lazy var searchController = UISearchController()

    private lazy var emptyStateLabel: UILabel = {
        let label = UILabel()
        label.textColor = .Signal.secondaryLabel
        label.font = .dynamicTypeBody
        label.numberOfLines = 0
        label.textAlignment = .center
        label.text = OWSLocalizedString("STORIES_NO_RECENT_MESSAGES", comment: "Indicates that there are no recent stories to render")
        label.isHidden = true
        label.isUserInteractionEnabled = false
        tableView.backgroundView = label
        return label
    }()

    private lazy var dataSource = StoryListDataSource(delegate: self, spoilerState: spoilerState)

    private lazy var contextMenuGenerator = StoryContextMenuGenerator(presentingController: self, delegate: self)

    private let appReadiness: AppReadinessSetter
    private let spoilerState: SpoilerRenderState

    init(appReadiness: AppReadinessSetter, spoilerState: SpoilerRenderState) {
        self.appReadiness = appReadiness
        self.spoilerState = spoilerState
        super.init()
        // Want to start loading right away to prevent cases where things aren't loaded
        // when you tab over into the stories list.
        dataSource.reloadStories()
        dataSource.beginObservingDatabase()

        NotificationCenter.default.addObserver(self, selector: #selector(profileDidChange), name: UserProfileNotifications.localProfileDidChange, object: nil)
    }

    var tableViewIfLoaded: UITableView? {
        return viewIfLoaded == nil ? nil : tableView
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .Signal.background
        tableView.backgroundColor = .Signal.background

        tableView.delegate = self
        tableView.dataSource = self
        view.addSubview(tableView)
        tableView.autoPinHeight(toHeightOf: view)
        tableViewHorizontalEdgeConstraints = [
            tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            view.trailingAnchor.constraint(equalTo: tableView.trailingAnchor),
        ]
        NSLayoutConstraint.activate(tableViewHorizontalEdgeConstraints)
        updateTableViewPaddingIfNeeded()

        // Search
        searchController.searchResultsUpdater = self
        navigationItem.searchController = searchController

        title = OWSLocalizedString("STORIES_TITLE", comment: "Title for the stories view.")

        tableView.register(MyStoryCell.self, forCellReuseIdentifier: MyStoryCell.reuseIdentifier)
        tableView.register(StoryCell.self, forCellReuseIdentifier: StoryCell.reuseIdentifier)
        tableView.register(HiddenStoryHeaderCell.self, forCellReuseIdentifier: HiddenStoryHeaderCell.reuseIdentifier)
        tableView.separatorStyle = .none
        tableView.rowHeight = UITableView.automaticDimension
        tableView.estimatedRowHeight = 116

        updateNavigationBar()

        OWSTableViewController2.removeBackButtonText(viewController: self)
    }

    private var timestampUpdateTimer: Timer?
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        timestampUpdateTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
            AssertIsOnMainThread()

            for indexPath in self.tableView.indexPathsForVisibleRows ?? [] {
                switch Section(rawValue: indexPath.section) {
                case .myStory:
                    guard let cell = self.tableView.cellForRow(at: indexPath) as? MyStoryCell else { continue }
                    guard let model = self.dataSource.myStory else { continue }
                    cell.configureSubtitle(with: model)
                case .visibleStories, .hiddenStories:
                    guard let cell = self.tableView.cellForRow(at: indexPath) as? StoryCell else { continue }
                    guard let model = self.model(for: indexPath) else { continue }
                    cell.configureSubtitle(with: model)
                case .none:
                    owsFailDebug("Unexpected story type")
                }
            }
        }
    }

    private var viewIsAppeared = false

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        self.viewIsAppeared = true

        // Whether or not the theme has changed, always ensure
        // the right theme is applied. The initial collapsed
        // state of the split view controller is determined between
        // `viewWillAppear` and `viewDidAppear`, so this is the soonest
        // we can know the right thing to display.
        applyTheme()
    }

    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransition(to: size, with: coordinator)

        // We could be changing between collapsed and expanded
        // split view state, so we must re-apply the theme.
        coordinator.animate { _ in
            self.applyTheme()
        } completion: { _ in
            self.applyTheme()
        }
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)

        defer {
            self.viewIsAppeared = false
        }

        timestampUpdateTimer?.invalidate()
        timestampUpdateTimer = nil
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)

        if !searchController.searchBar.text.isEmptyOrNil {
            searchController.isActive = false
            dataSource.setSearchText(nil)
        }
    }

    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
        super.traitCollectionDidChange(previousTraitCollection)
        updateTableViewPaddingIfNeeded()
    }

    private func applyTheme() {
        for indexPath in self.tableView.indexPathsForVisibleRows ?? [] {
            switch Section(rawValue: indexPath.section) {
            case .myStory:
                guard let cell = self.tableView.cellForRow(at: indexPath) as? MyStoryCell else { continue }
                guard let model = dataSource.myStory else { continue }
                cell.configure(with: model, spoilerState: spoilerState) { [weak self] in self?.showCameraView() }
            case .visibleStories:
                guard let cell = self.tableView.cellForRow(at: indexPath) as? StoryCell else { continue }
                guard let model = self.model(for: indexPath) else { continue }
                cell.configure(with: model, spoilerState: spoilerState)
            case .hiddenStories:
                let cell = self.tableView.cellForRow(at: indexPath)
                if
                    let storyCell = cell as? StoryCell,
                    let model = self.model(for: indexPath)
                {
                    storyCell.configure(with: model, spoilerState: spoilerState)
                } else if
                    let headerCell = cell as? HiddenStoryHeaderCell
                {
                    headerCell.configure(isCollapsed: dataSource.isHiddenStoriesSectionCollapsed)
                }
            case .none:
                owsFailDebug("Unexpected story type")
            }
        }

        updateNavigationBar()
    }

    /// Set to `true` when list is displayed in split view controller's "sidebar" on iOS 26 and later.
    /// Setting this to `true` would add an extra padding on both sides of the table view.
    /// This value is also passed down to table view cells that make their own layout choices based on the value.
    private var useSidebarStoryListCellAppearance = false {
        didSet {
            guard oldValue != useSidebarStoryListCellAppearance else { return }
            tableViewHorizontalEdgeConstraints.forEach {
                $0.constant = useSidebarStoryListCellAppearance ? 16 : 0
            }
            tableView.reloadData()
        }
    }

    private var tableViewHorizontalEdgeConstraints: [NSLayoutConstraint] = []

    /// iOS 26+: checks if this VC is displayed in the collapsed split view controller and updates `useSidebarCallListCellAppearance` accordingly.
    /// Does nothing on prior iOS versions.
    private func updateTableViewPaddingIfNeeded() {
        guard #available(iOS 26, *) else { return }

        if let splitViewController, !splitViewController.isCollapsed {
            useSidebarStoryListCellAppearance = true
        } else {
            useSidebarStoryListCellAppearance = false
        }
    }

    @objc
    private func profileDidChange() { updateNavigationBar() }

    func updateNavigationBar() {
        navigationItem.leftBarButtonItem = createSettingsBarButtonItem(
            databaseStorage: SSKEnvironment.shared.databaseStorageRef,
            buildActions: { settingsAction -> [UIMenuElement] in
                return [
                    UIAction(
                        title: OWSLocalizedString(
                            "STORY_PRIVACY_TITLE",
                            comment: "Title for the story privacy settings view",
                        ),
                        image: Theme.iconImage(.contextMenuPrivacy),
                        handler: { [weak self] _ in
                            self?.showPrivacySettings()
                        },
                    ),
                    settingsAction,
                ]
            },
            showAppSettings: { [weak self] in
                self?.showAppSettings()
            },
        )

        let cameraButton = UIBarButtonItem(image: Theme.iconImage(.buttonCamera), style: .plain, target: self, action: #selector(showCameraView))
        cameraButton.accessibilityLabel = OWSLocalizedString("CAMERA_BUTTON_LABEL", comment: "Accessibility label for camera button.")
        cameraButton.accessibilityHint = OWSLocalizedString("CAMERA_BUTTON_HINT", comment: "Accessibility hint describing what you can do with the camera button")

        navigationItem.rightBarButtonItems = [cameraButton]
    }

    @objc
    private func showCameraView() {
        AssertIsOnMainThread()

        // Dismiss any message actions if they're presented
        conversationSplitViewController?.selectedConversationViewController?.dismissMessageContextMenu(animated: true)

        let attachmentLimits = OutgoingAttachmentLimits.currentLimits()

        ows_askForCameraPermissions { cameraGranted in
            guard cameraGranted else {
                return Logger.warn("camera permission denied.")
            }
            self.ows_askForMicrophonePermissions { micGranted in
                if !micGranted {
                    // We can still continue without mic permissions, but any captured video will
                    // be silent.
                    Logger.warn("proceeding, though mic permission denied.")
                }

                let modal = CameraFirstCaptureNavigationController.cameraFirstModal(
                    storiesOnly: true,
                    hasQuotedReplyDraft: false,
                    attachmentLimits: attachmentLimits,
                    delegate: self,
                )
                self.presentFullScreen(modal, animated: true)
            }
        }
    }

    func showMyStories(animated: Bool) {
        navigationController?.pushViewController(MyStoriesViewController(spoilerState: spoilerState), animated: animated)
    }

    func showAppSettings() {
        AssertIsOnMainThread()

        conversationSplitViewController?.selectedConversationViewController?.dismissMessageContextMenu(animated: true)
        presentFormSheet(AppSettingsViewController.inModalNavigationController(appReadiness: appReadiness), animated: true)
    }

    func showPrivacySettings() {
        AssertIsOnMainThread()

        let vc = StoryPrivacySettingsViewController()
        presentFormSheet(OWSNavigationController(rootViewController: vc), animated: true)
    }

    // MARK: - Scrolling after reload

    private enum ScrollTarget {
        // Scroll to a section so its first cell is at the top.
        case section(Section)
        // Scroll to a context, optionally restricted to a given section.
        // Highlights after scroll.
        case context(StoryContext, section: Section?)
    }

    private var scrollTarget: ScrollTarget?

    func tableViewDidUpdate() {
        emptyStateLabel.isHidden = !dataSource.isEmpty
        tableView.isScrollEnabled = !dataSource.isEmpty
        // Because scrolling is disabled when data is empty, disable
        // collapsing to ensure the search bar stays visible.
        navigationItem.hidesSearchBarWhenScrolling = !dataSource.isEmpty

        guard let scrollTarget else {
            return
        }
        switch scrollTarget {
        case .section(let section):
            guard tableView.numberOfRows(inSection: section.rawValue) > 0 else {
                return
            }
            tableView.scrollToRow(at: IndexPath(item: 0, section: section.rawValue), at: .top, animated: true)
            self.scrollTarget = nil
        case let .context(context, sectionConstraint):
            let section: Section
            let index: Int
            if
                sectionConstraint ?? .visibleStories == .visibleStories,
                let visibleStoryIndex = dataSource.visibleStories.firstIndex(where: { $0.context == context })
            {
                section = .visibleStories
                index = visibleStoryIndex
            } else if
                sectionConstraint ?? .hiddenStories == .hiddenStories,
                let hiddenStoryIndex = dataSource.hiddenStories.firstIndex(where: { $0.context == context }),
                dataSource.shouldDisplayHiddenStories
            {
                section = .hiddenStories
                // Offset for the header
                let headerOffset = dataSource.shouldDisplayHiddenStoriesHeader ? 1 : 0
                index = hiddenStoryIndex + headerOffset
            } else {
                // Not found.
                return
            }
            let indexPath = IndexPath(row: index, section: section.rawValue)
            tableView.scrollToRow(at: indexPath, at: .middle, animated: true)
            self.scrollTarget = nil
        }
    }
}

extension StoriesViewController: CameraFirstCaptureDelegate {
    func cameraFirstCaptureSendFlowDidComplete(_ cameraFirstCaptureSendFlow: CameraFirstCaptureSendFlow) {
        dismiss(animated: true)
    }

    func cameraFirstCaptureSendFlowDidCancel(_ cameraFirstCaptureSendFlow: CameraFirstCaptureSendFlow) {
        dismiss(animated: true)
    }
}

extension StoriesViewController: UITableViewDelegate {

    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        guard searchController.isActive else { return }
        if searchController.searchBar.text.isEmptyOrNil {
            tableView.contentOffset.y += 1
            searchController.isActive = false
        } else {
            searchController.searchBar.resignFirstResponder()
        }
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)

        switch Section(rawValue: indexPath.section) {
        case .myStory:
            if dataSource.myStory?.messages.isEmpty == true {
                showCameraView()
            } else {
                showMyStories(animated: true)
            }
        case .hiddenStories:
            if indexPath.row == 0, dataSource.shouldDisplayHiddenStoriesHeader {
                // Tapping the collapsing header.
                let wasCollapsed = dataSource.isHiddenStoriesSectionCollapsed
                dataSource.isHiddenStoriesSectionCollapsed = !wasCollapsed
                if wasCollapsed {
                    // Scroll to it once we reload.
                    self.scrollTarget = .section(.hiddenStories)
                }
            } else {
                fallthrough
            }
        case .visibleStories:
            guard let model = model(for: indexPath) else {
                owsFailDebug("Missing model for story")
                return
            }

            // Navigate to "My Stories" rather than the viewer if the message is failed
            if model.latestMessageSendingState == .failed {
                guard let latestMessage = model.messages.last else {
                    owsFailDebug("Missing message for failed send")
                    return
                }
                guard let latestMessageThread = SSKEnvironment.shared.databaseStorageRef.read(block: { latestMessage.context.thread(transaction: $0) }) else {
                    owsFailDebug("Missing thread for failed send")
                    return
                }
                StoryUtil.askToResend(latestMessage, in: latestMessageThread, from: self)
                return
            }

            // If we tap on a story with unviewed stories, we only want the viewer
            // to page through unviewed contexts.
            let filterViewed = model.hasUnviewedMessages
            // If we tap on a non-hidden story, we only want the viewer to page through
            // non-hidden contexts, and vice versa.
            let startedFromHidden = model.isHidden
            let viewableContexts: [StoryContext] = dataSource.allStories
                .lazy
                .filter { !filterViewed || $0.hasUnviewedMessages }
                .filter { startedFromHidden == $0.isHidden }
                .map(\.context)

            let vc = StoryPageViewController(
                context: model.context,
                spoilerState: spoilerState,
                viewableContexts: viewableContexts,
                hiddenStoryFilter: startedFromHidden,
            )
            vc.contextDataSource = self
            presentFullScreen(vc, animated: true)
        case .none:
            owsFailDebug("Unexpected section \(indexPath.section)")
        }
    }

    func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
        switch Section(rawValue: indexPath.section) {
        case .hiddenStories, .visibleStories:
            return true
        case .myStory, .none:
            return false
        }
    }

    func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
        switch Section(rawValue: indexPath.section) {
        case .hiddenStories, .visibleStories:
            guard
                let model = model(for: indexPath),
                let action = contextMenuGenerator.goToChatContextualAction(for: model)
            else {
                return nil
            }
            return .init(actions: [action])
        case .myStory, .none:
            return nil
        }
    }

    func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
        switch Section(rawValue: indexPath.section) {
        case .hiddenStories, .visibleStories:
            guard
                let model = model(for: indexPath),
                let action = contextMenuGenerator.hideTableRowContextualAction(for: model)
            else {
                return nil
            }
            return .init(actions: [action])
        case .myStory, .none:
            return nil
        }
    }

    func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
        guard let model = model(for: indexPath) else {
            return nil
        }

        return .init(identifier: indexPath as NSCopying, previewProvider: nil, actionProvider: { [weak self] _ in
            guard let self else { return .init(children: []) }
            let actions = self.contextMenuGenerator.nativeContextMenuActions(
                for: model,
                spoilerState: self.spoilerState,
                sourceView: { [weak self] in
                    return self?.tableView.cellForRow(at: indexPath)
                },
            )
            return .init(children: actions)
        })
    }
}

extension StoriesViewController: UITableViewDataSource {
    typealias Section = StoryListDataSource.Section

    func model(for indexPath: IndexPath) -> StoryViewModel? {
        switch Section(rawValue: indexPath.section) {
        case .visibleStories:
            return dataSource.visibleStories[safe: indexPath.row]
        case .hiddenStories:
            // Offset by 1 to account for the header cell.
            let headerOffset = dataSource.shouldDisplayHiddenStoriesHeader ? 1 : 0
            return dataSource.hiddenStories[safe: indexPath.row - headerOffset]
        case .myStory, .none:
            return nil
        }
    }

    func model(for context: StoryContext) -> StoryViewModel? {
        dataSource.allStories.first { $0.context == context }
    }

    func cell(for context: StoryContext) -> StoryCell? {
        let indexPath: IndexPath
        if let visibleRow = dataSource.visibleStories.firstIndex(where: { $0.context == context }) {
            indexPath = IndexPath(row: visibleRow, section: Section.visibleStories.rawValue)
        } else if
            dataSource.shouldDisplayHiddenStories,
            let hiddenRow = dataSource.hiddenStories.firstIndex(where: { $0.context == context })
        {
            // Offset by 1 to account for the header cell.
            let headerOffset = dataSource.shouldDisplayHiddenStoriesHeader ? 1 : 0
            indexPath = IndexPath(row: hiddenRow + headerOffset, section: Section.hiddenStories.rawValue)
        } else {
            return nil
        }
        guard tableView.indexPathsForVisibleRows?.contains(indexPath) == true else { return nil }
        return tableView.cellForRow(at: indexPath) as? StoryCell
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        switch Section(rawValue: indexPath.section) {
        case .myStory:
            let cell = tableView.dequeueReusableCell(withIdentifier: MyStoryCell.reuseIdentifier) as! MyStoryCell
            guard let myStoryModel = dataSource.myStory else {
                owsFailDebug("Missing my story model")
                return cell
            }
            cell.useSidebarAppearance = useSidebarStoryListCellAppearance
            cell.configure(with: myStoryModel, spoilerState: spoilerState) { [weak self] in self?.showCameraView() }
            return cell
        case .hiddenStories:
            if indexPath.row == 0, dataSource.shouldDisplayHiddenStoriesHeader {
                let cell = tableView.dequeueReusableCell(
                    withIdentifier: HiddenStoryHeaderCell.reuseIdentifier,
                    for: indexPath,
                ) as! HiddenStoryHeaderCell
                cell.configure(isCollapsed: dataSource.isHiddenStoriesSectionCollapsed)
                return cell
            } else {
                fallthrough
            }
        case .visibleStories:
            let cell = tableView.dequeueReusableCell(withIdentifier: StoryCell.reuseIdentifier) as! StoryCell
            guard let model = model(for: indexPath) else {
                owsFailDebug("Missing model for story")
                return cell
            }
            cell.useSidebarAppearance = useSidebarStoryListCellAppearance
            cell.configure(with: model, spoilerState: spoilerState)
            return cell
        case .none:
            owsFailDebug("Unexpected section \(indexPath.section)")
            return UITableViewCell()
        }
    }

    func numberOfSections(in tableView: UITableView) -> Int {
        return 3
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        switch Section(rawValue: section) {
        case .myStory:
            return dataSource.shouldDisplayMyStory ? 1 : 0
        case .visibleStories:
            return dataSource.visibleStories.count
        case .hiddenStories:
            return (
                dataSource.shouldDisplayHiddenStoriesHeader ? 1 : 0,
            ) + (
                dataSource.shouldDisplayHiddenStories ? dataSource.hiddenStories.count : 0
            )
        case .none:
            owsFailDebug("Unexpected section \(section)")
            return 0
        }
    }
}

extension StoriesViewController: UISearchResultsUpdating {
    func updateSearchResults(for searchController: UISearchController) {
        dataSource.setSearchText(searchController.searchBar.text?.nilIfEmpty)
    }
}

extension StoriesViewController: StoryPageViewControllerDataSource {
    func storyPageViewControllerAvailableContexts(
        _ storyPageViewController: StoryPageViewController,
        hiddenStoryFilter: Bool?,
    ) -> [StoryContext] {
        if hiddenStoryFilter == true {
            return dataSource.threadSafeHiddenStoryContexts
        } else if hiddenStoryFilter == false {
            return dataSource.threadSafeVisibleStoryContexts
        } else {
            return dataSource.threadSafeStoryContexts
        }
    }
}

extension StoriesViewController: StoryContextMenuDelegate {

    func storyContextMenuDidUpdateHiddenState(_ message: StoryMessage, isHidden: Bool) -> Bool {
        if isHidden {
            // Uncollapse so we can scroll to the section.
            dataSource.isHiddenStoriesSectionCollapsed = false
        }
        self.scrollTarget = .context(message.context, section: isHidden ? .hiddenStories : .visibleStories)
        // Don't show a toast, we have the scroll action.
        return false
    }
}