Path: blob/main/Signal/src/ViewControllers/HomeView/Stories/Context View/StoryItemMediaView.swift
1 views
//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import CoreMedia
import Foundation
import SafariServices
import SDWebImage
import SignalServiceKit
import SignalUI
import UIKit
protocol StoryItemMediaViewDelegate: AnyObject {
func storyItemMediaViewWantsToPause(_ storyItemMediaView: StoryItemMediaView)
func storyItemMediaViewWantsToPlay(_ storyItemMediaView: StoryItemMediaView)
func storyItemMediaViewShouldBeMuted(_ storyItemMediaView: StoryItemMediaView) -> Bool
var contextMenuGenerator: StoryContextMenuGenerator { get }
var context: StoryContext { get }
}
class StoryItemMediaView: UIView {
weak var delegate: StoryItemMediaViewDelegate?
private(set) var item: StoryItem
private let spoilerState: SpoilerRenderState
private lazy var gradientProtectionView = GradientView(colors: [])
private var gradientProtectionViewHeightConstraint: NSLayoutConstraint?
private var contextButton: ContextMenuButton!
private let bottomContentVStack = UIStackView()
init(
item: StoryItem,
contextButton: ContextMenuButton,
spoilerState: SpoilerRenderState,
delegate: StoryItemMediaViewDelegate,
) {
self.item = item
self.spoilerState = spoilerState
self.delegate = delegate
super.init(frame: .zero)
autoPin(toAspectRatio: 9 / 16)
updateMediaView()
if UIDevice.current.hasIPhoneXNotch || UIDevice.current.isIPad {
layer.cornerRadius = 18
clipsToBounds = true
}
addSubview(gradientProtectionView)
gradientProtectionView.autoPinWidthToSuperview()
gradientProtectionView.autoPinEdge(toSuperviewEdge: .bottom)
bottomContentVStack.axis = .vertical
bottomContentVStack.spacing = 24
addSubview(bottomContentVStack)
bottomContentVStack.autoPinWidthToSuperview(withMargin: OWSTableViewController2.defaultHOuterMargin)
if UIDevice.current.hasIPhoneXNotch || UIDevice.current.isIPad {
// iPhone with notch or iPad (views/replies rendered below media, media is in a card)
bottomContentVStack.autoPinEdge(toSuperviewEdge: .bottom, withInset: OWSTableViewController2.defaultHOuterMargin + 16)
} else {
// iPhone with home button (views/replies rendered on top of media, media is fullscreen)
bottomContentVStack.autoPinEdge(toSuperviewEdge: .bottom, withInset: 80)
}
bottomContentVStack.autoPinEdge(toSuperviewEdge: .top, withInset: OWSTableViewController2.defaultHOuterMargin)
bottomContentVStack.addArrangedSubview(.vStretchingSpacer())
bottomContentVStack.addArrangedSubview(captionLabel)
bottomContentVStack.addArrangedSubview(authorRow)
updateCaption()
updateAuthorRow(newContextButton: contextButton)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func resetPlayback() {
videoPlayerLoopCount = 0
videoPlayer?.seek(to: .zero)
videoPlayer?.play()
yyImageView?.startAnimating()
updateTimestampText()
bottomContentVStack.alpha = 1
gradientProtectionView.alpha = 1
lastTruncationWidth = nil
}
func updateItem(_ newItem: StoryItem, newContextButton: ContextMenuButton) {
let oldItem = self.item
self.item = newItem
updateTimestampText()
updateAuthorRow(newContextButton: newContextButton)
// Only recreate the media view if the actual attachment changes.
if item.attachment != oldItem.attachment {
self.pause()
updateMediaView()
lastTruncationWidth = nil
updateCaption()
}
updateGradientProtection()
}
func updateTimestampText() {
timestampLabel.isHidden = item.message.authorAddress.isSystemStoryAddress
timestampLabel.text = DateUtil.formatTimestampRelatively(item.message.timestamp)
}
func willHandleTapGesture(_ gesture: UITapGestureRecognizer) -> Bool {
if startAttachmentDownloadIfNecessary(gesture) { return true }
if revealSpoilerIfNecessary(gesture) { return true }
if toggleCaptionExpansionIfNecessary(gesture) { return true }
if let textAttachmentView = mediaView as? TextAttachmentView {
let didHandle = textAttachmentView.willHandleTapGesture(gesture)
if didHandle {
if textAttachmentView.isPresentingLinkTooltip {
// If we presented a link, pause playback
delegate?.storyItemMediaViewWantsToPause(self)
} else {
// If we dismissed a link, resume playback
delegate?.storyItemMediaViewWantsToPlay(self)
}
}
return didHandle
}
if
let contextButton,
contextButton.bounds.contains(gesture.location(in: contextButton))
{
return true
}
return false
}
func willHandlePanGesture(_ gesture: UIPanGestureRecognizer) -> Bool {
if
let contextButton,
contextButton.bounds.contains(gesture.location(in: contextButton))
{
return true
}
return false
}
// MARK: - Appearance
private var isViewVisible = false {
didSet {
captionLabel.isViewVisible = isViewVisible
}
}
func setIsViewVisible(_ isVisible: Bool) {
self.isViewVisible = isVisible
let attachmentIdToMarkViewed: Attachment.IDType?
switch item.attachment {
case .pointer:
// we don't mark pointers viewed.
attachmentIdToMarkViewed = nil
case .stream(let stream):
attachmentIdToMarkViewed = stream.attachment.attachment.id
case .text(let preloadedTextAttachment):
attachmentIdToMarkViewed = preloadedTextAttachment.linkPreviewAttachment?.attachment.asStream()?.id
}
if let attachmentIdToMarkViewed {
let timestamp = Date().ows_millisecondsSince1970
Task {
await DependenciesBridge.shared.db.awaitableWrite { tx in
DependenciesBridge.shared.attachmentStore.markViewedFullscreen(
attachmentId: attachmentIdToMarkViewed,
timestamp: timestamp,
tx: tx,
)
}
}
}
}
// MARK: - Playback
func pause(hideChrome: Bool = false, animateAlongside: (() -> Void)? = nil) {
videoPlayer?.pause()
yyImageView?.stopAnimating()
if hideChrome {
UIView.animate(withDuration: 0.15, delay: 0, options: [.beginFromCurrentState, .curveEaseInOut]) {
self.bottomContentVStack.alpha = 0
self.gradientProtectionView.alpha = 0
animateAlongside?()
} completion: { _ in }
} else {
animateAlongside?()
}
}
func play(animateAlongside: @escaping () -> Void) {
videoPlayer?.play()
yyImageView?.startAnimating()
UIView.animate(withDuration: 0.15, delay: 0, options: [.beginFromCurrentState, .curveEaseInOut]) {
self.bottomContentVStack.alpha = 1
self.gradientProtectionView.alpha = 1
animateAlongside()
} completion: { _ in
}
}
var duration: CFTimeInterval {
var duration: CFTimeInterval = 0
var glyphCount: Int?
switch item.attachment {
case .pointer:
owsFailDebug("Undownloaded attachments should not progress.")
return 0
case .stream(let stream):
glyphCount = stream.caption?.glyphCount
if let asset = videoPlayer?.avPlayer.currentItem?.asset {
let videoDuration = CMTimeGetSeconds(asset.duration)
if stream.isLoopingVideo {
// GIFs should loop 3 times, or play for 5 seconds
// whichever is longer.
duration = max(5, videoDuration * 3)
} else {
// Videos should play for their duration
duration = videoDuration
// For now, we don't want to factor captions into video durations,
// as it would cause the video to loop leading to weird UX
glyphCount = nil
}
} else if let animatedImageDuration = (yyImageView?.image as? SDAnimatedImage)?.animationDuration {
// GIFs should loop 3 times, or play for 5 seconds
// whichever is longer.
return max(5, animatedImageDuration * 3)
} else {
// System stories play slightly longer.
if item.message.authorAddress.isSystemStoryAddress {
// Based off glyph calculation below for the text
// embedded in the images in english.
duration = 10
} else {
// At base static images should play for 5 seconds
duration = 5
}
}
case .text(let attachment):
switch attachment.textAttachment.textContent {
case .empty:
glyphCount = nil
case .styled(let text, _):
glyphCount = text.glyphCount
case .styledRanges(let body):
glyphCount = body.text.glyphCount
}
// As a base, all text attachments play for at least 5s,
// even if they have no text.
duration = 5
// If a text attachment includes a link preview, play
// for an additional 2s
if attachment.textAttachment.preview != nil { duration += 2 }
}
// If we have a glyph count, increase the duration to allow it to be readable
if let glyphCount {
// For each bucket of glyphs after the first 15,
// add an additional 1s of playback time.
let fifteenGlyphBuckets = (max(0, CGFloat(glyphCount) - 15) / 15).rounded(.up)
duration += fifteenGlyphBuckets
}
return duration
}
var elapsedTime: CFTimeInterval? {
guard
let currentTime = videoPlayer?.avPlayer.currentTime(),
let asset = videoPlayer?.avPlayer.currentItem?.asset else { return nil }
let loopedElapsedTime = Double(videoPlayerLoopCount) * CMTimeGetSeconds(asset.duration)
return CMTimeGetSeconds(currentTime) + loopedElapsedTime
}
private func startAttachmentDownloadIfNecessary(_ gesture: UITapGestureRecognizer) -> Bool {
// Only start downloads when the user taps in the center of the view.
let downloadHitRegion = CGRect(
origin: CGPoint(x: frame.center.x - 30, y: frame.center.y - 30),
size: CGSize(square: 60),
)
guard downloadHitRegion.contains(gesture.location(in: self)) else { return false }
return item.startAttachmentDownloadIfNecessary(priority: .userInitiated)
}
// MARK: - Author Row
private lazy var timestampLabel = UILabel()
private lazy var authorRow = UIStackView()
private func updateAuthorRow(newContextButton contextButton: ContextMenuButton) {
let (avatarView, nameLabel) = SSKEnvironment.shared.databaseStorageRef.read { (
buildAvatarView(transaction: $0),
buildNameLabel(transaction: $0),
) }
let nameTrailingView: UIView
let nameTrailingSpacing: CGFloat
if item.message.authorAddress.isSystemStoryAddress {
let icon = UIImageView(image: Theme.iconImage(.official))
icon.contentMode = .center
nameTrailingView = icon
nameTrailingSpacing = 3
} else {
nameTrailingView = timestampLabel
nameTrailingSpacing = 8
}
let metadataStackView: UIStackView
let nameHStack = UIStackView(arrangedSubviews: [
nameLabel,
nameTrailingView,
])
nameHStack.spacing = nameTrailingSpacing
nameHStack.axis = .horizontal
nameHStack.alignment = .center
if
case .privateStory(let uniqueId) = delegate?.context,
let privateStoryThread = SSKEnvironment.shared.databaseStorageRef.read(
block: { TSPrivateStoryThread.fetchPrivateStoryThreadViaCache(uniqueId: uniqueId, transaction: $0) },
),
!privateStoryThread.isMyStory
{
// For private stories, other than "My Story", render the name of the story
let contextIcon = UIImageView()
contextIcon.setTemplateImageName("stories-fill-compact", tintColor: Theme.darkThemePrimaryColor)
contextIcon.autoSetDimensions(to: .square(16))
let contextNameLabel = UILabel()
contextNameLabel.textColor = Theme.darkThemePrimaryColor
contextNameLabel.font = .dynamicTypeFootnote
contextNameLabel.text = privateStoryThread.name
let contextHStack = UIStackView(arrangedSubviews: [
contextIcon,
contextNameLabel,
])
contextHStack.spacing = 4
contextHStack.axis = .horizontal
contextHStack.alignment = .center
contextHStack.alpha = 0.8
metadataStackView = UIStackView(arrangedSubviews: [nameHStack, contextHStack])
metadataStackView.axis = .vertical
metadataStackView.alignment = .leading
metadataStackView.spacing = 1
} else {
metadataStackView = nameHStack
}
let contextButtonSize: CGFloat = 42
authorRow.removeAllSubviews()
authorRow.addArrangedSubviews([
avatarView,
.spacer(withWidth: 12),
metadataStackView,
.hStretchingSpacer(),
.spacer(withWidth: contextButtonSize),
])
authorRow.axis = .horizontal
authorRow.alignment = .center
self.contextButton = contextButton
contextButton.tintColor = Theme.darkThemePrimaryColor
contextButton.setImage(Theme.iconImage(.buttonMore), for: .normal)
contextButton.contentMode = .center
authorRow.addSubview(contextButton)
contextButton.autoSetDimensions(to: .square(contextButtonSize))
contextButton.autoPinEdge(toSuperviewEdge: .trailing)
NSLayoutConstraint.activate([
contextButton.centerYAnchor.constraint(equalTo: authorRow.centerYAnchor),
])
timestampLabel.setCompressionResistanceHorizontalHigh()
timestampLabel.setContentHuggingHorizontalHigh()
timestampLabel.font = .dynamicTypeFootnote
timestampLabel.textColor = Theme.darkThemePrimaryColor
timestampLabel.alpha = 0.8
updateTimestampText()
}
private func buildAvatarView(transaction: DBReadTransaction) -> UIView {
let authorAvatarView = ConversationAvatarView(
sizeClass: .twentyEight,
localUserDisplayMode: .asLocalUser,
badged: false,
shape: .circular,
useAutolayout: true,
)
authorAvatarView.update(transaction) { config in
config.dataSource = try? StoryUtil.authorAvatarDataSource(
for: item.message,
transaction: transaction,
)
}
switch item.message.context {
case .groupId:
guard
let groupAvatarDataSource = try? StoryUtil.contextAvatarDataSource(
for: item.message,
transaction: transaction,
)
else {
owsFailDebug("Unexpectedly missing group avatar")
return authorAvatarView
}
let groupAvatarView = ConversationAvatarView(
sizeClass: .twentyEight,
localUserDisplayMode: .asLocalUser,
badged: false,
shape: .circular,
useAutolayout: true,
)
groupAvatarView.update(transaction) { config in
config.dataSource = groupAvatarDataSource
}
let avatarContainer = UIView()
avatarContainer.addSubview(authorAvatarView)
authorAvatarView.autoPinHeightToSuperview()
authorAvatarView.autoPinEdge(toSuperviewEdge: .leading)
avatarContainer.addSubview(groupAvatarView)
groupAvatarView.autoPinHeightToSuperview()
groupAvatarView.autoPinEdge(toSuperviewEdge: .trailing)
groupAvatarView.autoPinEdge(.leading, to: .trailing, of: authorAvatarView, withOffset: -4)
return avatarContainer
case .authorAci, .privateStory, .none:
return authorAvatarView
}
}
private func buildNameLabel(transaction: DBReadTransaction) -> UIView {
let label = UILabel()
label.textColor = Theme.darkThemePrimaryColor
label.font = UIFont.dynamicTypeSubheadline.semibold()
label.text = StoryUtil.authorDisplayName(
for: item.message,
contactsManager: SSKEnvironment.shared.contactManagerRef,
useFullNameForLocalAddress: false,
useShortGroupName: false,
transaction: transaction,
)
return label
}
// MARK: - Caption
private class CaptionLabel: UILabel {
static let desiredFont = UIFont.systemFont(ofSize: 17)
static let minimumScaleFactor: CGFloat = 15 / 17
static var minimumScaleFont: UIFont { desiredFont.withSize(desiredFont.pointSize * minimumScaleFactor) }
static let maxCollapsedLines = 5
var interactionIdentifier: InteractionSnapshotIdentifier
private let spoilerState: SpoilerRenderState
init(
interactionIdentifier: InteractionSnapshotIdentifier,
spoilerState: SpoilerRenderState,
) {
self.interactionIdentifier = interactionIdentifier
self.spoilerState = spoilerState
self.spoilerConfig = .init(isViewVisible: false)
super.init(frame: .zero)
spoilerConfig.animationManager = spoilerState.animationManager
super.textColor = Theme.darkThemePrimaryColor
super.layer.shadowRadius = 48
super.layer.shadowOpacity = 0.8
super.layer.shadowColor = UIColor.black.cgColor
super.layer.shadowOffset = .zero
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private var spoilerConfig: SpoilerableTextConfig.Builder {
didSet {
spoilerAnimator.updateAnimationState(spoilerConfig)
}
}
private lazy var spoilerAnimator = SpoilerableLabelAnimator(label: self)
var isViewVisible: Bool = false {
didSet {
spoilerConfig.isViewVisible = isViewVisible
}
}
func stopAnimatingSpoiler() {
spoilerConfig.isViewVisible = false
}
func resumeAnimatingSpoiler() {
spoilerConfig.isViewVisible = isViewVisible
}
@available(*, unavailable)
override var text: String? {
get { return super.text }
set { super.text = newValue }
}
@available(*, unavailable)
override var attributedText: NSAttributedString? {
get { return super.attributedText }
set { super.attributedText = newValue }
}
@available(*, unavailable)
override var font: UIFont! {
get { return super.font }
set { super.font = newValue }
}
var isTruncated: Bool = false
var tappableItems = [HydratedMessageBody.TappableItem]()
func setBody(_ body: StyleOnlyMessageBody?, isTruncated: Bool) {
guard let body else {
super.attributedText = nil
spoilerConfig.text = nil
spoilerConfig.displayConfig = nil
return
}
let actualFont: UIFont
if isTruncated {
actualFont = Self.minimumScaleFont
super.numberOfLines = Self.maxCollapsedLines
} else {
let actualFontSize = self.actualFontSize(body: body)
actualFont = Self.desiredFont.withSize(actualFontSize)
super.numberOfLines = 0
}
super.font = actualFont
let hydratedBody = body.asHydratedMessageBody()
spoilerConfig.text = .messageBody(hydratedBody)
let revealedSpoilerIds = self.spoilerState.revealState.revealedSpoilerIds(
interactionIdentifier: interactionIdentifier,
)
let config = HydratedMessageBody.DisplayConfiguration.storyCaption(
font: actualFont,
revealedSpoilerIds: revealedSpoilerIds,
)
self.tappableItems = hydratedBody.tappableItems(
revealedSpoilerIds: revealedSpoilerIds,
dataDetector: nil,
)
spoilerConfig.text = .messageBody(hydratedBody)
spoilerConfig.displayConfig = config
super.attributedText = body.asAttributedStringForDisplay(
config: config.style,
baseFont: config.baseFont,
baseTextColor: config.baseTextColor.forCurrentTheme,
isDarkThemeEnabled: Theme.isDarkThemeEnabled,
)
}
private func actualFontSize(body: StyleOnlyMessageBody) -> CGFloat {
let drawingContext = NSStringDrawingContext()
drawingContext.minimumScaleFactor = Self.minimumScaleFactor
let attributedTextForSizing = body.asAttributedStringForDisplay(
config: HydratedMessageBody.DisplayConfiguration.storyCaption(
font: Self.desiredFont,
revealedSpoilerIds: Set(), // irrelevant for sizing.
).style,
isDarkThemeEnabled: false, // irrelevant for sizing.
)
attributedTextForSizing.boundingRect(
with: bounds.size,
options: [.usesLineFragmentOrigin, .usesFontLeading],
context: drawingContext,
)
return Self.desiredFont.pointSize * drawingContext.actualScaleFactor
}
}
private lazy var captionLabel = CaptionLabel(
interactionIdentifier: .fromStoryMessage(item.message),
spoilerState: spoilerState,
)
private var truncatedCaptionText: StyleOnlyMessageBody?
private var isTruncationRequired: Bool { truncatedCaptionText != nil }
private var hasCaption: Bool { item.caption != nil }
private func updateCaption() {
captionLabel.interactionIdentifier = .fromStoryMessage(item.message)
recomputeCaptionTruncation()
if !isCaptionExpanded, let truncatedCaptionText {
captionLabel.setBody(truncatedCaptionText, isTruncated: true)
} else {
captionLabel.setBody(item.caption, isTruncated: false)
}
}
private func revealSpoilerIfNecessary(_ gesture: UIGestureRecognizer) -> Bool {
let labelLocation = gesture.location(in: captionLabel)
guard
captionLabel.bounds.contains(labelLocation),
let tapIndex = captionLabel.characterIndex(of: labelLocation)
else {
return false
}
let spoilerItem = captionLabel.tappableItems.lazy
.compactMap {
switch $0 {
case .unrevealedSpoiler(let unrevealedSpoiler):
return unrevealedSpoiler
case .data, .mention:
return nil
}
}
.first(where: {
$0.range.contains(tapIndex)
})
if let spoilerItem {
spoilerState.revealState.setSpoilerRevealed(
withID: spoilerItem.id,
interactionIdentifier: .fromStoryMessage(item.message),
)
updateCaption()
return true
}
return false
}
private var isCaptionExpanded = false
private var captionBackdrop: UIView?
private func toggleCaptionExpansionIfNecessary(_ gesture: UIGestureRecognizer) -> Bool {
guard hasCaption, isTruncationRequired else { return false }
if !isCaptionExpanded {
guard captionLabel.bounds.contains(gesture.location(in: captionLabel)) else { return false }
} else if let captionBackdrop {
guard captionBackdrop.bounds.contains(gesture.location(in: captionBackdrop)) else { return false }
} else {
owsFailDebug("Unexpectedly missing caption backdrop")
}
let isExpanding = !isCaptionExpanded
isCaptionExpanded = isExpanding
if isExpanding {
self.captionBackdrop?.removeFromSuperview()
let captionBackdrop = UIView()
captionBackdrop.backgroundColor = .ows_blackAlpha60
captionBackdrop.alpha = 0
self.captionBackdrop = captionBackdrop
insertSubview(captionBackdrop, belowSubview: bottomContentVStack)
captionBackdrop.autoPinEdgesToSuperviewEdges()
delegate?.storyItemMediaViewWantsToPause(self)
} else {
delegate?.storyItemMediaViewWantsToPlay(self)
}
updateCaption()
// Hide spoilers for the animation's duration.
captionLabel.stopAnimatingSpoiler()
UIView.animate(withDuration: 0.2) {
self.captionBackdrop?.alpha = isExpanding ? 1 : 0
self.captionLabel.layoutIfNeeded()
} completion: { _ in
if !isExpanding {
self.captionBackdrop?.removeFromSuperview()
self.captionBackdrop = nil
}
self.captionLabel.resumeAnimatingSpoiler()
}
return true
}
private var lastTruncationWidth: CGFloat?
private func recomputeCaptionTruncation() {
guard let body = item.caption else {
lastTruncationWidth = nil
truncatedCaptionText = nil
return
}
// Only update truncation if the view's width has changed.
guard width != lastTruncationWidth else { return }
lastTruncationWidth = width
bottomContentVStack.layoutIfNeeded()
self.truncatedCaptionText = Self.truncatedCaptionText(
fullCaptionBody: body,
labelSize: CGSize(width: captionLabel.bounds.width, height: .infinity),
)
}
/// Nil means no truncation is necessary.
private static func truncatedCaptionText(
fullCaptionBody: StyleOnlyMessageBody,
labelSize: CGSize,
) -> StyleOnlyMessageBody? {
let labelMinimumScaledFont = CaptionLabel.minimumScaleFont
let layoutManager = NSLayoutManager()
let textContainer = NSTextContainer(size: labelSize)
let textStorage = NSTextStorage()
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
let displayConfigForSizing = HydratedMessageBody.DisplayConfiguration.storyCaption(
font: labelMinimumScaledFont,
revealedSpoilerIds: Set(), // irrelevant for sizing
)
let fullCaptionText = fullCaptionBody.asAttributedStringForDisplay(
config: displayConfigForSizing.style,
isDarkThemeEnabled: false, // irrelevant for sizing
)
textStorage.setAttributedString(fullCaptionText)
textContainer.lineFragmentPadding = 0
textContainer.lineBreakMode = .byWordWrapping
textContainer.maximumNumberOfLines = CaptionLabel.maxCollapsedLines
func visibleCaptionRange() -> NSRange {
layoutManager.characterRange(forGlyphRange: layoutManager.glyphRange(for: textContainer), actualGlyphRange: nil)
}
var visibleCharacterRangeUpperBound = visibleCaptionRange().upperBound
// Check if we're displaying less than the full length of the caption text.
guard visibleCharacterRangeUpperBound < (fullCaptionText.string as NSString).length else {
return nil
}
let readMoreString = OWSLocalizedString(
"STORIES_CAPTION_READ_MORE",
comment: "Text indication a story caption can be tapped to read more.",
)
let readMoreBody = StyleOnlyMessageBody(text: readMoreString, style: .bold)
let suffix = StyleOnlyMessageBody(plaintext: "… ").addingSuffix(readMoreBody)
var potentialTruncatedCaptionBody = fullCaptionBody
func truncatePotentialCaptionText(to index: Int) {
potentialTruncatedCaptionBody = potentialTruncatedCaptionBody.stripAndDropLast(
potentialTruncatedCaptionBody.length - index,
)
}
func buildTruncatedCaptionText() -> StyleOnlyMessageBody {
return potentialTruncatedCaptionBody.stripAndDropLast(0).addingSuffix(suffix)
}
// We might fit without further truncation, for example if the caption
// contains new line characters, so set the possible new text immediately.
truncatePotentialCaptionText(to: visibleCharacterRangeUpperBound)
visibleCharacterRangeUpperBound = visibleCaptionRange().upperBound - suffix.length
// If we're still truncated, trim down the visible text until
// we have space to fit the read more text without truncation.
// This should only take a few iterations.
var iterationCount = 0
while visibleCharacterRangeUpperBound < potentialTruncatedCaptionBody.length {
let truncateToIndex = max(0, visibleCharacterRangeUpperBound)
guard truncateToIndex > 0 else { break }
truncatePotentialCaptionText(to: truncateToIndex)
visibleCharacterRangeUpperBound = visibleCaptionRange().upperBound - suffix.length
iterationCount += 1
if iterationCount >= 5 {
owsFailDebug("Failed to calculate visible range for caption text. Bailing.")
break
}
}
return buildTruncatedCaptionText()
}
private func updateGradientProtection() {
gradientProtectionViewHeightConstraint?.isActive = false
if hasCaption {
gradientProtectionViewHeightConstraint = gradientProtectionView.autoMatch(.height, to: .height, of: self, withMultiplier: 0.4)
gradientProtectionView.colors = [
.clear,
.black.withAlphaComponent(0.8),
]
} else {
gradientProtectionViewHeightConstraint = gradientProtectionView.autoMatch(.height, to: .height, of: self, withMultiplier: 0.2)
gradientProtectionView.colors = [
.clear,
.black.withAlphaComponent(0.6),
]
}
}
override func layoutSubviews() {
super.layoutSubviews()
updateCaption()
}
// MARK: - Media
private weak var mediaView: UIView?
private func updateMediaView() {
mediaView?.removeFromSuperview()
videoPlayer = nil
yyImageView = nil
videoPlayerLoopCount = 0
let mediaView = buildMediaView()
self.mediaView = mediaView
insertSubview(mediaView, at: 0)
mediaView.autoPinEdgesToSuperviewEdges()
}
private func buildMediaView() -> UIView {
switch item.attachment {
case .stream(let stream):
let container = UIView()
guard let thumbnailImage = stream.attachment.attachmentStream.thumbnailImageSync(quality: .small) else {
owsFailDebug("Failed to generate thumbnail for attachment stream")
return buildContentUnavailableView()
}
let backgroundImageView = buildBackgroundImageView(thumbnailImage: thumbnailImage)
container.addSubview(backgroundImageView)
backgroundImageView.autoPinEdgesToSuperviewEdges()
switch stream.attachment.attachmentStream.contentType {
case .video:
let videoView = buildVideoView(attachment: stream.attachment)
container.addSubview(videoView)
videoView.autoPinEdgesToSuperviewEdges()
case .animatedImage:
let yyImageView = buildYYImageView(attachment: stream.attachment.attachmentStream)
container.addSubview(yyImageView)
yyImageView.autoPinEdgesToSuperviewEdges()
case .image:
let imageView = buildImageView(attachment: stream.attachment.attachmentStream)
container.addSubview(imageView)
imageView.autoPinEdgesToSuperviewEdges()
case .audio, .file, .invalid:
owsFailDebug("Unexpected content type.")
return buildContentUnavailableView()
}
return container
case .pointer(let pointer):
let container = UIView()
if let blurHashImageView = buildBlurHashImageViewIfAvailable(attachment: pointer.attachment.attachment) {
container.addSubview(blurHashImageView)
blurHashImageView.autoPinEdgesToSuperviewEdges()
}
let view = buildDownloadStateView(
for: pointer.attachment,
downloadState: pointer.downloadState,
)
container.addSubview(view)
view.autoPinEdgesToSuperviewEdges()
return container
case .text(let text):
return TextAttachmentView(
attachment: text,
interactionIdentifier: .fromStoryMessage(item.message),
spoilerState: spoilerState,
)
}
}
func updateMuteState() {
videoPlayer?.isMuted = delegate?.storyItemMediaViewShouldBeMuted(self) ?? false
}
private var videoPlayerLoopCount = 0
private var videoPlayer: VideoPlayer?
private func buildVideoView(attachment: ReferencedAttachmentStream) -> UIView {
guard let player = try? VideoPlayer(attachment: attachment, shouldMixAudioWithOthers: true) else {
owsFailDebug("Could not load attachment.")
return buildContentUnavailableView()
}
player.delegate = self
self.videoPlayer = player
updateMuteState()
videoPlayerLoopCount = 0
let playerView = VideoPlayerView()
playerView.contentMode = .scaleAspectFit
playerView.videoPlayer = player
player.play()
return playerView
}
private var yyImageView: SDAnimatedImageView?
private func buildYYImageView(attachment: AttachmentStream) -> UIView {
guard
let image = try? attachment.decryptedSDAnimatedImage()
else {
owsFailDebug("Could not load attachment.")
return buildContentUnavailableView()
}
guard
image.size.width > 0,
image.size.height > 0
else {
owsFailDebug("Attachment has invalid size.")
return buildContentUnavailableView()
}
let animatedImageView = SDAnimatedImageView()
animatedImageView.contentMode = .scaleAspectFit
animatedImageView.layer.minificationFilter = .trilinear
animatedImageView.layer.magnificationFilter = .trilinear
animatedImageView.layer.allowsEdgeAntialiasing = true
animatedImageView.image = image
self.yyImageView = animatedImageView
return animatedImageView
}
private func buildImageView(attachment: AttachmentStream) -> UIView {
guard let image = try? attachment.decryptedImage() else {
owsFailDebug("Could not load attachment.")
return buildContentUnavailableView()
}
guard
image.size.width > 0,
image.size.height > 0
else {
owsFailDebug("Attachment has invalid size.")
return buildContentUnavailableView()
}
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFit
imageView.layer.minificationFilter = .trilinear
imageView.layer.magnificationFilter = .trilinear
imageView.layer.allowsEdgeAntialiasing = true
imageView.image = image
return imageView
}
private func buildBlurHashImageViewIfAvailable(attachment: Attachment) -> UIView? {
guard
let blurHash = attachment.blurHash,
let blurHashImage = BlurHash.image(for: blurHash)
else {
return nil
}
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.layer.minificationFilter = .trilinear
imageView.layer.magnificationFilter = .trilinear
imageView.layer.allowsEdgeAntialiasing = true
imageView.image = blurHashImage
return imageView
}
private func buildBackgroundImageView(thumbnailImage: UIImage) -> UIView {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.image = thumbnailImage
imageView.clipsToBounds = true
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .dark))
imageView.addSubview(blurView)
blurView.autoPinEdgesToSuperviewEdges()
return imageView
}
private func buildDownloadStateView(
for pointer: AttachmentPointer,
downloadState: AttachmentDownloadState,
) -> UIView {
let progressView = CVAttachmentProgressView(
direction: .download(
attachmentPointer: pointer,
downloadState: downloadState,
),
colorConfiguration: .forMediaOverlay(),
)
let manualLayoutView = OWSLayerView(frame: .zero) { layerView in
progressView.frame.size = .square(56)
progressView.center = layerView.center
}
manualLayoutView.addSubview(progressView)
return manualLayoutView
}
private func buildContentUnavailableView() -> UIView {
// TODO: Error state
return UIView()
}
}
class StoryItem: NSObject {
let message: StoryMessage
let numberOfReplies: UInt64
enum Attachment: Equatable {
struct Pointer: Equatable {
let reference: AttachmentReference
let attachment: AttachmentPointer
let downloadState: AttachmentDownloadState
var caption: String? { reference.storyMediaCaption?.text }
var captionStyles: [NSRangedValue<MessageBodyRanges.CollapsedStyle>] { reference.storyMediaCaption?.collapsedStyles ?? [] }
static func ==(lhs: StoryItem.Attachment.Pointer, rhs: StoryItem.Attachment.Pointer) -> Bool {
return lhs.attachment.id == rhs.attachment.id
&& lhs.reference.hasSameOwner(as: rhs.reference)
&& lhs.downloadState == rhs.downloadState
}
}
struct Stream: Equatable {
let attachment: ReferencedAttachmentStream
var isLoopingVideo: Bool { attachment.reference.renderingFlag == .shouldLoop }
var caption: String? { attachment.reference.storyMediaCaption?.text }
var captionStyles: [NSRangedValue<MessageBodyRanges.CollapsedStyle>] { attachment.reference.storyMediaCaption?.collapsedStyles ?? [] }
static func ==(lhs: StoryItem.Attachment.Stream, rhs: StoryItem.Attachment.Stream) -> Bool {
return lhs.attachment.attachmentStream.id == rhs.attachment.attachmentStream.id
&& lhs.attachment.reference.hasSameOwner(as: rhs.attachment.reference)
}
}
case pointer(Pointer)
case stream(Stream)
case text(PreloadedTextAttachment)
}
var attachment: Attachment
init(message: StoryMessage, numberOfReplies: UInt64, attachment: Attachment) {
self.message = message
self.numberOfReplies = numberOfReplies
self.attachment = attachment
}
var caption: StyleOnlyMessageBody? {
switch attachment {
case let .stream(stream):
guard let text = stream.caption?.nilIfEmpty else {
return nil
}
return StyleOnlyMessageBody(text: text, collapsedStyles: stream.captionStyles)
case let .pointer(pointer):
guard let text = pointer.caption?.nilIfEmpty else {
return nil
}
return StyleOnlyMessageBody(text: text, collapsedStyles: pointer.captionStyles)
case .text:
return nil
}
}
}
extension StoryItem {
// MARK: - Downloading
@discardableResult
func startAttachmentDownloadIfNecessary(priority: AttachmentDownloadPriority = .default) -> Bool {
return SSKEnvironment.shared.databaseStorageRef.write { tx in
guard
case .pointer(let pointer) = attachment,
pointer.attachment.downloadState(tx: tx) != .enqueuedOrDownloading
else {
return false
}
DependenciesBridge.shared.attachmentDownloadManager.enqueueDownloadOfAttachmentsForStoryMessage(
message,
priority: priority,
tx: tx,
)
return true
}
}
var isPendingDownload: Bool {
switch attachment {
case .pointer:
return true
case .stream, .text:
return false
}
}
}
extension StoryItemMediaView: VideoPlayerDelegate {
func videoPlayerDidPlayToCompletion(_ videoPlayer: VideoPlayer) {
videoPlayerLoopCount += 1
}
}