Skip to content

Commit d2ca3db

Browse files
authored
Merge pull request #8989 from wordpress-mobile/issue/5984-post-change-conflict-resolution
Post change conflict resolution dialog
2 parents 948a888 + 9896f85 commit d2ca3db

File tree

8 files changed

+190
-20
lines changed

8 files changed

+190
-20
lines changed

RELEASE-NOTES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
* Added a dialog for user to resolve a conflicted Post (different local / web version)
12
* Refreshed page list layout that includes a timestamp and a featured image thumbnail
23
* Fixed a bug causing disappearance of old saved posts
34
* Add Importing from Giphy in Editor and Media Library

WordPress/src/main/java/org/wordpress/android/ui/posts/PostAdapterItem.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ data class PostAdapterItemData(
1818
val date: String,
1919
val postStatus: PostStatus,
2020
val isLocallyChanged: Boolean,
21+
val isConflicted: Boolean,
2122
val canShowStats: Boolean,
2223
val canPublishPost: Boolean,
2324
val canRetryUpload: Boolean,

WordPress/src/main/java/org/wordpress/android/ui/posts/PostUtils.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import android.text.format.DateUtils;
77

88
import org.apache.commons.lang3.StringUtils;
9+
import org.wordpress.android.R;
910
import org.wordpress.android.WordPress;
1011
import org.wordpress.android.analytics.AnalyticsTracker;
1112
import org.wordpress.android.fluxc.model.MediaModel;
@@ -18,15 +19,18 @@
1819
import org.wordpress.android.util.AppLog;
1920
import org.wordpress.android.util.DateTimeUtils;
2021
import org.wordpress.android.util.HtmlUtils;
22+
import org.wordpress.android.util.LocaleManager;
2123
import org.wordpress.android.util.analytics.AnalyticsUtils;
2224

2325
import java.text.BreakIterator;
26+
import java.text.SimpleDateFormat;
2427
import java.util.Date;
2528
import java.util.HashMap;
2629
import java.util.HashSet;
2730
import java.util.List;
2831
import java.util.Map;
2932
import java.util.Set;
33+
import java.util.TimeZone;
3034
import java.util.regex.Matcher;
3135
import java.util.regex.Pattern;
3236

@@ -373,4 +377,36 @@ public static boolean shouldShowGutenbergEditor(boolean isNewPost, PostModel pos
373377
|| contentContainsGutenbergBlocks(post.getContent())
374378
|| TextUtils.isEmpty(post.getContent()));
375379
}
380+
381+
public static boolean isPostInConflictWithRemote(PostModel post) {
382+
// at this point we know there's a potential version conflict (the post has been modified
383+
// both locally and on the remote)
384+
return !post.getLastModified().equals(post.getRemoteLastModified()) && post.isLocallyChanged();
385+
}
386+
387+
public static String getConflictedPostCustomStringForDialog(PostModel post) {
388+
Context context = WordPress.getContext();
389+
String firstPart = context.getString(R.string.dialog_confirm_load_remote_post_body);
390+
String secondPart =
391+
String.format(context.getString(R.string.dialog_confirm_load_remote_post_body_2),
392+
getFormattedDateForLastModified(
393+
context, DateTimeUtils.timestampFromIso8601Millis(post.getLastModified())),
394+
getFormattedDateForLastModified(
395+
context, DateTimeUtils.timestampFromIso8601Millis(post.getRemoteLastModified())));
396+
return firstPart + secondPart;
397+
}
398+
399+
/**
400+
* E.g. Jul 2, 2013 @ 21:57
401+
*/
402+
public static String getFormattedDateForLastModified(Context context, long timeSinceLastModified) {
403+
Date date = new Date(timeSinceLastModified);
404+
SimpleDateFormat sdf =
405+
new SimpleDateFormat("MMM d, yyyy '@' hh:mm a", LocaleManager.getSafeLocale(context));
406+
407+
// The timezone on the website is at GMT
408+
sdf.setTimeZone(TimeZone.getTimeZone("GMT"));
409+
410+
return sdf.format(date);
411+
}
376412
}

WordPress/src/main/java/org/wordpress/android/ui/posts/PostViewHolder.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,10 @@ class PostViewHolder(private val view: View, private val config: PostViewHolderC
173173
// the Post (or its related media if such a thing exist) *is strictly* queued
174174
statusTextResId = R.string.post_queued
175175
statusIconResId = R.drawable.ic_gridicons_cloud_upload
176+
} else if (postAdapterItem.isConflicted) {
177+
statusTextResId = R.string.local_post_is_conflicted
178+
statusIconResId = R.drawable.ic_gridicons_notice
179+
statusColorResId = R.color.alert_red
176180
} else if (postAdapterItem.isLocalDraft) {
177181
statusTextResId = R.string.local_draft
178182
statusIconResId = R.drawable.ic_gridicons_page

WordPress/src/main/java/org/wordpress/android/viewmodel/posts/PostListViewModel.kt

Lines changed: 124 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import org.wordpress.android.ui.uploads.PostEvents
6565
import org.wordpress.android.ui.uploads.UploadService
6666
import org.wordpress.android.ui.uploads.VideoOptimizer
6767
import org.wordpress.android.ui.utils.UiString.UiStringRes
68+
import org.wordpress.android.ui.utils.UiString.UiStringText
6869
import org.wordpress.android.util.AppLog
6970
import org.wordpress.android.util.AppLog.T
7071
import org.wordpress.android.util.SiteUtils
@@ -79,6 +80,7 @@ import javax.inject.Inject
7980

8081
const val CONFIRM_DELETE_POST_DIALOG_TAG = "CONFIRM_DELETE_POST_DIALOG_TAG"
8182
const val CONFIRM_PUBLISH_POST_DIALOG_TAG = "CONFIRM_PUBLISH_POST_DIALOG_TAG"
83+
const val CONFIRM_ON_CONFLICT_LOAD_REMOTE_POST_DIALOG_TAG = "CONFIRM_ON_CONFLICT_LOAD_REMOTE_POST_DIALOG_TAG"
8284

8385
enum class PostListEmptyViewState {
8486
EMPTY_LIST,
@@ -111,9 +113,12 @@ class PostListViewModel @Inject constructor(
111113

112114
// Keep a reference to the currently being trashed post, so we can hide it during Undo Snackbar
113115
private var postIdToTrash: Pair<Int, Long>? = null
114-
// Since we are using DialogFragments we need to hold onto which post will be published or trashed
116+
// Since we are using DialogFragments we need to hold onto which post will be published or trashed / resolved
115117
private var localPostIdForPublishDialog: Int? = null
116118
private var localPostIdForTrashDialog: Int? = null
119+
private var localPostIdForConflictResolutionDialog: Int? = null
120+
private var originalPostCopyForConflictUndo: PostModel? = null
121+
private var localPostIdForFetchingRemoteVersionOfConflictedPost: Int? = null
117122
// Initial target post to scroll to
118123
private var targetLocalPostId: Int? = null
119124

@@ -324,6 +329,18 @@ class PostListViewModel @Inject constructor(
324329
_dialogAction.postValue(dialogHolder)
325330
}
326331

332+
private fun showConflictedPostResolutionDialog(post: PostModel) {
333+
val dialogHolder = DialogHolder(
334+
tag = CONFIRM_ON_CONFLICT_LOAD_REMOTE_POST_DIALOG_TAG,
335+
title = UiStringRes(R.string.dialog_confirm_load_remote_post_title),
336+
message = UiStringText(PostUtils.getConflictedPostCustomStringForDialog(post)),
337+
positiveButton = UiStringRes(R.string.dialog_confirm_load_remote_post_discard_local),
338+
negativeButton = UiStringRes(R.string.dialog_confirm_load_remote_post_discard_web)
339+
)
340+
localPostIdForConflictResolutionDialog = post.id
341+
_dialogAction.postValue(dialogHolder)
342+
}
343+
327344
private fun publishPost(localPostId: Int) {
328345
val post = postStore.getPostByLocalPostId(localPostId)
329346
if (post != null) {
@@ -337,6 +354,16 @@ class PostListViewModel @Inject constructor(
337354
}
338355

339356
private fun editPostButtonAction(site: SiteModel, post: PostModel) {
357+
// first of all, check whether this post is in Conflicted state.
358+
if (doesPostHaveUnhandledConflict(post)) {
359+
showConflictedPostResolutionDialog(post)
360+
return
361+
}
362+
363+
checkGutenbergOrEdit(site, post)
364+
}
365+
366+
private fun checkGutenbergOrEdit(site: SiteModel, post: PostModel) {
340367
// Show Gutenberg Warning Dialog if post contains GB blocks and it's not disabled
341368
if (!isGutenbergEnabled() &&
342369
PostUtils.contentContainsGutenbergBlocks(post.content) &&
@@ -406,6 +433,14 @@ class PostListViewModel @Inject constructor(
406433
T.POSTS,
407434
"Error updating the post with type: ${event.error.type} and message: ${event.error.message}"
408435
)
436+
} else {
437+
originalPostCopyForConflictUndo?.id?.let {
438+
val updatedPost = postStore.getPostByLocalPostId(it)
439+
// Conflicted post has been successfully updated with its remote version
440+
if (!PostUtils.isPostInConflictWithRemote(updatedPost)) {
441+
conflictedPostUpdatedWithItsRemoteVersion()
442+
}
443+
}
409444
}
410445
}
411446
is CauseOfOnPostChanged.DeletePost -> {
@@ -525,13 +560,15 @@ class PostListViewModel @Inject constructor(
525560
date = PostUtils.getFormattedDate(post),
526561
postStatus = postStatus,
527562
isLocallyChanged = post.isLocallyChanged,
563+
isConflicted = doesPostHaveUnhandledConflict(post),
528564
canShowStats = canShowStats,
529565
canPublishPost = canPublishPost,
530566
canRetryUpload = uploadStatus.uploadError != null && !uploadStatus.hasInProgressMediaUpload,
531567
featuredImageId = post.featuredImageId,
532568
featuredImageUrl = getFeaturedImageUrl(post.featuredImageId, post.content),
533569
uploadStatus = uploadStatus
534570
)
571+
535572
return PostAdapterItem(
536573
data = postData,
537574
onSelected = { handlePostButton(PostListButton.BUTTON_EDIT, post) },
@@ -601,6 +638,11 @@ class PostListViewModel @Inject constructor(
601638
localPostIdForPublishDialog = null
602639
publishPost(it)
603640
}
641+
CONFIRM_ON_CONFLICT_LOAD_REMOTE_POST_DIALOG_TAG -> localPostIdForConflictResolutionDialog?.let {
642+
localPostIdForConflictResolutionDialog = null
643+
// here load version from remote
644+
updateConflictedPostWithItsRemoteVersion(it)
645+
}
604646
else -> throw IllegalArgumentException("Dialog's positive button click is not handled: $instanceTag")
605647
}
606648
}
@@ -609,13 +651,19 @@ class PostListViewModel @Inject constructor(
609651
when (instanceTag) {
610652
CONFIRM_DELETE_POST_DIALOG_TAG -> localPostIdForTrashDialog = null
611653
CONFIRM_PUBLISH_POST_DIALOG_TAG -> localPostIdForPublishDialog = null
654+
CONFIRM_ON_CONFLICT_LOAD_REMOTE_POST_DIALOG_TAG -> localPostIdForConflictResolutionDialog?.let {
655+
updateConflictedPostWithItsLocalVersion(it)
656+
}
612657
else -> throw IllegalArgumentException("Dialog's negative button click is not handled: $instanceTag")
613658
}
614659
}
615660

616661
fun onDismissByOutsideTouchForBasicDialog(instanceTag: String) {
617-
// Cancel and outside touch dismiss works the same way
618-
onNegativeClickedForBasicDialog(instanceTag)
662+
// Cancel and outside touch dismiss works the same way for all, except for conflict resolution dialog,
663+
// for which tapping outside and actively tapping the "edit local" have different meanings
664+
if (instanceTag != CONFIRM_ON_CONFLICT_LOAD_REMOTE_POST_DIALOG_TAG) {
665+
onNegativeClickedForBasicDialog(instanceTag)
666+
}
619667
}
620668

621669
// Gutenberg Events
@@ -662,8 +710,81 @@ class PostListViewModel @Inject constructor(
662710
}
663711
}
664712

713+
// Post Conflict Resolution
714+
715+
private fun updateConflictedPostWithItsRemoteVersion(localPostId: Int) {
716+
// We need network connection to load a remote post
717+
if (!checkNetworkConnection()) {
718+
return
719+
}
720+
721+
val post = postStore.getPostByLocalPostId(localPostId)
722+
if (post != null) {
723+
originalPostCopyForConflictUndo = post.clone()
724+
dispatcher.dispatch(PostActionBuilder.newFetchPostAction(RemotePostPayload(post, site)))
725+
_toastMessage.postValue(ToastMessageHolder(R.string.toast_conflict_updating_post, Duration.SHORT))
726+
}
727+
}
728+
729+
private fun conflictedPostUpdatedWithItsRemoteVersion() {
730+
val undoAction = {
731+
// here replace the post with whatever we had before, again
732+
if (originalPostCopyForConflictUndo != null) {
733+
dispatcher.dispatch(PostActionBuilder.newUpdatePostAction(originalPostCopyForConflictUndo))
734+
}
735+
}
736+
val onDismissAction = {
737+
originalPostCopyForConflictUndo = null
738+
}
739+
val snackbarHolder = SnackbarMessageHolder(R.string.snackbar_conflict_local_version_discarded,
740+
R.string.snackbar_conflict_undo, undoAction, onDismissAction)
741+
_snackbarAction.postValue(snackbarHolder)
742+
}
743+
744+
private fun updateConflictedPostWithItsLocalVersion(localPostId: Int) {
745+
// We need network connection to push local version to remote
746+
if (!checkNetworkConnection()) {
747+
return
748+
}
749+
750+
// Keep a reference to which post is being updated with the local version so we can avoid showing the conflicted
751+
// label during the undo snackbar.
752+
localPostIdForFetchingRemoteVersionOfConflictedPost = localPostId
753+
pagedListWrapper.invalidateData()
754+
755+
val post = postStore.getPostByLocalPostId(localPostId) ?: return
756+
757+
// and now show a snackbar, acting as if the Post was pushed, but effectively push it after the snackbar is gone
758+
var isUndoed = false
759+
val undoAction = {
760+
isUndoed = true
761+
762+
// Remove the reference for the post being updated and re-show the conflicted label on undo
763+
localPostIdForFetchingRemoteVersionOfConflictedPost = null
764+
pagedListWrapper.invalidateData()
765+
}
766+
767+
val onDismissAction = {
768+
if (!isUndoed) {
769+
localPostIdForFetchingRemoteVersionOfConflictedPost = null
770+
PostUtils.trackSavePostAnalytics(post, site)
771+
dispatcher.dispatch(PostActionBuilder.newPushPostAction(RemotePostPayload(post, site)))
772+
}
773+
}
774+
val snackbarHolder = SnackbarMessageHolder(R.string.snackbar_conflict_web_version_discarded,
775+
R.string.snackbar_conflict_undo, undoAction, onDismissAction)
776+
_snackbarAction.postValue(snackbarHolder)
777+
}
778+
665779
// Utils
666780

781+
private fun doesPostHaveUnhandledConflict(post: PostModel): Boolean {
782+
// If we are fetching the remote version of a conflicted post, it means it's already being handled
783+
val isFetchingConflictedPost = localPostIdForFetchingRemoteVersionOfConflictedPost != null &&
784+
localPostIdForFetchingRemoteVersionOfConflictedPost == post.id
785+
return !isFetchingConflictedPost && PostUtils.isPostInConflictWithRemote(post)
786+
}
787+
667788
private fun checkNetworkConnection(): Boolean =
668789
if (isNetworkAvailable) {
669790
true
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+
android:width="24dp"
3+
android:height="24dp"
4+
android:viewportWidth="24"
5+
android:viewportHeight="24">
6+
<path
7+
android:fillColor="#FF000000"
8+
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"/>
9+
</vector>

WordPress/src/main/res/values/strings.xml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,18 +310,29 @@
310310
<string name="width">Width</string>
311311
<string name="featured_in_post">Include image in post content</string>
312312
<string name="file_not_found">Couldn\'t find the file for upload. Was it deleted or moved?</string>
313+
<string name="dialog_confirm_cancel_post_media_uploading">Are you sure? Deleting this post will also cancel the media upload.</string>
313314
<string name="delete_post">Delete post?</string>
314315
<string name="delete_page">Delete page?</string>
315316
<string name="posts_fetching">Fetching posts…</string>
316317
<string name="pages_fetching">Fetching pages…</string>
317-
<string name="dialog_confirm_cancel_post_media_uploading">Are you sure? Deleting this post will also cancel the media upload.</string>
318318
<string name="dialog_confirm_delete_post">This post will be deleted and sent to Trash</string>
319319
<string name="dialog_confirm_publish_title">Ready to Publish?</string>
320320
<string name="dialog_confirm_publish_message_post">This post will be published immediately.</string>
321321
<string name="dialog_confirm_publish_message_page">This page will be published immediately.</string>
322322
<string name="dialog_confirm_publish_yes">Publish now</string>
323323
<string name="dialog_confirm_delete_permanently_post">Are you sure you\'d like to permanently delete this post?</string>
324324

325+
<!-- post version sync conflict dialog -->
326+
<string name="dialog_confirm_load_remote_post_title">Resolve sync conflict</string>
327+
<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>
328+
<string name="dialog_confirm_load_remote_post_body_2">Local\nSaved on %s\n\nWeb\nSaved on %s\n</string>
329+
<string name="dialog_confirm_load_remote_post_discard_local">Discard Local</string>
330+
<string name="dialog_confirm_load_remote_post_discard_web">Discard Web</string>
331+
<string name="toast_conflict_updating_post">Updating post</string>
332+
<string name="snackbar_conflict_local_version_discarded">Local version discarded</string>
333+
<string name="snackbar_conflict_web_version_discarded">Web version discarded</string>
334+
<string name="snackbar_conflict_undo">Undo</string>
335+
325336
<!-- gutenberg compatibility dialog -->
326337
<string name="dialog_gutenberg_compatibility_title">Before you continue</string>
327338
<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>
@@ -1381,6 +1392,9 @@
13811392
<string name="local_changes_discarding_error">There was an error in discarding your local changes. Please contact support for more assistance.</string>
13821393
<string name="local_changes_discarding_no_connection">A connection is required to perform this action. Please check your connection and try again.</string>
13831394

1395+
<!-- Remote Post has conflicts with local post -->
1396+
<string name="local_post_is_conflicted">Version conflict</string>
1397+
13841398
<!-- message on post preview explaining what local changes, local drafts and drafts are -->
13851399
<string name="local_changes_explainer">This post has local changes which haven\'t been published</string>
13861400
<string name="local_draft_explainer">This post is a local draft which hasn\'t been published</string>

libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/MediaUtils.java

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,7 @@
3030
import java.net.MalformedURLException;
3131
import java.net.URL;
3232
import java.net.URLConnection;
33-
import java.text.SimpleDateFormat;
34-
import java.util.Date;
3533
import java.util.Locale;
36-
import java.util.TimeZone;
3734
import java.util.regex.Matcher;
3835
import java.util.regex.Pattern;
3936

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

100-
/**
101-
* E.g. Jul 2, 2013 @ 21:57
102-
*/
103-
public static String getDate(long ms) {
104-
Date date = new Date(ms);
105-
SimpleDateFormat sdf = new SimpleDateFormat("MMM d, yyyy '@' HH:mm", Locale.ENGLISH);
106-
107-
// The timezone on the website is at GMT
108-
sdf.setTimeZone(TimeZone.getTimeZone("GMT"));
109-
110-
return sdf.format(date);
111-
}
112-
11397
public static boolean isLocalFile(String state) {
11498
if (state == null) {
11599
return false;

0 commit comments

Comments
 (0)