Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experimental: Add Media Upload to Post Settings #23106

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 7 additions & 0 deletions WordPress/Classes/Services/MediaCoordinator.swift
Expand Up @@ -282,6 +282,9 @@ class MediaCoordinator: NSObject {
func cancelUploadAndDeleteMedia(_ media: Media) {
cancelUpload(of: media)
delete(media: [media])
if RemoteFeatureFlag.syncPublishing.enabled() {
notifyObserversForMedia(media, ofStateChange: .cancelled)
}
}

/// Cancels any ongoing upload for the media object
Expand Down Expand Up @@ -588,6 +591,8 @@ class MediaCoordinator: NSObject {
case ended
case failed(error: NSError)
case progress(value: Double)
/// The upload was cancelled by the user.
case cancelled

var debugDescription: String {
switch self {
Expand All @@ -603,6 +608,8 @@ class MediaCoordinator: NSObject {
return "Failed: \(error)"
case .progress(let value):
return "Progress: \(value)"
case .cancelled:
return "Cancelled"
}
}
}
Expand Down
23 changes: 14 additions & 9 deletions WordPress/Classes/Services/PostCoordinator.swift
Expand Up @@ -442,6 +442,9 @@ class PostCoordinator: NSObject {
retryDelay = min(32, retryDelay * 1.5)
return retryDelay
}
func setLongerDelay() {
retryDelay = max(retryDelay, 20)
}
var retryDelay: TimeInterval
weak var retryTimer: Timer?

Expand Down Expand Up @@ -600,14 +603,16 @@ class PostCoordinator: NSObject {
worker.error = error
postDidUpdateNotification(for: operation.post)

if !PostCoordinator.isTerminalError(error) {
let delay = worker.nextRetryDelay
worker.retryTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self, weak worker] _ in
guard let self, let worker else { return }
self.didRetryTimerFire(for: worker)
}
worker.log("scheduled retry with delay: \(delay)s.")
if PostCoordinator.isTerminalError(error) {
worker.setLongerDelay()
}

let delay = worker.nextRetryDelay
worker.retryTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self, weak worker] _ in
guard let self, let worker else { return }
self.didRetryTimerFire(for: worker)
}
worker.log("scheduled retry with delay: \(delay)s.")

if let error = error as? PostRepository.PostSaveError, case .deleted = error {
operation.log("post was permanently deleted")
Expand Down Expand Up @@ -725,6 +730,7 @@ class PostCoordinator: NSObject {
completion(.success(post))
}

// - warning: deprecated (kahu-offline-mode)
func cancelAnyPendingSaveOf(post: AbstractPost) {
removeObserver(for: post)
}
Expand Down Expand Up @@ -1023,7 +1029,7 @@ class PostCoordinator: NSObject {
}
}

/// Cancel active and pending automatic uploads of the post.
// - warning: deprecated (kahu-offline-mode)
func cancelAutoUploadOf(_ post: AbstractPost) {
cancelAnyPendingSaveOf(post: post)

Expand Down Expand Up @@ -1079,7 +1085,6 @@ class PostCoordinator: NSObject {
do {
try await PostRepository(coreDataStack: coreDataStack)._trash(post)

cancelAnyPendingSaveOf(post: post)
MediaCoordinator.shared.cancelUploadOfAllMedia(for: post)
SearchManager.shared.deleteSearchableItem(post)
} catch {
Expand Down
39 changes: 30 additions & 9 deletions WordPress/Classes/Services/PostRepository.swift
Expand Up @@ -158,16 +158,10 @@ final class PostRepository {
}

if revision.revision == nil {
// No more revisions were created during the upload, so it's safe
// to fully replace the local version with the remote data
PostHelper.update(post, with: remotePost, in: context, overwrite: true)
} else {
// We have to keep the local changes to make sure the delta can
// still be computed accurately (a smarter algo could merge the changes)
post.clone(from: revision)
post.postID = remotePost.postID // important!
apply(remotePost, to: post, revision: revision)
}

post.deleteSyncedRevisions(until: revision)

if isCreated {
Expand All @@ -179,6 +173,33 @@ final class PostRepository {
WPAnalytics.track(isCreated ? .postRepositoryPostCreated : .postRepositoryPostUpdated, properties: post.analyticsUserInfo)
}

// The app currently computes changes between revision to track what
// needs to be uploaded on the server to reduce the risk of overwriting changes.
//
// The problem arises if you have more than one saved revision. Let's
// say you start with revision R1, then save save R2, and, while R2 is
// still syncing, save another revision – R3:
//
// R1 → R2 → R3
//
// This scenario is problematic for the conflict detection mechanism.
// When uploading `post.content`, the server might ever so slightly
// change it – e.g. remove redundant spaces in tag attributes. So,
// unless the app saves the new `dateModified` and/or saves the
// changed `content`, it *will* detect a false-positive data conflict.
//
// Another caveat is that R1 could be a new draft, and then the app
// needs to save the `postID`.
private func apply(_ remotePost: RemotePost, to original: AbstractPost, revision: AbstractPost) {
// Keep the changes consistent across revisions.
original.clone(from: revision)

// But update the parts that might end up leading to data conflicts.
original.postID = remotePost.postID
original.content = remotePost.content
original.dateModified = remotePost.dateModified
}

/// Patches the post.
///
/// - note: This method can be used for quick edits for published posts where
Expand Down Expand Up @@ -235,11 +256,11 @@ final class PostRepository {
let remotePost = try await service.post(withID: postID)
// Check for false positives
if changes.content != nil && remotePost.content != changes.content && remotePost.content != original.content {
WPAnalytics.track(.postRepositoryConflictEncountered, properties: ["false-positive": true])
WPAnalytics.track(.postRepositoryConflictEncountered, properties: ["false-positive": false])
// The conflict in content can be resolved only manually
throw PostSaveError.conflict(latest: remotePost)
}
WPAnalytics.track(.postRepositoryConflictEncountered, properties: ["false-positive": false])
WPAnalytics.track(.postRepositoryConflictEncountered, properties: ["false-positive": true])

// There is no conflict, so go ahead and overwrite the changes
changes.ifNotModifiedSince = nil
Expand Down
5 changes: 5 additions & 0 deletions WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift
Expand Up @@ -12,6 +12,7 @@ enum FeatureFlag: Int, CaseIterable {
case googleDomainsCard
case newTabIcons
case readerTagsFeed
case autoSaveDrafts

/// Returns a boolean indicating if the feature is enabled
var enabled: Bool {
Expand Down Expand Up @@ -40,6 +41,8 @@ enum FeatureFlag: Int, CaseIterable {
return true
case .readerTagsFeed:
return false
case .autoSaveDrafts:
return true
}
}

Expand Down Expand Up @@ -82,6 +85,8 @@ extension FeatureFlag {
return "New Tab Icons"
case .readerTagsFeed:
return "Reader Tags Feed"
case .autoSaveDrafts:
return "Autosave Drafts"
}
}
}
Expand Down
Expand Up @@ -2179,6 +2179,8 @@ extension AztecPostViewController {
handleError(error, onAttachment: attachment)
case .progress(let value):
handleProgress(value, forMedia: media, onAttachment: attachment)
case .cancelled:
richTextView.remove(attachmentID: attachment.identifier)
}
}

Expand Down
Expand Up @@ -236,6 +236,8 @@ class GutenbergMediaInserterHelper: NSObject {
}
case .progress(let value):
gutenberg.mediaUploadUpdate(id: mediaUploadID, state: .uploading, progress: Float(value), url: nil, serverID: nil)
case .cancelled:
gutenberg.mediaUploadUpdate(id: mediaUploadID, state: .reset, progress: 0, url: nil, serverID: nil)
}
}

Expand Down
Expand Up @@ -315,7 +315,9 @@ class GutenbergViewController: UIViewController, PostEditor, FeaturedImageDelega

addObservers(toPost: post)

PostCoordinator.shared.cancelAnyPendingSaveOf(post: post)
if !RemoteFeatureFlag.syncPublishing.enabled() {
PostCoordinator.shared.cancelAnyPendingSaveOf(post: post)
}
self.navigationBarManager.delegate = self
disableSocialConnectionsIfNecessary()
}
Expand Down
@@ -1,6 +1,19 @@
import UIKit
import Gifu
import AVKit
import SwiftUI

struct SiteMediaPreviewView: UIViewControllerRepresentable {
let media: Media

func makeUIViewController(context: Context) -> SiteMediaPreviewViewController {
SiteMediaPreviewViewController(media: media)
}

func updateUIViewController(_ vc: SiteMediaPreviewViewController, context: Context) {
// Do nothing
}
}

final class SiteMediaPreviewViewController: UIViewController {
private let imageView = GIFImageView()
Expand Down
20 changes: 17 additions & 3 deletions WordPress/Classes/ViewRelated/Post/PostEditor+Publish.swift
Expand Up @@ -371,7 +371,14 @@ extension PublishingEditor {
}

if post.original().isStatus(in: [.draft, .pending]) {
showCloseDraftConfirmationAlert()
if FeatureFlag.autoSaveDrafts.enabled {
performSaveDraftAction()
} else {
// The "Discard Changes" behavior is problematic due to the way
// the editor and `PostCoordinator` often update the content
// in the background without the user interaction.
showCloseDraftConfirmationAlert()
}
} else {
showClosePublishedPostConfirmationAlert()
}
Expand Down Expand Up @@ -429,8 +436,15 @@ extension PublishingEditor {

WPAppAnalytics.track(.editorDiscardedChanges, withProperties: [WPAppAnalyticsKeyEditorSource: analyticsEditorSource], with: post)

// TODO: this is incorrect because there might still be media in the previous revision
cancelUploadOfAllMedia(for: post)
// Cancel upload of only newly inserted media items
if let previous = post.original {
for media in post.media.subtracting(previous.media) {
DDLogInfo("post-editor: cancel upload for \(media.filename ?? media.description)")
MediaCoordinator.shared.cancelUpload(of: media)
}
} else {
wpAssertionFailure("the editor must be working with a revision")
}

AbstractPost.deleteLatestRevision(post, in: context)
ContextManager.shared.saveContextAndWait(context)
Expand Down
44 changes: 43 additions & 1 deletion WordPress/Classes/ViewRelated/Post/PostEditor.swift
@@ -1,5 +1,6 @@
import UIKit
import Combine
import WordPressFlux

enum EditMode {
case richText
Expand Down Expand Up @@ -142,6 +143,7 @@ extension PostEditor where Self: UIViewController {
return
}
showAutosaveAvailableAlertIfNeeded()
showTerminalUploadErrorAlertIfNeeded()

var cancellables: [AnyCancellable] = []

Expand Down Expand Up @@ -182,6 +184,37 @@ extension PostEditor where Self: UIViewController {
present(alert, animated: true)
}

private func showTerminalUploadErrorAlertIfNeeded() {
let hasTerminalError = post.media.contains {
guard let error = $0.error else { return false }
return MediaCoordinator.isTerminalError(error)
}
if hasTerminalError {
let notice = Notice(title: Strings.failingMediaUploadsMessage, feedbackType: .error, actionTitle: Strings.failingMediaUploadsViewAction, actionHandler: { [weak self] _ in
self?.showMediaUploadDetails()
})
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(700)) {
ActionDispatcherFacade().dispatch(NoticeAction.post(notice))
} // Delay to let the editor show first
}
}

private func showMediaUploadDetails() {
let viewController = PostMediaUploadsViewController(post: post)
let nav = UINavigationController(rootViewController: viewController)
nav.navigationBar.isTranslucent = true // Reset to default
viewController.navigationItem.leftBarButtonItem = UIBarButtonItem(systemItem: .close, primaryAction: UIAction { [weak self] _ in
self?.dismiss(animated: true)
})
if let sheetController = nav.sheetPresentationController {
sheetController.detents = [.medium(), .large()]
sheetController.prefersGrabberVisible = true
sheetController.preferredCornerRadius = 16
nav.additionalSafeAreaInsets = UIEdgeInsets(top: 8, left: 0, bottom: 0, right: 0)
}
self.present(nav, animated: true)
}

private func appWillTerminate() {
guard let context = post.managedObjectContext else {
return
Expand All @@ -195,7 +228,11 @@ extension PostEditor where Self: UIViewController {
if post.changes.isEmpty {
AbstractPost.deleteLatestRevision(post, in: context)
} else {
EditPostViewController.encode(post: post)
if FeatureFlag.autoSaveDrafts.enabled, PostCoordinator.shared.isSyncAllowed(for: post) {
PostCoordinator.shared.setNeedsSync(for: post)
} else {
EditPostViewController.encode(post: post)
}
}
if context.hasChanges {
ContextManager.sharedInstance().saveContextAndWait(context)
Expand Down Expand Up @@ -238,4 +275,9 @@ private enum Strings {

static let autosaveAlertContinue = NSLocalizedString("autosaveAlert.viewChanges", value: "View Changes", comment: "An alert suggesting to load autosaved revision for a published post")
static let autosaveAlertCancel = NSLocalizedString("autosaveAlert.cancel", value: "Cancel", comment: "An alert suggesting to load autosaved revision for a published post")

static let failingMediaUploadsMessage = NSLocalizedString("postEditor.postHasFailingMediaUploadsSnackbar.message", value: "Some media items failed to upload", comment: "A message for a snackbar informing the user that some media files requires their attention")

static let failingMediaUploadsViewAction = NSLocalizedString("postEditor.postHasFailingMediaUploadsSnackbar.actionView", value: "View", comment: "A 'View' action for a snackbar informing the user that some media files requires their attention")

}