394 lines
21 KiB
Swift
394 lines
21 KiB
Swift
//
|
|
// StatusProvider+UITableViewDelegate.swift
|
|
// Mastodon
|
|
//
|
|
// Created by MainasuK Cirno on 2021-3-3.
|
|
//
|
|
|
|
import Combine
|
|
import CoreDataStack
|
|
import MastodonSDK
|
|
import os.log
|
|
import UIKit
|
|
|
|
extension StatusTableViewCellDelegate where Self: StatusProvider {
|
|
|
|
func handleTableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
|
// update poll when status appear
|
|
let now = Date()
|
|
var pollID: Mastodon.Entity.Poll.ID?
|
|
status(for: cell, indexPath: indexPath)
|
|
.compactMap { [weak self] status -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Poll>, Error>? in
|
|
guard let self = self else { return nil }
|
|
guard let authenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { return nil }
|
|
guard let status = (status?.reblog ?? status) else { return nil }
|
|
guard let poll = status.poll else { return nil }
|
|
pollID = poll.id
|
|
|
|
// not expired AND last update > 60s
|
|
guard !poll.expired else {
|
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s expired. Skip for update", (#file as NSString).lastPathComponent, #line, #function, poll.id)
|
|
return nil
|
|
}
|
|
let timeIntervalSinceUpdate = now.timeIntervalSince(poll.updatedAt)
|
|
#if DEBUG
|
|
let autoRefreshTimeInterval: TimeInterval = 3 // speedup testing
|
|
#else
|
|
let autoRefreshTimeInterval: TimeInterval = 60
|
|
#endif
|
|
guard timeIntervalSinceUpdate > autoRefreshTimeInterval else {
|
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s updated in the %.2fs. Skip for update", (#file as NSString).lastPathComponent, #line, #function, poll.id, timeIntervalSinceUpdate)
|
|
return nil
|
|
}
|
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s info update…", (#file as NSString).lastPathComponent, #line, #function, poll.id)
|
|
|
|
return self.context.apiService.poll(
|
|
domain: status.domain,
|
|
pollID: poll.id,
|
|
pollObjectID: poll.objectID,
|
|
mastodonAuthenticationBox: authenticationBox
|
|
)
|
|
}
|
|
.setFailureType(to: Error.self)
|
|
.switchToLatest()
|
|
.receive(on: DispatchQueue.main)
|
|
.sink(receiveCompletion: { completion in
|
|
switch completion {
|
|
case .failure(let error):
|
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s info fail to update: %s", (#file as NSString).lastPathComponent, #line, #function, pollID ?? "?", error.localizedDescription)
|
|
case .finished:
|
|
break
|
|
}
|
|
}, receiveValue: { response in
|
|
let poll = response.value
|
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s info updated", (#file as NSString).lastPathComponent, #line, #function, poll.id)
|
|
})
|
|
.store(in: &disposeBag)
|
|
|
|
status(for: cell, indexPath: indexPath)
|
|
.receive(on: RunLoop.main)
|
|
.sink { [weak self] status in
|
|
guard let self = self else { return }
|
|
let status = status?.reblog ?? status
|
|
guard let media = (status?.mediaAttachments ?? Set()).first else { return }
|
|
guard let videoPlayerViewModel = self.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: media) else { return }
|
|
|
|
DispatchQueue.main.async {
|
|
videoPlayerViewModel.willDisplay()
|
|
}
|
|
}
|
|
.store(in: &disposeBag)
|
|
}
|
|
|
|
func handleTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
|
// os_log("%{public}s[%{public}ld], %{public}s: indexPath %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription)
|
|
|
|
status(for: cell, indexPath: indexPath)
|
|
.sink { [weak self] status in
|
|
guard let self = self else { return }
|
|
guard let media = (status?.mediaAttachments ?? Set()).first else { return }
|
|
|
|
if let videoPlayerViewModel = self.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: media) {
|
|
DispatchQueue.main.async {
|
|
videoPlayerViewModel.didEndDisplaying()
|
|
}
|
|
}
|
|
if let currentAudioAttachment = self.context.audioPlaybackService.attachment,
|
|
status?.mediaAttachments?.contains(currentAudioAttachment) == true {
|
|
self.context.audioPlaybackService.pause()
|
|
}
|
|
}
|
|
.store(in: &disposeBag)
|
|
}
|
|
|
|
func handleTableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
|
StatusProviderFacade.coordinateToStatusThreadScene(for: .primary, provider: self, indexPath: indexPath)
|
|
}
|
|
|
|
}
|
|
|
|
extension StatusTableViewCellDelegate where Self: StatusProvider {
|
|
|
|
private typealias ImagePreviewPresentableCell = UITableViewCell & DisposeBagCollectable & MosaicImageViewContainerPresentable
|
|
|
|
func handleTableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
|
guard let imagePreviewPresentableCell = tableView.cellForRow(at: indexPath) as? ImagePreviewPresentableCell else { return nil }
|
|
guard imagePreviewPresentableCell.isRevealing else { return nil }
|
|
|
|
let status = self.status(for: nil, indexPath: indexPath)
|
|
|
|
return contextMenuConfiguration(tableView, status: status, imagePreviewPresentableCell: imagePreviewPresentableCell, contextMenuConfigurationForRowAt: indexPath, point: point)
|
|
}
|
|
|
|
private func contextMenuConfiguration(
|
|
_ tableView: UITableView,
|
|
status: Future<Status?, Never>,
|
|
imagePreviewPresentableCell presentable: ImagePreviewPresentableCell,
|
|
contextMenuConfigurationForRowAt indexPath: IndexPath,
|
|
point: CGPoint
|
|
) -> UIContextMenuConfiguration? {
|
|
let imageViews = presentable.mosaicImageViewContainer.imageViews
|
|
guard !imageViews.isEmpty else { return nil }
|
|
|
|
for (i, imageView) in imageViews.enumerated() {
|
|
let pointInImageView = imageView.convert(point, from: tableView)
|
|
guard imageView.point(inside: pointInImageView, with: nil) else {
|
|
continue
|
|
}
|
|
guard let image = imageView.image, image.size != CGSize(width: 1, height: 1) else {
|
|
// not provide preview until image ready
|
|
return nil
|
|
|
|
}
|
|
// setup preview
|
|
let contextMenuImagePreviewViewModel = ContextMenuImagePreviewViewModel(aspectRatio: image.size, thumbnail: image)
|
|
status
|
|
.sink { status in
|
|
guard let status = (status?.reblog ?? status),
|
|
let media = status.mediaAttachments?.sorted(by:{ $0.index.compare($1.index) == .orderedAscending }),
|
|
i < media.count, let url = URL(string: media[i].url) else {
|
|
return
|
|
}
|
|
|
|
contextMenuImagePreviewViewModel.url.value = url
|
|
}
|
|
.store(in: &contextMenuImagePreviewViewModel.disposeBag)
|
|
|
|
// setup context menu
|
|
let contextMenuConfiguration = TimelineTableViewCellContextMenuConfiguration(identifier: nil) { () -> UIViewController? in
|
|
// know issue: preview size looks not as large as system default preview
|
|
let previewProvider = ContextMenuImagePreviewViewController()
|
|
previewProvider.viewModel = contextMenuImagePreviewViewModel
|
|
return previewProvider
|
|
} actionProvider: { _ -> UIMenu? in
|
|
let savePhotoAction = UIAction(
|
|
title: L10n.Common.Controls.Actions.savePhoto, image: UIImage(systemName: "square.and.arrow.down")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off
|
|
) { [weak self] _ in
|
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: save photo", ((#file as NSString).lastPathComponent), #line, #function)
|
|
guard let self = self else { return }
|
|
self.attachment(of: status, index: i)
|
|
.setFailureType(to: Error.self)
|
|
.compactMap { attachment -> AnyPublisher<UIImage, Error>? in
|
|
guard let attachment = attachment, let url = URL(string: attachment.url) else { return nil }
|
|
return self.context.photoLibraryService.saveImage(url: url)
|
|
}
|
|
.switchToLatest()
|
|
.sink(receiveCompletion: { [weak self] completion in
|
|
guard let self = self else { return }
|
|
switch completion {
|
|
case .failure(let error):
|
|
guard let error = error as? PhotoLibraryService.PhotoLibraryError,
|
|
case .noPermission = error else { return }
|
|
let alertController = SettingService.openSettingsAlertController(title: L10n.Common.Alerts.SavePhotoFailure.title, message: L10n.Common.Alerts.SavePhotoFailure.message)
|
|
self.coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil))
|
|
case .finished:
|
|
break
|
|
}
|
|
}, receiveValue: { _ in
|
|
// do nothing
|
|
})
|
|
.store(in: &self.context.disposeBag)
|
|
}
|
|
let copyPhotoAction = UIAction(
|
|
title: L10n.Common.Controls.Actions.copyPhoto,
|
|
image: UIImage(systemName: "doc.on.doc"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off
|
|
) { [weak self] _ in
|
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: copy photo", ((#file as NSString).lastPathComponent), #line, #function)
|
|
guard let self = self else { return }
|
|
self.attachment(of: status, index: i)
|
|
.setFailureType(to: Error.self)
|
|
.compactMap { attachment -> AnyPublisher<UIImage, Error>? in
|
|
guard let attachment = attachment, let url = URL(string: attachment.url) else { return nil }
|
|
return self.context.photoLibraryService.copyImage(url: url)
|
|
}
|
|
.switchToLatest()
|
|
.sink(receiveCompletion: { completion in
|
|
switch completion {
|
|
case .failure(let error):
|
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: copy photo fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
|
case .finished:
|
|
break
|
|
}
|
|
}, receiveValue: { _ in
|
|
// do nothing
|
|
})
|
|
.store(in: &self.context.disposeBag)
|
|
}
|
|
let shareAction = UIAction(
|
|
title: L10n.Common.Controls.Actions.share, image: UIImage(systemName: "square.and.arrow.up")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off
|
|
) { [weak self] _ in
|
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: share", ((#file as NSString).lastPathComponent), #line, #function)
|
|
guard let self = self else { return }
|
|
self.attachment(of: status, index: i)
|
|
.sink(receiveValue: { [weak self] attachment in
|
|
guard let self = self else { return }
|
|
guard let attachment = attachment, let url = URL(string: attachment.url) else { return }
|
|
let applicationActivities: [UIActivity] = [
|
|
SafariActivity(sceneCoordinator: self.coordinator)
|
|
]
|
|
let activityViewController = UIActivityViewController(
|
|
activityItems: [url],
|
|
applicationActivities: applicationActivities
|
|
)
|
|
activityViewController.popoverPresentationController?.sourceView = imageView
|
|
self.present(activityViewController, animated: true, completion: nil)
|
|
})
|
|
.store(in: &self.context.disposeBag)
|
|
}
|
|
let children = [savePhotoAction, copyPhotoAction, shareAction]
|
|
return UIMenu(title: "", image: nil, children: children)
|
|
}
|
|
contextMenuConfiguration.indexPath = indexPath
|
|
contextMenuConfiguration.index = i
|
|
return contextMenuConfiguration
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private func attachment(of status: Future<Status?, Never>, index: Int) -> AnyPublisher<Attachment?, Never> {
|
|
status
|
|
.map { status in
|
|
guard let status = status?.reblog ?? status else { return nil }
|
|
guard let media = status.mediaAttachments?.sorted(by: { $0.index.compare($1.index) == .orderedAscending }) else { return nil }
|
|
guard index < media.count else { return nil }
|
|
return media[index]
|
|
}
|
|
.eraseToAnyPublisher()
|
|
}
|
|
|
|
func handleTableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
|
return _handleTableView(tableView, configuration: configuration)
|
|
}
|
|
|
|
func handleTableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
|
return _handleTableView(tableView, configuration: configuration)
|
|
}
|
|
|
|
private func _handleTableView(_ tableView: UITableView, configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
|
guard let configuration = configuration as? TimelineTableViewCellContextMenuConfiguration else { return nil }
|
|
guard let indexPath = configuration.indexPath, let index = configuration.index else { return nil }
|
|
guard let cell = tableView.cellForRow(at: indexPath) as? ImagePreviewPresentableCell else {
|
|
return nil
|
|
}
|
|
let imageViews = cell.mosaicImageViewContainer.imageViews
|
|
guard index < imageViews.count else { return nil }
|
|
let imageView = imageViews[index]
|
|
return UITargetedPreview(view: imageView, parameters: UIPreviewParameters())
|
|
}
|
|
|
|
func handleTableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
|
guard let previewableViewController = self as? MediaPreviewableViewController else { return }
|
|
guard let configuration = configuration as? TimelineTableViewCellContextMenuConfiguration else { return }
|
|
guard let indexPath = configuration.indexPath, let index = configuration.index else { return }
|
|
guard let cell = tableView.cellForRow(at: indexPath) as? ImagePreviewPresentableCell else { return }
|
|
let imageViews = cell.mosaicImageViewContainer.imageViews
|
|
guard index < imageViews.count else { return }
|
|
let imageView = imageViews[index]
|
|
|
|
let status = self.status(for: nil, indexPath: indexPath)
|
|
let initialFrame: CGRect? = {
|
|
guard let previewViewController = animator.previewViewController else { return nil }
|
|
return UIView.findContextMenuPreviewFrameInWindow(previewController: previewViewController)
|
|
}()
|
|
animator.preferredCommitStyle = .pop
|
|
animator.addCompletion { [weak self] in
|
|
guard let self = self else { return }
|
|
status
|
|
//.delay(for: .milliseconds(500), scheduler: DispatchQueue.main)
|
|
.sink { [weak self] status in
|
|
guard let self = self else { return }
|
|
guard let status = (status?.reblog ?? status) else { return }
|
|
|
|
let meta = MediaPreviewViewModel.StatusImagePreviewMeta(
|
|
statusObjectID: status.objectID,
|
|
initialIndex: index,
|
|
preloadThumbnailImages: cell.mosaicImageViewContainer.thumbnails()
|
|
)
|
|
let pushTransitionItem = MediaPreviewTransitionItem(
|
|
source: .mosaic(cell.mosaicImageViewContainer),
|
|
previewableViewController: previewableViewController
|
|
)
|
|
pushTransitionItem.aspectRatio = {
|
|
if let image = imageView.image {
|
|
return image.size
|
|
}
|
|
guard let media = status.mediaAttachments?.sorted(by: { $0.index.compare($1.index) == .orderedAscending }) else { return nil }
|
|
guard index < media.count else { return nil }
|
|
let meta = media[index].meta
|
|
guard let width = meta?.original?.width, let height = meta?.original?.height else { return nil }
|
|
return CGSize(width: width, height: height)
|
|
}()
|
|
pushTransitionItem.sourceImageView = imageView
|
|
pushTransitionItem.initialFrame = {
|
|
if let initialFrame = initialFrame {
|
|
return initialFrame
|
|
}
|
|
return imageView.superview!.convert(imageView.frame, to: nil)
|
|
}()
|
|
pushTransitionItem.image = {
|
|
if let image = imageView.image {
|
|
return image
|
|
}
|
|
if index < cell.mosaicImageViewContainer.blurhashOverlayImageViews.count {
|
|
return cell.mosaicImageViewContainer.blurhashOverlayImageViews[index].image
|
|
}
|
|
|
|
return nil
|
|
}()
|
|
let mediaPreviewViewModel = MediaPreviewViewModel(
|
|
context: self.context,
|
|
meta: meta,
|
|
pushTransitionItem: pushTransitionItem
|
|
)
|
|
DispatchQueue.main.async {
|
|
self.coordinator.present(scene: .mediaPreview(viewModel: mediaPreviewViewModel), from: self, transition: .custom(transitioningDelegate: previewableViewController.mediaPreviewTransitionController))
|
|
}
|
|
}
|
|
.store(in: &cell.disposeBag)
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
extension UIView {
|
|
|
|
// hack to retrieve preview view frame in window
|
|
fileprivate static func findContextMenuPreviewFrameInWindow(
|
|
previewController: UIViewController
|
|
) -> CGRect? {
|
|
guard let window = previewController.view.window else { return nil }
|
|
|
|
let targetViews = window.subviews
|
|
.map { $0.findSameSize(view: previewController.view) }
|
|
.flatMap { $0 }
|
|
for targetView in targetViews {
|
|
guard let targetViewSuperview = targetView.superview else { continue }
|
|
let frame = targetViewSuperview.convert(targetView.frame, to: nil)
|
|
guard frame.origin.x > 0, frame.origin.y > 0 else { continue }
|
|
return frame
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private func findSameSize(view: UIView) -> [UIView] {
|
|
var views: [UIView] = []
|
|
|
|
if view.bounds.size == bounds.size {
|
|
views.append(self)
|
|
}
|
|
|
|
for subview in subviews {
|
|
let targetViews = subview.findSameSize(view: view)
|
|
views.append(contentsOf: targetViews)
|
|
}
|
|
|
|
return views
|
|
}
|
|
|
|
}
|