Add initial StoryPageViewController
This commit is contained in:
parent
d85d173946
commit
06dd88fbbc
@ -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 */,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.")
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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() {}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user