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

import Foundation
import PhotosUI
import SignalServiceKit
import SignalUI
import UIKit

class MyStoriesViewController: OWSViewController, FailedStorySendDisplayController {
    private let tableView = UITableView(frame: .zero, style: .grouped)
    private var items = OrderedDictionary<String, [OutgoingStoryItem]>() {
        didSet { emptyStateLabel.isHidden = items.orderedKeys.count > 0 }
    }

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

    private lazy var contextMenuGenerator = StoryContextMenuGenerator(presentingController: self)

    private let spoilerState: SpoilerRenderState

    init(spoilerState: SpoilerRenderState) {
        self.spoilerState = spoilerState
        super.init()
        hidesBottomBarWhenPushed = true
        DependenciesBridge.shared.databaseChangeObserver.appendDatabaseChangeDelegate(self)
    }

    override func viewDidLoad() {
        super.viewDidLoad()

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

        title = OWSLocalizedString("MY_STORIES_TITLE", comment: "Title for the 'My Stories' view")

        tableView.delegate = self
        tableView.dataSource = self
        tableView.register(SentStoryCell.self, forCellReuseIdentifier: SentStoryCell.reuseIdentifier)
        tableView.separatorStyle = .none
        tableView.rowHeight = UITableView.automaticDimension
        tableView.estimatedRowHeight = 116
        tableView.backgroundColor = .Signal.background
        view.addSubview(tableView)
        tableView.autoPinHeight(toHeightOf: view)
        tableViewHorizontalEdgeConstraints = [
            tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            view.trailingAnchor.constraint(equalTo: tableView.trailingAnchor),
        ]
        NSLayoutConstraint.activate(tableViewHorizontalEdgeConstraints)
        updateTableViewPaddingIfNeeded()

        reloadStories()

        navigationItem.rightBarButtonItem = .init(
            title: OWSLocalizedString("STORY_PRIVACY_SETTINGS", comment: "Button to access the story privacy settings menu"),
            style: .plain,
            target: self,
            action: #selector(showPrivacySettings),
        )
    }

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

    @objc
    private func showPrivacySettings() {
        let vc = StoryPrivacySettingsViewController()
        presentFormSheet(OWSNavigationController(rootViewController: vc), animated: true)
    }

    /// 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
        }
    }

    private func reloadStories() {
        AssertIsOnMainThread()

        let outgoingStories = SSKEnvironment.shared.databaseStorageRef.read { transaction in
            StoryFinder.outgoingStories(transaction: transaction)
                .flatMap { OutgoingStoryItem.build(message: $0, transaction: transaction) }
        }

        let groupedStories = Dictionary(grouping: outgoingStories) { $0.thread.uniqueId }

        items = .init(keyValueMap: groupedStories, orderedKeys: groupedStories.keys.sorted { lhsId, rhsId in
            guard let lhs = groupedStories[lhsId]?.first?.thread, let rhs = groupedStories[rhsId]?.first?.thread else {
                return false
            }
            if (lhs as? TSPrivateStoryThread)?.isMyStory == true { return true }
            if (rhs as? TSPrivateStoryThread)?.isMyStory == true { return false }
            if lhs.lastSentStoryTimestamp == rhs.lastSentStoryTimestamp {
                return StoryManager.storyName(for: lhs).localizedCaseInsensitiveCompare(
                    StoryManager.storyName(for: rhs),
                ) == .orderedAscending
            }
            return (lhs.lastSentStoryTimestamp ?? 0) > (rhs.lastSentStoryTimestamp ?? 0)
        })
        tableView.reloadData()
    }

    private func item(for indexPath: IndexPath) -> OutgoingStoryItem? {
        items.orderedValues[safe: indexPath.section]?[safe: indexPath.row]
    }

    private func thread(for section: Int) -> TSThread? {
        return items.orderedValues[safe: section]?.first?.thread
    }

    func cell(for message: StoryMessage, and context: StoryContext) -> SentStoryCell? {
        guard let thread = SSKEnvironment.shared.databaseStorageRef.read(block: { context.thread(transaction: $0) }) else { return nil }
        guard let section = items.orderedKeys.firstIndex(of: thread.uniqueId) else { return nil }
        guard let row = items[thread.uniqueId]?.firstIndex(where: { $0.message.uniqueId == message.uniqueId }) else { return nil }

        let indexPath = IndexPath(row: row, section: section)
        guard tableView.indexPathsForVisibleRows?.contains(indexPath) == true else { return nil }
        return tableView.cellForRow(at: indexPath) as? SentStoryCell
    }
}

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

        guard let thread = thread(for: indexPath.section), let item = item(for: indexPath) else { return }

        if item.message.sendingState == .failed {
            return StoryUtil.askToResend(item.message, in: item.thread, from: self)
        }

        let vc = StoryPageViewController(
            context: thread.storyContext,
            spoilerState: spoilerState,
            viewableContexts: items.orderedKeys.compactMap { items[$0]?.first?.thread.storyContext },
            loadMessage: item.message,
            onlyRenderMyStories: true,
        )
        vc.contextDataSource = self
        present(vc, animated: true)
    }

    func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
        return true
    }

    func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
        guard
            let item = item(for: indexPath),
            let action = contextMenuGenerator.goToChatContextualAction(thread: item.thread)
        else {
            return nil
        }
        return .init(actions: [action])
    }

    func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
        guard
            let item = item(for: indexPath),
            let action = contextMenuGenerator.deleteTableRowContextualAction(
                for: item.message,
                thread: item.thread,
            )
        else {
            return nil
        }
        return .init(actions: [action])
    }

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

        let actions = SSKEnvironment.shared.databaseStorageRef.read { transaction in
            return self.contextMenuGenerator.nativeContextMenuActions(
                for: item.message,
                in: item.thread,
                attachment: item.attachment,
                spoilerState: spoilerState,
                sourceView: { [weak self] in
                    // refetch the cell in case it changes out from underneath us.
                    return self?.tableView(tableView, cellForRowAt: indexPath)
                },
                hideSaveAction: true,
                onlyRenderMyStories: true,
                transaction: transaction,
            )
        }

        return .init(identifier: indexPath as NSCopying, previewProvider: nil) { _ in .init(children: actions) }
    }
}

extension MyStoriesViewController: UITableViewDataSource {
    func numberOfSections(in tableView: UITableView) -> Int {
        items.orderedKeys.count
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        items.orderedValues[safe: section]?.count ?? 0
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let item = item(for: indexPath) else {
            owsFailDebug("Missing item for row at indexPath \(indexPath)")
            return UITableViewCell()
        }

        let cell = tableView.dequeueReusableCell(withIdentifier: SentStoryCell.reuseIdentifier, for: indexPath) as! SentStoryCell

        let contextMenuActions: [UIAction] = {
            guard let item = self.item(for: indexPath) else { return [] }

            return SSKEnvironment.shared.databaseStorageRef.read { tx -> [UIAction] in
                contextMenuGenerator.nativeContextMenuActions(
                    for: item.message,
                    in: item.thread,
                    attachment: item.attachment,
                    spoilerState: spoilerState,
                    sourceView: { [weak self] in
                        // refetch the cell in case it changes out from underneath us.
                        return self?.tableView.dequeueReusableCell(withIdentifier: SentStoryCell.reuseIdentifier, for: indexPath)
                    },
                    transaction: tx,
                )
            }
        }()

        cell.useSidebarAppearance = useSidebarStoryListCellAppearance
        cell.configure(
            with: item,
            spoilerState: spoilerState,
            contextMenuActions: contextMenuActions,
            indexPath: indexPath,
        )
        return cell
    }

    func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
        guard let thread = thread(for: section) else {
            owsFailDebug("Missing thread for section \(section)")
            return nil
        }

        let textView = LinkingTextView()
        textView.textColor = Theme.isDarkThemeEnabled ? UIColor.ows_gray05 : UIColor.ows_gray90
        textView.font = UIFont.dynamicTypeHeadlineClamped
        textView.text = StoryManager.storyName(for: thread)

        var textContainerInset = OWSTableViewController2.cellOuterInsets(in: tableView)
        textContainerInset.top = 32
        textContainerInset.bottom = 10

        textContainerInset.left += OWSTableViewController2.cellHInnerMargin * 0.5
        textContainerInset.left += tableView.safeAreaInsets.left

        textContainerInset.right += OWSTableViewController2.cellHInnerMargin * 0.5
        textContainerInset.right += tableView.safeAreaInsets.right

        textView.textContainerInset = textContainerInset

        return textView
    }
}

extension MyStoriesViewController: DatabaseChangeDelegate {
    func databaseChangesDidUpdate(databaseChanges: DatabaseChanges) {
        reloadStories()
    }

    func databaseChangesDidUpdateExternally() {
        reloadStories()
    }

    func databaseChangesDidReset() {
        reloadStories()
    }
}

extension MyStoriesViewController: ForwardMessageDelegate {
    func forwardMessageFlowDidComplete(items: [ForwardMessageItem], recipientThreads: [TSThread]) {
        AssertIsOnMainThread()

        dismiss(animated: true) {
            ForwardMessageViewController.finalizeForward(
                items: items,
                recipientThreads: recipientThreads,
                fromViewController: self,
            )
        }
    }

    func forwardMessageFlowDidCancel() {
        dismiss(animated: true)
    }
}

extension MyStoriesViewController: StoryPageViewControllerDataSource {
    func storyPageViewControllerAvailableContexts(
        _ storyPageViewController: StoryPageViewController,
        hiddenStoryFilter: Bool?,
    ) -> [StoryContext] {
        return items.orderedValues.compactMap(\.first?.thread.storyContext)
    }
}

private struct OutgoingStoryItem {
    let message: StoryMessage
    let attachment: StoryThumbnailView.Attachment
    let thread: TSThread

    static func build(message: StoryMessage, transaction: DBReadTransaction) -> [OutgoingStoryItem] {
        message.threads(transaction: transaction).map {
            .init(
                message: message,
                attachment: .from(message, transaction: transaction),
                thread: $0,
            )
        }
    }
}

class SentStoryCell: UITableViewCell {
    static let reuseIdentifier = "SentStoryCell"

    let attachmentThumbnail = UIView()

    private let titleLabel: UILabel = {
        let label = UILabel()
        label.textColor = .Signal.label
        label.font = .dynamicTypeHeadline
        return label
    }()

    private let subtitleLabel: UILabel = {
        let label = UILabel()
        label.textColor = .Signal.secondaryLabel
        label.font = .dynamicTypeSubheadline
        return label
    }()

    private lazy var saveButton: UIButton = {
        let button = UIButton(
            configuration: .gray(),
            primaryAction: UIAction { [weak self] _ in
                self?.saveAttachmentBlock()
            },
        )
        button.configuration?.image = .save
        let margin: CGFloat = if #available(iOS 26, *) { 10 } else { 6 }
        button.configuration?.contentInsets = .init(margin: margin)
        button.configuration?.baseForegroundColor = .Signal.label
        button.configuration?.baseBackgroundColor = .Signal.secondaryBackground
        button.configuration?.cornerStyle = .capsule
        return button
    }()

    private let contextButton: ContextMenuButton = {
        let button = ContextMenuButton(empty: ())
        button.configuration = .gray()
        button.configuration?.image = .more
        let margin: CGFloat = if #available(iOS 26, *) { 10 } else { 8 }
        button.configuration?.contentInsets = .init(margin: margin)
        button.configuration?.baseForegroundColor = .Signal.label
        button.configuration?.baseBackgroundColor = .Signal.secondaryBackground
        button.configuration?.cornerStyle = .capsule
        // ContextMenuButton overrides `intrinsicContentSize` so manually specify size.
        let size: CGFloat = if #available(iOS 26, *) { 44 } else { 32 }
        button.addConstraints([
            button.widthAnchor.constraint(equalToConstant: size),
            button.heightAnchor.constraint(equalToConstant: size),
        ])
        return button
    }()

    private lazy var failedIconContainer: UIView = {
        let imageView = UIImageView(image: Theme.iconImage(.error16))
        imageView.tintColor = .Signal.red
        imageView.contentMode = .scaleAspectFit

        let view = UIView()
        view.addSubview(imageView)
        imageView.autoPinHeightToSuperview()
        imageView.autoPinEdge(toSuperviewEdge: .leading)
        imageView.autoSetDimension(.width, toSize: 16)
        view.autoSetDimension(.width, toSize: 28)
        return view
    }()

    /// If set to `true` background in `selected` state would have rounded corners.
    var useSidebarAppearance = false

    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)

        automaticallyUpdatesBackgroundConfiguration = false

        let vStackView = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel])
        vStackView.axis = .vertical
        vStackView.alignment = .leading

        let buttonSpacing: CGFloat = if #available(iOS 26, *) { 16 } else { 20 }
        let contentHStackView = UIStackView(
            arrangedSubviews: [
                attachmentThumbnail,
                .spacer(withWidth: 16),
                failedIconContainer,
                vStackView,
                .hStretchingSpacer(),
                saveButton,
                .spacer(withWidth: buttonSpacing),
                contextButton,
            ],
        )
        contentHStackView.axis = .horizontal
        contentHStackView.alignment = .center
        contentView.addSubview(contentHStackView)
        contentHStackView.autoPinEdgesToSuperviewMargins()

        attachmentThumbnail.autoSetDimensions(to: CGSize(width: 56, height: 84))
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func updateConfiguration(using state: UICellConfigurationState) {
        var configuration = UIBackgroundConfiguration.clear()
        if state.isSelected || state.isHighlighted {
            configuration.backgroundColor = Theme.tableCell2SelectedBackgroundColor
            if useSidebarAppearance {
                configuration.cornerRadius = 24
            }
        } else {
            configuration.backgroundColor = .Signal.background
        }
        backgroundConfiguration = configuration
    }

    private var attachment: StoryThumbnailView.Attachment?

    fileprivate func configure(
        with item: OutgoingStoryItem,
        spoilerState: SpoilerRenderState,
        contextMenuActions: [UIAction],
        indexPath: IndexPath,
    ) {
        if self.attachment != item.attachment {
            self.attachment = item.attachment
            let thumbnailView = StoryThumbnailView(
                attachment: item.attachment,
                interactionIdentifier: .fromStoryMessage(item.message),
                spoilerState: spoilerState,
            )
            attachmentThumbnail.removeAllSubviews()
            attachmentThumbnail.addSubview(thumbnailView)
            thumbnailView.autoPinEdgesToSuperviewEdges()
        }

        switch item.message.sendingState {
        case .pending, .sending:
            titleLabel.text = OWSLocalizedString("STORY_SENDING", comment: "Text indicating that the story is currently sending")
            subtitleLabel.text = ""
            failedIconContainer.isHiddenInStackView = true
        case .failed:
            failedIconContainer.isHiddenInStackView = false
            titleLabel.text = item.message.hasSentToAnyRecipients
                ? OWSLocalizedString("STORY_SEND_PARTIALLY_FAILED", comment: "Text indicating that the story send has partially failed")
                : OWSLocalizedString("STORY_SEND_FAILED", comment: "Text indicating that the story send has failed")
            subtitleLabel.text = OWSLocalizedString("STORY_SEND_FAILED_RETRY", comment: "Text indicating that you can tap to retry sending")
        case .sent:
            if StoryManager.areViewReceiptsEnabled {
                let format = OWSLocalizedString(
                    "STORY_VIEWS_%d",
                    tableName: "PluralAware",
                    comment: "Text explaining how many views a story has. Embeds {{ %d number of views }}",
                )
                titleLabel.text = String.localizedStringWithFormat(format, item.message.remoteViewCount(in: item.thread.storyContext))
            } else {
                titleLabel.text = OWSLocalizedString(
                    "STORY_VIEWS_OFF",
                    comment: "Text indicating that the user has views turned off",
                )
            }
            subtitleLabel.text = DateUtil.formatTimestampRelatively(item.message.timestamp)
            failedIconContainer.isHiddenInStackView = true
        case .sent_OBSOLETE, .delivered_OBSOLETE:
            owsFailDebug("Unexpected legacy sending state")
        }

        if item.attachment.isSaveable {
            saveButton.isHiddenInStackView = false
            saveAttachmentBlock = { item.attachment.save(
                interactionIdentifier: .fromStoryMessage(item.message),
                spoilerState: spoilerState,
            ) }
        } else {
            saveButton.isHiddenInStackView = true
            saveAttachmentBlock = {}
        }

        contextButton.setActions(actions: contextMenuActions)

    }

    private var saveAttachmentBlock: () -> Void = {}
}