Skip to content

Commit

Permalink
Merge pull request #8989 from wordpress-mobile/issue/5984-post-change…
Browse files Browse the repository at this point in the history
…-conflict-resolution

Post change conflict resolution dialog
  • Loading branch information
oguzkocer committed Jan 25, 2019
2 parents 948a888 + 9896f85 commit d2ca3db
Show file tree
Hide file tree
Showing 8 changed files with 190 additions and 20 deletions.
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
* Added a dialog for user to resolve a conflicted Post (different local / web version)
* Refreshed page list layout that includes a timestamp and a featured image thumbnail
* Fixed a bug causing disappearance of old saved posts
* Add Importing from Giphy in Editor and Media Library
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ data class PostAdapterItemData(
val date: String,
val postStatus: PostStatus,
val isLocallyChanged: Boolean,
val isConflicted: Boolean,
val canShowStats: Boolean,
val canPublishPost: Boolean,
val canRetryUpload: Boolean,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import android.text.format.DateUtils;

import org.apache.commons.lang3.StringUtils;
import org.wordpress.android.R;
import org.wordpress.android.WordPress;
import org.wordpress.android.analytics.AnalyticsTracker;
import org.wordpress.android.fluxc.model.MediaModel;
Expand All @@ -18,15 +19,18 @@
import org.wordpress.android.util.AppLog;
import org.wordpress.android.util.DateTimeUtils;
import org.wordpress.android.util.HtmlUtils;
import org.wordpress.android.util.LocaleManager;
import org.wordpress.android.util.analytics.AnalyticsUtils;

import java.text.BreakIterator;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

Expand Down Expand Up @@ -373,4 +377,36 @@ public static boolean shouldShowGutenbergEditor(boolean isNewPost, PostModel pos
|| contentContainsGutenbergBlocks(post.getContent())
|| TextUtils.isEmpty(post.getContent()));
}

public static boolean isPostInConflictWithRemote(PostModel post) {
// at this point we know there's a potential version conflict (the post has been modified
// both locally and on the remote)
return !post.getLastModified().equals(post.getRemoteLastModified()) && post.isLocallyChanged();
}

public static String getConflictedPostCustomStringForDialog(PostModel post) {
Context context = WordPress.getContext();
String firstPart = context.getString(R.string.dialog_confirm_load_remote_post_body);
String secondPart =
String.format(context.getString(R.string.dialog_confirm_load_remote_post_body_2),
getFormattedDateForLastModified(
context, DateTimeUtils.timestampFromIso8601Millis(post.getLastModified())),
getFormattedDateForLastModified(
context, DateTimeUtils.timestampFromIso8601Millis(post.getRemoteLastModified())));
return firstPart + secondPart;
}

/**
* E.g. Jul 2, 2013 @ 21:57
*/
public static String getFormattedDateForLastModified(Context context, long timeSinceLastModified) {
Date date = new Date(timeSinceLastModified);
SimpleDateFormat sdf =
new SimpleDateFormat("MMM d, yyyy '@' hh:mm a", LocaleManager.getSafeLocale(context));

// The timezone on the website is at GMT
sdf.setTimeZone(TimeZone.getTimeZone("GMT"));

return sdf.format(date);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,10 @@ class PostViewHolder(private val view: View, private val config: PostViewHolderC
// the Post (or its related media if such a thing exist) *is strictly* queued
statusTextResId = R.string.post_queued
statusIconResId = R.drawable.ic_gridicons_cloud_upload
} else if (postAdapterItem.isConflicted) {
statusTextResId = R.string.local_post_is_conflicted
statusIconResId = R.drawable.ic_gridicons_notice
statusColorResId = R.color.alert_red
} else if (postAdapterItem.isLocalDraft) {
statusTextResId = R.string.local_draft
statusIconResId = R.drawable.ic_gridicons_page
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import org.wordpress.android.ui.uploads.PostEvents
import org.wordpress.android.ui.uploads.UploadService
import org.wordpress.android.ui.uploads.VideoOptimizer
import org.wordpress.android.ui.utils.UiString.UiStringRes
import org.wordpress.android.ui.utils.UiString.UiStringText
import org.wordpress.android.util.AppLog
import org.wordpress.android.util.AppLog.T
import org.wordpress.android.util.SiteUtils
Expand All @@ -79,6 +80,7 @@ import javax.inject.Inject

const val CONFIRM_DELETE_POST_DIALOG_TAG = "CONFIRM_DELETE_POST_DIALOG_TAG"
const val CONFIRM_PUBLISH_POST_DIALOG_TAG = "CONFIRM_PUBLISH_POST_DIALOG_TAG"
const val CONFIRM_ON_CONFLICT_LOAD_REMOTE_POST_DIALOG_TAG = "CONFIRM_ON_CONFLICT_LOAD_REMOTE_POST_DIALOG_TAG"

enum class PostListEmptyViewState {
EMPTY_LIST,
Expand Down Expand Up @@ -111,9 +113,12 @@ class PostListViewModel @Inject constructor(

// Keep a reference to the currently being trashed post, so we can hide it during Undo Snackbar
private var postIdToTrash: Pair<Int, Long>? = null
// Since we are using DialogFragments we need to hold onto which post will be published or trashed
// Since we are using DialogFragments we need to hold onto which post will be published or trashed / resolved
private var localPostIdForPublishDialog: Int? = null
private var localPostIdForTrashDialog: Int? = null
private var localPostIdForConflictResolutionDialog: Int? = null
private var originalPostCopyForConflictUndo: PostModel? = null
private var localPostIdForFetchingRemoteVersionOfConflictedPost: Int? = null
// Initial target post to scroll to
private var targetLocalPostId: Int? = null

Expand Down Expand Up @@ -324,6 +329,18 @@ class PostListViewModel @Inject constructor(
_dialogAction.postValue(dialogHolder)
}

private fun showConflictedPostResolutionDialog(post: PostModel) {
val dialogHolder = DialogHolder(
tag = CONFIRM_ON_CONFLICT_LOAD_REMOTE_POST_DIALOG_TAG,
title = UiStringRes(R.string.dialog_confirm_load_remote_post_title),
message = UiStringText(PostUtils.getConflictedPostCustomStringForDialog(post)),
positiveButton = UiStringRes(R.string.dialog_confirm_load_remote_post_discard_local),
negativeButton = UiStringRes(R.string.dialog_confirm_load_remote_post_discard_web)
)
localPostIdForConflictResolutionDialog = post.id
_dialogAction.postValue(dialogHolder)
}

private fun publishPost(localPostId: Int) {
val post = postStore.getPostByLocalPostId(localPostId)
if (post != null) {
Expand All @@ -337,6 +354,16 @@ class PostListViewModel @Inject constructor(
}

private fun editPostButtonAction(site: SiteModel, post: PostModel) {
// first of all, check whether this post is in Conflicted state.
if (doesPostHaveUnhandledConflict(post)) {
showConflictedPostResolutionDialog(post)
return
}

checkGutenbergOrEdit(site, post)
}

private fun checkGutenbergOrEdit(site: SiteModel, post: PostModel) {
// Show Gutenberg Warning Dialog if post contains GB blocks and it's not disabled
if (!isGutenbergEnabled() &&
PostUtils.contentContainsGutenbergBlocks(post.content) &&
Expand Down Expand Up @@ -406,6 +433,14 @@ class PostListViewModel @Inject constructor(
T.POSTS,
"Error updating the post with type: ${event.error.type} and message: ${event.error.message}"
)
} else {
originalPostCopyForConflictUndo?.id?.let {
val updatedPost = postStore.getPostByLocalPostId(it)
// Conflicted post has been successfully updated with its remote version
if (!PostUtils.isPostInConflictWithRemote(updatedPost)) {
conflictedPostUpdatedWithItsRemoteVersion()
}
}
}
}
is CauseOfOnPostChanged.DeletePost -> {
Expand Down Expand Up @@ -525,13 +560,15 @@ class PostListViewModel @Inject constructor(
date = PostUtils.getFormattedDate(post),
postStatus = postStatus,
isLocallyChanged = post.isLocallyChanged,
isConflicted = doesPostHaveUnhandledConflict(post),
canShowStats = canShowStats,
canPublishPost = canPublishPost,
canRetryUpload = uploadStatus.uploadError != null && !uploadStatus.hasInProgressMediaUpload,
featuredImageId = post.featuredImageId,
featuredImageUrl = getFeaturedImageUrl(post.featuredImageId, post.content),
uploadStatus = uploadStatus
)

return PostAdapterItem(
data = postData,
onSelected = { handlePostButton(PostListButton.BUTTON_EDIT, post) },
Expand Down Expand Up @@ -601,6 +638,11 @@ class PostListViewModel @Inject constructor(
localPostIdForPublishDialog = null
publishPost(it)
}
CONFIRM_ON_CONFLICT_LOAD_REMOTE_POST_DIALOG_TAG -> localPostIdForConflictResolutionDialog?.let {
localPostIdForConflictResolutionDialog = null
// here load version from remote
updateConflictedPostWithItsRemoteVersion(it)
}
else -> throw IllegalArgumentException("Dialog's positive button click is not handled: $instanceTag")
}
}
Expand All @@ -609,13 +651,19 @@ class PostListViewModel @Inject constructor(
when (instanceTag) {
CONFIRM_DELETE_POST_DIALOG_TAG -> localPostIdForTrashDialog = null
CONFIRM_PUBLISH_POST_DIALOG_TAG -> localPostIdForPublishDialog = null
CONFIRM_ON_CONFLICT_LOAD_REMOTE_POST_DIALOG_TAG -> localPostIdForConflictResolutionDialog?.let {
updateConflictedPostWithItsLocalVersion(it)
}
else -> throw IllegalArgumentException("Dialog's negative button click is not handled: $instanceTag")
}
}

fun onDismissByOutsideTouchForBasicDialog(instanceTag: String) {
// Cancel and outside touch dismiss works the same way
onNegativeClickedForBasicDialog(instanceTag)
// Cancel and outside touch dismiss works the same way for all, except for conflict resolution dialog,
// for which tapping outside and actively tapping the "edit local" have different meanings
if (instanceTag != CONFIRM_ON_CONFLICT_LOAD_REMOTE_POST_DIALOG_TAG) {
onNegativeClickedForBasicDialog(instanceTag)
}
}

// Gutenberg Events
Expand Down Expand Up @@ -662,8 +710,81 @@ class PostListViewModel @Inject constructor(
}
}

// Post Conflict Resolution

private fun updateConflictedPostWithItsRemoteVersion(localPostId: Int) {
// We need network connection to load a remote post
if (!checkNetworkConnection()) {
return
}

val post = postStore.getPostByLocalPostId(localPostId)
if (post != null) {
originalPostCopyForConflictUndo = post.clone()
dispatcher.dispatch(PostActionBuilder.newFetchPostAction(RemotePostPayload(post, site)))
_toastMessage.postValue(ToastMessageHolder(R.string.toast_conflict_updating_post, Duration.SHORT))
}
}

private fun conflictedPostUpdatedWithItsRemoteVersion() {
val undoAction = {
// here replace the post with whatever we had before, again
if (originalPostCopyForConflictUndo != null) {
dispatcher.dispatch(PostActionBuilder.newUpdatePostAction(originalPostCopyForConflictUndo))
}
}
val onDismissAction = {
originalPostCopyForConflictUndo = null
}
val snackbarHolder = SnackbarMessageHolder(R.string.snackbar_conflict_local_version_discarded,
R.string.snackbar_conflict_undo, undoAction, onDismissAction)
_snackbarAction.postValue(snackbarHolder)
}

private fun updateConflictedPostWithItsLocalVersion(localPostId: Int) {
// We need network connection to push local version to remote
if (!checkNetworkConnection()) {
return
}

// Keep a reference to which post is being updated with the local version so we can avoid showing the conflicted
// label during the undo snackbar.
localPostIdForFetchingRemoteVersionOfConflictedPost = localPostId
pagedListWrapper.invalidateData()

val post = postStore.getPostByLocalPostId(localPostId) ?: return

// and now show a snackbar, acting as if the Post was pushed, but effectively push it after the snackbar is gone
var isUndoed = false
val undoAction = {
isUndoed = true

// Remove the reference for the post being updated and re-show the conflicted label on undo
localPostIdForFetchingRemoteVersionOfConflictedPost = null
pagedListWrapper.invalidateData()
}

val onDismissAction = {
if (!isUndoed) {
localPostIdForFetchingRemoteVersionOfConflictedPost = null
PostUtils.trackSavePostAnalytics(post, site)
dispatcher.dispatch(PostActionBuilder.newPushPostAction(RemotePostPayload(post, site)))
}
}
val snackbarHolder = SnackbarMessageHolder(R.string.snackbar_conflict_web_version_discarded,
R.string.snackbar_conflict_undo, undoAction, onDismissAction)
_snackbarAction.postValue(snackbarHolder)
}

// Utils

private fun doesPostHaveUnhandledConflict(post: PostModel): Boolean {
// If we are fetching the remote version of a conflicted post, it means it's already being handled
val isFetchingConflictedPost = localPostIdForFetchingRemoteVersionOfConflictedPost != null &&
localPostIdForFetchingRemoteVersionOfConflictedPost == post.id
return !isFetchingConflictedPost && PostUtils.isPostInConflictWithRemote(post)
}

private fun checkNetworkConnection(): Boolean =
if (isNetworkAvailable) {
true
Expand Down
9 changes: 9 additions & 0 deletions WordPress/src/main/res/drawable/ic_gridicons_notice.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M12,2C6.477,2 2,6.477 2,12s4.477,10 10,10 10,-4.477 10,-10S17.523,2 12,2zM13,17h-2v-2h2v2zM13,13h-2l-0.5,-6h3l-0.5,6z"/>
</vector>
16 changes: 15 additions & 1 deletion WordPress/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -310,18 +310,29 @@
<string name="width">Width</string>
<string name="featured_in_post">Include image in post content</string>
<string name="file_not_found">Couldn\'t find the file for upload. Was it deleted or moved?</string>
<string name="dialog_confirm_cancel_post_media_uploading">Are you sure? Deleting this post will also cancel the media upload.</string>
<string name="delete_post">Delete post?</string>
<string name="delete_page">Delete page?</string>
<string name="posts_fetching">Fetching posts…</string>
<string name="pages_fetching">Fetching pages…</string>
<string name="dialog_confirm_cancel_post_media_uploading">Are you sure? Deleting this post will also cancel the media upload.</string>
<string name="dialog_confirm_delete_post">This post will be deleted and sent to Trash</string>
<string name="dialog_confirm_publish_title">Ready to Publish?</string>
<string name="dialog_confirm_publish_message_post">This post will be published immediately.</string>
<string name="dialog_confirm_publish_message_page">This page will be published immediately.</string>
<string name="dialog_confirm_publish_yes">Publish now</string>
<string name="dialog_confirm_delete_permanently_post">Are you sure you\'d like to permanently delete this post?</string>

<!-- post version sync conflict dialog -->
<string name="dialog_confirm_load_remote_post_title">Resolve sync conflict</string>
<string name="dialog_confirm_load_remote_post_body">This post has two versions that are in conflict. Select the version you would like to discard.\n\n</string>
<string name="dialog_confirm_load_remote_post_body_2">Local\nSaved on %s\n\nWeb\nSaved on %s\n</string>
<string name="dialog_confirm_load_remote_post_discard_local">Discard Local</string>
<string name="dialog_confirm_load_remote_post_discard_web">Discard Web</string>
<string name="toast_conflict_updating_post">Updating post</string>
<string name="snackbar_conflict_local_version_discarded">Local version discarded</string>
<string name="snackbar_conflict_web_version_discarded">Web version discarded</string>
<string name="snackbar_conflict_undo">Undo</string>

<!-- gutenberg compatibility dialog -->
<string name="dialog_gutenberg_compatibility_title">Before you continue</string>
<string name="dialog_gutenberg_compatibility_message">We are working on the brand new WordPress editor. You can still edit this post, but we recommend previewing it before publishing.</string>
Expand Down Expand Up @@ -1381,6 +1392,9 @@
<string name="local_changes_discarding_error">There was an error in discarding your local changes. Please contact support for more assistance.</string>
<string name="local_changes_discarding_no_connection">A connection is required to perform this action. Please check your connection and try again.</string>

<!-- Remote Post has conflicts with local post -->
<string name="local_post_is_conflicted">Version conflict</string>

<!-- message on post preview explaining what local changes, local drafts and drafts are -->
<string name="local_changes_explainer">This post has local changes which haven\'t been published</string>
<string name="local_draft_explainer">This post is a local draft which hasn\'t been published</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,7 @@
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

Expand Down Expand Up @@ -97,19 +94,6 @@ public static boolean isGif(String url) {
return "gif".equals(MimeTypeMap.getFileExtensionFromUrl(url));
}

/**
* E.g. Jul 2, 2013 @ 21:57
*/
public static String getDate(long ms) {
Date date = new Date(ms);
SimpleDateFormat sdf = new SimpleDateFormat("MMM d, yyyy '@' HH:mm", Locale.ENGLISH);

// The timezone on the website is at GMT
sdf.setTimeZone(TimeZone.getTimeZone("GMT"));

return sdf.format(date);
}

public static boolean isLocalFile(String state) {
if (state == null) {
return false;
Expand Down

0 comments on commit d2ca3db

Please sign in to comment.