Signal-iOS/Signal/src/ViewControllers/HomeView/Stories/StoriesViewController.swift

546 lines
21 KiB
Swift

//
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import Foundation
import Photos
import SignalServiceKit
import UIKit
import SignalUI
class StoriesViewController: OWSViewController, StoryListDataSourceDelegate {
let tableView = UITableView()
private lazy var emptyStateLabel: UILabel = {
let label = UILabel()
label.textColor = Theme.secondaryTextAndIconColor
label.font = .ows_dynamicTypeBody
label.numberOfLines = 0
label.textAlignment = .center
label.text = NSLocalizedString("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)
private lazy var contextMenuGenerator = StoryContextMenuGenerator(presentingController: self, delegate: self)
override init() {
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: .localProfileDidChange, object: nil)
}
override func loadView() {
view = tableView
tableView.delegate = self
tableView.dataSource = self
}
var tableViewIfLoaded: UITableView? {
return viewIfLoaded as? UITableView
}
override func viewDidLoad() {
super.viewDidLoad()
title = NSLocalizedString("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")
}
}
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// 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)
timestampUpdateTimer?.invalidate()
timestampUpdateTimer = nil
}
override func applyTheme() {
super.applyTheme()
emptyStateLabel.textColor = Theme.secondaryTextAndIconColor
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) { [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)
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)
} else if
let headerCell = cell as? HiddenStoryHeaderCell
{
headerCell.configure(isCollapsed: dataSource.isHiddenStoriesSectionCollapsed)
}
case .none:
owsFailDebug("Unexpected story type")
}
}
view.backgroundColor = Theme.backgroundColor
tableView.backgroundColor = Theme.backgroundColor
updateNavigationBar()
}
@objc
func profileDidChange() { updateNavigationBar() }
private func updateNavigationBar() {
let contextButton = ContextMenuButton()
contextButton.showsContextMenuAsPrimaryAction = true
contextButton.contextMenu = .init([
.init(
title: NSLocalizedString("STORIES_SETTINGS_TITLE", comment: "Title for the story privacy settings view"),
image: Theme.iconImage(.settingsPrivacy),
handler: { [weak self] _ in
self?.showPrivacySettings()
}
),
.init(
title: CommonStrings.openSettingsButton,
image: Theme.isDarkThemeEnabled ? UIImage(named: "settings-solid-24")?.tintedImage(color: .white) : UIImage(named: "settings-outline-24"),
handler: { [weak self] _ in
self?.showAppSettings()
}
)
])
let avatarView = ConversationAvatarView(sizeClass: .twentyEight, localUserDisplayMode: .asUser)
databaseStorage.read { transaction in
avatarView.update(transaction) { config in
if let address = tsAccountManager.localAddress(with: transaction) {
config.dataSource = .address(address)
config.applyConfigurationSynchronously()
}
}
}
contextButton.addSubview(avatarView)
avatarView.autoPinEdgesToSuperviewEdges()
navigationItem.leftBarButtonItem = .init(customView: contextButton)
let cameraButton = UIBarButtonItem(image: Theme.iconImage(.cameraButton), style: .plain, target: self, action: #selector(showCameraView))
cameraButton.accessibilityLabel = NSLocalizedString("CAMERA_BUTTON_LABEL", comment: "Accessibility label for camera button.")
cameraButton.accessibilityHint = NSLocalizedString("CAMERA_BUTTON_HINT", comment: "Accessibility hint describing what you can do with the camera button")
navigationItem.rightBarButtonItems = [cameraButton]
}
@objc
func showCameraView() {
AssertIsOnMainThread()
// Dismiss any message actions if they're presented
conversationSplitViewController?.selectedConversationViewController?.dismissMessageContextMenu(animated: true)
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, delegate: self)
self.presentFullScreen(modal, animated: true)
}
}
}
func showAppSettings() {
AssertIsOnMainThread()
conversationSplitViewController?.selectedConversationViewController?.dismissMessageContextMenu(animated: true)
presentFormSheet(AppSettingsViewController.inModalNavigationController(), 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?
public func tableViewDidUpdate() {
guard let scrollTarget = 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.isHiddenStoriesSectionCollapsed {
section = .hiddenStories
index = hiddenStoryIndex
} 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 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 {
navigationController?.pushViewController(MyStoriesViewController(), animated: true)
}
case .hiddenStories:
if indexPath.row == 0 {
// 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 = databaseStorage.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,
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
}
}
@available(iOS 13, *)
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
let actions = self?.contextMenuGenerator.nativeContextMenuActions(
for: model,
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.
return dataSource.hiddenStories[safe: indexPath.row - 1]
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.isHiddenStoriesSectionCollapsed,
let hiddenRow = dataSource.hiddenStories.firstIndex(where: { $0.context == context }) {
// Offset by 1 to account for the header cell.
indexPath = IndexPath(row: hiddenRow + 1, 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.configure(with: myStoryModel) { [weak self] in self?.showCameraView() }
return cell
case .hiddenStories:
if indexPath.row == 0 {
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.configure(with: model)
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 {
emptyStateLabel.isHidden = !dataSource.allStories.isEmpty || dataSource.myStory?.messages.isEmpty == false
switch Section(rawValue: section) {
case .myStory:
return dataSource.myStory == nil ? 0 : 1
case .visibleStories:
return dataSource.visibleStories.count
case .hiddenStories:
guard !dataSource.hiddenStories.isEmpty else {
return 0
}
return dataSource.isHiddenStoriesSectionCollapsed ? 1 : dataSource.hiddenStories.count + 1
case .none:
owsFailDebug("Unexpected section \(section)")
return 0
}
}
}
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
}
}