Add initial StoryPageViewController

This commit is contained in:
Nora Trapp 2022-03-13 20:55:14 -07:00
parent d85d173946
commit 06dd88fbbc
19 changed files with 1444 additions and 72 deletions

View File

@ -841,6 +841,10 @@
8847E6F226A0EFBD0063E319 /* AvatarEditViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8847E6F126A0EFBD0063E319 /* AvatarEditViewController.swift */; };
884DB94527DD70F700C6A309 /* IncomingStoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884DB94427DD70F700C6A309 /* IncomingStoryViewModel.swift */; };
884DB94727DD754700C6A309 /* StoryCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884DB94627DD754700C6A309 /* StoryCell.swift */; };
884DB94F27DE67BB00C6A309 /* StoryPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884DB94D27DE67BB00C6A309 /* StoryPageViewController.swift */; };
884DB95027DE67BB00C6A309 /* StoryHorizontalPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884DB94E27DE67BB00C6A309 /* StoryHorizontalPageViewController.swift */; };
884DB95227DE67D900C6A309 /* StoryItemViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884DB95127DE67D900C6A309 /* StoryItemViewController.swift */; };
884DB95427DEB9E900C6A309 /* StoryPlaybackProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884DB95327DEB9E900C6A309 /* StoryPlaybackProgressView.swift */; };
8851DB4324CCF0EB001EACD2 /* ConversationInputTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8851DB4224CCF0EB001EACD2 /* ConversationInputTextView.swift */; };
8851DB4524CCFB93001EACD2 /* ConversationViewController+Mentions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8851DB4424CCFB93001EACD2 /* ConversationViewController+Mentions.swift */; };
8852572927DD366D0032073C /* StoriesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8852572827DD366D0032073C /* StoriesViewController.swift */; };
@ -2056,6 +2060,10 @@
8847E6F126A0EFBD0063E319 /* AvatarEditViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarEditViewController.swift; sourceTree = "<group>"; };
884DB94427DD70F700C6A309 /* IncomingStoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingStoryViewModel.swift; sourceTree = "<group>"; };
884DB94627DD754700C6A309 /* StoryCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryCell.swift; sourceTree = "<group>"; };
884DB94D27DE67BB00C6A309 /* StoryPageViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StoryPageViewController.swift; sourceTree = "<group>"; };
884DB94E27DE67BB00C6A309 /* StoryHorizontalPageViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StoryHorizontalPageViewController.swift; sourceTree = "<group>"; };
884DB95127DE67D900C6A309 /* StoryItemViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryItemViewController.swift; sourceTree = "<group>"; };
884DB95327DEB9E900C6A309 /* StoryPlaybackProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryPlaybackProgressView.swift; sourceTree = "<group>"; };
8851DB4224CCF0EB001EACD2 /* ConversationInputTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationInputTextView.swift; sourceTree = "<group>"; };
8851DB4424CCFB93001EACD2 /* ConversationViewController+Mentions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConversationViewController+Mentions.swift"; sourceTree = "<group>"; };
8852572827DD366D0032073C /* StoriesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoriesViewController.swift; sourceTree = "<group>"; };
@ -4011,12 +4019,24 @@
path = Avatars;
sourceTree = "<group>";
};
884DB94A27DE66E000C6A309 /* Context View */ = {
isa = PBXGroup;
children = (
884DB94E27DE67BB00C6A309 /* StoryHorizontalPageViewController.swift */,
884DB94D27DE67BB00C6A309 /* StoryPageViewController.swift */,
884DB95127DE67D900C6A309 /* StoryItemViewController.swift */,
884DB95327DEB9E900C6A309 /* StoryPlaybackProgressView.swift */,
);
path = "Context View";
sourceTree = "<group>";
};
8852572727DD365D0032073C /* Stories */ = {
isa = PBXGroup;
children = (
8852572827DD366D0032073C /* StoriesViewController.swift */,
884DB94427DD70F700C6A309 /* IncomingStoryViewModel.swift */,
884DB94627DD754700C6A309 /* StoryCell.swift */,
884DB94A27DE66E000C6A309 /* Context View */,
);
path = Stories;
sourceTree = "<group>";
@ -6022,6 +6042,7 @@
881677C522DD2B21007BAF49 /* OWSPinReminderViewController.swift in Sources */,
34D99C931F2937CC00D284D6 /* OWSAnalytics.swift in Sources */,
3437F63A2512835300AC1767 /* LinkedDevicesTableViewController.swift in Sources */,
884DB95227DE67D900C6A309 /* StoryItemViewController.swift in Sources */,
8852572C27DD40870032073C /* HomeTabBarController.swift in Sources */,
4C8A6DFC22E5499300469AE7 /* MediaZoomAnimationController.swift in Sources */,
341F2C0F1F2B8AE700D07D6B /* DebugUIMisc.m in Sources */,
@ -6160,6 +6181,7 @@
34E5DC8220D8050D00C08145 /* RegistrationUtils.m in Sources */,
341D392925472F3B00996E7B /* CVViewState.swift in Sources */,
88238EA224E9DDB700F28079 /* LocalVideoView.swift in Sources */,
884DB95427DEB9E900C6A309 /* StoryPlaybackProgressView.swift in Sources */,
45638BDC1F3DD0D400128435 /* DebugUICalling.swift in Sources */,
4CFF115323A9C2130007F9D7 /* UnreadIndicatorInteraction.swift in Sources */,
34E20D4C24256563002C011E /* ConversationHeaderBuilder.swift in Sources */,
@ -6244,6 +6266,7 @@
34C7C7152625D8E100F4DC2A /* DebugUIMessages.swift in Sources */,
348BB25D20A0C5530047AEC2 /* ContactShareViewHelper.swift in Sources */,
3497971525D6D55400E99FA4 /* PaymentsSendRecipientViewController.swift in Sources */,
884DB94F27DE67BB00C6A309 /* StoryPageViewController.swift in Sources */,
88A4CC1B246CEC8B0082211F /* DeviceTransferQRScanningViewController.swift in Sources */,
34E95C24269F4F4F004807EC /* CLVViewState.swift in Sources */,
34B3F8801E8DF1700035BE1A /* InviteFlow.swift in Sources */,
@ -6270,6 +6293,7 @@
88D23D2323CEC0C700B0E74B /* NonCallKitCallUIAdaptee.swift in Sources */,
88FBE9502649E7EB005F6C80 /* DonationViewController.swift in Sources */,
34B6A905218B4C91007C4606 /* TypingIndicatorInteraction.swift in Sources */,
884DB95027DE67BB00C6A309 /* StoryHorizontalPageViewController.swift in Sources */,
34A4D87F2677B23100A794E7 /* ConversationViewController+MessageActions.swift in Sources */,
4517642B1DE939FD00EDB8B9 /* ContactCell.swift in Sources */,
887CD47F247307D900FDD265 /* DeviceTransferService+Restore.swift in Sources */,

View File

@ -1,5 +1,5 @@
//
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import Foundation
@ -210,7 +210,7 @@ public class CVComponentGenericAttachment: CVComponentBase, CVComponent {
return CVAttachmentProgressView(direction: direction,
style: .withoutCircle(diameter: progressSize),
conversationStyle: conversationStyle,
isDarkThemeEnabled: conversationStyle.isDarkThemeEnabled,
mediaCache: mediaCache)
}

View File

@ -1,5 +1,5 @@
//
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import Foundation
@ -74,7 +74,7 @@ public class CVComponentSticker: CVComponentBase, CVComponent {
case .uploading:
let progressView = CVAttachmentProgressView(direction: .upload(attachmentStream: attachmentStream),
style: .withCircle,
conversationStyle: conversationStyle,
isDarkThemeEnabled: conversationStyle.isDarkThemeEnabled,
mediaCache: mediaCache)
stackView.addSubview(progressView)
stackView.centerSubviewOnSuperview(progressView, size: progressView.layoutSize)
@ -103,7 +103,7 @@ public class CVComponentSticker: CVComponentBase, CVComponent {
let progressView = CVAttachmentProgressView(direction: .download(attachmentPointer: attachmentPointer),
style: .withCircle,
conversationStyle: conversationStyle,
isDarkThemeEnabled: conversationStyle.isDarkThemeEnabled,
mediaCache: mediaCache)
stackView.addSubview(progressView)
stackView.centerSubviewOnSuperview(progressView, size: progressView.layoutSize)

View File

@ -1,5 +1,5 @@
//
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import Foundation
@ -91,7 +91,7 @@ public class CVComponentViewOnce: CVComponentBase, CVComponent {
case .incomingDownloading(let attachmentPointer):
let progressView = CVAttachmentProgressView(direction: .download(attachmentPointer: attachmentPointer),
style: .withoutCircle(diameter: iconSize),
conversationStyle: conversationStyle,
isDarkThemeEnabled: conversationStyle.isDarkThemeEnabled,
mediaCache: mediaCache)
subviews.append(progressView)
default:

View File

@ -135,7 +135,7 @@ class AudioMessageView: ManualStackView {
} else if let attachmentPointer = audioAttachment.attachmentPointer {
leftView = CVAttachmentProgressView(direction: .download(attachmentPointer: attachmentPointer),
style: .withoutCircle(diameter: Self.animationSize),
conversationStyle: conversationStyle,
isDarkThemeEnabled: conversationStyle.isDarkThemeEnabled,
mediaCache: mediaCache)
} else {
owsFailDebug("Unexpected state.")

View File

@ -1,5 +1,5 @@
//
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import Foundation
@ -46,7 +46,7 @@ public class CVAttachmentProgressView: ManualLayoutView {
private let direction: Direction
private let style: Style
private let conversationStyle: ConversationStyle
private let isDarkThemeEnabled: Bool
private let stateView: StateView
@ -54,15 +54,15 @@ public class CVAttachmentProgressView: ManualLayoutView {
public required init(direction: Direction,
style: Style,
conversationStyle: ConversationStyle,
isDarkThemeEnabled: Bool,
mediaCache: CVMediaCache) {
self.direction = direction
self.style = style
self.conversationStyle = conversationStyle
self.isDarkThemeEnabled = isDarkThemeEnabled
self.stateView = StateView(diameter: Self.innerDiameter(style: style),
direction: direction,
style: style,
conversationStyle: conversationStyle,
isDarkThemeEnabled: isDarkThemeEnabled,
mediaCache: mediaCache)
super.init(name: "CVAttachmentProgressView")
@ -132,7 +132,7 @@ public class CVAttachmentProgressView: ManualLayoutView {
private let diameter: CGFloat
private let direction: Direction
private let style: Style
private let conversationStyle: ConversationStyle
private let isDarkThemeEnabled: Bool
private lazy var imageView = CVImageView()
private var unknownProgressView: Lottie.AnimationView?
private var progressView: Lottie.AnimationView?
@ -147,7 +147,6 @@ public class CVAttachmentProgressView: ManualLayoutView {
}
}
private var isDarkThemeEnabled: Bool { conversationStyle.isDarkThemeEnabled }
private var isIncoming: Bool {
switch direction {
case .upload:
@ -160,12 +159,12 @@ public class CVAttachmentProgressView: ManualLayoutView {
required init(diameter: CGFloat,
direction: Direction,
style: Style,
conversationStyle: ConversationStyle,
isDarkThemeEnabled: Bool,
mediaCache: CVMediaCache) {
self.diameter = diameter
self.direction = direction
self.style = style
self.conversationStyle = conversationStyle
self.isDarkThemeEnabled = isDarkThemeEnabled
self.mediaCache = mediaCache
super.init(name: "CVAttachmentProgressView.StateView")

View File

@ -1,5 +1,5 @@
//
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import Foundation
@ -111,7 +111,7 @@ public class CVMediaView: ManualLayoutViewWithLayer {
let progressView = CVAttachmentProgressView(direction: direction,
style: .withCircle,
conversationStyle: conversationStyle,
isDarkThemeEnabled: conversationStyle.isDarkThemeEnabled,
mediaCache: mediaCache)
addSubviewToCenterOnSuperview(progressView, size: progressView.layoutSize)

View File

@ -0,0 +1,448 @@
//
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import Foundation
import SignalServiceKit
import UIKit
import SignalUI
protocol StoryHorizontalPageViewControllerDelegate: AnyObject {
func storyHorizontalPageViewControllerWantsTransitionToNextContext(_ storyHorizontalPageViewController: StoryHorizontalPageViewController)
func storyHorizontalPageViewControllerWantsTransitionToPreviousContext(_ storyHorizontalPageViewController: StoryHorizontalPageViewController)
}
class StoryHorizontalPageViewController: OWSViewController {
let context: StoryContext
weak var delegate: StoryHorizontalPageViewControllerDelegate?
private lazy var pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal,
options: nil
)
private lazy var playbackProgressView = StoryPlaybackProgressView()
private var items = [StoryItem]()
var currentItem: StoryItem? {
set {
let viewControllers: [StoryItemViewController]
if let newValue = newValue {
viewControllers = [StoryItemViewController(item: newValue)]
} else {
viewControllers = []
}
pageViewController.setViewControllers(viewControllers, direction: .forward, animated: false)
updateProgressState()
}
get { currentItemViewController?.item }
}
var currentItemViewController: StoryItemViewController? {
pageViewController.viewControllers?.first as? StoryItemViewController
}
required init(context: StoryContext, delegate: StoryHorizontalPageViewControllerDelegate) {
self.context = context
super.init()
self.delegate = delegate
databaseStorage.appendDatabaseChangeDelegate(self)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func resetForPresentation() {
// If we've loaded already, reset to the first item
if let firstItem = items.first {
currentItem = firstItem
}
}
@objc
func transitionToNextItem() {
guard let currentVC = currentItemViewController,
let nextVC = pageViewController(pageViewController, viewControllerAfter: currentVC) else {
delegate?.storyHorizontalPageViewControllerWantsTransitionToNextContext(self)
return
}
pageViewController.setViewControllers([nextVC], direction: .forward, animated: true)
updateProgressState()
}
@objc
func transitionToPreviousItem() {
guard let currentVC = currentItemViewController,
let previousVC = pageViewController(pageViewController, viewControllerBefore: currentVC) else {
delegate?.storyHorizontalPageViewControllerWantsTransitionToPreviousContext(self)
return
}
pageViewController.setViewControllers([previousVC], direction: .reverse, animated: true)
updateProgressState()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
displayLink?.isPaused = true
}
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))
override func viewDidLoad() {
super.viewDidLoad()
view.addGestureRecognizer(leftTapGestureRecognizer)
view.addGestureRecognizer(rightTapGestureRecognizer)
view.addGestureRecognizer(pauseGestureRecognizer)
leftTapGestureRecognizer.delegate = self
rightTapGestureRecognizer.delegate = self
pauseGestureRecognizer.delegate = self
pauseGestureRecognizer.minimumPressDuration = 0.2
leftTapGestureRecognizer.require(toFail: pauseGestureRecognizer)
rightTapGestureRecognizer.require(toFail: pauseGestureRecognizer)
pageViewController.view.alpha = 0
pageViewController.dataSource = self
pageViewController.delegate = self
pageViewController.view.isUserInteractionEnabled = false
addChild(pageViewController)
view.addSubview(pageViewController.view)
pageViewController.view.autoPinEdgesToSuperviewSafeArea()
view.addLayoutGuide(mediaLayoutGuide)
mediaLayoutGuide.widthAnchor.constraint(equalTo: mediaLayoutGuide.heightAnchor, multiplier: 9/16).isActive = true
if !UIDevice.current.hasIPhoneXNotch && !UIDevice.current.isIPad {
mediaLayoutGuide.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor).isActive = true
}
applyConstraints()
let spinner = UIActivityIndicatorView(style: .white)
view.addSubview(spinner)
spinner.autoCenterInSuperview()
spinner.startAnimating()
let closeButton = OWSButton(imageName: "x-24", tintColor: .ows_white) { [weak self] in
self?.dismiss(animated: true)
}
closeButton.imageEdgeInsets = UIEdgeInsets(hMargin: 16, vMargin: 16)
view.addSubview(closeButton)
closeButton.autoSetDimensions(to: CGSize(square: 56))
closeButton.autoPinEdge(.top, to: .top, of: pageViewController.view)
closeButton.autoPinEdge(.leading, to: .leading, of: pageViewController.view)
view.addSubview(playbackProgressView)
playbackProgressView.leadingAnchor.constraint(equalTo: mediaLayoutGuide.leadingAnchor, constant: OWSTableViewController2.defaultHOuterMargin).isActive = true
playbackProgressView.trailingAnchor.constraint(equalTo: mediaLayoutGuide.trailingAnchor, constant: -OWSTableViewController2.defaultHOuterMargin).isActive = true
playbackProgressView.bottomAnchor.constraint(equalTo: mediaLayoutGuide.bottomAnchor, constant: -OWSTableViewController2.defaultHOuterMargin).isActive = true
playbackProgressView.autoSetDimension(.height, toSize: 2)
playbackProgressView.isUserInteractionEnabled = false
loadStoryItems { [weak self] storyItems in
// If there are no stories for this context, dismiss.
guard let firstStoryItem = storyItems.first else {
self?.dismiss(animated: true)
return
}
UIView.animate(withDuration: 0.2) {
spinner.alpha = 0
self?.pageViewController.view.alpha = 1
} completion: { _ in
spinner.stopAnimating()
spinner.removeFromSuperview()
}
self?.items = storyItems
self?.currentItem = firstStoryItem
}
}
private static let maxItemsToRender = 100
private func loadStoryItems(completion: @escaping ([StoryItem]) -> Void) {
var storyItems = [StoryItem]()
databaseStorage.asyncRead { transaction in
StoryFinder.enumerateStoriesForContext(self.context, transaction: transaction.unwrapGrdbRead) { record, stop in
guard let storyItem = self.buildStoryItem(for: record, transaction: transaction) else { return }
storyItems.append(storyItem)
if storyItems.count >= Self.maxItemsToRender { stop.pointee = true }
}
DispatchQueue.main.async {
completion(storyItems)
}
}
}
private func buildStoryItem(for record: StoryMessageRecord, transaction: SDSAnyReadTransaction) -> StoryItem? {
switch record.attachment {
case .file(let attachmentId):
guard let attachment = TSAttachment.anyFetch(uniqueId: attachmentId, transaction: transaction) else {
owsFailDebug("Missing attachment for StoryMessage with timestamp \(record.timestamp)")
return nil
}
if let attachment = attachment as? TSAttachmentPointer {
return .init(record: record, attachment: .pointer(attachment))
} else if let attachment = attachment as? TSAttachmentStream {
return .init(record: record, attachment: .stream(attachment))
} else {
owsFailDebug("Unexpected attachment type \(type(of: attachment))")
return nil
}
case .text(let attachment):
return .init(record: record, attachment: .text(attachment))
}
}
private var pauseTime: CFTimeInterval?
private var displayLink: CADisplayLink?
private var lastTransitionTime: CFTimeInterval?
private static let transitionDuration: CFTimeInterval = 5
private func updateProgressState() {
AssertIsOnMainThread()
lastTransitionTime = CACurrentMediaTime()
if let displayLink = displayLink {
displayLink.isPaused = false
} else {
let displayLink = CADisplayLink(target: self, selector: #selector(displayLinkStep(_:)))
displayLink.add(to: .main, forMode: .common)
self.displayLink = displayLink
}
}
@objc
func displayLinkStep(_ displayLink: CADisplayLink) {
AssertIsOnMainThread()
playbackProgressView.numberOfItems = items.count
if let currentItemVC = currentItemViewController, let idx = items.firstIndex(of: currentItemVC.item) {
currentItemVC.updateTimestampText()
if currentItemVC.isDownloading {
lastTransitionTime = CACurrentMediaTime()
playbackProgressView.itemState = .init(index: idx, value: 0)
} else if let lastTransitionTime = lastTransitionTime {
let currentTime: CFTimeInterval
if let elapsedTime = currentItemVC.elapsedTime {
currentTime = lastTransitionTime + elapsedTime
} else {
currentTime = displayLink.targetTimestamp
}
let value = currentTime.inverseLerp(
lastTransitionTime,
(lastTransitionTime + currentItemVC.duration),
shouldClamp: true
)
playbackProgressView.itemState = .init(index: idx, value: value)
if value >= 1 {
displayLink.isPaused = true
transitionToNextItem()
}
} else {
displayLink.isPaused = true
playbackProgressView.itemState = .init(index: idx, value: 0)
}
} else {
displayLink.isPaused = true
playbackProgressView.itemState = .init(index: 0, value: 0)
}
}
private lazy var iPadLandscapeConstraints = [
mediaLayoutGuide.heightAnchor.constraint(lessThanOrEqualTo: pageViewController.view.heightAnchor, multiplier: 0.75)
]
private lazy var iPadPortraitConstraints = [
mediaLayoutGuide.heightAnchor.constraint(lessThanOrEqualTo: pageViewController.view.heightAnchor, multiplier: 0.65)
]
private let mediaLayoutGuide = UILayoutGuide()
private lazy var iPhoneConstraints = [
mediaLayoutGuide.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
mediaLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
mediaLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor)
]
private lazy var iPadConstraints: [NSLayoutConstraint] = {
var constraints = [
mediaLayoutGuide.centerXAnchor.constraint(equalTo: view.centerXAnchor),
mediaLayoutGuide.centerYAnchor.constraint(equalTo: view.centerYAnchor)
]
// Prefer to be as big as possible.
let heightConstraint = mediaLayoutGuide.heightAnchor.constraint(equalTo: pageViewController.view.heightAnchor)
heightConstraint.priority = .defaultHigh
constraints.append(heightConstraint)
let widthConstraint = mediaLayoutGuide.widthAnchor.constraint(equalTo: pageViewController.view.widthAnchor)
widthConstraint.priority = .defaultHigh
constraints.append(widthConstraint)
return constraints
}()
private func applyConstraints(newSize: CGSize = CurrentAppContext().frame.size) {
NSLayoutConstraint.deactivate(iPhoneConstraints)
NSLayoutConstraint.deactivate(iPadConstraints)
NSLayoutConstraint.deactivate(iPadPortraitConstraints)
NSLayoutConstraint.deactivate(iPadLandscapeConstraints)
if UIDevice.current.isIPad {
NSLayoutConstraint.activate(iPadConstraints)
if newSize.width > newSize.height {
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(newSize: size)
} completion: { _ in
self.applyConstraints()
}
}
}
extension StoryHorizontalPageViewController: UIGestureRecognizerDelegate {
@objc
func didTapLeft() {
guard currentItemViewController?.startAttachmentDownloadIfNecessary() != true else { return }
CurrentAppContext().isRTL ? transitionToPreviousItem() : transitionToNextItem()
}
@objc
func didTapRight() {
guard currentItemViewController?.startAttachmentDownloadIfNecessary() != true else { return }
CurrentAppContext().isRTL ? transitionToNextItem() : transitionToPreviousItem()
}
@objc
func handleLongPress() {
switch pauseGestureRecognizer.state {
case .began:
pauseTime = CACurrentMediaTime()
displayLink?.isPaused = true
currentItemViewController?.pause()
case .ended:
if let lastTransitionTime = lastTransitionTime, let pauseTime = pauseTime {
let pauseDuration = CACurrentMediaTime() - pauseTime
self.lastTransitionTime = lastTransitionTime + pauseDuration
self.pauseTime = nil
}
currentItemViewController?.play()
displayLink?.isPaused = false
default:
break
}
}
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
let touchLocation = gestureRecognizer.location(in: view)
if gestureRecognizer == leftTapGestureRecognizer {
var nextFrame = mediaLayoutGuide.layoutFrame
nextFrame.width = nextFrame.width / 2
nextFrame.x += nextFrame.width
return nextFrame.contains(touchLocation)
} else if gestureRecognizer == rightTapGestureRecognizer {
var previousFrame = mediaLayoutGuide.layoutFrame
previousFrame.width = previousFrame.width / 2
return previousFrame.contains(touchLocation)
} else {
return true
}
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
}
extension StoryHorizontalPageViewController: UIPageViewControllerDelegate {
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
updateProgressState()
}
func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
pendingViewControllers
.lazy
.map { $0 as! StoryItemViewController }
.forEach { $0.reset() }
}
}
extension StoryHorizontalPageViewController: UIPageViewControllerDataSource {
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let currentItem = currentItem,
let currentItemIndex = items.firstIndex(of: currentItem),
let itemBefore = items[safe: currentItemIndex.advanced(by: -1)] else {
return nil
}
return StoryItemViewController(item: itemBefore)
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
guard let currentItem = currentItem,
let currentItemIndex = items.firstIndex(of: currentItem),
let itemAfter = items[safe: currentItemIndex.advanced(by: 1)] else {
return nil
}
return StoryItemViewController(item: itemAfter)
}
}
extension StoryHorizontalPageViewController: DatabaseChangeDelegate {
func databaseChangesDidUpdate(databaseChanges: DatabaseChanges) {
guard var currentItem = currentItem else { return }
guard !databaseChanges.storyMessageRowIds.isEmpty else { return }
databaseStorage.asyncRead { transaction in
var newItems = self.items
var shouldDismiss = false
for (idx, item) in self.items.enumerated().reversed() where databaseChanges.storyMessageRowIds.contains(item.record.id!) {
if let record = try? StoryMessageRecord.fetchOne(transaction.unwrapGrdbRead.database, key: item.record.id!) {
if let newItem = self.buildStoryItem(for: record, transaction: transaction) {
newItems[idx] = newItem
if item.record.id == currentItem.record.id {
currentItem = newItem
}
continue
}
}
newItems.remove(at: idx)
if item.record.id == currentItem.record.id {
shouldDismiss = true
break
}
}
DispatchQueue.main.async {
if shouldDismiss {
self.dismiss(animated: true)
} else {
self.items = newItems
self.currentItem = currentItem
}
}
}
}
func databaseChangesDidUpdateExternally() {}
func databaseChangesDidReset() {}
}

View File

@ -0,0 +1,453 @@
//
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import Foundation
import SignalCoreKit
import YYImage
import UIKit
import SignalUI
class StoryItemViewController: OWSViewController {
let item: StoryItem
init(item: StoryItem) {
self.item = item
}
func reset() {
videoPlayer?.seek(to: .zero)
videoPlayer?.play()
updateTimestampText()
}
func pause() {
videoPlayer?.pause()
}
func play() {
videoPlayer?.play()
}
func updateTimestampText() {
timestampLabel.text = DateUtil.formatTimestampShort(item.record.timestamp)
}
func startAttachmentDownloadIfNecessary() -> Bool {
guard case .pointer(let pointer) = item.attachment, ![.enqueued, .downloading].contains(pointer.state) else { return false }
attachmentDownloads.enqueueDownloadOfAttachments(
forStoryMessageId: item.record.id!,
attachmentGroup: .allAttachmentsIncoming,
downloadBehavior: .bypassAll,
touchMessageImmediately: true) { [weak self] _ in
Logger.info("Successfully re-downloaded attachment.")
DispatchQueue.main.async { self?.updateMediaView() }
} failure: { [weak self] error in
Logger.warn("Failed to redownload attachment with error: \(error)")
DispatchQueue.main.async { self?.updateMediaView() }
}
return true
}
var isDownloading: Bool {
guard case .pointer(let pointer) = item.attachment else { return false }
return [.enqueued, .downloading].contains(pointer.state)
}
var duration: CFTimeInterval {
if let videoPlayer = videoPlayer, let asset = videoPlayer.avPlayer.currentItem?.asset {
return CMTimeGetSeconds(asset.duration)
} else {
return 5
}
}
var elapsedTime: CFTimeInterval? {
guard let currentTime = videoPlayer?.avPlayer.currentTime() else { return nil }
return CMTimeGetSeconds(currentTime)
}
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 let mediaViewContainer = UIView()
private lazy var iPhoneConstraints = [
mediaViewContainer.autoPinEdge(toSuperviewEdge: .top),
mediaViewContainer.autoPinEdge(toSuperviewEdge: .leading),
mediaViewContainer.autoPinEdge(toSuperviewEdge: .trailing)
]
private lazy var iPadConstraints: [NSLayoutConstraint] = {
var constraints = mediaViewContainer.autoCenterInSuperview()
// Prefer to be as big as possible.
let heightConstraint = mediaViewContainer.autoMatch(.height, to: .height, of: view)
heightConstraint.priority = .defaultHigh
constraints.append(heightConstraint)
let widthConstraint = mediaViewContainer.autoMatch(.width, to: .width, of: view)
widthConstraint.priority = .defaultHigh
constraints.append(widthConstraint)
return constraints
}()
private lazy var topGradientView: UIView = {
let gradientLayer = CAGradientLayer()
gradientLayer.colors = [
UIColor.black.withAlphaComponent(0.5).cgColor,
UIColor.black.withAlphaComponent(0).cgColor
]
let view = OWSLayerView(frame: .zero) { view in
gradientLayer.frame = view.bounds
}
view.layer.addSublayer(gradientLayer)
return view
}()
private lazy var bottomGradientView: UIView = {
let gradientLayer = CAGradientLayer()
gradientLayer.colors = [
UIColor.black.withAlphaComponent(0).cgColor,
UIColor.black.withAlphaComponent(0.5).cgColor
]
let view = OWSLayerView(frame: .zero) { view in
gradientLayer.frame = view.bounds
}
view.layer.addSublayer(gradientLayer)
return view
}()
override func viewDidLoad() {
super.viewDidLoad()
mediaViewContainer.backgroundColor = .black
mediaViewContainer.autoPin(toAspectRatio: 9/16)
view.addSubview(mediaViewContainer)
updateMediaView()
if UIDevice.current.hasIPhoneXNotch || UIDevice.current.isIPad {
mediaViewContainer.layer.cornerRadius = 18
mediaViewContainer.clipsToBounds = true
} else {
mediaViewContainer.autoPinEdge(toSuperviewEdge: .bottom)
}
mediaViewContainer.addSubview(topGradientView)
topGradientView.autoPinWidthToSuperview()
topGradientView.autoPinEdge(toSuperviewEdge: .top)
topGradientView.autoMatch(.height, to: .height, of: mediaViewContainer, withMultiplier: 0.4)
mediaViewContainer.addSubview(bottomGradientView)
bottomGradientView.autoPinWidthToSuperview()
bottomGradientView.autoPinEdge(toSuperviewEdge: .bottom)
bottomGradientView.autoMatch(.height, to: .height, of: mediaViewContainer, withMultiplier: 0.4)
createAuthorRow()
applyConstraints()
}
private func applyConstraints(newSize: CGSize = CurrentAppContext().frame.size) {
NSLayoutConstraint.deactivate(iPhoneConstraints)
NSLayoutConstraint.deactivate(iPadConstraints)
NSLayoutConstraint.deactivate(iPadPortraitConstraints)
NSLayoutConstraint.deactivate(iPadLandscapeConstraints)
if UIDevice.current.isIPad {
NSLayoutConstraint.activate(iPadConstraints)
if newSize.width > newSize.height {
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(newSize: size)
} completion: { _ in
self.applyConstraints()
}
}
private lazy var timestampLabel = UILabel()
private func createAuthorRow() {
let (avatarView, nameLabel) = databaseStorage.read { (
buildAvatarView(transaction: $0),
buildNameLabel(transaction: $0)
) }
let stackView = UIStackView(arrangedSubviews: [
avatarView,
.spacer(withWidth: 12),
nameLabel,
.spacer(withWidth: 8),
timestampLabel,
.hStretchingSpacer()
])
stackView.axis = .horizontal
stackView.alignment = .center
timestampLabel.font = .ows_dynamicTypeFootnote
timestampLabel.textColor = Theme.darkThemeSecondaryTextAndIconColor
updateTimestampText()
mediaViewContainer.addSubview(stackView)
stackView.autoPinWidthToSuperview(withMargin: OWSTableViewController2.defaultHOuterMargin)
stackView.autoPinEdge(toSuperviewEdge: .bottom, withInset: OWSTableViewController2.defaultHOuterMargin + 16)
}
private func buildAvatarView(transaction: SDSAnyReadTransaction) -> UIView {
let authorAvatarView = ConversationAvatarView(
sizeClass: .twentyEight,
localUserDisplayMode: .asLocalUser,
badged: false,
shape: .circular,
useAutolayout: true
)
authorAvatarView.update(transaction) { config in
config.dataSource = .address(item.record.authorAddress)
}
switch item.record.context {
case .groupId(let groupId):
guard let groupThread = TSGroupThread.fetch(groupId: groupId, transaction: transaction) else {
owsFailDebug("Unexpectedly missing group thread")
return authorAvatarView
}
let groupAvatarView = ConversationAvatarView(
sizeClass: .twentyEight,
localUserDisplayMode: .asLocalUser,
badged: false,
shape: .circular,
useAutolayout: true
)
groupAvatarView.update(transaction) { config in
config.dataSource = .thread(groupThread)
}
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 .authorUuid, .none:
return authorAvatarView
}
}
private func buildNameLabel(transaction: SDSAnyReadTransaction) -> UIView {
let label = UILabel()
label.textColor = Theme.darkThemePrimaryColor
label.font = UIFont.ows_dynamicTypeSubheadline.ows_semibold
label.text = {
switch item.record.context {
case .groupId(let groupId):
let groupName: String = {
guard let groupThread = TSGroupThread.fetch(groupId: groupId, transaction: transaction) else {
owsFailDebug("Missing group thread for group story")
return TSGroupThread.defaultGroupName
}
return groupThread.groupNameOrDefault
}()
let authorShortName = Self.contactsManager.shortDisplayName(
for: item.record.authorAddress,
transaction: transaction
)
let nameFormat = NSLocalizedString(
"GROUP_STORY_NAME_FORMAT",
comment: "Name for a group story on the stories list. Embeds {author's name}, {group name}")
return String(format: nameFormat, authorShortName, groupName)
default:
return Self.contactsManager.displayName(
for: item.record.authorAddress,
transaction: transaction
)
}
}()
return label
}
private var mediaView: UIView?
private func updateMediaView() {
mediaView?.removeFromSuperview()
let mediaView = buildMediaView()
self.mediaView = mediaView
mediaViewContainer.insertSubview(mediaView, at: 0)
mediaView.autoPinEdgesToSuperviewEdges()
}
private func buildMediaView() -> UIView {
// TODO: Talk to design about how we handle things that are not 9:16.
// Do we letter box? What does the letterboxing look like?
let contentMode: UIView.ContentMode = .scaleAspectFill
switch item.attachment {
case .stream(let stream):
guard let originalMediaUrl = stream.originalMediaURL else {
owsFailDebug("Missing media for attachment stream")
return buildContentUnavailableView()
}
if stream.isVideo {
return buildVideoView(originalMediaUrl: originalMediaUrl, contentMode: contentMode)
} else if stream.shouldBeRenderedByYY {
return buildYYImageView(originalMediaUrl: originalMediaUrl, contentMode: contentMode)
} else if stream.isImage {
return buildImageView(originalMediaUrl: originalMediaUrl, contentMode: contentMode)
} else {
owsFailDebug("Unexpected content type.")
return buildContentUnavailableView()
}
case .pointer(let pointer):
let container = UIView()
if let blurHashImageView = buildBlurHashImageViewIfAvailable(pointer: pointer, contentMode: contentMode) {
container.addSubview(blurHashImageView)
blurHashImageView.autoPinEdgesToSuperviewEdges()
}
let view = buildDownloadStateView(for: pointer)
container.addSubview(view)
view.autoPinEdgesToSuperviewEdges()
return container
case .text(let text):
// TODO:
return UIView()
}
}
private var videoPlayer: OWSVideoPlayer?
private func buildVideoView(originalMediaUrl: URL, contentMode: UIView.ContentMode) -> UIView {
let player = OWSVideoPlayer(url: originalMediaUrl, shouldLoop: false)
self.videoPlayer = player
let playerView = VideoPlayerView()
playerView.contentMode = contentMode
playerView.videoPlayer = player
player.play()
return playerView
}
private func buildYYImageView(originalMediaUrl: URL, contentMode: UIView.ContentMode) -> UIView {
guard let image = YYImage(contentsOfFile: originalMediaUrl.path) 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 = YYAnimatedImageView()
animatedImageView.contentMode = contentMode
animatedImageView.layer.minificationFilter = .trilinear
animatedImageView.layer.magnificationFilter = .trilinear
animatedImageView.layer.allowsEdgeAntialiasing = true
animatedImageView.image = image
return animatedImageView
}
private func buildImageView(originalMediaUrl: URL, contentMode: UIView.ContentMode) -> UIView {
guard let image = UIImage(contentsOfFile: originalMediaUrl.path) 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 = contentMode
imageView.layer.minificationFilter = .trilinear
imageView.layer.magnificationFilter = .trilinear
imageView.layer.allowsEdgeAntialiasing = true
imageView.image = image
return imageView
}
private func buildBlurHashImageViewIfAvailable(pointer: TSAttachmentPointer, contentMode: UIView.ContentMode) -> UIView? {
guard let blurHash = pointer.blurHash, let blurHashImage = BlurHash.image(for: blurHash) else {
return nil
}
let imageView = UIImageView()
imageView.contentMode = contentMode
imageView.layer.minificationFilter = .trilinear
imageView.layer.magnificationFilter = .trilinear
imageView.layer.allowsEdgeAntialiasing = true
imageView.image = blurHashImage
return imageView
}
private static let mediaCache = CVMediaCache()
private func buildDownloadStateView(for pointer: TSAttachmentPointer) -> UIView {
let view = UIView()
let progressView = CVAttachmentProgressView(
direction: .download(attachmentPointer: pointer),
style: .withCircle,
isDarkThemeEnabled: true,
mediaCache: Self.mediaCache
)
view.addSubview(progressView)
progressView.autoSetDimensions(to: progressView.layoutSize)
progressView.autoCenterInSuperview()
return view
}
private func buildContentUnavailableView() -> UIView {
// TODO: Error state
return UIView()
}
}
class StoryItem: NSObject {
let record: StoryMessageRecord
enum Attachment {
case pointer(TSAttachmentPointer)
case stream(TSAttachmentStream)
case text(TextAttachment)
}
var attachment: Attachment
init(record: StoryMessageRecord, attachment: Attachment) {
self.record = record
self.attachment = attachment
}
}

View File

@ -0,0 +1,158 @@
//
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import Foundation
import UIKit
protocol StoryPageViewControllerDataSource: AnyObject {
func storyPageViewController(_ storyPageViewController: StoryPageViewController, storyContextBefore storyContext: StoryContext) -> StoryContext?
func storyPageViewController(_ storyPageViewController: StoryPageViewController, storyContextAfter storyContext: StoryContext) -> StoryContext?
}
class StoryPageViewController: UIPageViewController {
var currentContext: StoryContext {
set {
setViewControllers([StoryHorizontalPageViewController(context: newValue, delegate: self)], direction: .forward, animated: false)
}
get {
(viewControllers!.first as! StoryHorizontalPageViewController).context
}
}
weak var contextDataSource: StoryPageViewControllerDataSource?
required init(context: StoryContext) {
super.init(transitionStyle: .scroll, navigationOrientation: .vertical, options: nil)
self.currentContext = context
}
public func present(from fromViewController: UIViewController, animated: Bool) {
AssertIsOnMainThread()
modalPresentationStyle = .custom
modalPresentationCapturesStatusBarAppearance = true
transitioningDelegate = self
fromViewController.present(self, animated: animated)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var prefersStatusBarHidden: Bool { !UIDevice.current.hasIPhoneXNotch && !UIDevice.current.isIPad }
override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent }
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
UIDevice.current.isIPad ? .all : .portrait
}
override func viewDidLoad() {
super.viewDidLoad()
dataSource = self
delegate = self
}
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// For now, the design only allows for portrait layout on non-iPads
if !UIDevice.current.isIPad && CurrentAppContext().interfaceOrientation != .portrait {
UIDevice.current.ows_setOrientation(.portrait)
}
}
}
extension StoryPageViewController: UIPageViewControllerDelegate {
func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
pendingViewControllers
.lazy
.map { $0 as! StoryHorizontalPageViewController }
.forEach { $0.resetForPresentation() }
}
}
extension StoryPageViewController: UIPageViewControllerDataSource {
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let contextBefore = contextDataSource?.storyPageViewController(self, storyContextBefore: currentContext) else {
return nil
}
return StoryHorizontalPageViewController(context: contextBefore, delegate: self)
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
guard let contextAfter = contextDataSource?.storyPageViewController(self, storyContextAfter: currentContext) else {
return nil
}
return StoryHorizontalPageViewController(context: contextAfter, delegate: self)
}
}
extension StoryPageViewController: StoryHorizontalPageViewControllerDelegate {
func storyHorizontalPageViewControllerWantsTransitionToNextContext(_ storyHorizontalPageViewController: StoryHorizontalPageViewController) {
guard let nextContext = contextDataSource?.storyPageViewController(self, storyContextAfter: currentContext) else {
dismiss(animated: true)
return
}
setViewControllers(
[StoryHorizontalPageViewController(context: nextContext, delegate: self)],
direction: .forward,
animated: true
)
}
func storyHorizontalPageViewControllerWantsTransitionToPreviousContext(_ storyHorizontalPageViewController: StoryHorizontalPageViewController) {
guard let previousContext = contextDataSource?.storyPageViewController(self, storyContextBefore: currentContext) else {
storyHorizontalPageViewController.resetForPresentation()
return
}
setViewControllers(
[StoryHorizontalPageViewController(context: previousContext, delegate: self)],
direction: .reverse,
animated: true
)
}
}
private class AnimationController: UIPresentationController {
let backdropView: UIView = UIView()
override init(presentedViewController: UIViewController, presenting presentingViewController: UIViewController?) {
super.init(presentedViewController: presentedViewController, presenting: presentingViewController)
if UIAccessibility.isReduceTransparencyEnabled {
backdropView.backgroundColor = Theme.backdropColor
} else {
let blurEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .dark))
backdropView.addSubview(blurEffectView)
blurEffectView.autoPinEdgesToSuperviewEdges()
backdropView.backgroundColor = .ows_blackAlpha60
}
}
override func presentationTransitionWillBegin() {
guard let containerView = containerView else { return }
backdropView.alpha = 0
containerView.addSubview(backdropView)
backdropView.autoPinEdgesToSuperviewEdges()
presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in
self.backdropView.alpha = 1
}, completion: nil)
}
override func dismissalTransitionWillBegin() {
presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in
self.backdropView.alpha = 0
}, completion: { _ in
self.backdropView.removeFromSuperview()
})
}
}
extension StoryPageViewController: UIViewControllerTransitioningDelegate {
public func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
return AnimationController(presentedViewController: presented, presenting: presenting)
}
}

View File

@ -0,0 +1,139 @@
//
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import Foundation
class StoryPlaybackProgressView: UIView {
var playedColor: UIColor = .ows_white {
didSet {
playedShapeLayer.fillColor = playedColor.cgColor
}
}
var unplayedColor: UIColor = .ows_whiteAlpha40 {
didSet {
unplayedShapeLayer.fillColor = unplayedColor.cgColor
}
}
public override var bounds: CGRect {
didSet {
guard bounds != oldValue else { return }
redrawItems()
}
}
public override var frame: CGRect {
didSet {
guard frame != oldValue else { return }
redrawItems()
}
}
public override var center: CGPoint {
didSet {
guard center != oldValue else { return }
redrawItems()
}
}
struct ItemState: Equatable {
let index: Int
let value: CGFloat
}
var itemState: ItemState = .init(index: 0, value: 0) {
didSet {
guard itemState != oldValue else { return }
redrawItems()
}
}
var numberOfItems: Int = 0 {
didSet {
guard numberOfItems != oldValue else { return }
redrawItems()
}
}
init() {
super.init(frame: .zero)
playedShapeLayer.fillColor = playedColor.cgColor
layer.addSublayer(playedShapeLayer)
unplayedShapeLayer.fillColor = unplayedColor.cgColor
layer.addSublayer(unplayedShapeLayer)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private let playedShapeLayer = CAShapeLayer()
private let unplayedShapeLayer = CAShapeLayer()
private func redrawItems() {
AssertIsOnMainThread()
guard numberOfItems > 0 else {
playedShapeLayer.path = nil
unplayedShapeLayer.path = nil
return
}
guard width > 0 else { return }
let idealSpacing: CGFloat = 2
let numberOfSpacers = numberOfItems - 1
let maxItemWidth: CGFloat = (width - (idealSpacing * CGFloat(numberOfSpacers))) / CGFloat(numberOfItems)
let minItemWidth: CGFloat = 2
let itemWidth: CGFloat = max(maxItemWidth, minItemWidth)
let itemSpacing: CGFloat = numberOfSpacers > 0 ? (width - (itemWidth * CGFloat(numberOfItems))) / CGFloat(numberOfSpacers) : 0
let itemHeight: CGFloat = 2
let playedBezierPath = UIBezierPath()
let unplayedBezierPath = UIBezierPath()
playedShapeLayer.frame = bounds
unplayedShapeLayer.frame = bounds
defer {
playedShapeLayer.path = playedBezierPath.cgPath
unplayedShapeLayer.path = unplayedBezierPath.cgPath
}
for x in 0..<numberOfItems {
if itemState.index == x, itemState.value < 1, itemState.value > 0 {
let playedItemFrame = CGRect(
x: CGFloat(x) * (itemWidth + itemSpacing),
y: 0,
width: itemWidth * itemState.value,
height: itemHeight
)
playedBezierPath.append(UIBezierPath(roundedRect: playedItemFrame, byRoundingCorners: [.topLeft, .bottomLeft], cornerRadii: CGSize(square: itemHeight / 2)))
let unplayedItemFrame = CGRect(
x: playedItemFrame.x + playedItemFrame.width,
y: 0,
width: itemWidth * (1 - itemState.value),
height: itemHeight
)
unplayedBezierPath.append(UIBezierPath(roundedRect: unplayedItemFrame, byRoundingCorners: [.topRight, .bottomRight], cornerRadii: CGSize(square: itemHeight / 2)))
} else {
let path: UIBezierPath
if itemState.index < x || (itemState.index == x && itemState.value <= 0) {
path = unplayedBezierPath
} else {
owsAssertDebug(itemState.index > x || (itemState.index == x && itemState.value >= 1))
path = playedBezierPath
}
let itemFrame = CGRect(
x: CGFloat(x) * (itemWidth + itemSpacing),
y: 0,
width: itemWidth,
height: itemHeight
)
path.append(UIBezierPath(roundedRect: itemFrame, cornerRadius: itemHeight / 2))
}
}
}
}

View File

@ -6,7 +6,7 @@ import Foundation
import SignalServiceKit
struct IncomingStoryViewModel: Dependencies {
let identifier: UUID
let context: StoryContext
let records: [StoryMessageRecord]
let recordIds: [Int64]
@ -23,9 +23,7 @@ struct IncomingStoryViewModel: Dependencies {
let latestRecordAvatarDataSource: ConversationAvatarDataSource
init(records: [StoryMessageRecord], identifier: UUID = UUID(), transaction: SDSAnyReadTransaction) throws {
self.identifier = identifier
init(records: [StoryMessageRecord], transaction: SDSAnyReadTransaction) throws {
let sortedFilteredRecords = records.lazy.filter { $0.direction == .incoming }.sorted { $0.timestamp > $1.timestamp }
self.records = sortedFilteredRecords
self.recordIds = sortedFilteredRecords.compactMap { $0.id }
@ -43,6 +41,8 @@ struct IncomingStoryViewModel: Dependencies {
throw OWSAssertionError("At least one record is required.")
}
self.context = latestRecord.context
if let groupId = latestRecord.groupId {
guard let groupThread = TSGroupThread.fetch(groupId: groupId, transaction: transaction) else {
throw OWSAssertionError("Missing group thread for group story")
@ -92,6 +92,21 @@ struct IncomingStoryViewModel: Dependencies {
}
} + updatedRecords
guard !records.isEmpty else { return nil }
return try .init(records: records, identifier: identifier, transaction: transaction)
return try .init(records: records, transaction: transaction)
}
}
extension StoryContext: BatchUpdateValue {
public var batchUpdateId: String {
switch self {
case .groupId(let data):
return data.hexadecimalString
case .authorUuid(let uuid):
return uuid.uuidString
case .none:
owsFailDebug("Unexpected StoryContext for batch update")
return "none"
}
}
public var logSafeDescription: String { batchUpdateId }
}

View File

@ -86,8 +86,7 @@ class StoriesViewController: OWSViewController {
let modal = CameraFirstCaptureNavigationController.cameraFirstModal()
modal.cameraFirstCaptureSendFlow.delegate = self
modal.modalPresentationStyle = .fullScreen
self.present(modal, animated: true)
self.presentFullScreen(modal, animated: true)
}
}
}
@ -116,8 +115,8 @@ class StoriesViewController: OWSViewController {
var deletedRowIds = rowIds.subtracting(updatedRecords.compactMap { $0.id })
var groupedRecords = self.groupStoryRecordsByContext(updatedRecords)
let oldIdentifiers = self.models.map { $0.identifier }
var changedIdentifiers = [UUID]()
let oldContexts = self.models.map { $0.context }
var changedContexts = [StoryContext]()
let newModels = Self.databaseStorage.read { transaction in
self.models.compactMap { model in
@ -126,12 +125,11 @@ class StoriesViewController: OWSViewController {
let modelDeletedRowIds = model.recordIds.filter { deletedRowIds.contains($0) }
deletedRowIds.subtract(deletedRowIds)
let modelContext: StoryContext = latestRecord.groupId.map { .groupId($0) } ?? .authorUuid(latestRecord.authorUuid)
let modelUpdatedRecords = groupedRecords.removeValue(forKey: modelContext) ?? []
let modelUpdatedRecords = groupedRecords.removeValue(forKey: latestRecord.context) ?? []
guard !modelUpdatedRecords.isEmpty || !modelDeletedRowIds.isEmpty else { return model }
changedIdentifiers.append(model.identifier)
changedContexts.append(model.context)
return try! model.copy(
updatedRecords: modelUpdatedRecords,
@ -143,9 +141,9 @@ class StoriesViewController: OWSViewController {
let batchUpdateItems = try! BatchUpdate.build(
viewType: .uiTableView,
oldValues: oldIdentifiers,
newValues: newModels.map { $0.identifier },
changedValues: changedIdentifiers
oldValues: oldContexts,
newValues: newModels.map { $0.context },
changedValues: changedContexts
)
DispatchQueue.main.async {
@ -179,16 +177,11 @@ class StoriesViewController: OWSViewController {
}
}
private enum StoryContext: Hashable {
case groupId(Data)
case authorUuid(UUID)
}
private func groupStoryRecordsByContext(_ storyRecords: [StoryMessageRecord]) -> [StoryContext: [StoryMessageRecord]] {
storyRecords.reduce(into: [StoryContext: [StoryMessageRecord]]()) { partialResult, record in
let context: StoryContext = record.groupId.map { .groupId($0) } ?? .authorUuid(record.authorUuid)
var records = partialResult[context] ?? []
var records = partialResult[record.context] ?? []
records.append(record)
partialResult[context] = records
partialResult[record.context] = records
}
}
}
@ -218,7 +211,16 @@ extension StoriesViewController: DatabaseChangeDelegate {
}
extension StoriesViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let model = models[safe: indexPath.row] else {
owsFailDebug("Missing model for story")
return
}
let vc = StoryPageViewController(context: model.context)
vc.contextDataSource = self
vc.present(from: self, animated: true)
}
}
extension StoriesViewController: UITableViewDataSource {
@ -240,3 +242,21 @@ extension StoriesViewController: UITableViewDataSource {
return models.count
}
}
extension StoriesViewController: StoryPageViewControllerDataSource {
func storyPageViewController(_ storyPageViewController: StoryPageViewController, storyContextBefore storyContext: StoryContext) -> StoryContext? {
guard let contextIndex = models.firstIndex(where: { $0.context == storyContext }),
let contextBefore = models[safe: contextIndex.advanced(by: -1)]?.context else {
return nil
}
return contextBefore
}
func storyPageViewController(_ storyPageViewController: StoryPageViewController, storyContextAfter storyContext: StoryContext) -> StoryContext? {
guard let contextIndex = models.firstIndex(where: { $0.context == storyContext }),
let contextAfter = models[safe: contextIndex.advanced(by: 1)]?.context else {
return nil
}
return contextAfter
}
}

View File

@ -53,18 +53,30 @@ class StoryCell: UITableViewCell {
attachmentThumbnail.backgroundColor = Theme.washColor
attachmentThumbnail.removeAllSubviews()
let contentMode: UIView.ContentMode = .scaleAspectFill
switch model.latestRecordAttachment {
case .file(let attachment):
// TODO: Downloading state
guard let attachment = attachment as? TSAttachmentStream else { break }
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
attachmentThumbnail.addSubview(imageView)
imageView.autoPinEdgesToSuperviewEdges()
attachment.thumbnailImageSmall {
imageView.image = $0
} failure: {
owsFailDebug("Failed to generate thumbnail")
if let pointer = attachment as? TSAttachmentPointer {
let pointerView = UIView()
if let blurHashImageView = buildBlurHashImageViewIfAvailable(pointer: pointer, contentMode: contentMode) {
pointerView.addSubview(blurHashImageView)
blurHashImageView.autoPinEdgesToSuperviewEdges()
}
let downloadStateView = buildDownloadStateView(for: pointer)
pointerView.addSubview(downloadStateView)
downloadStateView.autoPinEdgesToSuperviewEdges()
attachmentThumbnail.addSubview(pointerView)
pointerView.autoPinEdgesToSuperviewEdges()
} else if let stream = attachment as? TSAttachmentStream {
let imageView = buildThumbnailImageView(stream: stream, contentMode: contentMode)
attachmentThumbnail.addSubview(imageView)
imageView.autoPinEdgesToSuperviewEdges()
} else {
owsFailDebug("Unexpected attachment type \(type(of: attachment))")
}
case .text(let attachment):
// TODO: Render text attachments
@ -75,6 +87,52 @@ class StoryCell: UITableViewCell {
}
}
private func buildThumbnailImageView(stream: TSAttachmentStream, contentMode: UIView.ContentMode) -> UIView {
let imageView = UIImageView()
imageView.contentMode = contentMode
imageView.layer.minificationFilter = .trilinear
imageView.layer.magnificationFilter = .trilinear
imageView.layer.allowsEdgeAntialiasing = true
stream.thumbnailImageSmall {
imageView.image = $0
} failure: {
owsFailDebug("Failed to generate thumbnail")
}
return imageView
}
private func buildBlurHashImageViewIfAvailable(pointer: TSAttachmentPointer, contentMode: UIView.ContentMode) -> UIView? {
guard let blurHash = pointer.blurHash, let blurHashImage = BlurHash.image(for: blurHash) else {
return nil
}
let imageView = UIImageView()
imageView.contentMode = contentMode
imageView.layer.minificationFilter = .trilinear
imageView.layer.magnificationFilter = .trilinear
imageView.layer.allowsEdgeAntialiasing = true
imageView.image = blurHashImage
return imageView
}
private static let mediaCache = CVMediaCache()
private func buildDownloadStateView(for pointer: TSAttachmentPointer) -> UIView {
let view = UIView()
let progressView = CVAttachmentProgressView(
direction: .download(attachmentPointer: pointer),
style: .withCircle,
isDarkThemeEnabled: true,
mediaCache: Self.mediaCache
)
view.addSubview(progressView)
progressView.autoSetDimensions(to: progressView.layoutSize)
progressView.autoCenterInSuperview()
return view
}
func configureTimestamp(with model: IncomingStoryViewModel) {
timestampLabel.font = .ows_dynamicTypeSubheadline
timestampLabel.textColor = Theme.secondaryTextAndIconColor

View File

@ -9,11 +9,6 @@ public protocol BatchUpdateValue: Equatable {
var logSafeDescription: String { get }
}
extension UUID: BatchUpdateValue {
public var batchUpdateId: String { uuidString }
public var logSafeDescription: String { uuidString }
}
// MARK: -
public enum BatchUpdateType: Equatable {

View File

@ -62,24 +62,17 @@ public enum StoryFinder {
}
public static func latestStoryForThread(_ thread: TSThread, transaction: GRDBReadTransaction) -> StoryMessageRecord? {
let threadQuery: String
if let groupThread = thread as? TSGroupThread {
threadQuery = "groupId = x'\(groupThread.groupId.hexadecimalString)'"
} else if let contactThread = thread as? TSContactThread {
guard let uuid = contactThread.contactAddress.uuid else {
// No stories for contacts without UUIDs
return nil
}
threadQuery = "authorUuid = '\(uuid.uuidString)' AND groupId is NULL"
} else {
owsFailDebug("Unexpected thread")
return nil
}
latestStoryForContext(thread.storyContext, transaction: transaction)
}
public static func latestStoryForContext(_ context: StoryContext, transaction: GRDBReadTransaction) -> StoryMessageRecord? {
guard let contextQuery = context.query else { return nil }
let sql = """
SELECT *
FROM \(StoryMessageRecord.databaseTableName)
WHERE \(threadQuery)
WHERE \(contextQuery)
ORDER BY timestamp DESC
LIMIT 1
"""
@ -92,6 +85,31 @@ public enum StoryFinder {
}
}
public static func enumerateStoriesForContext(_ context: StoryContext, transaction: GRDBReadTransaction, block: @escaping (StoryMessageRecord, UnsafeMutablePointer<ObjCBool>) -> Void) {
guard let contextQuery = context.query else { return }
let sql = """
SELECT *
FROM \(StoryMessageRecord.databaseTableName)
WHERE \(contextQuery)
ORDER BY timestamp DESC
"""
do {
let cursor = try StoryMessageRecord.fetchCursor(transaction.database, sql: sql)
while let record = try cursor.next() {
var stop: ObjCBool = false
block(record, &stop)
if stop.boolValue {
return
}
}
} catch {
owsFail("error: \(error)")
}
}
// The stories should be enumerated in order from "next to expire" to "last to expire".
public static func enumerateExpiredStories(transaction: GRDBReadTransaction, block: @escaping (StoryMessageRecord, UnsafeMutablePointer<ObjCBool>) -> Void) {
@ -155,3 +173,16 @@ public enum StoryFinder {
}
}
}
private extension StoryContext {
var query: String? {
switch self {
case .groupId(let data):
return "groupId = x'\(data.hexadecimalString)'"
case .authorUuid(let uuid):
return "authorUuid = '\(uuid.uuidString)' AND groupId is NULL"
case .none:
return nil
}
}
}

View File

@ -51,3 +51,21 @@ public class StoryManager: NSObject {
return NSNumber(value: timestamp + storyLifetime)
}
}
public enum StoryContext: Equatable, Hashable {
case groupId(Data)
case authorUuid(UUID)
case none
}
public extension TSThread {
var storyContext: StoryContext {
if let groupThread = self as? TSGroupThread {
return .groupId(groupThread.groupId)
} else if let contactThread = self as? TSContactThread, let authorUuid = contactThread.contactAddress.uuid {
return .authorUuid(authorUuid)
} else {
return .none
}
}
}

View File

@ -11,6 +11,8 @@ import UIKit
public final class StoryMessageRecord: NSObject, Codable, Identifiable, FetchableRecord, PersistableRecord {
public static let databaseTableName = "story_messages"
public var context: StoryContext { groupId.map { .groupId($0) } ?? .authorUuid(authorUuid) }
public var id: Int64?
public let timestamp: UInt64

View File

@ -1,5 +1,5 @@
//
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import Foundation
@ -28,6 +28,18 @@ public class VideoPlayerView: UIView {
}
}
@objc
override public var contentMode: UIView.ContentMode {
didSet {
switch contentMode {
case .scaleAspectFill: playerLayer.videoGravity = .resizeAspectFill
case .scaleToFill: playerLayer.videoGravity = .resize
case .scaleAspectFit: playerLayer.videoGravity = .resizeAspect
default: playerLayer.videoGravity = .resizeAspect
}
}
}
@objc
public var player: AVPlayer? {
get {