Path: blob/main/Signal/src/ViewControllers/HomeView/Stories/Context View/StoryContextViewController.swift
1 views
//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import BonMot
import Foundation
import Lottie
import SignalServiceKit
import SignalUI
import UIKit
protocol StoryContextViewControllerDelegate: AnyObject {
func storyContextViewControllerWantsTransitionToNextContext(
_ storyContextViewController: StoryContextViewController,
loadPositionIfRead: StoryContextViewController.LoadPosition,
)
func storyContextViewControllerWantsTransitionToPreviousContext(
_ storyContextViewController: StoryContextViewController,
loadPositionIfRead: StoryContextViewController.LoadPosition,
)
func storyContextViewController(_ storyContextViewController: StoryContextViewController, contextAfter context: StoryContext) -> StoryContext?
func storyContextViewControllerDidPause(_ storyContextViewController: StoryContextViewController)
func storyContextViewControllerDidResume(_ storyContextViewController: StoryContextViewController)
func storyContextViewControllerShouldOnlyRenderMyStories(_ storyContextViewController: StoryContextViewController) -> Bool
func storyContextViewControllerShouldBeMuted(_ storyContextViewController: StoryContextViewController) -> Bool
}
class StoryContextViewController: OWSViewController {
let context: StoryContext
weak var delegate: StoryContextViewControllerDelegate?
private let spoilerState: SpoilerRenderState
private lazy var playbackProgressView = StoryPlaybackProgressView()
private var items = [StoryItem]()
var currentItem: StoryItem? {
didSet {
currentItemWasUpdated(messageDidChange: oldValue?.message.uniqueId != currentItem?.message.uniqueId)
}
}
var currentItemMediaView: StoryItemMediaView? {
didSet {
if
oldValue == nil,
let pauseAndHideChromeWhenMediaViewIsCreated
{
pauseCurrentMediaItem(hideChrome: pauseAndHideChromeWhenMediaViewIsCreated)
}
oldValue?.setIsViewVisible(false)
currentItemMediaView?.setIsViewVisible(true)
}
}
var allowsReplies: Bool {
guard let currentItem else {
return false
}
return currentItem.message.localUserAllowedToReply
}
var loadMessage: StoryMessage?
enum Action {
case none
case presentReplies
case presentInfo
}
var action: Action = .none
enum LoadPosition {
case `default`
case newest
case oldest
}
private(set) var loadPositionIfRead: LoadPosition
private(set) lazy var contextMenuGenerator = StoryContextMenuGenerator(presentingController: self, delegate: self)
init(
context: StoryContext,
loadPositionIfRead: LoadPosition = .default,
spoilerState: SpoilerRenderState,
delegate: StoryContextViewControllerDelegate,
) {
self.context = context
self.spoilerState = spoilerState
self.loadPositionIfRead = loadPositionIfRead
super.init()
self.delegate = delegate
overrideUserInterfaceStyle = .dark
DependenciesBridge.shared.databaseChangeObserver.appendDatabaseChangeDelegate(self)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func resetForPresentation() {
pauseTime = nil
lastTransitionTime = nil
if let currentItemMediaView {
pauseAndHideChromeWhenMediaViewIsCreated = nil
// Restart playback for the current item
currentItemMediaView.resetPlayback()
updateProgressState()
} else {
// If a specific message was specified to load to, present that first.
if
let loadMessage, let item = items.first(where: {
$0.message.uniqueId == loadMessage.uniqueId
})
{
currentItem = item
// Otherwise, if there's an unviewed story, we always want to present that first.
} else if
let firstUnviewedStory = items.first(where: {
$0.message.localUserViewedTimestamp == nil
})
{
currentItem = firstUnviewedStory
} else {
switch loadPositionIfRead {
case .newest:
currentItem = items.last
case .oldest, .default:
currentItem = items.first
}
}
// For subsequent loads, use the default position.
loadPositionIfRead = .default
loadMessage = nil
switch action {
case .none:
break
case .presentReplies:
presentRepliesAndViewsSheet()
action = .none
case .presentInfo:
presentInfoSheet()
action = .none
}
}
playbackProgressView.alpha = 1
closeButton.alpha = 1
repliesAndViewsButton.alpha = 1
if onboardingOverlay.isDisplaying {
pause(hideChrome: true)
}
}
func updateMuteState() {
currentItemMediaView?.updateMuteState()
}
func transitionToNextItem(nextContextLoadPositionIfRead: LoadPosition = .default) {
guard
let currentItem,
let currentItemIndex = items.firstIndex(of: currentItem),
let itemAfter = items[safe: currentItemIndex.advanced(by: 1)]
else {
delegate?.storyContextViewControllerWantsTransitionToNextContext(self, loadPositionIfRead: nextContextLoadPositionIfRead)
return
}
self.currentItem = itemAfter
}
func transitionToPreviousItem(previousContextLoadPositionIfRead: LoadPosition = .default) {
guard
let currentItem,
let currentItemIndex = items.firstIndex(of: currentItem),
let itemBefore = items[safe: currentItemIndex.advanced(by: -1)]
else {
delegate?.storyContextViewControllerWantsTransitionToPreviousContext(self, loadPositionIfRead: previousContextLoadPositionIfRead)
return
}
self.currentItem = itemBefore
}
private lazy var leftTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(didTapLeft))
private lazy var rightTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(didTapRight))
private lazy var pauseGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress))
private lazy var zoomPinchGestureRecognizer = UIPinchGestureRecognizer(target: self, action: #selector(handlePinchZoom))
private lazy var zoomPanGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePinchZoom))
private lazy var closeButton = OWSButton(imageName: Theme.iconName(.buttonX), tintColor: .ows_white)
private lazy var mediaViewContainer = UIView()
private lazy var onboardingOverlay = StoryContextOnboardingOverlayView(delegate: self)
private lazy var sendingIndicatorStackView = UIStackView()
private lazy var repliesAndViewsButton = OWSButton()
override func viewDidLoad() {
super.viewDidLoad()
view.layer.masksToBounds = true
view.addGestureRecognizer(leftTapGestureRecognizer)
view.addGestureRecognizer(rightTapGestureRecognizer)
view.addGestureRecognizer(pauseGestureRecognizer)
view.addGestureRecognizer(zoomPinchGestureRecognizer)
view.addGestureRecognizer(zoomPanGestureRecognizer)
leftTapGestureRecognizer.delegate = self
rightTapGestureRecognizer.delegate = self
pauseGestureRecognizer.delegate = self
zoomPinchGestureRecognizer.delegate = self
zoomPanGestureRecognizer.delegate = self
pauseGestureRecognizer.minimumPressDuration = 0.2
leftTapGestureRecognizer.require(toFail: pauseGestureRecognizer)
rightTapGestureRecognizer.require(toFail: pauseGestureRecognizer)
view.addSubview(mediaViewContainer)
view.addSubview(onboardingOverlay)
onboardingOverlay.autoPinEdges(toEdgesOf: mediaViewContainer)
repliesAndViewsButton.block = { [weak self] in self?.presentRepliesAndViewsSheet() }
repliesAndViewsButton.autoSetDimension(.height, toSize: 64)
repliesAndViewsButton.setTitleColor(Theme.darkThemePrimaryColor, for: .normal)
repliesAndViewsButton.setTitleColor(Theme.darkThemePrimaryColor.withAlphaComponent(0.4), for: .highlighted)
view.addSubview(repliesAndViewsButton)
repliesAndViewsButton.autoPinEdge(.leading, to: .leading, of: mediaViewContainer)
repliesAndViewsButton.autoPinEdge(.trailing, to: .trailing, of: mediaViewContainer)
sendingIndicatorStackView.axis = .horizontal
sendingIndicatorStackView.spacing = 13
sendingIndicatorStackView.alignment = .center
view.addSubview(sendingIndicatorStackView)
sendingIndicatorStackView.autoPinEdges(toEdgesOf: repliesAndViewsButton)
view.addSubview(playbackProgressView)
playbackProgressView.autoPinEdge(.leading, to: .leading, of: mediaViewContainer, withOffset: OWSTableViewController2.defaultHOuterMargin)
playbackProgressView.autoPinEdge(.trailing, to: .trailing, of: mediaViewContainer, withOffset: -OWSTableViewController2.defaultHOuterMargin)
playbackProgressView.autoSetDimension(.height, toSize: 2)
playbackProgressView.isUserInteractionEnabled = false
if UIDevice.current.hasIPhoneXNotch || UIDevice.current.isIPad {
// iPhone with notch or iPad (views/replies rendered below media, media is in a card)
mediaViewContainer.layer.cornerRadius = 18
mediaViewContainer.clipsToBounds = true
onboardingOverlay.layer.cornerRadius = 18
onboardingOverlay.clipsToBounds = true
repliesAndViewsButton.autoPinEdge(.top, to: .bottom, of: mediaViewContainer)
playbackProgressView.autoPinEdge(.bottom, to: .top, of: repliesAndViewsButton, withOffset: -OWSTableViewController2.defaultHOuterMargin)
} else {
// iPhone with home button (views/replies rendered on top of media, media is fullscreen)
repliesAndViewsButton.autoPinEdge(.bottom, to: .bottom, of: mediaViewContainer)
playbackProgressView.autoPinEdge(.bottom, to: .top, of: repliesAndViewsButton)
mediaViewContainer.autoPinEdge(toSuperviewSafeArea: .bottom)
}
applyConstraints()
let spinner = UIActivityIndicatorView(style: .medium)
view.addSubview(spinner)
spinner.autoCenterInSuperview()
spinner.startAnimating()
closeButton.block = { [weak self] in
self?.dismiss(animated: true)
}
closeButton.setShadow()
closeButton.ows_imageEdgeInsets = UIEdgeInsets(hMargin: 16, vMargin: 16)
view.addSubview(closeButton)
closeButton.autoSetDimensions(to: CGSize(square: 56))
closeButton.autoPinEdge(toSuperviewSafeArea: .top)
closeButton.autoPinEdge(toSuperviewSafeArea: .trailing)
loadStoryItems { [weak self] storyItems in
// If there are no stories for this context, dismiss.
guard !storyItems.isEmpty else {
self?.dismiss(animated: true)
return
}
UIView.animate(withDuration: 0.2) {
spinner.alpha = 0
} completion: { _ in
spinner.stopAnimating()
spinner.removeFromSuperview()
}
self?.items = storyItems
self?.resetForPresentation()
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// Precompute if it should display before we mark anything viewed.
onboardingOverlay.checkIfShouldDisplay()
}
/// NOTE: once this becomes true, it stays true. Becomes true once interactive presentation animations finish.
private var viewHasAppeared = false
/// This controller's view gets generated early to use for a zoom animation, which triggers
/// viewWill- and viewDidAppear before presentation has really finished.
/// The parent page controller calls this method when presentation actually finishes.
func pageControllerDidAppear() {
onboardingOverlay.showIfNeeded()
viewHasAppeared = true
}
private static let maxItemsToRender = 100
private func loadStoryItems(completion: @escaping ([StoryItem]) -> Void) {
var storyItems = [StoryItem]()
SSKEnvironment.shared.databaseStorageRef.asyncRead { [weak self] transaction in
guard let self else { return }
StoryFinder.enumerateStoriesForContext(self.context, transaction: transaction) { message, stop in
if self.delegate?.storyContextViewControllerShouldOnlyRenderMyStories(self) == true, !message.authorAddress.isLocalAddress { return }
guard let storyItem = self.buildStoryItem(for: message, transaction: transaction) else { return }
storyItems.append(storyItem)
if storyItems.count >= Self.maxItemsToRender { stop = true }
}
DispatchQueue.main.async {
completion(storyItems)
}
}
}
private func buildStoryItem(for message: StoryMessage, transaction: DBReadTransaction) -> StoryItem? {
let replyCount = message.replyCount
let attachment: ReferencedAttachment?
switch message.attachment {
case .media:
attachment = message.id.map {
return DependenciesBridge.shared.attachmentStore.fetchAnyReferencedAttachment(
for: .storyMessageMedia(storyMessageRowId: $0),
tx: transaction,
)
} ?? nil
case .text(let attachment):
let preloadedAttachment = PreloadedTextAttachment.from(
attachment,
storyMessage: message,
tx: transaction,
)
return .init(message: message, numberOfReplies: replyCount, attachment: .text(preloadedAttachment))
}
guard let attachment else {
owsFailDebug("Missing attachment for StoryMessage with timestamp \(message.timestamp)")
return nil
}
if attachment.attachment.asStream() == nil, let attachmentPointer = attachment.attachment.asAnyPointer() {
let downloadState = attachmentPointer.downloadState(tx: transaction)
let pointer = StoryItem.Attachment.Pointer(
reference: attachment.reference,
attachment: attachmentPointer,
downloadState: downloadState,
)
return StoryItem(
message: message,
numberOfReplies: replyCount,
attachment: .pointer(pointer),
)
} else if let attachmentStream = attachment.attachment.asStream() {
let stream = StoryItem.Attachment.Stream(
attachment: .init(reference: attachment.reference, attachmentStream: attachmentStream),
)
return StoryItem(
message: message,
numberOfReplies: replyCount,
attachment: .stream(stream),
)
} else {
owsFailDebug("Unexpected attachment type \(type(of: attachment))")
return nil
}
}
private func currentItemWasUpdated(messageDidChange: Bool) {
if let currentItem {
let newContextButton: ContextMenuButton = {
let attachment: StoryThumbnailView.Attachment
switch currentItem.attachment {
case .pointer(let pointer):
attachment = .file(.init(reference: pointer.reference, attachment: pointer.attachment.attachment))
case .stream(let stream):
attachment = .file(stream.attachment)
case .text(let textAttachment):
attachment = .text(textAttachment)
}
let contextButton = ContextMenuButton(
empty: (),
onWillDisplayContextMenu: { [weak self] in
guard let self else { return }
pause()
},
onDidDismissContextMenu: { [weak self] in
guard let self else { return }
if !contextMenuGenerator.isDisplayingFollowup {
play()
}
},
)
let actions = SSKEnvironment.shared.databaseStorageRef.read { tx -> [UIAction] in
self.contextMenuGenerator.nativeContextMenuActions(
for: currentItem.message,
in: self.context.thread(transaction: tx),
attachment: attachment,
spoilerState: self.spoilerState,
sourceView: { [weak contextButton] in return contextButton },
transaction: tx,
)
}
contextButton.setActions(actions: actions)
return contextButton
}()
if currentItemMediaView == nil {
let itemView = StoryItemMediaView(
item: currentItem,
contextButton: newContextButton,
spoilerState: spoilerState,
delegate: self,
)
self.currentItemMediaView = itemView
mediaViewContainer.addSubview(itemView)
itemView.autoPinEdgesToSuperviewEdges()
}
currentItemMediaView!.updateItem(
currentItem,
newContextButton: newContextButton,
)
if currentItem.message.sendingState != .sent {
updateSendingIndicator(currentItem)
} else {
updateRepliesAndViewsButton(currentItem)
}
} else {
repliesAndViewsButton.isHidden = true
sendingIndicatorStackView.isHidden = true
}
if messageDidChange {
ensureSubsequentItemsDownloaded()
updateProgressState()
}
}
private func updateSendingIndicator(_ currentItem: StoryItem) {
repliesAndViewsButton.isHidden = true
sendingIndicatorStackView.gestureRecognizers?.removeAll()
sendingIndicatorStackView.removeAllSubviews()
switch currentItem.message.sendingState {
case .pending, .sending:
sendingIndicatorStackView.isHidden = false
let sendingSpinner = LottieAnimationView(name: "indeterminate_spinner_20")
sendingSpinner.contentMode = .scaleAspectFit
sendingSpinner.loopMode = .loop
sendingSpinner.backgroundBehavior = .pauseAndRestore
sendingSpinner.autoSetDimension(.width, toSize: 20)
sendingSpinner.play()
let sendingLabel = UILabel()
sendingLabel.font = .dynamicTypeBody
sendingLabel.textColor = Theme.darkThemePrimaryColor
sendingLabel.textAlignment = .center
sendingLabel.text = OWSLocalizedString("STORY_SENDING", comment: "Text indicating that the story is currently sending")
sendingLabel.setContentHuggingHigh()
let leadingSpacer = UIView.hStretchingSpacer()
let trailingSpacer = UIView.hStretchingSpacer()
sendingIndicatorStackView.addArrangedSubviews([
leadingSpacer,
sendingSpinner,
sendingLabel,
trailingSpacer,
])
leadingSpacer.autoMatch(.width, to: .width, of: trailingSpacer)
case .failed:
sendingIndicatorStackView.isHidden = false
sendingIndicatorStackView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(askToResendFailedMessage)))
let failedIcon = UIImageView()
failedIcon.contentMode = .scaleAspectFit
failedIcon.setTemplateImageName("error-circle-20", tintColor: .ows_accentRed)
failedIcon.autoSetDimension(.width, toSize: 20)
sendingIndicatorStackView.addArrangedSubview(failedIcon)
let failedLabel = UILabel()
failedLabel.font = .dynamicTypeBody
failedLabel.textColor = Theme.darkThemePrimaryColor
failedLabel.textAlignment = .center
failedLabel.text = currentItem.message.hasSentToAnyRecipients
? OWSLocalizedString("STORY_SEND_PARTIALLY_FAILED_TAP_FOR_DETAILS", comment: "Text indicating that the story send has partially failed")
: OWSLocalizedString("STORY_SEND_FAILED_TAP_FOR_DETAILS", comment: "Text indicating that the story send has failed")
failedLabel.setContentHuggingHigh()
sendingIndicatorStackView.addArrangedSubview(failedLabel)
let leadingSpacer = UIView.hStretchingSpacer()
let trailingSpacer = UIView.hStretchingSpacer()
sendingIndicatorStackView.addArrangedSubviews([
leadingSpacer,
failedIcon,
failedLabel,
trailingSpacer,
])
leadingSpacer.autoMatch(.width, to: .width, of: trailingSpacer)
case .sent:
sendingIndicatorStackView.isHidden = true
case .sent_OBSOLETE, .delivered_OBSOLETE:
owsFailDebug("Unexpected legacy sending state")
}
}
@objc
private func askToResendFailedMessage() {
guard
let message = currentItem?.message,
let thread = SSKEnvironment.shared.databaseStorageRef.read(block: { context.thread(transaction: $0) })
else { return }
pause()
StoryUtil.askToResend(message, in: thread, from: self) { [weak self] in
self?.play()
}
}
private func updateRepliesAndViewsButton(_ currentItem: StoryItem) {
sendingIndicatorStackView.isHidden = true
if currentItem.message.localUserAllowedToReply {
repliesAndViewsButton.isHidden = false
let repliesAndViewsButtonText: String
var leadingIcon: UIImage?
var trailingIcon: UIImage?
switch currentItem.message.direction {
case .incoming:
if case .groupId = context {
if currentItem.numberOfReplies == 0 {
leadingIcon = UIImage(imageLiteralResourceName: "reply-20")
repliesAndViewsButtonText = OWSLocalizedString(
"STORY_REPLY_TO_GROUP_BUTTON",
comment: "Button for replying to a group story with no existing replies.",
)
} else {
trailingIcon = UIImage(imageLiteralResourceName: "chevron-right-20")
let format = OWSLocalizedString(
"STORY_REPLIES_COUNT_%d",
tableName: "PluralAware",
comment: "Button for replying to a story with N existing replies.",
)
repliesAndViewsButtonText = String.localizedStringWithFormat(format, currentItem.numberOfReplies)
}
} else {
leadingIcon = UIImage(imageLiteralResourceName: "reply-20")
repliesAndViewsButtonText = OWSLocalizedString(
"STORY_REPLY_BUTTON",
comment: "Button for replying to a story with no existing replies.",
)
}
case .outgoing:
var textSegments = [String]()
if StoryManager.areViewReceiptsEnabled {
let format = OWSLocalizedString(
"STORY_VIEWS_COUNT_%d",
tableName: "PluralAware",
comment: "Button for viewing the views for a story sent to a private list",
)
textSegments.append(
String.localizedStringWithFormat(format, currentItem.message.remoteViewCount(in: context)),
)
}
if case .groupId = context, StoryManager.areViewReceiptsEnabled || currentItem.numberOfReplies > 0 {
let format = OWSLocalizedString(
"STORY_REPLIES_COUNT_%d",
tableName: "PluralAware",
comment: "Button for replying to a story with N existing replies.",
)
textSegments.append(
String.localizedStringWithFormat(format, currentItem.numberOfReplies),
)
}
if textSegments.isEmpty {
repliesAndViewsButtonText = OWSLocalizedString(
"STORY_VIEWS_OFF",
comment: "Text indicating that the user has views turned off",
)
} else {
trailingIcon = UIImage(imageLiteralResourceName: "chevron-right-20")
repliesAndViewsButtonText = textSegments.joined(separator: " ")
}
}
repliesAndViewsButton.semanticContentAttribute = .unspecified
if let leadingIcon {
repliesAndViewsButton.setImage(leadingIcon.withTintColor(Theme.darkThemePrimaryColor, renderingMode: .alwaysOriginal), for: .normal)
repliesAndViewsButton.ows_imageEdgeInsets = UIEdgeInsets(top: 2, leading: 0, bottom: 0, trailing: 16)
} else if let trailingIcon {
repliesAndViewsButton.setImage(trailingIcon.withTintColor(Theme.darkThemePrimaryColor, renderingMode: .alwaysOriginal), for: .normal)
repliesAndViewsButton.semanticContentAttribute = CurrentAppContext().isRTL ? .forceLeftToRight : .forceRightToLeft
repliesAndViewsButton.ows_imageEdgeInsets = UIEdgeInsets(top: 3, leading: 0, bottom: 0, trailing: 0)
} else {
repliesAndViewsButton.setImage(nil, for: .normal)
repliesAndViewsButton.contentHorizontalAlignment = .center
}
let semiboldStyle = StringStyle(.font(.systemFont(ofSize: 17, weight: .semibold)))
repliesAndViewsButton.setAttributedTitle(
repliesAndViewsButtonText.styled(
with: .font(.systemFont(ofSize: 17)),
.color(Theme.darkThemePrimaryColor),
.xmlRules([.style("bold", semiboldStyle)]),
),
for: .normal,
)
repliesAndViewsButton.setAttributedTitle(
repliesAndViewsButtonText.styled(
with: .font(.systemFont(ofSize: 17)),
.color(Theme.darkThemePrimaryColor.withAlphaComponent(0.4)),
.xmlRules([.style("bold", semiboldStyle)]),
),
for: .highlighted,
)
} else {
repliesAndViewsButton.isHidden = true
}
}
private var pauseTime: CFTimeInterval?
private var lastTransitionTime: CFTimeInterval?
private func updateProgressState() {
lastTransitionTime = CACurrentMediaTime()
if let currentItemView = currentItemMediaView, let idx = items.firstIndex(of: currentItemView.item) {
playbackProgressView.itemState = .init(index: idx, value: 0)
}
}
@objc
func displayLinkStep(_ displayLink: CADisplayLink) {
AssertIsOnMainThread()
playbackProgressView.numberOfItems = items.count
if let currentItemView = currentItemMediaView, let idx = items.firstIndex(of: currentItemView.item) {
// When we present a story, mark it as viewed if it's not already, as long as it's downloaded.
if
self.viewHasAppeared,
!onboardingOverlay.isDisplaying,
!currentItemView.item.isPendingDownload,
currentItemView.item.message.localUserViewedTimestamp == nil
{
SSKEnvironment.shared.databaseStorageRef.write { transaction in
currentItemView.item.message.markAsViewed(at: Date.ows_millisecondTimestamp(), circumstance: .onThisDevice, transaction: transaction)
}
}
currentItemView.updateTimestampText()
if currentItemView.item.isPendingDownload {
// Don't progress stories that are pending download.
lastTransitionTime = CACurrentMediaTime()
playbackProgressView.itemState = .init(index: idx, value: 0)
} else if let lastTransitionTime {
let currentTime: CFTimeInterval
if let elapsedTime = currentItemView.elapsedTime {
currentTime = lastTransitionTime + elapsedTime
} else {
currentTime = displayLink.targetTimestamp
}
let value = currentTime.inverseLerp(
lastTransitionTime,
lastTransitionTime + currentItemView.duration,
shouldClamp: true,
)
playbackProgressView.itemState = .init(index: idx, value: value)
if value >= 1 {
transitionToNextItem()
}
} else {
playbackProgressView.itemState = .init(index: idx, value: 0)
}
} else {
playbackProgressView.itemState = .init(index: 0, value: 0)
}
}
private static let subsequentItemsToLoad = 3
private func ensureSubsequentItemsDownloaded() {
guard let currentItem, let currentItemIdx = items.firstIndex(of: currentItem) else { return }
let endingIdx = min(items.count - 1, currentItemIdx + Self.subsequentItemsToLoad)
var subsequentItems = items[currentItemIdx...endingIdx]
var context = context
DispatchQueue.sharedUtility.async {
// If the current context has less than 3 unloaded items, try the next context until we reach the end or the limit
while subsequentItems.count < Self.subsequentItemsToLoad {
guard let nextContext = self.delegate?.storyContextViewController(self, contextAfter: context) else { break }
SSKEnvironment.shared.databaseStorageRef.read { transaction in
StoryFinder.enumerateUnviewedIncomingStoriesForContext(self.context, transaction: transaction) { message, stop in
if self.delegate?.storyContextViewControllerShouldOnlyRenderMyStories(self) == true, !message.authorAddress.isLocalAddress { return }
guard let storyItem = self.buildStoryItem(for: message, transaction: transaction) else { return }
subsequentItems.append(storyItem)
if subsequentItems.count >= Self.subsequentItemsToLoad { stop = true }
}
}
context = nextContext
}
subsequentItems.forEach { $0.startAttachmentDownloadIfNecessary() }
}
}
private lazy var iPhoneConstraints = [
mediaViewContainer.autoPinEdge(toSuperviewSafeArea: .top),
mediaViewContainer.autoPinEdge(toSuperviewSafeArea: .leading),
mediaViewContainer.autoPinEdge(toSuperviewSafeArea: .trailing),
]
private lazy var iPadConstraints: [NSLayoutConstraint] = {
var constraints = mediaViewContainer.autoCenterInSuperview()
// Prefer to be as big as possible.
NSLayoutConstraint.autoSetPriority(.defaultHigh) {
constraints.append(contentsOf: [
mediaViewContainer.autoMatch(.height, to: .height, of: view),
mediaViewContainer.autoMatch(.width, to: .width, of: view),
])
}
let maxWidthConstraint = mediaViewContainer.autoMatch(
.width,
to: .width,
of: view,
withOffset: 0,
relation: .lessThanOrEqual,
)
constraints.append(maxWidthConstraint)
return constraints
}()
private lazy var iPadLandscapeConstraints = [
mediaViewContainer.autoMatch(
.height,
to: .height,
of: view,
withMultiplier: 0.75,
relation: .lessThanOrEqual,
),
]
private lazy var iPadPortraitConstraints = [
mediaViewContainer.autoMatch(
.height,
to: .height,
of: view,
withMultiplier: 0.65,
relation: .lessThanOrEqual,
),
]
private func applyConstraints() {
NSLayoutConstraint.deactivate(iPhoneConstraints)
NSLayoutConstraint.deactivate(iPadConstraints)
NSLayoutConstraint.deactivate(iPadPortraitConstraints)
NSLayoutConstraint.deactivate(iPadLandscapeConstraints)
if UIDevice.current.isIPad {
NSLayoutConstraint.activate(iPadConstraints)
if UIDevice.current.orientation.isLandscape {
NSLayoutConstraint.activate(iPadLandscapeConstraints)
} else {
NSLayoutConstraint.activate(iPadPortraitConstraints)
}
} else {
NSLayoutConstraint.activate(iPhoneConstraints)
}
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate { _ in
self.applyConstraints()
} completion: { _ in
self.applyConstraints()
}
}
private var isPinchZooming = false
@objc
private func handlePinchZoom() {
func beginIfNecessary(with sender: UIGestureRecognizer) {
guard !isPinchZooming else { return }
isPinchZooming = true
pause(hideChrome: true)
let touchPoint = sender.location(in: mediaViewContainer)
mediaViewContainer.setAnchorPointAndMaintainPosition(CGPoint(
x: touchPoint.x / mediaViewContainer.width,
y: touchPoint.y / mediaViewContainer.height,
))
}
func endIfNecessary() {
guard isPinchZooming else { return }
let endableStates: [UIGestureRecognizer.State] = [
.possible,
.ended,
.cancelled,
.failed,
]
guard
endableStates.contains(zoomPanGestureRecognizer.state),
endableStates.contains(zoomPinchGestureRecognizer.state) else { return }
isPinchZooming = false
UIView.animate(withDuration: 0.35) {
self.mediaViewContainer.transform = .identity
self.mediaViewContainer.setAnchorPointAndMaintainPosition(CGPoint(x: 0.5, y: 0.5))
} completion: { _ in
self.play()
}
}
func update() {
mediaViewContainer.transform = .scale(zoomPinchGestureRecognizer.scale)
.translate(zoomPanGestureRecognizer.translation(in: mediaViewContainer))
}
for gesture in [zoomPanGestureRecognizer, zoomPinchGestureRecognizer] {
switch gesture.state {
case .possible:
break
case .began:
beginIfNecessary(with: gesture)
case .changed:
update()
case .ended, .cancelled, .failed:
endIfNecessary()
@unknown default:
break
}
}
}
func pause(hideChrome: Bool = false) {
guard pauseTime == nil else { return }
pauseTime = CACurrentMediaTime()
delegate?.storyContextViewControllerDidPause(self)
pauseCurrentMediaItem(hideChrome: hideChrome)
}
/// If we try and pause before the media has been loaded and media view created,
/// we remember that state in this variable and apply it once the view is created.
private var pauseAndHideChromeWhenMediaViewIsCreated: Bool?
private func pauseCurrentMediaItem(hideChrome: Bool) {
guard let currentItemMediaView else {
pauseAndHideChromeWhenMediaViewIsCreated = hideChrome
return
}
currentItemMediaView.pause(hideChrome: hideChrome) {
if hideChrome {
self.playbackProgressView.alpha = 0
self.closeButton.alpha = 0
self.repliesAndViewsButton.alpha = 0
}
}
pauseAndHideChromeWhenMediaViewIsCreated = nil
}
func play() {
if let lastTransitionTime, let pauseTime {
let pauseDuration = CACurrentMediaTime() - pauseTime
self.lastTransitionTime = lastTransitionTime + pauseDuration
self.pauseTime = nil
}
currentItemMediaView?.play {
self.playbackProgressView.alpha = 1
self.closeButton.alpha = 1
self.repliesAndViewsButton.alpha = 1
}
delegate?.storyContextViewControllerDidResume(self)
}
}
extension StoryContextViewController: UIGestureRecognizerDelegate {
@objc
func didTapLeft() {
guard currentItemMediaView?.willHandleTapGesture(leftTapGestureRecognizer) != true else {
return
}
if CurrentAppContext().isRTL {
transitionToNextItem(nextContextLoadPositionIfRead: .oldest)
} else {
transitionToPreviousItem(previousContextLoadPositionIfRead: .newest)
}
}
@objc
func didTapRight() {
guard currentItemMediaView?.willHandleTapGesture(rightTapGestureRecognizer) != true else {
return
}
if CurrentAppContext().isRTL {
transitionToPreviousItem(previousContextLoadPositionIfRead: .newest)
} else {
transitionToNextItem(nextContextLoadPositionIfRead: .oldest)
}
}
func willHandleInteractivePanGesture(_ gestureRecognizer: UIPanGestureRecognizer) -> Bool {
return currentItemMediaView?.willHandlePanGesture(gestureRecognizer) == true
}
@objc
func handleLongPress() {
switch pauseGestureRecognizer.state {
case .began:
pause(hideChrome: true)
case .ended:
play()
default:
break
}
}
func presentRepliesAndViewsSheet() {
guard let currentItem, currentItem.message.localUserAllowedToReply else {
owsFailDebug("Unexpectedly attempting to present reply sheet")
return
}
switch self.context {
case .groupId:
switch currentItem.message.direction {
case .outgoing:
let groupRepliesAndViewsVC = StoryGroupRepliesAndViewsSheet(
storyMessage: currentItem.message,
context: context,
spoilerState: spoilerState,
)
groupRepliesAndViewsVC.dismissHandler = { [weak self] in self?.play() }
groupRepliesAndViewsVC.focusedTab = currentItem.numberOfReplies > 0 ? .replies : .views
self.pause()
self.present(groupRepliesAndViewsVC, animated: true)
case .incoming:
let groupReplyVC = StoryGroupReplySheet(
storyMessage: currentItem.message,
spoilerState: spoilerState,
)
groupReplyVC.dismissHandler = { [weak self] in self?.play() }
self.pause()
self.present(groupReplyVC, animated: true)
}
case .authorAci:
owsAssertDebug(
!currentItem.message.authorAddress.isSystemStoryAddress,
"Should be impossible to reply to system stories",
)
let directReplyVC = StoryDirectReplySheet(storyMessage: currentItem.message, spoilerState: spoilerState)
directReplyVC.dismissHandler = { [weak self] in self?.play() }
self.pause()
self.present(directReplyVC, animated: true)
case .privateStory:
let privateViewsVC = StoryPrivateViewsSheet(storyMessage: currentItem.message, context: context)
privateViewsVC.dismissHandler = { [weak self] in self?.play() }
self.pause()
self.present(privateViewsVC, animated: true)
case .none:
owsFailDebug("Unexpected context")
}
}
func presentInfoSheet() {
guard let currentItem else { return }
let vc = StoryInfoSheet(storyMessage: currentItem.message, context: context, spoilerState: spoilerState)
vc.dismissHandler = { [weak self] in self?.play() }
pause()
present(vc, animated: true)
}
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
let isMultiTouchGesture = gestureRecognizer == zoomPinchGestureRecognizer || gestureRecognizer == zoomPanGestureRecognizer
if gestureRecognizer.numberOfTouches > 1 {
// Only allow pinch-zoom on downloaded image attachments
guard case .stream = currentItem?.attachment else { return false }
return isMultiTouchGesture
} else if isMultiTouchGesture {
return false
}
if gestureRecognizer == pauseGestureRecognizer {
// Don't pause while long pressing the replies and views button
guard repliesAndViewsButton.isHidden || !repliesAndViewsButton.bounds.contains(gestureRecognizer.location(in: repliesAndViewsButton)) else {
return false
}
}
let nextFrameWidth = mediaViewContainer.width * 0.8
let previousFrameWidth = mediaViewContainer.width * 0.2
let leftFrameWidth: CGFloat
let rightFrameWidth: CGFloat
if CurrentAppContext().isRTL {
leftFrameWidth = nextFrameWidth
rightFrameWidth = previousFrameWidth
} else {
leftFrameWidth = previousFrameWidth
rightFrameWidth = nextFrameWidth
}
let touchLocation = gestureRecognizer.location(in: view)
if gestureRecognizer == leftTapGestureRecognizer {
var leftFrame = mediaViewContainer.frame
leftFrame.width = leftFrameWidth
return leftFrame.contains(touchLocation)
} else if gestureRecognizer == rightTapGestureRecognizer {
var rightFrame = mediaViewContainer.frame
rightFrame.width = rightFrameWidth
rightFrame.x += leftFrameWidth
return rightFrame.contains(touchLocation)
} else {
return true
}
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
[zoomPanGestureRecognizer, zoomPinchGestureRecognizer].contains(gestureRecognizer)
&& [zoomPanGestureRecognizer, zoomPinchGestureRecognizer].contains(otherGestureRecognizer)
}
}
extension StoryContextViewController: DatabaseChangeDelegate {
func databaseChangesDidUpdate(databaseChanges: DatabaseChanges) {
guard var currentItem else { return }
guard !databaseChanges.storyMessageRowIds.isEmpty else { return }
SSKEnvironment.shared.databaseStorageRef.asyncRead { transaction in
var newItems = self.items
var shouldGoToNextContext = false
for (idx, item) in self.items.enumerated().reversed() {
guard let id = item.message.id, databaseChanges.storyMessageRowIds.contains(id) else { continue }
if let message = StoryMessage.anyFetch(uniqueId: item.message.uniqueId, transaction: transaction) {
if let newItem = self.buildStoryItem(for: message, transaction: transaction) {
newItems[idx] = newItem
if item.message.uniqueId == currentItem.message.uniqueId {
currentItem = newItem
}
continue
}
}
newItems.remove(at: idx)
if item.message.uniqueId == currentItem.message.uniqueId {
shouldGoToNextContext = true
break
}
}
DispatchQueue.main.async {
if shouldGoToNextContext, let delegate = self.delegate {
delegate.storyContextViewControllerWantsTransitionToNextContext(self, loadPositionIfRead: .default)
} else if shouldGoToNextContext {
self.presentingViewController?.dismiss(animated: true)
} else {
self.items = newItems
self.currentItem = currentItem
}
}
}
}
func databaseChangesDidUpdateExternally() {}
func databaseChangesDidReset() {}
}
extension StoryContextViewController: StoryItemMediaViewDelegate {
func storyItemMediaViewWantsToPlay(_ storyItemMediaView: StoryItemMediaView) {
play()
}
func storyItemMediaViewWantsToPause(_ storyItemMediaView: StoryItemMediaView) {
pause()
}
func storyItemMediaViewShouldBeMuted(_ storyItemMediaView: StoryItemMediaView) -> Bool {
return delegate?.storyContextViewControllerShouldBeMuted(self) ?? false
}
}
extension StoryContextViewController: StoryContextMenuDelegate {
func storyContextMenuWillDelete(_ completion: @escaping () -> Void) {
// Go to the next item after deleting.
self.transitionToNextItem()
completion()
}
func storyContextMenuDidUpdateHiddenState(_ message: StoryMessage, isHidden: Bool) -> Bool {
// Go to the next context after hiding or unhiding; the current context is no longer
// a part of this view session as its hide state is now opposite.
self.delegate?.storyContextViewControllerWantsTransitionToNextContext(self, loadPositionIfRead: .default)
// Return true so we show a toast confirming the hide action.
return true
}
func storyContextMenuDidFinishDisplayingFollowups() {
play()
}
}
extension StoryContextViewController: StoryContextOnboardingOverlayViewDelegate {
func storyContextOnboardingOverlayWillDisplay(_: StoryContextOnboardingOverlayView) {
pause(hideChrome: true)
}
func storyContextOnboardingOverlayDidDismiss(_: StoryContextOnboardingOverlayView) {
play()
}
func storyContextOnboardingOverlayWantsToExitStoryViewer(_: StoryContextOnboardingOverlayView) {
self.dismiss(animated: true)
}
}
private extension UIView {
func setAnchorPointAndMaintainPosition(_ newAnchorPoint: CGPoint) {
layer.position = CGPoint(
x: layer.position.x + (newAnchorPoint.x * width) - (layer.anchorPoint.x * width),
y: layer.position.y + (newAnchorPoint.y * height) - (layer.anchorPoint.y * height),
)
layer.anchorPoint = newAnchorPoint
}
}