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

721 lines
31 KiB
Swift

//
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import Foundation
import SignalServiceKit
import UIKit
import SignalUI
private let loadingQueue = DispatchQueue(label: "StoriesViewController.loadingQueue", qos: .userInitiated)
class StoriesViewController: OWSViewController {
let tableView = UITableView()
private let syncingModels = SyncingStoryViewModelArray()
private var myStoryModel = AtomicOptional<MyStoryViewModel>(nil)
private var viewerContexts: [StoryContext]?
private lazy var emptyStateLabel: UILabel = {
let label = UILabel()
label.textColor = Theme.secondaryTextAndIconColor
label.font = .ows_dynamicTypeBody
label.numberOfLines = 0
label.textAlignment = .center
label.text = NSLocalizedString("STORIES_NO_RECENT_MESSAGES", comment: "Indicates that there are no recent stories to render")
label.isHidden = true
label.isUserInteractionEnabled = false
tableView.backgroundView = label
return label
}()
private lazy var contextMenu = ContextMenuInteraction(delegate: self)
override init() {
super.init()
reloadStories()
databaseStorage.appendDatabaseChangeDelegate(self)
NotificationCenter.default.addObserver(self, selector: #selector(profileDidChange), name: .localProfileDidChange, object: nil)
}
override func loadView() {
view = tableView
tableView.delegate = self
tableView.dataSource = self
}
override func viewDidLoad() {
super.viewDidLoad()
title = NSLocalizedString("STORIES_TITLE", comment: "Title for the stories view.")
tableView.register(MyStoryCell.self, forCellReuseIdentifier: MyStoryCell.reuseIdentifier)
tableView.register(StoryCell.self, forCellReuseIdentifier: StoryCell.reuseIdentifier)
tableView.separatorStyle = .none
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 116
updateNavigationBar()
tableView.addInteraction(contextMenu)
}
private var timestampUpdateTimer: Timer?
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
timestampUpdateTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
AssertIsOnMainThread()
for indexPath in self.tableView.indexPathsForVisibleRows ?? [] {
switch Section(rawValue: indexPath.section) {
case .myStory:
guard let cell = self.tableView.cellForRow(at: indexPath) as? MyStoryCell else { continue }
guard let model = self.myStoryModel.get() else { continue }
cell.configureTimestamp(with: model)
case .visibleStories:
guard let cell = self.tableView.cellForRow(at: indexPath) as? StoryCell else { continue }
guard let model = self.model(for: indexPath) else { continue }
cell.configureTimestamp(with: model)
case .hiddenStories:
// TODO:
break
default:
owsFailDebug("Unexpected story type")
}
}
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// Whether or not the theme has changed, always ensure
// the right theme is applied. The initial collapsed
// state of the split view controller is determined between
// `viewWillAppear` and `viewDidAppear`, so this is the soonest
// we can know the right thing to display.
applyTheme()
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
// We could be changing between collapsed and expanded
// split view state, so we must re-apply the theme.
coordinator.animate { _ in
self.applyTheme()
} completion: { _ in
self.applyTheme()
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
timestampUpdateTimer?.invalidate()
timestampUpdateTimer = nil
}
override func applyTheme() {
super.applyTheme()
emptyStateLabel.textColor = Theme.secondaryTextAndIconColor
contextMenu.dismissMenu(animated: true) {}
for indexPath in self.tableView.indexPathsForVisibleRows ?? [] {
switch Section(rawValue: indexPath.section) {
case .myStory:
guard let cell = self.tableView.cellForRow(at: indexPath) as? MyStoryCell else { continue }
guard let model = self.myStoryModel.get() else { continue }
cell.configure(with: model) { [weak self] in self?.showCameraView() }
case .visibleStories:
guard let cell = self.tableView.cellForRow(at: indexPath) as? StoryCell else { continue }
guard let model = self.model(for: indexPath) else { continue }
cell.configure(with: model)
case .hiddenStories:
// TODO:
break
default:
owsFailDebug("Unexpected story type")
}
}
if splitViewController?.isCollapsed == true {
view.backgroundColor = Theme.backgroundColor
tableView.backgroundColor = Theme.backgroundColor
} else {
view.backgroundColor = Theme.secondaryBackgroundColor
tableView.backgroundColor = Theme.secondaryBackgroundColor
}
updateNavigationBar()
}
@objc
func profileDidChange() { updateNavigationBar() }
private func updateNavigationBar() {
let avatarButton = UIButton(type: .custom)
avatarButton.accessibilityLabel = CommonStrings.openSettingsButton
avatarButton.addTarget(self, action: #selector(showAppSettings), for: .touchUpInside)
let avatarView = ConversationAvatarView(sizeClass: .twentyEight, localUserDisplayMode: .asUser)
databaseStorage.read { transaction in
avatarView.update(transaction) { config in
if let address = tsAccountManager.localAddress(with: transaction) {
config.dataSource = .address(address)
config.applyConfigurationSynchronously()
}
}
}
avatarButton.addSubview(avatarView)
avatarView.autoPinEdgesToSuperviewEdges()
navigationItem.leftBarButtonItem = UIBarButtonItem(customView: avatarButton)
let cameraButton = UIBarButtonItem(image: Theme.iconImage(.cameraButton), style: .plain, target: self, action: #selector(showCameraView))
cameraButton.accessibilityLabel = NSLocalizedString("CAMERA_BUTTON_LABEL", comment: "Accessibility label for camera button.")
cameraButton.accessibilityHint = NSLocalizedString("CAMERA_BUTTON_HINT", comment: "Accessibility hint describing what you can do with the camera button")
navigationItem.rightBarButtonItems = [cameraButton]
}
@objc
func showCameraView() {
AssertIsOnMainThread()
// Dismiss any message actions if they're presented
conversationSplitViewController?.selectedConversationViewController?.dismissMessageContextMenu(animated: true)
ows_askForCameraPermissions { cameraGranted in
guard cameraGranted else {
return Logger.warn("camera permission denied.")
}
self.ows_askForMicrophonePermissions { micGranted in
if !micGranted {
// We can still continue without mic permissions, but any captured video will
// be silent.
Logger.warn("proceeding, though mic permission denied.")
}
let modal = CameraFirstCaptureNavigationController.cameraFirstModal(storiesOnly: true)
modal.cameraFirstCaptureSendFlow.delegate = self
self.presentFullScreen(modal, animated: true)
}
}
}
@objc
func showAppSettings() {
AssertIsOnMainThread()
conversationSplitViewController?.selectedConversationViewController?.dismissMessageContextMenu(animated: true)
presentFormSheet(AppSettingsViewController.inModalNavigationController(), animated: true)
}
private func reloadStories() {
loadingQueue.async {
self.syncingModels.mutate { _ -> ([StoryViewModel], Void)? in
let (listStories, outgoingStories) = Self.databaseStorage.read {
(
StoryFinder.storiesForListView(transaction: $0),
StoryFinder.outgoingStories(limit: 2, transaction: $0)
)
}
let myStoryModel = Self.databaseStorage.read { MyStoryViewModel(messages: outgoingStories, transaction: $0) }
let groupedMessages = Dictionary(grouping: listStories) { $0.context }
self.myStoryModel.set(myStoryModel)
let newValues = Self.databaseStorage.read { transaction in
groupedMessages.compactMap { try? StoryViewModel(messages: $1, transaction: transaction) }
}.sorted(by: self.sortStoryModels)
return (newValues, ())
} sync: { (_, _) in
self.tableView.reloadData()
}
}
}
private func updateStories(forRowIds rowIds: Set<Int64>) {
AssertIsOnMainThread()
guard !rowIds.isEmpty else { return }
loadingQueue.async {
let ok =
self.syncingModels.mutate { models -> ([StoryViewModel], (Bool, [BatchUpdate<StoryContext>.Item]))? in
let myStoryModel = self.myStoryModel.get()
let (updatedListMessages, outgoingStories) = Self.databaseStorage.read {
(
StoryFinder.listStoriesWithRowIds(Array(rowIds), transaction: $0),
StoryFinder.outgoingStories(limit: 2, transaction: $0)
)
}
var deletedRowIds = rowIds.subtracting(updatedListMessages.lazy.map { $0.id! })
var groupedMessages = Dictionary(grouping: updatedListMessages) { $0.context }
let oldContexts = models.map { $0.context }
var changedContexts = [StoryContext]()
let newModels: [StoryViewModel]
do {
newModels = try Self.databaseStorage.read { transaction in
try models.compactMap { model in
guard let latestMessage = model.messages.first else { return model }
let modelDeletedRowIds: [Int64] = model.messages.lazy.compactMap { $0.id }.filter { deletedRowIds.contains($0) }
deletedRowIds.subtract(modelDeletedRowIds)
let modelUpdatedMessages = groupedMessages.removeValue(forKey: latestMessage.context) ?? []
guard !modelUpdatedMessages.isEmpty || !modelDeletedRowIds.isEmpty else { return model }
changedContexts.append(model.context)
return try model.copy(
updatedMessages: modelUpdatedMessages,
deletedMessageRowIds: modelDeletedRowIds,
transaction: transaction
)
} + groupedMessages.map { try StoryViewModel(messages: $1, transaction: transaction) }
}.sorted(by: self.sortStoryModels)
} catch {
owsFailDebug("Failed to build new models, hard reloading \(error)")
return nil
}
let myStoryChanged = rowIds.intersection(outgoingStories.map { $0.id! }).count > 0
|| Set(myStoryModel?.messages.map { $0.uniqueId } ?? []) != Set(outgoingStories.map { $0.uniqueId })
let newMyStoryModel = myStoryChanged
? Self.databaseStorage.read { MyStoryViewModel(messages: outgoingStories, transaction: $0) }
: myStoryModel
let batchUpdateItems: [BatchUpdate<StoryContext>.Item]
do {
batchUpdateItems = try BatchUpdate.build(
viewType: .uiTableView,
oldValues: oldContexts,
newValues: newModels.map { $0.context },
changedValues: changedContexts
)
} catch {
owsFailDebug("Failed to calculate batch updates, hard reloading \(error)")
return nil
}
self.myStoryModel.set(newMyStoryModel)
return (newModels, (myStoryChanged, batchUpdateItems))
} sync: { (newModels, userInfo: (Bool, [BatchUpdate<StoryContext>.Item])) in
let (myStoryChanged, batchUpdateItems) = userInfo
guard self.isViewLoaded else { return }
self.tableView.beginUpdates()
if myStoryChanged {
self.tableView.reloadRows(at: [IndexPath(row: 0, section: Section.myStory.rawValue)], with: .automatic)
}
for update in batchUpdateItems {
switch update.updateType {
case .delete(let oldIndex):
self.tableView.deleteRows(at: [IndexPath(row: oldIndex, section: Section.visibleStories.rawValue)], with: .automatic)
case .insert(let newIndex):
self.tableView.insertRows(at: [IndexPath(row: newIndex, section: Section.visibleStories.rawValue)], with: .automatic)
case .move(let oldIndex, let newIndex):
self.tableView.deleteRows(at: [IndexPath(row: oldIndex, section: Section.visibleStories.rawValue)], with: .automatic)
self.tableView.insertRows(at: [IndexPath(row: newIndex, section: Section.visibleStories.rawValue)], with: .automatic)
case .update(_, let newIndex):
// If the cell is visible, reconfigure it directly without reloading.
let path = IndexPath(row: newIndex, section: Section.visibleStories.rawValue)
if (self.tableView.indexPathsForVisibleRows ?? []).contains(path),
let visibleCell = self.tableView.cellForRow(at: path) as? StoryCell {
guard let model = newModels[safe: newIndex] else {
return owsFailDebug("Missing model for story")
}
visibleCell.configure(with: model)
} else {
self.tableView.reloadRows(at: [path], with: .none)
}
}
}
self.tableView.endUpdates()
}
if !ok {
DispatchQueue.main.async { self.reloadStories() }
return
}
}
}
// Sort story models for display.
// * We show unviewed stories first, sorted by their sent timestamp, with the most recently sent at the top
// * We then show viewed stories, sorted by when they were viewed, with the most recently viewed at the top
private func sortStoryModels(lhs: StoryViewModel, rhs: StoryViewModel) -> Bool {
if let lhsViewedTimestamp = lhs.latestMessageViewedTimestamp,
let rhsViewedTimestamp = rhs.latestMessageViewedTimestamp {
return lhsViewedTimestamp > rhsViewedTimestamp
} else if lhs.latestMessageViewedTimestamp != nil {
return false
} else if rhs.latestMessageViewedTimestamp != nil {
return true
} else {
return lhs.latestMessageTimestamp > rhs.latestMessageTimestamp
}
}
}
extension StoriesViewController: CameraFirstCaptureDelegate {
func cameraFirstCaptureSendFlowDidComplete(_ cameraFirstCaptureSendFlow: CameraFirstCaptureSendFlow) {
dismiss(animated: true)
}
func cameraFirstCaptureSendFlowDidCancel(_ cameraFirstCaptureSendFlow: CameraFirstCaptureSendFlow) {
dismiss(animated: true)
}
}
extension StoriesViewController: DatabaseChangeDelegate {
func databaseChangesDidUpdate(databaseChanges: DatabaseChanges) {
updateStories(forRowIds: databaseChanges.storyMessageRowIds)
}
func databaseChangesDidUpdateExternally() {
reloadStories()
}
func databaseChangesDidReset() {
reloadStories()
}
}
extension StoriesViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
switch Section(rawValue: indexPath.section) {
case .myStory:
navigationController?.pushViewController(MyStoriesViewController(), animated: true)
case .visibleStories:
guard let model = model(for: indexPath) else {
owsFailDebug("Missing model for story")
return
}
// If we tap on a story with unviewed stories, we only want the viewer
// to page through unviewed contexts.
if model.hasUnviewedMessages {
viewerContexts = syncingModels.get().lazy.filter { $0.hasUnviewedMessages }.map { $0.context }
} else {
viewerContexts = syncingModels.get().map { $0.context }
}
let vc = StoryPageViewController(context: model.context)
vc.contextDataSource = self
presentFullScreen(vc, animated: true)
case .hiddenStories:
// TODO:
break
default:
owsFailDebug("Unexpected section \(indexPath.section)")
}
}
}
extension StoriesViewController: UITableViewDataSource {
enum Section: Int {
case myStory = 0
case visibleStories = 1
case hiddenStories = 2
}
func model(for indexPath: IndexPath) -> StoryViewModel? {
// TODO: Hidden stories
guard indexPath.section == Section.visibleStories.rawValue else { return nil }
return syncingModels.get()[safe: indexPath.row]
}
func model(for context: StoryContext) -> StoryViewModel? {
syncingModels.get().first { $0.context == context }
}
func cell(for context: StoryContext) -> StoryCell? {
guard let row = syncingModels.get().firstIndex(where: { $0.context == context }) else { return nil }
let indexPath = IndexPath(row: row, section: Section.visibleStories.rawValue)
guard tableView.indexPathsForVisibleRows?.contains(indexPath) == true else { return nil }
return tableView.cellForRow(at: indexPath) as? StoryCell
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
switch Section(rawValue: indexPath.section) {
case .myStory:
let cell = tableView.dequeueReusableCell(withIdentifier: MyStoryCell.reuseIdentifier) as! MyStoryCell
guard let myStoryModel = myStoryModel.get() else {
owsFailDebug("Missing my story model")
return cell
}
cell.configure(with: myStoryModel) { [weak self] in self?.showCameraView() }
return cell
case .visibleStories:
let cell = tableView.dequeueReusableCell(withIdentifier: StoryCell.reuseIdentifier) as! StoryCell
guard let model = model(for: indexPath) else {
owsFailDebug("Missing model for story")
return cell
}
cell.configure(with: model)
return cell
case .hiddenStories:
return UITableViewCell()
default:
owsFailDebug("Unexpected section \(indexPath.section)")
return UITableViewCell()
}
}
func numberOfSections(in tableView: UITableView) -> Int {
return 3
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
emptyStateLabel.isHidden = syncingModels.get().count > 0 || myStoryModel.get()?.messages.isEmpty == false
switch Section(rawValue: section) {
case .myStory:
return 1
case .visibleStories:
return syncingModels.get().count
case .hiddenStories:
return 0 // TODO: Hidden stories
default:
owsFailDebug("Unexpected section \(section)")
return 0
}
}
}
extension StoriesViewController: StoryPageViewControllerDataSource {
func storyPageViewControllerAvailableContexts(_ storyPageViewController: StoryPageViewController) -> [StoryContext] {
viewerContexts ?? []
}
}
extension StoriesViewController: ContextMenuInteractionDelegate {
func contextMenuInteraction(_ interaction: ContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> ContextMenuConfiguration? {
guard let indexPath = tableView.indexPathForRow(at: location),
let model = model(for: indexPath) else { return nil }
return .init(identifier: indexPath as NSCopying) { _ in
var actions = [ContextMenuAction]()
actions.append(.init(
title: NSLocalizedString(
"STORIES_HIDE_STORY_ACTION",
comment: "Context menu action to hide the selected story"),
image: Theme.iconImage(.xCircle24),
handler: { _ in
OWSActionSheets.showActionSheet(title: LocalizationNotNeeded("Hiding stories is not yet implemented."))
}))
func appendForwardAction() {
actions.append(.init(
title: NSLocalizedString(
"STORIES_FORWARD_STORY_ACTION",
comment: "Context menu action to forward the selected story"),
image: Theme.iconImage(.messageActionForward),
handler: { [weak self] _ in
guard let self = self else { return }
switch model.latestMessageAttachment {
case .file(let attachment):
ForwardMessageViewController.present([attachment], from: self, delegate: self)
case .text:
OWSActionSheets.showActionSheet(title: LocalizationNotNeeded("Forwarding text stories is not yet implemented."))
case .missing:
owsFailDebug("Unexpectedly missing attachment for story.")
}
}))
}
func appendShareAction() {
actions.append(.init(
title: NSLocalizedString(
"STORIES_SHARE_STORY_ACTION",
comment: "Context menu action to share the selected story"),
image: Theme.iconImage(.messageActionShare),
handler: { [weak self] _ in
guard let self = self else { return }
guard let cell = self.tableView.cellForRow(at: indexPath) else { return }
switch model.latestMessageAttachment {
case .file(let attachment):
guard let attachment = attachment as? TSAttachmentStream else {
return owsFailDebug("Unexpectedly tried to share undownloaded attachment")
}
AttachmentSharing.showShareUI(forAttachment: attachment, sender: cell)
case .text(let attachment):
if let url = attachment.preview?.urlString {
AttachmentSharing.showShareUI(for: URL(string: url)!, sender: cell)
} else if let text = attachment.text {
AttachmentSharing.showShareUI(forText: text, sender: cell)
}
case .missing:
owsFailDebug("Unexpectedly missing attachment for story.")
}
}))
}
switch model.latestMessageAttachment {
case .file(let attachment):
guard attachment is TSAttachmentStream else { break }
appendForwardAction()
appendShareAction()
case .text:
appendForwardAction()
appendShareAction()
case .missing:
owsFailDebug("Unexpectedly missing attachment for story.")
}
actions.append(.init(
title: NSLocalizedString(
"STORIES_GO_TO_CHAT_ACTION",
comment: "Context menu action to open the chat associated with the selected story"),
image: Theme.iconImage(.open24),
handler: { _ in
switch model.context {
case .groupId(let groupId):
guard let thread = Self.databaseStorage.read(block: { TSGroupThread.fetch(groupId: groupId, transaction: $0) }) else {
return owsFailDebug("Unexpectedly missing thread for group story")
}
Self.signalApp.presentConversation(for: thread, action: .compose, animated: true)
case .authorUuid(let authorUuid):
guard let thread = Self.databaseStorage.read(
block: { TSContactThread.getWithContactAddress(SignalServiceAddress(uuid: authorUuid), transaction: $0) }
) else {
return owsFailDebug("Unexpectedly missing thread for 1:1 story")
}
Self.signalApp.presentConversation(for: thread, action: .compose, animated: true)
case .privateStory:
owsFailDebug("Unexpectedly had private story on stories list")
case .none:
owsFailDebug("Unexpectedly missing context for story")
}
}))
return .init(actions)
}
}
func contextMenuInteraction(_ interaction: ContextMenuInteraction, previewForHighlightingMenuWithConfiguration configuration: ContextMenuConfiguration) -> ContextMenuTargetedPreview? {
guard let indexPath = configuration.identifier as? IndexPath else { return nil }
guard let cell = tableView.cellForRow(at: indexPath) as? StoryCell,
let cellSnapshot = cell.contentHStackView.snapshotView(afterScreenUpdates: false) else { return nil }
// Build a custom preview that wraps the cell contents in a bubble.
// Normally, our context menus just present the cell row full width.
let previewView = UIView()
previewView.frame = cell.contentView
.convert(cell.contentHStackView.frame, to: cell.superview)
.insetBy(dx: -12, dy: -12)
previewView.layer.cornerRadius = 18
previewView.backgroundColor = Theme.backgroundColor
previewView.clipsToBounds = true
previewView.addSubview(cellSnapshot)
cellSnapshot.frame.origin = CGPoint(x: 12, y: 12)
let preview = ContextMenuTargetedPreview(
view: cell,
previewView: previewView,
alignment: .leading,
accessoryViews: []
)
preview.alignmentOffset = CGPoint(x: 12, y: 12)
return preview
}
func contextMenuInteraction(_ interaction: ContextMenuInteraction, willDisplayMenuForConfiguration: ContextMenuConfiguration) {}
func contextMenuInteraction(_ interaction: ContextMenuInteraction, willEndForConfiguration: ContextMenuConfiguration) {}
func contextMenuInteraction(_ interaction: ContextMenuInteraction, didEndForConfiguration configuration: ContextMenuConfiguration) {}
}
extension StoriesViewController: ForwardMessageDelegate {
public func forwardMessageFlowDidComplete(items: [ForwardMessageItem],
recipientThreads: [TSThread]) {
AssertIsOnMainThread()
self.dismiss(animated: true) {
ForwardMessageViewController.finalizeForward(items: items,
recipientThreads: recipientThreads,
fromViewController: self)
}
}
public func forwardMessageFlowDidCancel() {
self.dismiss(animated: true)
}
}
@propertyWrapper
struct ThreadBoundValue<T> {
private var value: T
private var queue: DispatchQueue
var wrappedValue: T {
get {
dispatchPrecondition(condition: .onQueue(queue))
return value
}
set {
dispatchPrecondition(condition: .onQueue(queue))
value = newValue
}
}
init(wrappedValue: T, queue: DispatchQueue) {
self.value = wrappedValue
self.queue = queue
}
}
private class SyncingStoryViewModelArray {
// This is always the most up-to-date collection of models.
@ThreadBoundValue(wrappedValue: [], queue: loadingQueue) private var trueModels: [StoryViewModel]
// This may lag behind `trueModels` and is eventually consistent. It is exposed to UITableView.
@ThreadBoundValue(wrappedValue: [], queue: .main) private var exposedModels: [StoryViewModel]
/// Safely modify the list of models. This method must be called on the loading queue.
///
/// - Parameters
/// - closure: Called synchronously. Returns nil to abort mutation without side-effects. Otherwise, it returns the new values for the models array and user data to pass to `sync`.
/// - models: The existing array of models.
/// - sync: Runs asynchronously on the main queue after `closure` returns.
/// - newModels: The array of models returned by `closure`.
/// - userData: The second value returned by `closure`.
///
/// - Returns whether the closure returned a nonnil list of models.
@discardableResult
func mutate<T>(_ closure: (_ models: [StoryViewModel]) -> ([StoryViewModel], T)?,
sync: @escaping (_ newModels: [StoryViewModel], _ userData: T) -> Void) -> Bool {
dispatchPrecondition(condition: .onQueue(loadingQueue))
guard let (newModels, userInfo) = closure(trueModels) else {
return false
}
trueModels = newModels
DispatchQueue.main.async {
self.exposedModels = newModels
sync(newModels, userInfo)
}
return true
}
func get() -> [StoryViewModel] {
return exposedModels
}
}