diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt
index 8d7c0bf91690..860853f11202 100644
--- a/RELEASE-NOTES.txt
+++ b/RELEASE-NOTES.txt
@@ -2,6 +2,7 @@
24.4
-----
+[***] [Jetpack-only] Improved Notifications experience with richer UI elements and interactions [https://github.com/wordpress-mobile/WordPress-Android/pull/20072]
* [**] [Jetpack-only] Block editor: Introduce VideoPress v5 support, to fix issues using video block with dotcom and Jetpack sites [https://github.com/wordpress-mobile/gutenberg-mobile/pull/6634]
24.3
diff --git a/WordPress/src/jetpack/res/drawable/bg_note_avatar_badge.xml b/WordPress/src/jetpack/res/drawable/bg_note_avatar_badge.xml
deleted file mode 100644
index 6b7cf25d33c3..000000000000
--- a/WordPress/src/jetpack/res/drawable/bg_note_avatar_badge.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/NotificationsTable.java b/WordPress/src/main/java/org/wordpress/android/datasets/NotificationsTable.java
index 4918c8d671a3..890ca77e99c6 100644
--- a/WordPress/src/main/java/org/wordpress/android/datasets/NotificationsTable.java
+++ b/WordPress/src/main/java/org/wordpress/android/datasets/NotificationsTable.java
@@ -6,6 +6,7 @@
import android.database.sqlite.SQLiteDatabase;
import android.text.TextUtils;
+import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.json.JSONException;
@@ -77,10 +78,10 @@ public static ArrayList getLatestNotes(int limit) {
}
private static boolean putNote(Note note, boolean checkBeforeInsert) {
- String rawNote = prepareNote(note.getId(), note.getJSON().toString());
+ String rawNote = prepareNote(note.getId(), note.getJson().toString());
ContentValues values = new ContentValues();
- values.put("type", note.getType());
+ values.put("type", note.getRawType());
values.put("timestamp", note.getTimestamp());
values.put("raw_note_data", rawNote);
@@ -124,7 +125,7 @@ private static String prepareNote(String noteId, String noteSrc) {
return noteSrc;
}
- public static void saveNotes(List notes, boolean clearBeforeSaving) {
+ public static void saveNotes(@NonNull List notes, boolean clearBeforeSaving) {
getDb().beginTransaction();
try {
if (clearBeforeSaving) {
@@ -142,7 +143,7 @@ public static void saveNotes(List notes, boolean clearBeforeSaving) {
}
}
- public static boolean saveNote(Note note) {
+ public static boolean saveNote(@NonNull Note note) {
getDb().beginTransaction();
boolean saved = false;
try {
diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/ReaderLikeTable.java b/WordPress/src/main/java/org/wordpress/android/datasets/ReaderLikeTable.java
index da721afea783..973a79244d70 100644
--- a/WordPress/src/main/java/org/wordpress/android/datasets/ReaderLikeTable.java
+++ b/WordPress/src/main/java/org/wordpress/android/datasets/ReaderLikeTable.java
@@ -77,14 +77,18 @@ public static void setCurrentUserLikesPost(ReaderPost post, boolean isLiked, lon
if (post == null) {
return;
}
+ setCurrentUserLikesPost(post.postId, post.blogId, isLiked, wpComUserId);
+ }
+
+ public static void setCurrentUserLikesPost(long postId, long blogId, boolean isLiked, long wpComUserId) {
if (isLiked) {
ContentValues values = new ContentValues();
- values.put("blog_id", post.blogId);
- values.put("post_id", post.postId);
+ values.put("blog_id", blogId);
+ values.put("post_id", postId);
values.put("user_id", wpComUserId);
ReaderDatabase.getWritableDb().insert("tbl_post_likes", null, values);
} else {
- String[] args = {Long.toString(post.blogId), Long.toString(post.postId), Long.toString(wpComUserId)};
+ String[] args = {Long.toString(blogId), Long.toString(postId), Long.toString(wpComUserId)};
ReaderDatabase.getWritableDb().delete("tbl_post_likes", "blog_id=? AND post_id=? AND user_id=?", args);
}
}
diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/wrappers/NotificationsTableWrapper.kt b/WordPress/src/main/java/org/wordpress/android/datasets/wrappers/NotificationsTableWrapper.kt
new file mode 100644
index 000000000000..8c667439ad2c
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/datasets/wrappers/NotificationsTableWrapper.kt
@@ -0,0 +1,15 @@
+package org.wordpress.android.datasets.wrappers
+
+import dagger.Reusable
+import org.wordpress.android.datasets.NotificationsTable
+import org.wordpress.android.models.Note
+import javax.inject.Inject
+
+@Reusable
+class NotificationsTableWrapper @Inject constructor() {
+ fun saveNote(note: Note): Boolean = NotificationsTable.saveNote(note)
+
+ fun saveNotes(notes: List, clearBeforeSaving: Boolean) {
+ NotificationsTable.saveNotes(notes, clearBeforeSaving)
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/Note.java b/WordPress/src/main/java/org/wordpress/android/models/Note.java
deleted file mode 100644
index 25421c90d7d4..000000000000
--- a/WordPress/src/main/java/org/wordpress/android/models/Note.java
+++ /dev/null
@@ -1,559 +0,0 @@
-/**
- * Note represents a single WordPress.com notification
- */
-package org.wordpress.android.models;
-
-import android.text.Spannable;
-import android.text.TextUtils;
-import android.util.Base64;
-
-import androidx.annotation.NonNull;
-
-import org.json.JSONArray;
-import org.json.JSONException;
-import org.json.JSONObject;
-import org.wordpress.android.fluxc.model.CommentModel;
-import org.wordpress.android.fluxc.model.CommentStatus;
-import org.wordpress.android.ui.notifications.utils.NotificationsUtilsWrapper;
-import org.wordpress.android.util.AppLog;
-import org.wordpress.android.util.DateTimeUtils;
-import org.wordpress.android.util.DateUtils;
-import org.wordpress.android.util.JSONUtils;
-import org.wordpress.android.util.StringUtils;
-
-import java.io.UnsupportedEncodingException;
-import java.util.Comparator;
-import java.util.Date;
-import java.util.EnumSet;
-import java.util.zip.DataFormatException;
-import java.util.zip.Inflater;
-
-public class Note {
- private static final String TAG = "NoteModel";
-
- // Maximum character length for a comment preview
- private static final int MAX_COMMENT_PREVIEW_LENGTH = 200;
-
- // Note types
- public static final String NOTE_FOLLOW_TYPE = "follow";
- public static final String NOTE_LIKE_TYPE = "like";
- public static final String NOTE_COMMENT_TYPE = "comment";
- public static final String NOTE_MATCHER_TYPE = "automattcher";
- public static final String NOTE_COMMENT_LIKE_TYPE = "comment_like";
- public static final String NOTE_REBLOG_TYPE = "reblog";
- public static final String NOTE_NEW_POST_TYPE = "new_post";
- public static final String NOTE_VIEW_MILESTONE = "view_milestone";
- public static final String NOTE_UNKNOWN_TYPE = "unknown";
-
- // JSON action keys
- private static final String ACTION_KEY_REPLY = "replyto-comment";
- private static final String ACTION_KEY_APPROVE = "approve-comment";
- private static final String ACTION_KEY_SPAM = "spam-comment";
- private static final String ACTION_KEY_LIKE = "like-comment";
-
- private JSONObject mActions;
- private JSONObject mNoteJSON;
- private final String mKey;
-
- private final Object mSyncLock = new Object();
- private String mLocalStatus;
-
- public enum EnabledActions {
- ACTION_REPLY,
- ACTION_APPROVE,
- ACTION_UNAPPROVE,
- ACTION_SPAM,
- ACTION_LIKE
- }
-
- public enum NoteTimeGroup {
- GROUP_TODAY,
- GROUP_YESTERDAY,
- GROUP_OLDER_TWO_DAYS,
- GROUP_OLDER_WEEK,
- GROUP_OLDER_MONTH
- }
-
- public Note(String key, JSONObject noteJSON) {
- mKey = key;
- mNoteJSON = noteJSON;
- }
-
- public Note(JSONObject noteJSON) {
- mNoteJSON = noteJSON;
- mKey = mNoteJSON.optString("id", "");
- }
-
- public JSONObject getJSON() {
- return mNoteJSON != null ? mNoteJSON : new JSONObject();
- }
-
- public String getId() {
- return mKey;
- }
-
- public String getType() {
- return queryJSON("type", NOTE_UNKNOWN_TYPE);
- }
-
- private Boolean isType(String type) {
- return getType().equals(type);
- }
-
- public Boolean isCommentType() {
- synchronized (mSyncLock) {
- return (isAutomattcherType() && JSONUtils.queryJSON(mNoteJSON, "meta.ids.comment", -1) != -1)
- || isType(NOTE_COMMENT_TYPE);
- }
- }
-
- public Boolean isAutomattcherType() {
- return isType(NOTE_MATCHER_TYPE);
- }
-
- public Boolean isNewPostType() {
- return isType(NOTE_NEW_POST_TYPE);
- }
-
- public Boolean isFollowType() {
- return isType(NOTE_FOLLOW_TYPE);
- }
-
- public Boolean isLikeType() {
- return isPostLikeType() || isCommentLikeType();
- }
-
- public Boolean isPostLikeType() {
- return isType(NOTE_LIKE_TYPE);
- }
-
- public Boolean isCommentLikeType() {
- return isType(NOTE_COMMENT_LIKE_TYPE);
- }
-
- public Boolean isReblogType() {
- return isType(NOTE_REBLOG_TYPE);
- }
-
- public Boolean isViewMilestoneType() {
- return isType(NOTE_VIEW_MILESTONE);
- }
-
- public Boolean isCommentReplyType() {
- return isCommentType() && getParentCommentId() > 0;
- }
-
- // Returns true if the user has replied to this comment note
- public Boolean isCommentWithUserReply() {
- return isCommentType() && !TextUtils.isEmpty(getCommentSubjectNoticon());
- }
-
- public Boolean isUserList() {
- return isLikeType() || isFollowType() || isReblogType();
- }
-
- /*
- * does user have permission to moderate/reply/spam this comment?
- */
- public boolean canModerate() {
- EnumSet enabledActions = getEnabledActions();
- return enabledActions != null && (enabledActions.contains(EnabledActions.ACTION_APPROVE) || enabledActions
- .contains(EnabledActions.ACTION_UNAPPROVE));
- }
-
- public boolean canMarkAsSpam() {
- EnumSet enabledActions = getEnabledActions();
- return (enabledActions != null && enabledActions.contains(EnabledActions.ACTION_SPAM));
- }
-
- public boolean canReply() {
- EnumSet enabledActions = getEnabledActions();
- return (enabledActions != null && enabledActions.contains(EnabledActions.ACTION_REPLY));
- }
-
- public boolean canTrash() {
- return canModerate();
- }
-
- public boolean canLike() {
- EnumSet enabledActions = getEnabledActions();
- return (enabledActions != null && enabledActions.contains(EnabledActions.ACTION_LIKE));
- }
-
- public String getLocalStatus() {
- return StringUtils.notNullStr(mLocalStatus);
- }
-
- public void setLocalStatus(String localStatus) {
- mLocalStatus = localStatus;
- }
-
- public JSONObject getSubject() {
- try {
- synchronized (mSyncLock) {
- JSONArray subjectArray = mNoteJSON.getJSONArray("subject");
- if (subjectArray.length() > 0) {
- return subjectArray.getJSONObject(0);
- }
- }
- } catch (JSONException e) {
- return null;
- }
-
- return null;
- }
-
- public Spannable getFormattedSubject(NotificationsUtilsWrapper notificationsUtilsWrapper) {
- return notificationsUtilsWrapper.getSpannableContentForRanges(getSubject());
- }
-
- public String getTitle() {
- return queryJSON("title", "");
- }
-
- public String getIconURL() {
- return queryJSON("icon", "");
- }
-
- public String getCommentSubject() {
- synchronized (mSyncLock) {
- JSONArray subjectArray = mNoteJSON.optJSONArray("subject");
- if (subjectArray != null) {
- String commentSubject = JSONUtils.queryJSON(subjectArray, "subject[1].text", "");
-
- // Trim down the comment preview if the comment text is too large.
- if (commentSubject != null && commentSubject.length() > MAX_COMMENT_PREVIEW_LENGTH) {
- commentSubject = commentSubject.substring(0, MAX_COMMENT_PREVIEW_LENGTH - 1);
- }
-
- return commentSubject;
- }
- }
-
- return "";
- }
-
- public String getCommentSubjectNoticon() {
- JSONArray subjectRanges = queryJSON("subject[0].ranges", new JSONArray());
- if (subjectRanges != null) {
- for (int i = 0; i < subjectRanges.length(); i++) {
- try {
- JSONObject rangeItem = subjectRanges.getJSONObject(i);
- if (rangeItem.has("type") && rangeItem.optString("type").equals("noticon")) {
- return rangeItem.optString("value", "");
- }
- } catch (JSONException e) {
- return "";
- }
- }
- }
-
- return "";
- }
-
- public long getCommentReplyId() {
- return queryJSON("meta.ids.reply_comment", 0);
- }
-
- /**
- * Compare note timestamp to now and return a time grouping
- */
- public static NoteTimeGroup getTimeGroupForTimestamp(long timestamp) {
- Date today = new Date();
- Date then = new Date(timestamp * 1000);
-
- if (then.compareTo(DateUtils.addMonths(today, -1)) < 0) {
- return NoteTimeGroup.GROUP_OLDER_MONTH;
- } else if (then.compareTo(DateUtils.addWeeks(today, -1)) < 0) {
- return NoteTimeGroup.GROUP_OLDER_WEEK;
- } else if (then.compareTo(DateUtils.addDays(today, -2)) < 0
- || DateUtils.isSameDay(DateUtils.addDays(today, -2), then)) {
- return NoteTimeGroup.GROUP_OLDER_TWO_DAYS;
- } else if (DateUtils.isSameDay(DateUtils.addDays(today, -1), then)) {
- return NoteTimeGroup.GROUP_YESTERDAY;
- } else {
- return NoteTimeGroup.GROUP_TODAY;
- }
- }
-
- public static class TimeStampComparator implements Comparator {
- @Override
- public int compare(Note a, Note b) {
- return b.getTimestampString().compareTo(a.getTimestampString());
- }
- }
-
- /**
- * The inverse of isRead
- */
- public Boolean isUnread() {
- return !isRead();
- }
-
- private Boolean isRead() {
- return queryJSON("read", 0) == 1;
- }
-
- public void setRead() {
- try {
- mNoteJSON.putOpt("read", 1);
- } catch (JSONException e) {
- AppLog.e(AppLog.T.NOTIFS, "Failed to set 'read' property", e);
- }
- }
-
- /**
- * Get the timestamp provided by the API for the note
- */
- public long getTimestamp() {
- return DateTimeUtils.timestampFromIso8601(getTimestampString());
- }
-
- public String getTimestampString() {
- return queryJSON("timestamp", "");
- }
-
- public JSONArray getBody() {
- try {
- synchronized (mSyncLock) {
- return mNoteJSON.getJSONArray("body");
- }
- } catch (JSONException e) {
- return new JSONArray();
- }
- }
-
- // returns character code for notification font
- public String getNoticonCharacter() {
- return queryJSON("noticon", "");
- }
-
- private JSONObject getCommentActions() {
- if (mActions == null) {
- // Find comment block that matches the root note comment id
- long commentId = getCommentId();
- JSONArray bodyArray = getBody();
- for (int i = 0; i < bodyArray.length(); i++) {
- try {
- JSONObject bodyItem = bodyArray.getJSONObject(i);
- if (bodyItem.has("type") && bodyItem.optString("type").equals("comment")
- && commentId == JSONUtils.queryJSON(bodyItem, "meta.ids.comment", 0)) {
- mActions = JSONUtils.queryJSON(bodyItem, "actions", new JSONObject());
- break;
- }
- } catch (JSONException e) {
- break;
- }
- }
-
- if (mActions == null) {
- mActions = new JSONObject();
- }
- }
-
- return mActions;
- }
-
- /*
- * returns the actions allowed on this note, assumes it's a comment notification
- */
- public EnumSet getEnabledActions() {
- EnumSet actions = EnumSet.noneOf(EnabledActions.class);
- JSONObject jsonActions = getCommentActions();
- if (jsonActions == null || jsonActions.length() == 0) {
- return actions;
- }
-
- if (jsonActions.has(ACTION_KEY_REPLY)) {
- actions.add(EnabledActions.ACTION_REPLY);
- }
- if (jsonActions.has(ACTION_KEY_APPROVE) && jsonActions.optBoolean(ACTION_KEY_APPROVE, false)) {
- actions.add(EnabledActions.ACTION_UNAPPROVE);
- }
- if (jsonActions.has(ACTION_KEY_APPROVE) && !jsonActions.optBoolean(ACTION_KEY_APPROVE, false)) {
- actions.add(EnabledActions.ACTION_APPROVE);
- }
- if (jsonActions.has(ACTION_KEY_SPAM)) {
- actions.add(EnabledActions.ACTION_SPAM);
- }
- if (jsonActions.has(ACTION_KEY_LIKE)) {
- actions.add(EnabledActions.ACTION_LIKE);
- }
-
- return actions;
- }
-
- public int getSiteId() {
- return queryJSON("meta.ids.site", 0);
- }
-
- public int getPostId() {
- return queryJSON("meta.ids.post", 0);
- }
-
- public long getCommentId() {
- return queryJSON("meta.ids.comment", 0);
- }
-
- public long getParentCommentId() {
- return queryJSON("meta.ids.parent_comment", 0);
- }
-
- /**
- * Rudimentary system for pulling an item out of a JSON object hierarchy
- */
- private U queryJSON(String query, U defaultObject) {
- synchronized (mSyncLock) {
- if (mNoteJSON == null) {
- return defaultObject;
- }
- return JSONUtils.queryJSON(mNoteJSON, query, defaultObject);
- }
- }
-
- /**
- * Constructs a new Comment object based off of data in a Note
- */
- @NonNull
- public CommentModel buildComment() {
- CommentModel comment = new CommentModel();
- comment.setRemotePostId(getPostId());
- comment.setRemoteCommentId(getCommentId());
- comment.setAuthorName(getCommentAuthorName());
- comment.setDatePublished(DateTimeUtils.iso8601FromTimestamp(getTimestamp()));
- comment.setContent(getCommentText());
- comment.setStatus(getCommentStatus().toString());
- comment.setAuthorUrl(getCommentAuthorUrl());
- comment.setPostTitle(getTitle()); // unavailable in note model
- comment.setAuthorEmail(""); // unavailable in note model
- comment.setAuthorProfileImageUrl(getIconURL());
- comment.setILike(hasLikedComment());
- return comment;
- }
-
- public String getCommentAuthorName() {
- JSONArray bodyArray = getBody();
-
- for (int i = 0; i < bodyArray.length(); i++) {
- try {
- JSONObject bodyItem = bodyArray.getJSONObject(i);
- if (bodyItem.has("type") && bodyItem.optString("type").equals("user")) {
- return bodyItem.optString("text");
- }
- } catch (JSONException e) {
- return "";
- }
- }
-
- return "";
- }
-
- private String getCommentText() {
- return queryJSON("body[last].text", "");
- }
-
- private String getCommentAuthorUrl() {
- JSONArray bodyArray = getBody();
-
- for (int i = 0; i < bodyArray.length(); i++) {
- try {
- JSONObject bodyItem = bodyArray.getJSONObject(i);
- if (bodyItem.has("type") && bodyItem.optString("type").equals("user")) {
- return JSONUtils.queryJSON(bodyItem, "meta.links.home", "");
- }
- } catch (JSONException e) {
- return "";
- }
- }
-
- return "";
- }
-
- public CommentStatus getCommentStatus() {
- EnumSet enabledActions = getEnabledActions();
-
- if (enabledActions.contains(EnabledActions.ACTION_UNAPPROVE)) {
- return CommentStatus.APPROVED;
- } else if (enabledActions.contains(EnabledActions.ACTION_APPROVE)) {
- return CommentStatus.UNAPPROVED;
- }
-
- return CommentStatus.ALL;
- }
-
- public boolean hasLikedComment() {
- JSONObject jsonActions = getCommentActions();
- return !(jsonActions == null || jsonActions.length() == 0) && jsonActions.optBoolean(ACTION_KEY_LIKE);
- }
-
- public String getUrl() {
- return queryJSON("url", "");
- }
-
- public JSONArray getHeader() {
- synchronized (mSyncLock) {
- return mNoteJSON.optJSONArray("header");
- }
- }
-
- // this method is used to compare two Notes: as it's potentially a very processing intensive operation,
- // we're only comparing the note id, timestamp, and raw JSON length, which is accurate enough for
- // the purpose of checking if the local Note is any different from a remote note.
- public boolean equalsTimeAndLength(Note note) {
- if (note == null) {
- return false;
- }
-
- if (this.getTimestampString().equalsIgnoreCase(note.getTimestampString())
- && this.getJSON().length() == note.getJSON().length()) {
- return true;
- }
- return false;
- }
-
- public static synchronized Note buildFromBase64EncodedData(String noteId, String base64FullNoteData) {
- Note note = null;
-
- if (base64FullNoteData == null) {
- return null;
- }
-
- byte[] b64DecodedPayload = Base64.decode(base64FullNoteData, Base64.DEFAULT);
-
- // Decompress the payload
- Inflater decompresser = new Inflater();
- decompresser.setInput(b64DecodedPayload, 0, b64DecodedPayload.length);
- byte[] result = new byte[4096]; // max length an Android PN payload can have
- int resultLength = 0;
- try {
- resultLength = decompresser.inflate(result);
- decompresser.end();
- } catch (DataFormatException e) {
- AppLog.e(AppLog.T.NOTIFS, "Can't decompress the PN BlockListPayload. It could be > 4K", e);
- }
-
- String out = null;
- try {
- out = new String(result, 0, resultLength, "UTF8");
- } catch (UnsupportedEncodingException e) {
- AppLog.e(AppLog.T.NOTIFS, "Notification data contains non UTF8 characters.", e);
- }
-
- if (out != null) {
- try {
- JSONObject jsonObject = new JSONObject(out);
- if (jsonObject.has("notes")) {
- JSONArray jsonArray = jsonObject.getJSONArray("notes");
- if (jsonArray != null && jsonArray.length() == 1) {
- jsonObject = jsonArray.getJSONObject(0);
- }
- }
- note = new Note(noteId, jsonObject);
- } catch (JSONException e) {
- AppLog.e(AppLog.T.NOTIFS, "Can't parse the Note JSON received in the PN", e);
- }
- }
-
- return note;
- }
-}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/Note.kt b/WordPress/src/main/java/org/wordpress/android/models/Note.kt
new file mode 100644
index 000000000000..e0f86f39d518
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/Note.kt
@@ -0,0 +1,438 @@
+/**
+ * Note represents a single WordPress.com notification
+ */
+package org.wordpress.android.models
+
+import android.text.Spannable
+import android.text.SpannableString
+import android.text.TextUtils
+import android.util.Base64
+import org.json.JSONArray
+import org.json.JSONException
+import org.json.JSONObject
+import org.wordpress.android.fluxc.model.CommentModel
+import org.wordpress.android.fluxc.model.CommentStatus
+import org.wordpress.android.ui.notifications.utils.NotificationsUtilsWrapper
+import org.wordpress.android.util.AppLog
+import org.wordpress.android.util.DateTimeUtils
+import org.wordpress.android.util.DateUtils.addDays
+import org.wordpress.android.util.DateUtils.addMonths
+import org.wordpress.android.util.DateUtils.addWeeks
+import org.wordpress.android.util.DateUtils.isSameDay
+import org.wordpress.android.util.JSONUtils
+import org.wordpress.android.util.StringUtils
+import java.io.UnsupportedEncodingException
+import java.util.Date
+import java.util.EnumSet
+import java.util.zip.DataFormatException
+import java.util.zip.Inflater
+
+class Note {
+ val id: String
+ var localStatus: String? = null
+ get() = StringUtils.notNullStr(field)
+
+ private var mNoteJSON: JSONObject? = null
+
+ constructor(key: String, noteJSON: JSONObject?) {
+ id = key
+ mNoteJSON = noteJSON
+ }
+
+ constructor(noteJSON: JSONObject?) {
+ mNoteJSON = noteJSON
+ id = mNoteJSON?.optString("id", "") ?: ""
+ }
+
+ enum class EnabledActions {
+ ACTION_REPLY,
+ ACTION_APPROVE,
+ ACTION_UNAPPROVE,
+ ACTION_SPAM,
+ ACTION_LIKE_COMMENT,
+ ACTION_LIKE_POST
+ }
+
+ enum class NoteTimeGroup {
+ GROUP_TODAY,
+ GROUP_YESTERDAY,
+ GROUP_OLDER_TWO_DAYS,
+ GROUP_OLDER_WEEK,
+ GROUP_OLDER_MONTH
+ }
+
+ /**
+ * Immutable lazily initialised properties from the note JSON
+ */
+
+ val siteId: Int by lazy { queryJSON("meta.ids.site", 0) }
+ val postId: Int by lazy { queryJSON("meta.ids.post", 0) }
+ val rawType: String by lazy { queryJSON("type", NOTE_UNKNOWN_TYPE) }
+ val commentId: Long by lazy { queryJSON("meta.ids.comment", 0).toLong() }
+ val parentCommentId: Long by lazy { queryJSON("meta.ids.parent_comment", 0).toLong() }
+ val url: String by lazy { queryJSON("url", "") }
+ val header: JSONArray? by lazy { mNoteJSON?.optJSONArray("header") }
+ val commentReplyId: Long by lazy { queryJSON("meta.ids.reply_comment", 0).toLong() }
+ val title: String by lazy { queryJSON("title", "") }
+ val iconURL: String by lazy { queryJSON("icon", "") }
+ val enabledCommentActions: EnumSet by lazy { getEnabledActions(commentActions) }
+ private val enabledPostActions: EnumSet by lazy { getEnabledActions(postActions) }
+ private val timestampString: String by lazy { queryJSON("timestamp", "") }
+ private val commentText: String by lazy { queryJSON("body[last].text", "") }
+ private val commentActions: JSONObject by lazy { getActions(commentId, "comment") }
+ private val postActions: JSONObject by lazy { getActions(postId.toLong(), "post") }
+
+ val body: JSONArray by lazy {
+ runCatching {
+ mNoteJSON?.getJSONArray("body") ?: JSONArray()
+ }.getOrElse {
+ JSONArray()
+ }
+ }
+
+ val subject: JSONObject? by lazy {
+ runCatching {
+ val subjectArray = mNoteJSON?.getJSONArray("subject")
+ if (subjectArray != null && subjectArray.length() > 0) {
+ subjectArray.getJSONObject(0)
+ } else null
+ }.getOrElse {
+ null
+ }
+ }
+
+ val iconURLs: List? by lazy {
+ val bodyArray = mNoteJSON?.optJSONArray("body")
+ if (bodyArray != null && bodyArray.length() > 0) {
+ val iconUrls = ArrayList()
+ for (i in 0 until bodyArray.length()) {
+ val iconUrl = JSONUtils.queryJSON(bodyArray, "body[$i].media[0].url", "")
+ if (iconUrl != null && iconUrl.isNotEmpty()) {
+ iconUrls.add(iconUrl)
+ }
+ }
+ return@lazy iconUrls
+ }
+ null
+ }
+
+ val commentSubject: String? by lazy {
+ val subjectArray = mNoteJSON?.optJSONArray("subject")
+ if (subjectArray != null) {
+ var commentSubject = JSONUtils.queryJSON(subjectArray, "subject[1].text", "")
+
+ // Trim down the comment preview if the comment text is too large.
+ if (commentSubject != null && commentSubject.length > MAX_COMMENT_PREVIEW_LENGTH) {
+ commentSubject = commentSubject.substring(0, MAX_COMMENT_PREVIEW_LENGTH - 1)
+ }
+ return@lazy commentSubject
+ }
+ ""
+ }
+
+ val commentSubjectNoticon: String by lazy {
+ with(queryJSON("subject[0].ranges", JSONArray())) {
+ for (i in 0 until length()) {
+ runCatching {
+ val rangeItem = getJSONObject(i)
+ if (rangeItem.has("type") && rangeItem.optString("type") == "noticon") {
+ return@lazy rangeItem.optString("value", "")
+ }
+ }.getOrElse {
+ return@lazy ""
+ }
+ }
+ }
+ ""
+ }
+
+ val commentAuthorName: String by lazy {
+ val bodyArray = body
+ for (i in 0 until bodyArray.length()) {
+ runCatching {
+ val bodyItem = bodyArray.getJSONObject(i)
+ if (bodyItem.has("type") && bodyItem.optString("type") == "user") {
+ return@lazy bodyItem.optString("text")
+ }
+ }.getOrElse {
+ return@lazy ""
+ }
+ }
+ ""
+ }
+
+ val isCommentType: Boolean by lazy {
+ isTypeRaw(NOTE_COMMENT_TYPE) ||
+ isAutomattcherType && JSONUtils.queryJSON(mNoteJSON, "meta.ids.comment", -1) != -1
+ }
+
+ private val commentAuthorUrl: String by lazy {
+ val bodyArray = body
+ for (i in 0 until bodyArray.length()) {
+ runCatching {
+ val bodyItem = bodyArray.getJSONObject(i)
+ if (bodyItem.has("type") && bodyItem.optString("type") == "user") {
+ return@lazy JSONUtils.queryJSON(bodyItem, "meta.links.home", "")
+ }
+ }.getOrElse {
+ return@lazy ""
+ }
+ }
+ ""
+ }
+
+ /**
+ * Computed properties
+ */
+ val json: JSONObject
+ get() = mNoteJSON ?: JSONObject()
+ val isAutomattcherType: Boolean
+ get() = isTypeRaw(NOTE_MATCHER_TYPE)
+ val isNewPostType: Boolean
+ get() = isTypeRaw(NOTE_NEW_POST_TYPE)
+ val isFollowType: Boolean
+ get() = isTypeRaw(NOTE_FOLLOW_TYPE)
+ val isLikeType: Boolean
+ get() = isPostLikeType || isCommentLikeType
+ val isPostLikeType: Boolean
+ get() = isTypeRaw(NOTE_LIKE_TYPE)
+ val isCommentLikeType: Boolean
+ get() = isTypeRaw(NOTE_COMMENT_LIKE_TYPE)
+ val isViewMilestoneType: Boolean
+ get() = isTypeRaw(NOTE_VIEW_MILESTONE)
+ val isCommentReplyType: Boolean
+ get() = isCommentType && parentCommentId > 0
+ val isCommentWithUserReply: Boolean
+ // Returns true if the user has replied to this comment note
+ get() = isCommentType && !TextUtils.isEmpty(commentSubjectNoticon)
+ val isUserList: Boolean
+ get() = isLikeType || isFollowType
+ val isUnread: Boolean // Parsing every time since it may change
+ get() = queryJSON("read", 0) != 1
+ val timestamp: Long
+ get() = DateTimeUtils.timestampFromIso8601(timestampString)
+ val commentStatus: CommentStatus
+ get() = if (enabledCommentActions.contains(EnabledActions.ACTION_UNAPPROVE)) {
+ CommentStatus.APPROVED
+ } else if (enabledCommentActions.contains(EnabledActions.ACTION_APPROVE)) {
+ CommentStatus.UNAPPROVED
+ } else {
+ CommentStatus.ALL
+ }
+
+ /**
+ * Setters
+ */
+
+ fun setRead() {
+ try {
+ mNoteJSON?.putOpt("read", 1)
+ } catch (e: JSONException) {
+ AppLog.e(AppLog.T.NOTIFS, "Failed to set 'read' property", e)
+ }
+ }
+
+ fun setLikedComment(liked: Boolean) {
+ try {
+ commentActions.put(ACTION_KEY_LIKE_COMMENT, liked)
+ } catch (e: JSONException) {
+ AppLog.e(AppLog.T.NOTIFS, "Failed to set 'like' property for the note", e)
+ }
+ }
+
+ fun setLikedPost(liked: Boolean) {
+ try {
+ postActions.put(ACTION_KEY_LIKE_POST, liked)
+ } catch (e: JSONException) {
+ AppLog.e(AppLog.T.NOTIFS, "Failed to set 'like' property for the note", e)
+ }
+ }
+
+ /**
+ * Helper methods
+ */
+
+ fun canModerate() = enabledCommentActions.contains(EnabledActions.ACTION_APPROVE) ||
+ enabledCommentActions.contains(EnabledActions.ACTION_UNAPPROVE)
+
+ fun canReply() = enabledCommentActions.contains(EnabledActions.ACTION_REPLY)
+
+ fun canLikeComment() = enabledCommentActions.contains(EnabledActions.ACTION_LIKE_COMMENT)
+
+ fun canLikePost() = enabledPostActions.contains(EnabledActions.ACTION_LIKE_POST)
+
+ fun getFormattedSubject(notificationsUtilsWrapper: NotificationsUtilsWrapper): Spannable {
+ return subject?.let { notificationsUtilsWrapper.getSpannableContentForRanges(it) } ?: SpannableString("")
+ }
+
+ fun hasLikedComment() = commentActions.length() > 0 && commentActions.optBoolean(ACTION_KEY_LIKE_COMMENT)
+
+ fun hasLikedPost() = postActions.length() > 0 && postActions.optBoolean(ACTION_KEY_LIKE_POST)
+
+ /**
+ * Compares two notes to see if they are the same: as it's potentially a very processing intensive operation,
+ * we're only comparing the note id, timestamp, and raw JSON length, which is accurate enough for the purpose of
+ * checking if the local Note is any different from a remote note.
+ */
+ fun equalsTimeAndLength(note: Note?) = note != null &&
+ (timestampString.equals(note.timestampString, ignoreCase = true) &&
+ json.length() == note.json.length())
+
+ /**
+ * Constructs a new Comment object based off of data in a Note
+ */
+ fun buildComment(): CommentModel {
+ val comment = CommentModel()
+ comment.remotePostId = postId.toLong()
+ comment.remoteCommentId = commentId
+ comment.authorName = commentAuthorName
+ comment.datePublished = DateTimeUtils.iso8601FromTimestamp(timestamp)
+ comment.content = commentText
+ comment.status = commentStatus.toString()
+ comment.authorUrl = commentAuthorUrl
+ comment.postTitle = title // unavailable in note model
+ comment.authorEmail = "" // unavailable in note model
+ comment.authorProfileImageUrl = iconURL
+ comment.iLike = hasLikedComment()
+ return comment
+ }
+
+ private fun isTypeRaw(rawType: String) = this.rawType == rawType
+
+ /**
+ * Rudimentary system for pulling an item out of a JSON object hierarchy
+ */
+ private fun queryJSON(query: String?, defaultObject: U): U =
+ if (mNoteJSON == null) defaultObject
+ else JSONUtils.queryJSON(mNoteJSON, query, defaultObject)
+
+ /**
+ * Get the actions for a given comment or post
+ * @param itemId The comment or post id
+ * @param type The type of the item: `post` or `comment`
+ */
+ private fun getActions(itemId: Long, type: String): JSONObject {
+ var actions: JSONObject? = null
+ var foundOrError = false
+ var i = 0
+ while (!foundOrError && i < body.length()) {
+ val bodyItem = runCatching { body.getJSONObject(i) }.getOrNull()
+ if (bodyItem?.has("type") == true && bodyItem.optString("type") == type &&
+ itemId == JSONUtils.queryJSON(bodyItem, "meta.ids.$type", 0).toLong()) {
+ actions = JSONUtils.queryJSON(bodyItem, "actions", JSONObject())
+ foundOrError = true
+ }
+ i++
+ }
+ return actions ?: JSONObject()
+ }
+
+ private fun getEnabledActions(jsonActions: JSONObject): EnumSet {
+ val actions = EnumSet.noneOf(EnabledActions::class.java)
+ if (jsonActions.length() == 0) return actions
+
+ if (jsonActions.has(ACTION_KEY_REPLY)) {
+ actions.add(EnabledActions.ACTION_REPLY)
+ }
+ if (jsonActions.has(ACTION_KEY_APPROVE) && jsonActions.optBoolean(ACTION_KEY_APPROVE, false)) {
+ actions.add(EnabledActions.ACTION_UNAPPROVE)
+ }
+ if (jsonActions.has(ACTION_KEY_APPROVE) && !jsonActions.optBoolean(ACTION_KEY_APPROVE, false)) {
+ actions.add(EnabledActions.ACTION_APPROVE)
+ }
+ if (jsonActions.has(ACTION_KEY_SPAM)) {
+ actions.add(EnabledActions.ACTION_SPAM)
+ }
+ if (jsonActions.has(ACTION_KEY_LIKE_COMMENT)) {
+ actions.add(EnabledActions.ACTION_LIKE_COMMENT)
+ }
+ if (jsonActions.has(ACTION_KEY_LIKE_POST)) {
+ actions.add(EnabledActions.ACTION_LIKE_POST)
+ }
+ return actions
+ }
+
+ companion object {
+ private const val MAX_COMMENT_PREVIEW_LENGTH = 200 // maximum character length for a comment preview
+ private const val MAX_PN_LENGTH = 4096 // max length an Android PN payload can have
+
+ // Note types
+ const val NOTE_FOLLOW_TYPE = "follow"
+ const val NOTE_LIKE_TYPE = "like"
+ const val NOTE_COMMENT_TYPE = "comment"
+ const val NOTE_MATCHER_TYPE = "automattcher"
+ const val NOTE_COMMENT_LIKE_TYPE = "comment_like"
+ const val NOTE_NEW_POST_TYPE = "new_post"
+ const val NOTE_VIEW_MILESTONE = "view_milestone"
+ const val NOTE_UNKNOWN_TYPE = "unknown"
+
+ // JSON action keys
+ private const val ACTION_KEY_REPLY = "replyto-comment"
+ private const val ACTION_KEY_APPROVE = "approve-comment"
+ private const val ACTION_KEY_SPAM = "spam-comment"
+ private const val ACTION_KEY_LIKE_COMMENT = "like-comment"
+ private const val ACTION_KEY_LIKE_POST = "like-post"
+
+ // Time constants
+ private const val LAST_MONTH = -1
+ private const val LAST_WEEK = -1
+ private const val LAST_TWO_DAYS = -2
+ private const val SINCE_YESTERDAY = -1
+ private const val MILLISECOND = 1000
+
+ /**
+ * Compare note timestamp to now and return a time grouping
+ */
+ fun getTimeGroupForTimestamp(timestamp: Long): NoteTimeGroup {
+ val today = Date()
+ val then = Date(timestamp * MILLISECOND)
+ return when {
+ then < addMonths(today, LAST_MONTH) -> NoteTimeGroup.GROUP_OLDER_MONTH
+ then < addWeeks(today, LAST_WEEK) -> NoteTimeGroup.GROUP_OLDER_WEEK
+ then < addDays(today, LAST_TWO_DAYS) ||
+ isSameDay(addDays(today, LAST_TWO_DAYS), then) -> NoteTimeGroup.GROUP_OLDER_TWO_DAYS
+ isSameDay(addDays(today, SINCE_YESTERDAY), then) -> NoteTimeGroup.GROUP_YESTERDAY
+ else -> NoteTimeGroup.GROUP_TODAY
+ }
+ }
+
+ @JvmStatic
+ @Synchronized
+ fun buildFromBase64EncodedData(noteId: String, base64FullNoteData: String?): Note? {
+ if (base64FullNoteData == null) return null
+ val b64DecodedPayload = Base64.decode(base64FullNoteData, Base64.DEFAULT)
+
+ // Decompress the payload
+ val decompresser = Inflater()
+ decompresser.setInput(b64DecodedPayload, 0, b64DecodedPayload.size)
+ val result = ByteArray(MAX_PN_LENGTH)
+ val resultLength = try {
+ val length = decompresser.inflate(result)
+ decompresser.end()
+ length
+ } catch (e: DataFormatException) {
+ AppLog.e(AppLog.T.NOTIFS, "Can't decompress the PN BlockListPayload. It could be > 4K", e)
+ 0
+ }
+ val out: String? = try {
+ String(result, 0, resultLength, charset("UTF8"))
+ } catch (e: UnsupportedEncodingException) {
+ AppLog.e(AppLog.T.NOTIFS, "Notification data contains non UTF8 characters.", e)
+ null
+ }
+ return out?.runCatching {
+ var jsonObject = JSONObject(out)
+ if (jsonObject.has("notes")) {
+ val jsonArray: JSONArray? = jsonObject.getJSONArray("notes")
+ if (jsonArray != null && jsonArray.length() == 1) {
+ jsonObject = jsonArray.getJSONObject(0)
+ }
+ }
+ Note(noteId, jsonObject)
+ }?.getOrElse {
+ AppLog.e(AppLog.T.NOTIFS, "Can't parse the Note JSON received in the PN")
+ null
+ }
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/NoteExtensions.kt b/WordPress/src/main/java/org/wordpress/android/models/NoteExtensions.kt
new file mode 100644
index 000000000000..28b0d9b949ac
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/NoteExtensions.kt
@@ -0,0 +1,36 @@
+package org.wordpress.android.models
+
+
+val Note.type
+ get() = NoteType.from(rawType)
+
+sealed class Notification {
+ data class PostLike(val url: String, val title: String): Notification()
+ data object NewPost: Notification()
+ data object Comment: Notification()
+ data object Unknown: Notification()
+
+ companion object {
+ fun from(rawNote: Note) = when(rawNote.type) {
+ NoteType.PostLike -> PostLike(url = rawNote.url, title = rawNote.title)
+ NoteType.NewPost -> NewPost
+ NoteType.Comment -> Comment
+ else -> Unknown
+ }
+ }
+}
+enum class NoteType(val rawType: String) {
+ Follow(Note.NOTE_FOLLOW_TYPE),
+ PostLike(Note.NOTE_LIKE_TYPE),
+ Comment(Note.NOTE_COMMENT_TYPE),
+ Matcher(Note.NOTE_MATCHER_TYPE),
+ CommentLike(Note.NOTE_COMMENT_LIKE_TYPE),
+ NewPost(Note.NOTE_NEW_POST_TYPE),
+ ViewMilestone(Note.NOTE_VIEW_MILESTONE),
+ Unknown(Note.NOTE_UNKNOWN_TYPE);
+
+ companion object {
+ private val map = entries.associateBy(NoteType::rawType)
+ fun from(rawType: String) = map[rawType] ?: Unknown
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/NoticonUtils.kt b/WordPress/src/main/java/org/wordpress/android/models/NoticonUtils.kt
deleted file mode 100644
index ce02662dc261..000000000000
--- a/WordPress/src/main/java/org/wordpress/android/models/NoticonUtils.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-package org.wordpress.android.models
-
-import org.wordpress.android.R
-import javax.inject.Inject
-
-class NoticonUtils
-@Inject constructor() {
- fun noticonToGridicon(noticon: String): Int {
- // Transformation based on Calypso: https://git.io/JqUEC
- return when (noticon) {
- "\uf814" -> R.drawable.ic_mention_white_24dp
- "\uf300" -> R.drawable.ic_comment_white_24dp
- "\uf801" -> R.drawable.ic_add_white_24dp
- "\uf455" -> R.drawable.ic_info_white_24dp
- "\uf470" -> R.drawable.ic_lock_white_24dp
- "\uf806" -> R.drawable.ic_stats_alt_white_24dp
- "\uf805" -> R.drawable.ic_reblog_white_24dp
- "\uf408" -> R.drawable.ic_star_white_24dp
- "\uf804" -> R.drawable.ic_trophy_white_24dp
- "\uf467" -> R.drawable.ic_reply_white_24dp
- "\uf414" -> R.drawable.ic_notice_white_24dp
- "\uf418" -> R.drawable.ic_checkmark_white_24dp
- "\uf447" -> R.drawable.ic_cart_white_24dp
- else -> R.drawable.ic_info_white_24dp
- }
- }
-}
diff --git a/WordPress/src/main/java/org/wordpress/android/modules/AppComponent.java b/WordPress/src/main/java/org/wordpress/android/modules/AppComponent.java
index b5ced9b4f59b..f1f8aadc6697 100644
--- a/WordPress/src/main/java/org/wordpress/android/modules/AppComponent.java
+++ b/WordPress/src/main/java/org/wordpress/android/modules/AppComponent.java
@@ -61,6 +61,7 @@
import org.wordpress.android.ui.notifications.NotificationsDetailListFragment;
import org.wordpress.android.ui.notifications.NotificationsListFragmentPage;
import org.wordpress.android.ui.notifications.adapters.NotesAdapter;
+import org.wordpress.android.ui.notifications.adapters.NoteViewHolder;
import org.wordpress.android.ui.notifications.receivers.NotificationsPendingDraftsReceiver;
import org.wordpress.android.ui.pages.PageListFragment;
import org.wordpress.android.ui.pages.PageParentFragment;
@@ -344,6 +345,8 @@ public interface AppComponent {
void inject(NotesAdapter object);
+ void inject(NoteViewHolder object);
+
void inject(ThemeBrowserFragment object);
void inject(SelectCategoriesActivity object);
diff --git a/WordPress/src/main/java/org/wordpress/android/push/GCMMessageHandler.java b/WordPress/src/main/java/org/wordpress/android/push/GCMMessageHandler.java
index 5495ae79f441..e2a732f6a284 100644
--- a/WordPress/src/main/java/org/wordpress/android/push/GCMMessageHandler.java
+++ b/WordPress/src/main/java/org/wordpress/android/push/GCMMessageHandler.java
@@ -32,6 +32,7 @@
import org.wordpress.android.ui.notifications.SystemNotificationsTracker;
import org.wordpress.android.ui.notifications.utils.NotificationsActions;
import org.wordpress.android.ui.notifications.utils.NotificationsUtils;
+import org.wordpress.android.ui.notifications.utils.NotificationsUtilsWrapper;
import org.wordpress.android.ui.prefs.AppPrefs;
import org.wordpress.android.util.AppLog;
import org.wordpress.android.util.AppLog.T;
@@ -74,15 +75,14 @@ public class GCMMessageHandler {
private static final String PUSH_ARG_TYPE = "type";
private static final String PUSH_ARG_USER = "user";
- private static final String PUSH_ARG_TITLE = "title";
- private static final String PUSH_ARG_MSG = "msg";
+ protected static final String PUSH_ARG_TITLE = "title";
+ protected static final String PUSH_ARG_MSG = "msg";
- private static final String PUSH_TYPE_COMMENT = "c";
+ protected static final String PUSH_TYPE_COMMENT = "c";
private static final String PUSH_TYPE_LIKE = "like";
private static final String PUSH_TYPE_COMMENT_LIKE = "comment_like";
private static final String PUSH_TYPE_AUTOMATTCHER = "automattcher";
private static final String PUSH_TYPE_FOLLOW = "follow";
- private static final String PUSH_TYPE_REBLOG = "reblog";
private static final String PUSH_TYPE_PUSH_AUTH = "push_auth";
private static final String PUSH_TYPE_BADGE_RESET = "badge-reset";
private static final String PUSH_TYPE_NOTE_DELETE = "note-delete";
@@ -100,9 +100,10 @@ public class GCMMessageHandler {
private final ArrayMap mActiveNotificationsMap;
private final NotificationHelper mNotificationHelper;
- @Inject GCMMessageHandler(SystemNotificationsTracker systemNotificationsTracker) {
+ @Inject GCMMessageHandler(SystemNotificationsTracker systemNotificationsTracker,
+ NotificationsUtilsWrapper notificationsUtilsWrapper) {
mActiveNotificationsMap = new ArrayMap<>();
- mNotificationHelper = new NotificationHelper(this, systemNotificationsTracker);
+ mNotificationHelper = new NotificationHelper(this, systemNotificationsTracker, notificationsUtilsWrapper);
}
synchronized void rebuildAndUpdateNotificationsOnSystemBarForThisNote(Context context,
@@ -120,13 +121,6 @@ synchronized void rebuildAndUpdateNotificationsOnSystemBarForThisNote(Context co
}
}
- public synchronized void rebuildAndUpdateNotifsOnSystemBarForRemainingNote(Context context) {
- if (mActiveNotificationsMap.size() > 0) {
- Bundle remainingNote = mActiveNotificationsMap.values().iterator().next();
- mNotificationHelper.rebuildAndUpdateNotificationsOnSystemBar(context, remainingNote);
- }
- }
-
private synchronized Bundle getCurrentNoteBundleForNoteId(String noteId) {
if (mActiveNotificationsMap.size() > 0) {
// get the corresponding bundle for this noteId
@@ -278,10 +272,14 @@ public static class NotificationHelper {
private GCMMessageHandler mGCMMessageHandler;
private SystemNotificationsTracker mSystemNotificationsTracker;
+ private NotificationsUtilsWrapper mNotificationsUtilsWrapper;
+
NotificationHelper(GCMMessageHandler gCMMessageHandler,
- SystemNotificationsTracker systemNotificationsTracker) {
+ SystemNotificationsTracker systemNotificationsTracker,
+ NotificationsUtilsWrapper notificationsUtilsWrapper) {
mGCMMessageHandler = gCMMessageHandler;
mSystemNotificationsTracker = systemNotificationsTracker;
+ mNotificationsUtilsWrapper = notificationsUtilsWrapper;
}
void handleDefaultPush(Context context, @NonNull Bundle data, long wpcomUserId) {
@@ -354,11 +352,8 @@ private void buildAndShowNotificationFromNoteData(Context context, Bundle data)
String noteType = StringUtils.notNullStr(data.getString(PUSH_ARG_TYPE));
- String title = StringEscapeUtils.unescapeHtml4(data.getString(PUSH_ARG_TITLE));
- if (title == null) {
- title = context.getString(R.string.app_name);
- }
- String message = StringEscapeUtils.unescapeHtml4(data.getString(PUSH_ARG_MSG));
+ String title = getNotificationTitle(data, noteType, context.getString(R.string.app_name));
+ String message = getNotificationMessage(data, noteType);
/*
* if this has the same note_id as the previous notification, and the previous notification
@@ -421,6 +416,45 @@ private void buildAndShowNotificationFromNoteData(Context context, Bundle data)
);
}
+ @NonNull
+ protected String getNotificationTitle(@NonNull Bundle data,
+ @NonNull String noteType,
+ @NonNull String defaultTitle) {
+ String title;
+ if (noteType.equals(PUSH_TYPE_COMMENT)) {
+ title = StringEscapeUtils.unescapeHtml4(data.getString(PUSH_ARG_MSG));
+ } else {
+ title = StringEscapeUtils.unescapeHtml4(data.getString(PUSH_ARG_TITLE));
+ }
+ if (title == null) {
+ return defaultTitle;
+ }
+ return title;
+ }
+
+ @NonNull
+ protected String getNotificationMessage(@NonNull Bundle data, @NonNull String noteType) {
+ if (noteType.equals(PUSH_TYPE_COMMENT)) {
+ String noteId = data.getString(PUSH_ARG_NOTE_ID);
+ if (noteId != null) {
+ Note note = mNotificationsUtilsWrapper.getNoteById(noteId);
+ if (note != null) {
+ String summary = note.getCommentSubject();
+ if (!TextUtils.isEmpty(summary)) {
+ return summary;
+ }
+ }
+ }
+ }
+
+ // Not a comment or the comment content was not retrieved
+ String message = StringEscapeUtils.unescapeHtml4(data.getString(PUSH_ARG_MSG));
+ if (message == null) {
+ return "";
+ }
+ return message;
+ }
+
private void showNotificationForNoteData(Context context, Bundle noteData, NotificationCompat.Builder builder) {
String noteType = StringUtils.notNullStr(noteData.getString(PUSH_ARG_TYPE));
String wpcomNoteID = noteData.getString(PUSH_ARG_NOTE_ID, "");
@@ -481,7 +515,7 @@ private void addActionsForCommentNotification(Context context, NotificationCompa
} else {
// else offer REPLY / LIKE actions
// LIKE can only be enabled for wp.com sites, so if this is a Jetpack site don't enable LIKEs
- if (note.canLike()) {
+ if (note.canLikeComment()) {
addCommentLikeActionForCommentNotification(context, builder, noteId);
}
}
@@ -714,8 +748,6 @@ private NotificationType fromNoteType(String noteType) {
return NotificationType.AUTOMATTCHER;
case PUSH_TYPE_FOLLOW:
return NotificationType.FOLLOW;
- case PUSH_TYPE_REBLOG:
- return NotificationType.REBLOG;
case PUSH_TYPE_PUSH_AUTH:
return NotificationType.AUTHENTICATION;
case PUSH_TYPE_BADGE_RESET:
@@ -1069,7 +1101,6 @@ private boolean shouldCircularizeNoteIcon(String noteType) {
case PUSH_TYPE_COMMENT_LIKE:
case PUSH_TYPE_AUTOMATTCHER:
case PUSH_TYPE_FOLLOW:
- case PUSH_TYPE_REBLOG:
return true;
default:
return false;
diff --git a/WordPress/src/main/java/org/wordpress/android/push/NotificationType.kt b/WordPress/src/main/java/org/wordpress/android/push/NotificationType.kt
index 21cb74ab6878..63f9f2155910 100644
--- a/WordPress/src/main/java/org/wordpress/android/push/NotificationType.kt
+++ b/WordPress/src/main/java/org/wordpress/android/push/NotificationType.kt
@@ -6,7 +6,6 @@ enum class NotificationType {
COMMENT_LIKE,
AUTOMATTCHER,
FOLLOW,
- REBLOG,
BADGE_RESET,
NOTE_DELETE,
TEST_NOTE,
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentDetailFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentDetailFragment.java
index 03f93d8622ac..555a834cf46e 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentDetailFragment.java
+++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentDetailFragment.java
@@ -1354,7 +1354,7 @@ private boolean canEdit(@NonNull SiteModel site) {
}
private boolean canLike(@NonNull SiteModel site) {
- return mEnabledActions.contains(EnabledActions.ACTION_LIKE)
+ return mEnabledActions.contains(EnabledActions.ACTION_LIKE_COMMENT)
&& SiteUtils.isAccessedViaWPComRest(site);
}
@@ -1387,7 +1387,7 @@ private void showCommentAsNotification(
* this user made on someone else's blog
*/
if (note != null) {
- mEnabledActions = note.getEnabledActions();
+ mEnabledActions = note.getEnabledCommentActions();
}
// Set 'Reply to (Name)' in comment reply EditText if it's a reasonable size
@@ -1471,6 +1471,11 @@ private void likeComment(
mCommentsStoreAdapter.dispatch(CommentActionBuilder.newLikeCommentAction(
new RemoteLikeCommentPayload(site, comment, actionBinding.btnLike.isActivated()))
);
+ if (mNote != null) {
+ EventBus.getDefault().postSticky(new NotificationEvents
+ .OnNoteCommentLikeChanged(mNote, actionBinding.btnLike.isActivated()));
+ }
+
actionBinding.btnLike.announceForAccessibility(
getText(actionBinding.btnLike.isActivated() ? R.string.comment_liked_talkback
: R.string.comment_unliked_talkback)
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/engagement/ListScenarioUtils.kt b/WordPress/src/main/java/org/wordpress/android/ui/engagement/ListScenarioUtils.kt
index 6d02ba429ca6..50f5221b7183 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/engagement/ListScenarioUtils.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/engagement/ListScenarioUtils.kt
@@ -30,7 +30,7 @@ class ListScenarioUtils @Inject constructor(
val notificationsUtilsWrapper: NotificationsUtilsWrapper
) {
fun mapLikeNoteToListScenario(note: Note, context: Context): ListScenario {
- require(note.isLikeType) { "mapLikeNoteToListScenario > unexpected note type ${note.type}" }
+ require(note.isLikeType) { "mapLikeNoteToListScenario > unexpected note type ${note.rawType}" }
val imageType = AVATAR_WITH_BACKGROUND
val headerNoteBlock = HeaderNoteBlock(
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java
index 417dfaf25b38..6c2e4bf43a3d 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java
+++ b/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java
@@ -108,8 +108,9 @@
import org.wordpress.android.ui.mysite.cards.quickstart.QuickStartRepository;
import org.wordpress.android.ui.notifications.NotificationEvents;
import org.wordpress.android.ui.notifications.NotificationsListFragment;
+import org.wordpress.android.ui.notifications.NotificationsListViewModel;
import org.wordpress.android.ui.notifications.SystemNotificationsTracker;
-import org.wordpress.android.ui.notifications.adapters.NotesAdapter;
+import org.wordpress.android.ui.notifications.adapters.Filter;
import org.wordpress.android.ui.notifications.receivers.NotificationsPendingDraftsReceiver;
import org.wordpress.android.ui.notifications.utils.NotificationsActions;
import org.wordpress.android.ui.notifications.utils.NotificationsUtils;
@@ -128,7 +129,9 @@
import org.wordpress.android.ui.prefs.privacy.banner.PrivacyBannerFragment;
import org.wordpress.android.ui.quickstart.QuickStartMySitePrompts;
import org.wordpress.android.ui.quickstart.QuickStartTracker;
+import org.wordpress.android.ui.reader.ReaderActivityLauncher;
import org.wordpress.android.ui.reader.ReaderFragment;
+import org.wordpress.android.ui.reader.comments.ThreadedCommentsActionSource;
import org.wordpress.android.ui.reader.services.update.ReaderUpdateLogic.UpdateTask;
import org.wordpress.android.ui.reader.services.update.ReaderUpdateServiceStarter;
import org.wordpress.android.ui.reader.tracker.ReaderTracker;
@@ -192,6 +195,9 @@
import static org.wordpress.android.util.extensions.InAppReviewExtensionsKt.logException;
import dagger.hilt.android.AndroidEntryPoint;
+import kotlin.Unit;
+import kotlin.jvm.functions.Function0;
+import kotlin.jvm.functions.Function3;
/**
* Main activity which hosts sites, reader, me and notifications pages
@@ -253,6 +259,7 @@ public class WPMainActivity extends LocaleAwareActivity implements
private ModalLayoutPickerViewModel mMLPViewModel;
@NonNull private ReviewViewModel mReviewViewModel;
private BloggingRemindersViewModel mBloggingRemindersViewModel;
+ private NotificationsListViewModel mNotificationsViewModel;
private FloatingActionButton mFloatingActionButton;
private static final String MAIN_BOTTOM_SHEET_TAG = "MAIN_BOTTOM_SHEET_TAG";
private static final String BLOGGING_REMINDERS_BOTTOM_SHEET_TAG = "BLOGGING_REMINDERS_BOTTOM_SHEET_TAG";
@@ -1060,11 +1067,34 @@ public void onTokenInvalid() {
// we processed the voice reply, so we exit this function immediately
return;
} else {
- boolean shouldShowKeyboard =
- getIntent().getBooleanExtra(NotificationsListFragment.NOTE_INSTANT_REPLY_EXTRA, false);
- NotificationsListFragment
- .openNoteForReply(this, noteId, shouldShowKeyboard, null,
- NotesAdapter.FILTERS.FILTER_ALL, true);
+ if (mNotificationsViewModel == null) {
+ mNotificationsViewModel = new ViewModelProvider(this).get(NotificationsListViewModel.class);
+ }
+ mNotificationsViewModel.openNote(noteId, new Function3() {
+ @Nullable @Override
+ public Unit invoke(@NonNull Long siteId, @NonNull Long postId,
+ @NonNull Long commentId) {
+ ReaderActivityLauncher.showReaderComments(
+ WPMainActivity.this,
+ siteId,
+ postId,
+ commentId,
+ ThreadedCommentsActionSource.COMMENT_NOTIFICATION.getSourceDescription()
+ );
+ return null;
+ }
+ }, new Function0() {
+ @Nullable @Override
+ public Unit invoke() {
+ boolean shouldShowKeyboard = getIntent().getBooleanExtra(
+ NotificationsListFragment.NOTE_INSTANT_REPLY_EXTRA,
+ false);
+ NotificationsListFragment.openNoteForReply(WPMainActivity.this, noteId,
+ shouldShowKeyboard, null, Filter.ALL, true);
+ return null;
+ }
+ }
+ );
}
} else {
AppLog.e(T.NOTIFS, "app launched from a PN that doesn't have a note_id in it!!");
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationEvents.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationEvents.java
index 899c697240a7..72bc5f102e92 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationEvents.java
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationEvents.java
@@ -1,5 +1,7 @@
package org.wordpress.android.ui.notifications;
+import androidx.annotation.NonNull;
+
import com.android.volley.VolleyError;
import org.wordpress.android.models.Note;
@@ -62,4 +64,24 @@ public NotificationsRefreshError(VolleyError error) {
public NotificationsRefreshError() {}
}
+
+ public static class OnNoteCommentLikeChanged {
+ public final Note note;
+ public final boolean liked;
+
+ public OnNoteCommentLikeChanged(@NonNull Note note, boolean liked) {
+ this.note = note;
+ this.liked = liked;
+ }
+ }
+
+ public static class OnNotePostLikeChanged {
+ public final Note note;
+ public final boolean liked;
+
+ public OnNotePostLikeChanged(@NonNull Note note, boolean liked) {
+ this.note = note;
+ this.liked = liked;
+ }
+ }
}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailActivity.java
index 750629d332f5..47a8d87f0e67 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailActivity.java
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailActivity.java
@@ -15,6 +15,7 @@
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentStatePagerAdapter;
+import androidx.lifecycle.ViewModelProvider;
import androidx.viewpager.widget.ViewPager;
import org.greenrobot.eventbus.EventBus;
@@ -42,6 +43,7 @@
import org.wordpress.android.ui.comments.CommentDetailFragment;
import org.wordpress.android.ui.engagement.EngagedPeopleListFragment;
import org.wordpress.android.ui.engagement.ListScenarioUtils;
+import org.wordpress.android.ui.notifications.adapters.Filter;
import org.wordpress.android.ui.notifications.adapters.NotesAdapter;
import org.wordpress.android.ui.notifications.services.NotificationsUpdateServiceStarter;
import org.wordpress.android.ui.notifications.utils.NotificationsActions;
@@ -67,6 +69,7 @@
import org.wordpress.android.widgets.WPViewPagerTransformer;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
@@ -88,6 +91,8 @@ public class NotificationsDetailActivity extends LocaleAwareActivity implements
private static final String ARG_TITLE = "activityTitle";
private static final String DOMAIN_WPCOM = "wordpress.com";
+ private NotificationsListViewModel mViewModel;
+
@Inject AccountStore mAccountStore;
@Inject SiteStore mSiteStore;
@Inject GCMMessageHandler mGCMMessageHandler;
@@ -109,6 +114,7 @@ public void onCreate(@Nullable Bundle savedInstanceState) {
((WordPress) getApplication()).component().inject(this);
AppLog.i(AppLog.T.NOTIFS, "Creating NotificationsDetailActivity");
+ mViewModel = new ViewModelProvider(this).get(NotificationsListViewModel.class);
mBinding = NotificationsDetailActivityBinding.inflate(getLayoutInflater());
setContentView(mBinding.getRoot());
@@ -199,9 +205,9 @@ private void updateUIAndNote(boolean doRefresh) {
}
}
- NotesAdapter.FILTERS filter = NotesAdapter.FILTERS.FILTER_ALL;
+ Filter filter = Filter.ALL;
if (getIntent().hasExtra(NotificationsListFragment.NOTE_CURRENT_LIST_FILTER_EXTRA)) {
- filter = (NotesAdapter.FILTERS) getIntent()
+ filter = (Filter) getIntent()
.getSerializableExtra(NotificationsListFragment.NOTE_CURRENT_LIST_FILTER_EXTRA);
}
@@ -211,14 +217,14 @@ private void updateUIAndNote(boolean doRefresh) {
// set title
setActionBarTitleForNote(note);
- markNoteAsRead(note);
+ mViewModel.markNoteAsRead(this, Collections.singletonList(note));
// If `note.getTimestamp()` is not the most recent seen note, the server will discard the value.
NotificationsActions.updateSeenTimestamp(note);
// analytics tracking
Map properties = new HashMap<>();
- properties.put("notification_type", note.getType());
+ properties.put("notification_type", note.getRawType());
AnalyticsTracker.track(AnalyticsTracker.Stat.NOTIFICATIONS_OPENED_NOTIFICATION_DETAILS, properties);
setProgressVisible(false);
@@ -247,7 +253,8 @@ public void onPageSelected(int position) {
Note currentNote = mAdapter.getNoteAtPosition(position);
if (currentNote != null) {
setActionBarTitleForNote(currentNote);
- markNoteAsRead(currentNote);
+ mViewModel.markNoteAsRead(NotificationsDetailActivity.this,
+ Collections.singletonList(currentNote));
NotificationsActions.updateSeenTimestamp(currentNote);
// track subsequent comment note views
trackCommentNote(currentNote);
@@ -333,23 +340,12 @@ private void showErrorToastAndFinish() {
finish();
}
- private void markNoteAsRead(Note note) {
- mGCMMessageHandler.removeNotificationWithNoteIdFromSystemBar(this, note.getId());
- // mark the note as read if it's unread
- if (note.isUnread()) {
- NotificationsActions.markNoteAsRead(note);
- note.setRead();
- NotificationsTable.saveNote(note);
- EventBus.getDefault().post(new NotificationEvents.NotificationsChanged());
- }
- }
-
private void setActionBarTitleForNote(Note note) {
if (getSupportActionBar() != null) {
String title = note.getTitle();
if (TextUtils.isEmpty(title)) {
// set a default title if title is not set within the note
- switch (note.getType()) {
+ switch (note.getRawType()) {
case NOTE_FOLLOW_TYPE:
title = getString(R.string.follows);
break;
@@ -377,13 +373,13 @@ private void setActionBarTitleForNote(Note note) {
}
private NotificationDetailFragmentAdapter buildNoteListAdapterAndSetPosition(Note note,
- NotesAdapter.FILTERS filter) {
+ Filter filter) {
NotificationDetailFragmentAdapter adapter;
ArrayList notes = NotificationsTable.getLatestNotes();
- ArrayList filteredNotes = new ArrayList<>();
// apply filter to the list so we show the same items that the list show vertically, but horizontally
- NotesAdapter.buildFilteredNotesList(filteredNotes, notes, filter);
+ ArrayList filteredNotes = NotesAdapter.buildFilteredNotesList(notes, filter);
+
adapter = new NotificationDetailFragmentAdapter(getSupportFragmentManager(), filteredNotes);
if (mBinding != null) {
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailListFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailListFragment.kt
index 54bf71f827ec..7ce5399d2524 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailListFragment.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailListFragment.kt
@@ -420,7 +420,7 @@ class NotificationsDetailListFragment : ListFragment(), NotificationFragment {
).also {
if (noteObject.ranges != null && noteObject.ranges!!.isNotEmpty()) {
val range = noteObject.ranges!![noteObject.ranges!!.size - 1]
- it.setClickableSpan(range, note.type)
+ it.setClickableSpan(range, note.rawType)
}
}
} else {
@@ -470,7 +470,7 @@ class NotificationsDetailListFragment : ListFragment(), NotificationFragment {
}
var pingbackUrl: String? = null
val isPingback = isPingback(note)
- if (bodyArray != null && bodyArray.length() > 0) {
+ if (bodyArray.length() > 0) {
pingbackUrl = addNotesBlock(note, noteList, bodyArray, isPingback)
}
if (isPingback) {
@@ -489,7 +489,7 @@ class NotificationsDetailListFragment : ListFragment(), NotificationFragment {
private fun isPingback(note: Note): Boolean {
var hasRangeOfTypeSite = false
var hasRangeOfTypePost = false
- val rangesArray = note.subject.optJSONArray("ranges")
+ val rangesArray = note.subject?.optJSONArray("ranges")
if (rangesArray != null) {
for (i in 0 until rangesArray.length()) {
val rangeObject = rangesArray.optJSONObject(i) ?: continue
@@ -550,7 +550,7 @@ class NotificationsDetailListFragment : ListFragment(), NotificationFragment {
// Check if this is a comment notification that has been replied to
// The block will not have a type, and its id will match the comment reply id in the Note.
(blockObject.type == null && note.commentReplyId == commentReplyId)
- } else if (note.isFollowType || note.isLikeType || note.isReblogType) {
+ } else if (note.isFollowType || note.isLikeType) {
// User list notifications have a footer if they have 10 or more users in the body
// The last block will not have a type, so we can use that to determine if it is the footer
blockObject.type == null
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragment.kt
index 459dd85958d1..fdc261876c13 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragment.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragment.kt
@@ -3,15 +3,17 @@
package org.wordpress.android.ui.notifications
import android.Manifest
+import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.text.TextUtils
+import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
-import android.view.MenuItem
import android.view.View
+import android.widget.PopupWindow
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.core.text.HtmlCompat
@@ -30,6 +32,8 @@ import org.greenrobot.eventbus.EventBus
import org.wordpress.android.R
import org.wordpress.android.analytics.AnalyticsTracker
import org.wordpress.android.analytics.AnalyticsTracker.NOTIFICATIONS_SELECTED_FILTER
+import org.wordpress.android.analytics.AnalyticsTracker.Stat.NOTIFICATIONS_MARK_ALL_READ_TAPPED
+import org.wordpress.android.analytics.AnalyticsTracker.Stat.NOTIFICATION_MENU_TAPPED
import org.wordpress.android.analytics.AnalyticsTracker.Stat.NOTIFICATION_TAPPED_SEGMENTED_CONTROL
import org.wordpress.android.databinding.NotificationsListFragmentBinding
import org.wordpress.android.fluxc.store.AccountStore
@@ -47,22 +51,18 @@ import org.wordpress.android.ui.main.WPMainNavigationView.PageType
import org.wordpress.android.ui.mysite.jetpackbadge.JetpackPoweredBottomSheetFragment
import org.wordpress.android.ui.notifications.NotificationEvents.NotificationsUnseenStatus
import org.wordpress.android.ui.notifications.NotificationsListFragment.Companion.TabPosition.All
-import org.wordpress.android.ui.notifications.adapters.NotesAdapter.FILTERS
-import org.wordpress.android.ui.notifications.adapters.NotesAdapter.FILTERS.FILTER_ALL
-import org.wordpress.android.ui.notifications.adapters.NotesAdapter.FILTERS.FILTER_COMMENT
-import org.wordpress.android.ui.notifications.adapters.NotesAdapter.FILTERS.FILTER_FOLLOW
-import org.wordpress.android.ui.notifications.adapters.NotesAdapter.FILTERS.FILTER_LIKE
-import org.wordpress.android.ui.notifications.adapters.NotesAdapter.FILTERS.FILTER_UNREAD
+import org.wordpress.android.ui.notifications.NotificationsListFragmentPage.Companion.KEY_TAB_POSITION
+import org.wordpress.android.ui.notifications.adapters.Filter
import org.wordpress.android.ui.notifications.services.NotificationsUpdateServiceStarter
import org.wordpress.android.ui.notifications.services.NotificationsUpdateServiceStarter.IS_TAPPED_ON_NOTIFICATION
import org.wordpress.android.ui.stats.StatsConnectJetpackActivity
import org.wordpress.android.ui.utils.UiHelpers
import org.wordpress.android.util.JetpackBrandingUtils
-import org.wordpress.android.util.NetworkUtils
import org.wordpress.android.util.PermissionUtils
import org.wordpress.android.util.WPPermissionUtils
import org.wordpress.android.util.WPPermissionUtils.NOTIFICATIONS_PERMISSION_REQUEST_CODE
import org.wordpress.android.util.WPUrlUtils
+import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper
import org.wordpress.android.util.extensions.setLiftOnScrollTargetViewIdAndRequestLayout
import org.wordpress.android.viewmodel.observeEvent
import javax.inject.Inject
@@ -78,9 +78,11 @@ class NotificationsListFragment : Fragment(R.layout.notifications_list_fragment)
@Inject
lateinit var uiHelpers: UiHelpers
+ @Inject
+ lateinit var analyticsTrackerWrapper: AnalyticsTrackerWrapper
+
private val viewModel: NotificationsListViewModel by viewModels()
- private var shouldRefreshNotifications = false
private var lastTabPosition = 0
private var binding: NotificationsListFragmentBinding? = null
@@ -92,11 +94,6 @@ class NotificationsListFragment : Fragment(R.layout.notifications_list_fragment)
}
}
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- shouldRefreshNotifications = true
- }
-
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setHasOptionsMenu(true)
@@ -152,11 +149,6 @@ class NotificationsListFragment : Fragment(R.layout.notifications_list_fragment)
}
}
- override fun onPause() {
- super.onPause()
- shouldRefreshNotifications = true
- }
-
override fun onDestroyView() {
super.onDestroyView()
binding = null
@@ -175,9 +167,7 @@ class NotificationsListFragment : Fragment(R.layout.notifications_list_fragment)
connectJetpack.visibility = View.GONE
tabLayout.visibility = View.VISIBLE
viewPager.visibility = View.VISIBLE
- if (shouldRefreshNotifications) {
- fetchNotesFromRemote()
- }
+ fetchRemoteNotes()
}
setSelectedTab(lastTabPosition)
setNotificationPermissionWarning()
@@ -185,6 +175,13 @@ class NotificationsListFragment : Fragment(R.layout.notifications_list_fragment)
viewModel.onResume()
}
+ private fun fetchRemoteNotes() {
+ if (!isAdded) {
+ return
+ }
+ NotificationsUpdateServiceStarter.startService(activity)
+ }
+
override fun onSaveInstanceState(outState: Bundle) {
outState.putInt(KEY_LAST_TAB_POSITION, lastTabPosition)
super.onSaveInstanceState(outState)
@@ -197,13 +194,6 @@ class NotificationsListFragment : Fragment(R.layout.notifications_list_fragment)
}
}
- private fun fetchNotesFromRemote() {
- if (!isAdded || !NetworkUtils.isNetworkAvailable(activity)) {
- return
- }
- NotificationsUpdateServiceStarter.startService(activity)
- }
-
private fun NotificationsListFragmentBinding.setSelectedTab(position: Int) {
lastTabPosition = position
tabLayout.getTabAt(lastTabPosition)?.select()
@@ -280,8 +270,12 @@ class NotificationsListFragment : Fragment(R.layout.notifications_list_fragment)
@Suppress("OVERRIDE_DEPRECATION")
override fun onPrepareOptionsMenu(menu: Menu) {
- val notificationSettings = menu.findItem(R.id.notifications_settings)
- notificationSettings.isVisible = accountStore.hasAccessToken()
+ val notificationActions = menu.findItem(R.id.notifications_actions)
+ notificationActions.isVisible = accountStore.hasAccessToken()
+ notificationActions.actionView?.setOnClickListener {
+ analyticsTrackerWrapper.track(NOTIFICATION_MENU_TAPPED)
+ showNotificationActionsPopup(it)
+ }
super.onPrepareOptionsMenu(menu)
}
@@ -291,13 +285,37 @@ class NotificationsListFragment : Fragment(R.layout.notifications_list_fragment)
super.onCreateOptionsMenu(menu, inflater)
}
- @Suppress("OVERRIDE_DEPRECATION")
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- if (item.itemId == R.id.notifications_settings) {
- ActivityLauncher.viewNotificationsSettings(activity)
- return true
- }
- return super.onOptionsItemSelected(item)
+ /**
+ * For displaying the popup of notifications settings
+ */
+ @SuppressLint("InflateParams")
+ private fun showNotificationActionsPopup(anchorView: View) {
+ val popupWindow = PopupWindow(requireContext(), null, R.style.WordPress)
+ popupWindow.isOutsideTouchable = true
+ popupWindow.elevation = resources.getDimension(R.dimen.popup_over_toolbar_elevation)
+ popupWindow.contentView = LayoutInflater.from(requireContext())
+ .inflate(R.layout.notification_actions, null).apply {
+ findViewById(R.id.text_mark_all_as_read).setOnClickListener {
+ markAllAsRead()
+ popupWindow.dismiss()
+ }
+ findViewById(R.id.text_settings).setOnClickListener {
+ ActivityLauncher.viewNotificationsSettings(activity)
+ popupWindow.dismiss()
+ }
+ }
+ popupWindow.showAsDropDown(anchorView)
+ }
+
+ /**
+ * For marking the status of every notification as read
+ */
+ private fun markAllAsRead() {
+ analyticsTrackerWrapper.track(NOTIFICATIONS_MARK_ALL_READ_TAPPED)
+ (childFragmentManager.fragments.firstOrNull {
+ // use -1 to make sure that the (null == null) will not happen
+ (it.arguments?.getInt(KEY_TAB_POSITION) ?: -1) == binding?.viewPager?.currentItem
+ } as? NotificationsListFragmentPage)?.markAllNotesAsRead()
}
companion object {
@@ -308,12 +326,12 @@ class NotificationsListFragment : Fragment(R.layout.notifications_list_fragment)
const val NOTE_MODERATE_STATUS_EXTRA = "moderateNoteStatus"
const val NOTE_CURRENT_LIST_FILTER_EXTRA = "currentFilter"
- enum class TabPosition(@StringRes val titleRes: Int, val filter: FILTERS) {
- All(R.string.notifications_tab_title_all, FILTER_ALL),
- Unread(R.string.notifications_tab_title_unread_notifications, FILTER_UNREAD),
- Comment(R.string.notifications_tab_title_comments, FILTER_COMMENT),
- Follow(R.string.notifications_tab_title_follows, FILTER_FOLLOW),
- Like(R.string.notifications_tab_title_likes, FILTER_LIKE);
+ enum class TabPosition(@StringRes val titleRes: Int, val filter: Filter) {
+ All(R.string.notifications_tab_title_all, Filter.ALL),
+ Unread(R.string.notifications_tab_title_unread_notifications, Filter.UNREAD),
+ Comment(R.string.notifications_tab_title_comments, Filter.COMMENT),
+ Follow(R.string.notifications_tab_title_follows, Filter.FOLLOW),
+ Like(R.string.notifications_tab_title_likes, Filter.LIKE);
}
private const val KEY_LAST_TAB_POSITION = "lastTabPosition"
@@ -334,7 +352,7 @@ class NotificationsListFragment : Fragment(R.layout.notifications_list_fragment)
noteId: String?,
shouldShowKeyboard: Boolean,
replyText: String?,
- filter: FILTERS?,
+ filter: Filter?,
isTappedFromPushNotification: Boolean
) {
if (noteId == null || activity == null) {
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragmentPage.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragmentPage.kt
index 6aa147d16258..ce7c9ab04702 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragmentPage.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragmentPage.kt
@@ -1,17 +1,29 @@
package org.wordpress.android.ui.notifications
import android.app.Activity
+import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.text.TextUtils
+import android.util.AttributeSet
import android.view.View
import android.view.animation.Animation
import android.view.animation.Animation.AnimationListener
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.OnScrollListener
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode.MAIN
@@ -19,6 +31,7 @@ import org.wordpress.android.BuildConfig
import org.wordpress.android.R
import org.wordpress.android.WordPress
import org.wordpress.android.analytics.AnalyticsTracker.Stat.APP_REVIEWS_EVENT_INCREMENTED_BY_CHECKING_NOTIFICATION
+import org.wordpress.android.analytics.AnalyticsTracker.Stat.NOTIFICATIONS_INLINE_ACTION_TAPPED
import org.wordpress.android.databinding.NotificationsListFragmentPageBinding
import org.wordpress.android.datasets.NotificationsTable
import org.wordpress.android.fluxc.model.CommentStatus
@@ -36,35 +49,40 @@ import org.wordpress.android.ui.notifications.NotificationEvents.NotificationsCh
import org.wordpress.android.ui.notifications.NotificationEvents.NotificationsRefreshCompleted
import org.wordpress.android.ui.notifications.NotificationEvents.NotificationsRefreshError
import org.wordpress.android.ui.notifications.NotificationEvents.NotificationsUnseenStatus
+import org.wordpress.android.ui.notifications.NotificationEvents.OnNoteCommentLikeChanged
import org.wordpress.android.ui.notifications.NotificationsListFragment.Companion.TabPosition
import org.wordpress.android.ui.notifications.NotificationsListFragment.Companion.TabPosition.All
import org.wordpress.android.ui.notifications.NotificationsListFragment.Companion.TabPosition.Comment
import org.wordpress.android.ui.notifications.NotificationsListFragment.Companion.TabPosition.Follow
import org.wordpress.android.ui.notifications.NotificationsListFragment.Companion.TabPosition.Like
import org.wordpress.android.ui.notifications.NotificationsListFragment.Companion.TabPosition.Unread
+import org.wordpress.android.ui.notifications.NotificationsListViewModel.InlineActionEvent
+import org.wordpress.android.ui.notifications.NotificationsListViewModel.InlineActionEvent.SharePostButtonTapped
+import org.wordpress.android.ui.notifications.adapters.Filter
import org.wordpress.android.ui.notifications.adapters.NotesAdapter
-import org.wordpress.android.ui.notifications.adapters.NotesAdapter.DataLoadedListener
-import org.wordpress.android.ui.notifications.adapters.NotesAdapter.FILTERS
import org.wordpress.android.ui.notifications.services.NotificationsUpdateServiceStarter
import org.wordpress.android.ui.notifications.utils.NotificationsActions
+import org.wordpress.android.ui.reader.ReaderActivityLauncher
+import org.wordpress.android.ui.reader.comments.ThreadedCommentsActionSource
import org.wordpress.android.util.AniUtils
import org.wordpress.android.util.AppLog
import org.wordpress.android.util.AppLog.T
import org.wordpress.android.util.DisplayUtils
import org.wordpress.android.util.NetworkUtils
import org.wordpress.android.util.WPSwipeToRefreshHelper
+import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper
import org.wordpress.android.util.helpers.SwipeToRefreshHelper
import org.wordpress.android.widgets.AppRatingDialog.incrementInteractions
import javax.inject.Inject
+@AndroidEntryPoint
class NotificationsListFragmentPage : ViewPagerFragment(R.layout.notifications_list_fragment_page),
- OnScrollToTopListener,
- DataLoadedListener {
- private var notesAdapter: NotesAdapter? = null
+ OnScrollToTopListener {
+ private lateinit var notesAdapter: NotesAdapter
private var swipeToRefreshHelper: SwipeToRefreshHelper? = null
private var isAnimatingOutNewNotificationsBar = false
- private var shouldRefreshNotifications = false
private var tabPosition = 0
+ private val viewModel: NotificationsListViewModel by viewModels()
@Inject
lateinit var accountStore: AccountStore
@@ -72,6 +90,9 @@ class NotificationsListFragmentPage : ViewPagerFragment(R.layout.notifications_l
@Inject
lateinit var gcmMessageHandler: GCMMessageHandler
+ @Inject
+ lateinit var analyticsTrackerWrapper: AnalyticsTrackerWrapper
+
private val showNewUnseenNotificationsRunnable = Runnable {
if (isAdded) {
binding?.notificationsList?.addOnScrollListener(mOnScrollListener)
@@ -80,25 +101,9 @@ class NotificationsListFragmentPage : ViewPagerFragment(R.layout.notifications_l
private var binding: NotificationsListFragmentPageBinding? = null
- interface OnNoteClickListener {
- fun onClickNote(noteId: String?)
- }
-
- @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
- override fun onActivityCreated(savedInstanceState: Bundle?) {
- super.onActivityCreated(savedInstanceState)
- val adapter = createOrGetNotesAdapter()
- binding?.notificationsList?.adapter = adapter
- if (savedInstanceState != null) {
- tabPosition = savedInstanceState.getInt(KEY_TAB_POSITION, All.ordinal)
- }
- (TabPosition.values().getOrNull(tabPosition) ?: All).let { adapter.setFilter(it.filter) }
- }
-
- @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
+ @Suppress("OVERRIDE_DEPRECATION")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == RequestCodes.NOTE_DETAIL) {
- shouldRefreshNotifications = false
if (resultCode == Activity.RESULT_OK) {
val noteId = data?.getStringExtra(NotificationsListFragment.NOTE_MODERATE_ID_EXTRA)
val newStatus = data?.getStringExtra(NotificationsListFragment.NOTE_MODERATE_STATUS_EXTRA)
@@ -112,7 +117,6 @@ class NotificationsListFragmentPage : ViewPagerFragment(R.layout.notifications_l
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
(requireActivity().application as WordPress).component().inject(this)
- shouldRefreshNotifications = true
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -120,28 +124,45 @@ class NotificationsListFragmentPage : ViewPagerFragment(R.layout.notifications_l
arguments?.let {
tabPosition = it.getInt(KEY_TAB_POSITION, All.ordinal)
}
+ notesAdapter = NotesAdapter(requireActivity(), inlineActionEvents = viewModel.inlineActionEvents).apply {
+ onNoteClicked = { noteId -> handleNoteClick(noteId) }
+ onNotesLoaded = {
+ itemCount -> updateEmptyLayouts(itemCount)
+ swipeToRefreshHelper?.isRefreshing = false
+ }
+ viewModel.inlineActionEvents.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
+ .onEach(::handleInlineActionEvent)
+ .launchIn(viewLifecycleOwner.lifecycleScope)
+ }
binding = NotificationsListFragmentPageBinding.bind(view).apply {
- notificationsList.layoutManager = LinearLayoutManager(activity)
+ notificationsList.layoutManager = LinearLayoutManagerWrapper(view.context)
+ notificationsList.adapter = notesAdapter
swipeToRefreshHelper = WPSwipeToRefreshHelper.buildSwipeToRefreshHelper(notificationsRefresh) {
hideNewNotificationsBar()
- fetchNotesFromRemote()
+ fetchRemoteNotes()
}
layoutNewNotificatons.visibility = View.GONE
layoutNewNotificatons.setOnClickListener { onScrollToTop() }
+ (TabPosition.entries.getOrNull(tabPosition) ?: All).let { notesAdapter.setFilter(it.filter) }
+ }
+ viewModel.updatedNote.observe(viewLifecycleOwner) {
+ notesAdapter.updateNote(it)
}
+
+ swipeToRefreshHelper?.isRefreshing = true
+ notesAdapter.reloadLocalNotes()
}
override fun onDestroyView() {
super.onDestroyView()
- notesAdapter?.cancelReloadNotesTask()
- notesAdapter = null
+ notesAdapter.cancelReloadLocalNotes()
swipeToRefreshHelper = null
binding?.notificationsList?.adapter = null
binding?.notificationsList?.removeCallbacks(showNewUnseenNotificationsRunnable)
binding = null
}
- override fun onDataLoaded(itemsCount: Int) {
+ private fun updateEmptyLayouts(itemsCount: Int) {
if (!isAdded) {
AppLog.d(
T.NOTIFS,
@@ -157,11 +178,6 @@ class NotificationsListFragmentPage : ViewPagerFragment(R.layout.notifications_l
}
}
- override fun onPause() {
- super.onPause()
- shouldRefreshNotifications = true
- }
-
override fun getScrollableViewForUniqueIdProvision(): View? {
return binding?.notificationsList
}
@@ -170,12 +186,6 @@ class NotificationsListFragmentPage : ViewPagerFragment(R.layout.notifications_l
super.onResume()
binding?.hideNewNotificationsBar()
EventBus.getDefault().post(NotificationsUnseenStatus(false))
- if (accountStore.hasAccessToken()) {
- notesAdapter!!.reloadNotesFromDBAsync()
- if (shouldRefreshNotifications) {
- fetchNotesFromRemote()
- }
- }
}
override fun onSaveInstanceState(outState: Bundle) {
@@ -206,21 +216,32 @@ class NotificationsListFragmentPage : ViewPagerFragment(R.layout.notifications_l
super.onStop()
}
- private val mOnNoteClickListener: OnNoteClickListener = object : OnNoteClickListener {
- override fun onClickNote(noteId: String?) {
- if (!isAdded) {
- return
- }
- if (TextUtils.isEmpty(noteId)) {
- return
- }
- incrementInteractions(APP_REVIEWS_EVENT_INCREMENTED_BY_CHECKING_NOTIFICATION)
-
- // Open the latest version of this note in case it has changed, which can happen if the note was tapped
- // from the list after it was updated by another fragment (such as NotificationsDetailListFragment).
- openNoteForReply(activity, noteId, false, null, notesAdapter!!.currentFilter, false)
+ private fun handleNoteClick(noteId: String) {
+ if (!isAdded || noteId.isEmpty()) {
+ return
}
+ incrementInteractions(APP_REVIEWS_EVENT_INCREMENTED_BY_CHECKING_NOTIFICATION)
+
+ viewModel.openNote(
+ noteId,
+ { siteId, postId, commentId ->
+ ReaderActivityLauncher.showReaderComments(
+ activity,
+ siteId,
+ postId,
+ commentId,
+ ThreadedCommentsActionSource.COMMENT_NOTIFICATION.sourceDescription
+ )
+ },
+ {
+ // Open the latest version of this note in case it has changed, which can happen if the note was
+ // tapped from the list after it was updated by another fragment (such as the
+ // NotificationsDetailListFragment).
+ openNoteForReply(activity, noteId, filter = notesAdapter.currentFilter)
+ }
+ )
}
+
private val mOnScrollListener: OnScrollListener = object : OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
@@ -232,18 +253,23 @@ class NotificationsListFragmentPage : ViewPagerFragment(R.layout.notifications_l
private fun NotificationsListFragmentPageBinding.clearPendingNotificationsItemsOnUI() {
hideNewNotificationsBar()
EventBus.getDefault().post(NotificationsUnseenStatus(false))
- NotificationsActions.updateNotesSeenTimestamp()
- Thread { gcmMessageHandler.removeAllNotifications(activity) }.start()
+ lifecycleScope.launch {
+ withContext(Dispatchers.IO) {
+ NotificationsActions.updateNotesSeenTimestamp()
+ gcmMessageHandler.removeAllNotifications(activity)
+ }
+ }
}
- private fun fetchNotesFromRemote() {
- if (!isAdded || notesAdapter == null) {
+ private fun fetchRemoteNotes() {
+ if (!isAdded) {
return
}
if (!NetworkUtils.isNetworkAvailable(activity)) {
swipeToRefreshHelper?.isRefreshing = false
return
}
+ swipeToRefreshHelper?.isRefreshing = true
NotificationsUpdateServiceStarter.startService(activity)
}
@@ -389,45 +415,67 @@ class NotificationsListFragmentPage : ViewPagerFragment(R.layout.notifications_l
}
}
- private fun updateNote(noteId: String, status: CommentStatus) {
- val note = NotificationsTable.getNoteById(noteId)
- if (note != null) {
- note.localStatus = status.toString()
- NotificationsTable.saveNote(note)
- EventBus.getDefault().post(NotificationsChanged())
+ private fun updateNote(noteId: String, status: CommentStatus) = lifecycleScope.launch {
+ withContext(Dispatchers.IO) {
+ val note = NotificationsTable.getNoteById(noteId)
+ if (note != null) {
+ note.localStatus = status.toString()
+ NotificationsTable.saveNote(note)
+ EventBus.getDefault().post(NotificationsChanged())
+ }
}
}
- private fun createOrGetNotesAdapter(): NotesAdapter {
- return notesAdapter ?: NotesAdapter(requireActivity(), this, null).apply {
- notesAdapter = this
- this.setOnNoteClickListener(mOnNoteClickListener)
+ private fun handleInlineActionEvent(actionEvent: InlineActionEvent) {
+ analyticsTrackerWrapper.track(NOTIFICATIONS_INLINE_ACTION_TAPPED, mapOf(
+ InlineActionEvent.KEY_INLINE_ACTION to actionEvent::class.simpleName
+ ))
+ when (actionEvent) {
+ is SharePostButtonTapped -> actionEvent.notification.let { postNotification ->
+ context?.let {
+ ActivityLauncher.openShareIntent(it, postNotification.url, postNotification.title)
+ }
+ }
+ is InlineActionEvent.LikeCommentButtonTapped -> viewModel.likeComment(actionEvent.note, actionEvent.liked)
+ is InlineActionEvent.LikePostButtonTapped -> viewModel.likePost(actionEvent.note, actionEvent.liked)
}
}
+
+ /**
+ * Mark notifications as read in CURRENT tab, use filteredNotes instead of notes
+ */
+ fun markAllNotesAsRead() {
+ viewModel.markNoteAsRead(requireContext(), notesAdapter.filteredNotes)
+ }
+
@Subscribe(sticky = true, threadMode = MAIN)
fun onEventMainThread(event: NoteLikeOrModerationStatusChanged) {
- NotificationsActions.downloadNoteAndUpdateDB(
- event.noteId,
- {
- EventBus.getDefault()
- .removeStickyEvent(
+ lifecycleScope.launch {
+ withContext(Dispatchers.IO) {
+ NotificationsActions.downloadNoteAndUpdateDB(
+ event.noteId,
+ {
+ EventBus.getDefault()
+ .removeStickyEvent(
+ NoteLikeOrModerationStatusChanged::class.java
+ )
+ }
+ ) {
+ EventBus.getDefault().removeStickyEvent(
NoteLikeOrModerationStatusChanged::class.java
)
+ }
}
- ) {
- EventBus.getDefault().removeStickyEvent(
- NoteLikeOrModerationStatusChanged::class.java
- )
}
}
- @Subscribe(threadMode = MAIN)
+ @Subscribe(sticky = true, threadMode = MAIN)
fun onEventMainThread(event: NotificationsChanged) {
if (!isAdded) {
return
}
- notesAdapter!!.reloadNotesFromDBAsync()
+ notesAdapter.reloadLocalNotes()
if (event.hasUnseenNotes) {
binding?.showNewUnseenNotificationsUI()
}
@@ -439,7 +487,7 @@ class NotificationsListFragmentPage : ViewPagerFragment(R.layout.notifications_l
return
}
swipeToRefreshHelper?.isRefreshing = false
- notesAdapter!!.addAll(event.notes, true)
+ notesAdapter.addAll(event.notes)
}
@Suppress("unused", "UNUSED_PARAMETER")
@@ -464,8 +512,24 @@ class NotificationsListFragmentPage : ViewPagerFragment(R.layout.notifications_l
}
}
+ @Subscribe(sticky = true, threadMode = MAIN)
+ fun onEventMainThread(event: OnNoteCommentLikeChanged) {
+ if (!isAdded) {
+ return
+ }
+ notesAdapter.updateNote(event.note)
+ }
+
+ @Subscribe(sticky = true, threadMode = MAIN)
+ fun onEventMainThread(event: NotificationEvents.OnNotePostLikeChanged) {
+ if (!isAdded) {
+ return
+ }
+ notesAdapter.updateNote(event.note.apply { setLikedPost(event.liked) })
+ }
+
companion object {
- private const val KEY_TAB_POSITION = "tabPosition"
+ const val KEY_TAB_POSITION = "tabPosition"
fun newInstance(position: Int): Fragment {
val fragment = NotificationsListFragmentPage()
val bundle = Bundle()
@@ -484,10 +548,10 @@ class NotificationsListFragmentPage : ViewPagerFragment(R.layout.notifications_l
fun openNoteForReply(
activity: Activity?,
noteId: String?,
- shouldShowKeyboard: Boolean,
- replyText: String?,
- filter: FILTERS?,
- isTappedFromPushNotification: Boolean
+ shouldShowKeyboard: Boolean = false,
+ replyText: String? = null,
+ filter: Filter? = null,
+ isTappedFromPushNotification: Boolean = false,
) {
if (noteId == null || activity == null || activity.isFinishing) {
return
@@ -502,11 +566,29 @@ class NotificationsListFragmentPage : ViewPagerFragment(R.layout.notifications_l
NotificationsUpdateServiceStarter.IS_TAPPED_ON_NOTIFICATION,
isTappedFromPushNotification
)
- openNoteForReplyWithParams(detailIntent, activity)
- }
-
- private fun openNoteForReplyWithParams(detailIntent: Intent, activity: Activity) {
activity.startActivityForResult(detailIntent, RequestCodes.NOTE_DETAIL)
}
}
+
+ /**
+ * LinearLayoutManagerWrapper is a workaround for a bug in RecyclerView that blocks the UI thread
+ * when we perform the first click on the inline actions in the notifications list.
+ */
+ internal class LinearLayoutManagerWrapper : LinearLayoutManager {
+ constructor(context: Context) : super(context)
+ constructor(context: Context, orientation: Int, reverseLayout: Boolean) : super(
+ context,
+ orientation,
+ reverseLayout
+ )
+
+ constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(
+ context,
+ attrs,
+ defStyleAttr,
+ defStyleRes
+ )
+
+ override fun supportsPredictiveItemAnimations(): Boolean = false
+ }
}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListViewModel.kt
index f9f8b14113a9..0acc60b613ff 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListViewModel.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListViewModel.kt
@@ -1,14 +1,33 @@
package org.wordpress.android.ui.notifications
+import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
-import org.wordpress.android.modules.UI_THREAD
+import kotlinx.coroutines.flow.MutableSharedFlow
+import org.wordpress.android.datasets.wrappers.NotificationsTableWrapper
+import org.wordpress.android.datasets.wrappers.ReaderPostTableWrapper
+import org.wordpress.android.fluxc.model.SiteModel
+import org.wordpress.android.fluxc.store.AccountStore
+import org.wordpress.android.fluxc.store.CommentsStore
+import org.wordpress.android.fluxc.store.SiteStore
+import org.wordpress.android.fluxc.utils.AppLogWrapper
+import org.wordpress.android.models.Note
+import org.wordpress.android.models.Notification.PostLike
+import org.wordpress.android.modules.BG_THREAD
+import org.wordpress.android.push.GCMMessageHandler
import org.wordpress.android.ui.jetpackoverlay.JetpackFeatureRemovalOverlayUtil
import org.wordpress.android.ui.jetpackoverlay.JetpackOverlayConnectedFeature.NOTIFICATIONS
+import org.wordpress.android.ui.notifications.NotificationEvents.NotificationsChanged
+import org.wordpress.android.ui.notifications.NotificationEvents.OnNoteCommentLikeChanged
+import org.wordpress.android.ui.notifications.utils.NotificationsActionsWrapper
+import org.wordpress.android.ui.notifications.utils.NotificationsUtilsWrapper
import org.wordpress.android.ui.prefs.AppPrefsWrapper
-import org.wordpress.android.util.JetpackBrandingUtils
+import org.wordpress.android.ui.reader.actions.ReaderActions
+import org.wordpress.android.ui.reader.actions.ReaderPostActionsWrapper
+import org.wordpress.android.util.AppLog
+import org.wordpress.android.util.EventBusWrapper
import org.wordpress.android.viewmodel.Event
import org.wordpress.android.viewmodel.ScopedViewModel
import javax.inject.Inject
@@ -16,28 +35,34 @@ import javax.inject.Named
@HiltViewModel
class NotificationsListViewModel @Inject constructor(
- @Named(UI_THREAD) mainDispatcher: CoroutineDispatcher,
+ @Named(BG_THREAD) bgDispatcher: CoroutineDispatcher,
private val appPrefsWrapper: AppPrefsWrapper,
- private val jetpackBrandingUtils: JetpackBrandingUtils,
- private val jetpackFeatureRemovalOverlayUtil: JetpackFeatureRemovalOverlayUtil
-
-) : ScopedViewModel(mainDispatcher) {
+ private val jetpackFeatureRemovalOverlayUtil: JetpackFeatureRemovalOverlayUtil,
+ private val gcmMessageHandler: GCMMessageHandler,
+ private val notificationsUtilsWrapper: NotificationsUtilsWrapper,
+ private val appLogWrapper: AppLogWrapper,
+ private val siteStore: SiteStore,
+ private val commentStore: CommentsStore,
+ private val readerPostTableWrapper: ReaderPostTableWrapper,
+ private val readerPostActionsWrapper: ReaderPostActionsWrapper,
+ private val notificationsTableWrapper: NotificationsTableWrapper,
+ private val notificationsActionsWrapper: NotificationsActionsWrapper,
+ private val eventBusWrapper: EventBusWrapper,
+ private val accountStore: AccountStore
+) : ScopedViewModel(bgDispatcher) {
private val _showJetpackPoweredBottomSheet = MutableLiveData>()
val showJetpackPoweredBottomSheet: LiveData> = _showJetpackPoweredBottomSheet
private val _showJetpackOverlay = MutableLiveData>()
val showJetpackOverlay: LiveData> = _showJetpackOverlay
- val isNotificationsPermissionsWarningDismissed
- get() = appPrefsWrapper.notificationPermissionsWarningDismissed
+ private val _updatedNote = MutableLiveData()
+ val updatedNote: LiveData = _updatedNote
- init {
- if (jetpackBrandingUtils.shouldShowJetpackPoweredBottomSheet()) showJetpackPoweredBottomSheet()
- }
+ val inlineActionEvents = MutableSharedFlow()
- private fun showJetpackPoweredBottomSheet() {
-// _showJetpackPoweredBottomSheet.value = Event(true)
- }
+ val isNotificationsPermissionsWarningDismissed
+ get() = appPrefsWrapper.notificationPermissionsWarningDismissed
fun onResume() {
if (jetpackFeatureRemovalOverlayUtil.shouldShowFeatureSpecificJetpackOverlay(NOTIFICATIONS))
@@ -55,4 +80,95 @@ class NotificationsListViewModel @Inject constructor(
fun resetNotificationsPermissionWarningDismissState() {
appPrefsWrapper.notificationPermissionsWarningDismissed = false
}
+
+ fun markNoteAsRead(context: Context, notes: List) {
+ notes.filter { it.isUnread }
+ .map {
+ gcmMessageHandler.removeNotificationWithNoteIdFromSystemBar(context, it.id)
+ notificationsActionsWrapper.markNoteAsRead(it)
+ it.setRead()
+ it
+ }.takeIf { it.isNotEmpty() }?.let {
+ notificationsTableWrapper.saveNotes(it, false)
+ eventBusWrapper.post(NotificationsChanged())
+ }
+ }
+
+ fun likeComment(note: Note, liked: Boolean) = launch {
+ val site = siteStore.getSiteBySiteId(note.siteId.toLong()) ?: SiteModel().apply {
+ siteId = note.siteId.toLong()
+ setIsWPCom(true)
+ }
+ note.setLikedComment(liked)
+ _updatedNote.postValue(note)
+ // for updating the UI in other tabs
+ eventBusWrapper.postSticky(OnNoteCommentLikeChanged(note, liked))
+ val result = commentStore.likeComment(site, note.commentId, null, liked)
+ if (result.isError.not()) {
+ notificationsTableWrapper.saveNote(note)
+ }
+ }
+
+ fun openNote(
+ noteId: String?,
+ openInTheReader: (siteId: Long, postId: Long, commentId: Long) -> Unit,
+ openDetailView: () -> Unit
+ ) {
+ val note = noteId?.let { notificationsUtilsWrapper.getNoteById(noteId) }
+ if (note != null && note.isCommentType && !note.canModerate()) {
+ val readerPost = readerPostTableWrapper.getBlogPost(note.siteId.toLong(), note.postId.toLong(), false)
+ if (readerPost != null) {
+ openInTheReader(note.siteId.toLong(), note.postId.toLong(), note.commentId)
+ } else {
+ readerPostActionsWrapper.requestBlogPost(
+ note.siteId.toLong(),
+ note.postId.toLong(),
+ object : ReaderActions.OnRequestListener {
+ override fun onSuccess(result: String?) {
+ openInTheReader(note.siteId.toLong(), note.postId.toLong(), note.commentId)
+ }
+
+ override fun onFailure(statusCode: Int) {
+ appLogWrapper.w(AppLog.T.NOTIFS, "Failed to fetch post for comment: $statusCode")
+ openDetailView()
+ }
+ })
+ }
+ } else {
+ openDetailView()
+ }
+ }
+
+ fun likePost(note: Note, liked: Boolean) = launch {
+ note.setLikedPost(liked)
+ _updatedNote.postValue(note)
+ // for updating the UI in other tabs
+ eventBusWrapper.postSticky(NotificationEvents.OnNotePostLikeChanged(note, liked))
+ val post = readerPostTableWrapper.getBlogPost(note.siteId.toLong(), note.postId.toLong(), true)
+ readerPostActionsWrapper.performLikeActionRemote(
+ post = post,
+ postId = note.postId.toLong(),
+ blogId = note.siteId.toLong(),
+ isAskingToLike = liked,
+ wpComUserId = accountStore.account.userId
+ ) { success ->
+ if (success) {
+ notificationsTableWrapper.saveNote(note)
+ if (post == null) {
+ // sync post from server
+ readerPostActionsWrapper.requestBlogPost(note.siteId.toLong(), note.postId.toLong(), null)
+ }
+ }
+ }
+ }
+
+ sealed class InlineActionEvent {
+ data class SharePostButtonTapped(val notification: PostLike) : InlineActionEvent()
+ class LikeCommentButtonTapped(val note: Note, val liked: Boolean) : InlineActionEvent()
+ class LikePostButtonTapped(val note: Note, val liked: Boolean) : InlineActionEvent()
+
+ companion object {
+ const val KEY_INLINE_ACTION = "inline_action"
+ }
+ }
}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/SystemNotificationsTracker.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/SystemNotificationsTracker.kt
index ee8cc3a162a9..5ec418cecfe7 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/SystemNotificationsTracker.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/SystemNotificationsTracker.kt
@@ -23,7 +23,6 @@ import org.wordpress.android.push.NotificationType.POST_PUBLISHED
import org.wordpress.android.push.NotificationType.POST_UPLOAD_ERROR
import org.wordpress.android.push.NotificationType.POST_UPLOAD_SUCCESS
import org.wordpress.android.push.NotificationType.QUICK_START_REMINDER
-import org.wordpress.android.push.NotificationType.REBLOG
import org.wordpress.android.push.NotificationType.STORY_FRAME_SAVE_ERROR
import org.wordpress.android.push.NotificationType.STORY_FRAME_SAVE_SUCCESS
import org.wordpress.android.push.NotificationType.STORY_SAVE_ERROR
@@ -91,7 +90,6 @@ class SystemNotificationsTracker
COMMENT_LIKE -> COMMENT_LIKE_VALUE
AUTOMATTCHER -> AUTOMATTCHER_VALUE
FOLLOW -> FOLLOW_VALUE
- REBLOG -> REBLOG_VALUE
BADGE_RESET -> BADGE_RESET_VALUE
NOTE_DELETE -> NOTE_DELETE_VALUE
TEST_NOTE -> TEST_NOTE_VALUE
@@ -127,7 +125,6 @@ class SystemNotificationsTracker
private const val COMMENT_LIKE_VALUE = "comment_like"
private const val AUTOMATTCHER_VALUE = "automattcher"
private const val FOLLOW_VALUE = "follow"
- private const val REBLOG_VALUE = "reblog"
private const val BADGE_RESET_VALUE = "badge_reset"
private const val NOTE_DELETE_VALUE = "note_delete"
private const val TEST_NOTE_VALUE = "test_note"
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/adapters/NoteViewHolder.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/adapters/NoteViewHolder.kt
new file mode 100644
index 000000000000..8d9383422890
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/adapters/NoteViewHolder.kt
@@ -0,0 +1,272 @@
+package org.wordpress.android.ui.notifications.adapters
+
+import android.content.res.ColorStateList
+import android.text.Spanned
+import android.text.TextUtils
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewTreeObserver
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.annotation.StringRes
+import androidx.core.text.BidiFormatter
+import androidx.core.view.ViewCompat
+import androidx.core.view.isVisible
+import androidx.core.widget.ImageViewCompat
+import androidx.recyclerview.widget.RecyclerView
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.launch
+import org.wordpress.android.R
+import org.wordpress.android.WordPress
+import org.wordpress.android.databinding.NotificationsListItemBinding
+import org.wordpress.android.models.Note
+import org.wordpress.android.models.Notification
+import org.wordpress.android.ui.comments.CommentUtils
+import org.wordpress.android.ui.notifications.NotificationsListViewModel
+import org.wordpress.android.ui.notifications.blocks.NoteBlockClickableSpan
+import org.wordpress.android.ui.notifications.utils.NotificationsUtilsWrapper
+import org.wordpress.android.util.GravatarUtils
+import org.wordpress.android.util.RtlUtils
+import org.wordpress.android.util.extensions.getColorFromAttribute
+import org.wordpress.android.util.image.ImageManager
+import org.wordpress.android.util.image.ImageType
+import javax.inject.Inject
+import kotlin.math.roundToInt
+
+class NoteViewHolder(
+ private val binding: NotificationsListItemBinding,
+ private val inlineActionEvents: MutableSharedFlow,
+ private val coroutineScope: CoroutineScope
+) : RecyclerView.ViewHolder(binding.root) {
+ @Inject
+ lateinit var notificationsUtilsWrapper: NotificationsUtilsWrapper
+ @Inject
+ lateinit var imageManager: ImageManager
+
+ init {
+ (itemView.context.applicationContext as WordPress).component().inject(this)
+ }
+
+ fun bindTimeGroupHeader(note: Note, previousNote: Note?, position: Int) {
+ // Display time group header
+ timeGroupHeaderText(note, previousNote)?.let { timeGroupText ->
+ with(binding.headerText) {
+ visibility = View.VISIBLE
+ setText(timeGroupText)
+ }
+ } ?: run {
+ binding.headerText.visibility = View.GONE
+ }
+
+ // handle the margin top for the header
+ val headerMarginTop: Int
+ val context = itemView.context
+ headerMarginTop = if (position == 0) {
+ context.resources
+ .getDimensionPixelSize(R.dimen.notifications_header_margin_top_position_0)
+ } else {
+ context.resources
+ .getDimensionPixelSize(R.dimen.notifications_header_margin_top_position_n)
+ }
+ val layoutParams = binding.headerText.layoutParams as ViewGroup.MarginLayoutParams
+ layoutParams.topMargin = headerMarginTop
+ binding.headerText.layoutParams = layoutParams
+ }
+
+ fun bindInlineActions(note: Note) = Notification.from(note).let { notification ->
+ when (notification) {
+ Notification.Comment -> bindLikeCommentAction(note)
+ is Notification.NewPost -> bindLikePostAction(note)
+ is Notification.PostLike -> bindShareAction(notification)
+ is Notification.Unknown -> {
+ binding.action.isVisible = false
+ }
+ }
+ }
+
+ private fun bindShareAction(notification: Notification.PostLike) {
+ binding.action.setImageResource(R.drawable.block_share)
+ val color = binding.root.context.getColorFromAttribute(R.attr.wpColorOnSurfaceMedium)
+ ImageViewCompat.setImageTintList(binding.action, ColorStateList.valueOf(color))
+ binding.action.isVisible = true
+ binding.action.setOnClickListener {
+ coroutineScope.launch {
+ inlineActionEvents.emit(
+ NotificationsListViewModel.InlineActionEvent.SharePostButtonTapped(notification)
+ )
+ }
+ }
+ binding.action.contentDescription = binding.root.context.getString(R.string.share_action)
+ }
+
+ private fun bindLikePostAction(note: Note) {
+ if (note.canLikePost().not()) return
+ setupLikeIcon(note.hasLikedPost())
+ binding.action.setOnClickListener {
+ val liked = note.hasLikedPost().not()
+ setupLikeIcon(liked)
+ coroutineScope.launch {
+ inlineActionEvents.emit(
+ NotificationsListViewModel.InlineActionEvent.LikePostButtonTapped(note, liked)
+ )
+ }
+ }
+ }
+
+ private fun bindLikeCommentAction(note: Note) {
+ if (note.canLikeComment().not()) return
+ setupLikeIcon(note.hasLikedComment())
+ binding.action.setOnClickListener {
+ val liked = note.hasLikedComment().not()
+ setupLikeIcon(liked)
+ coroutineScope.launch {
+ inlineActionEvents.emit(
+ NotificationsListViewModel.InlineActionEvent.LikeCommentButtonTapped(
+ note,
+ liked
+ )
+ )
+ }
+ }
+ }
+
+ private fun setupLikeIcon(liked: Boolean) {
+ binding.action.isVisible = true
+ binding.action.setImageResource(if (liked) R.drawable.star_filled else R.drawable.star_empty)
+ val color = if (liked) binding.root.context.getColor(R.color.inline_action_filled)
+ else binding.root.context.getColorFromAttribute(R.attr.wpColorOnSurfaceMedium)
+ ImageViewCompat.setImageTintList(binding.action, ColorStateList.valueOf(color))
+ binding.action.contentDescription =
+ binding.root.context.getString(if (liked) R.string.mnu_comment_liked else R.string.reader_label_like)
+ }
+
+ @StringRes
+ private fun timeGroupHeaderText(note: Note, previousNote: Note?) =
+ previousNote?.timeGroup.let { previousTimeGroup ->
+ val timeGroup = note.timeGroup
+ if (previousTimeGroup?.let { it == timeGroup } == true) {
+ // If the previous time group exists and is the same, we don't need a new one
+ null
+ } else {
+ // Otherwise, we create a new one
+ when (timeGroup) {
+ Note.NoteTimeGroup.GROUP_TODAY -> R.string.stats_timeframe_today
+ Note.NoteTimeGroup.GROUP_YESTERDAY -> R.string.stats_timeframe_yesterday
+ Note.NoteTimeGroup.GROUP_OLDER_TWO_DAYS -> R.string.older_two_days
+ Note.NoteTimeGroup.GROUP_OLDER_WEEK -> R.string.older_last_week
+ Note.NoteTimeGroup.GROUP_OLDER_MONTH -> R.string.older_month
+ }
+ }
+ }
+
+ fun bindSubject(note: Note) {
+ // Subject is stored in db as html to preserve text formatting
+ var noteSubjectSpanned: Spanned = note.getFormattedSubject(notificationsUtilsWrapper)
+ // Trim the '\n\n' added by HtmlCompat.fromHtml(...)
+ noteSubjectSpanned = noteSubjectSpanned.subSequence(
+ 0,
+ TextUtils.getTrimmedLength(noteSubjectSpanned)
+ ) as Spanned
+ val spans = noteSubjectSpanned.getSpans(
+ 0,
+ noteSubjectSpanned.length,
+ NoteBlockClickableSpan::class.java
+ )
+ for (span in spans) {
+ span.enableColors(itemView.context)
+ }
+ binding.noteSubject.text = noteSubjectSpanned
+ }
+
+ fun bindSubjectNoticon(note: Note) {
+ val noteSubjectNoticon = note.commentSubjectNoticon
+ if (!TextUtils.isEmpty(noteSubjectNoticon)) {
+ val parent = binding.noteSubject.parent
+ // Fix position of the subject noticon in the RtL mode
+ if (parent is ViewGroup) {
+ val textDirection = if (BidiFormatter.getInstance()
+ .isRtl(binding.noteSubject.text)
+ ) ViewCompat.LAYOUT_DIRECTION_RTL else ViewCompat.LAYOUT_DIRECTION_LTR
+ ViewCompat.setLayoutDirection(parent, textDirection)
+ }
+ // mirror noticon in the rtl mode
+ if (RtlUtils.isRtl(itemView.context)) {
+ binding.noteSubjectNoticon.scaleX = -1f
+ }
+ val textIndentSize = itemView.context.resources
+ .getDimensionPixelSize(R.dimen.notifications_text_indent_sz)
+ CommentUtils.indentTextViewFirstLine(binding.noteSubject, textIndentSize)
+ binding.noteSubjectNoticon.text = noteSubjectNoticon
+ binding.noteSubjectNoticon.visibility = View.VISIBLE
+ } else {
+ binding.noteSubjectNoticon.visibility = View.GONE
+ }
+ }
+
+ fun bindContent(note: Note) {
+ val noteSnippet = note.commentSubject
+ if (!TextUtils.isEmpty(noteSnippet)) {
+ handleMaxLines(binding.noteSubject, binding.noteDetail)
+ binding.noteDetail.text = noteSnippet
+ binding.noteDetail.visibility = View.VISIBLE
+ } else {
+ binding.noteDetail.visibility = View.GONE
+ }
+ }
+
+ private fun handleMaxLines(subject: TextView, detail: TextView) {
+ subject.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener {
+ override fun onPreDraw(): Boolean {
+ subject.viewTreeObserver.removeOnPreDrawListener(this)
+ if (subject.lineCount == 2) {
+ detail.maxLines = 1
+ } else {
+ detail.maxLines = 2
+ }
+ return false
+ }
+ })
+ }
+
+ fun bindAvatars(note: Note) {
+ if (note.shouldShowMultipleAvatars() && note.iconURLs != null && note.iconURLs!!.size > 1) {
+ val avatars = note.iconURLs!!.toList()
+ if (avatars.size == 2) {
+ binding.noteAvatar.visibility = View.INVISIBLE
+ binding.twoAvatarsView.root.visibility = View.VISIBLE
+ binding.threeAvatarsView.root.visibility = View.INVISIBLE
+ loadAvatar(binding.twoAvatarsView.twoAvatars1, avatars[1])
+ loadAvatar(binding.twoAvatarsView.twoAvatars2, avatars[0])
+ } else { // size > 3
+ binding.noteAvatar.visibility = View.INVISIBLE
+ binding.twoAvatarsView.root.visibility = View.INVISIBLE
+ binding.threeAvatarsView.root.visibility = View.VISIBLE
+ loadAvatar(binding.threeAvatarsView.threeAvatars1, avatars[2])
+ loadAvatar(binding.threeAvatarsView.threeAvatars2, avatars[1])
+ loadAvatar(binding.threeAvatarsView.threeAvatars3, avatars[0])
+ }
+ } else { // single avatar
+ binding.noteAvatar.visibility = View.VISIBLE
+ binding.twoAvatarsView.root.visibility = View.INVISIBLE
+ binding.threeAvatarsView.root.visibility = View.INVISIBLE
+ loadAvatar(binding.noteAvatar, note.iconURL)
+ }
+ }
+
+ private fun loadAvatar(imageView: ImageView, avatarUrl: String) {
+ val avatarSize = imageView.context.resources.getDimension(R.dimen.notifications_avatar_sz).roundToInt()
+ val url = GravatarUtils.fixGravatarUrl(avatarUrl, avatarSize)
+ imageManager.loadIntoCircle(imageView, ImageType.AVATAR_WITH_BACKGROUND, url)
+ }
+
+ private fun Note.shouldShowMultipleAvatars() = isFollowType || isLikeType || isCommentLikeType
+
+ fun bindOthers(note: Note, onNoteClicked: (String) -> Unit) {
+ binding.noteContentContainer.setOnClickListener { onNoteClicked(note.id) }
+ binding.notificationUnread.isVisible = note.isUnread
+ }
+
+ private val Note.timeGroup
+ get() = Note.getTimeGroupForTimestamp(timestamp)
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/adapters/NotesAdapter.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/adapters/NotesAdapter.java
deleted file mode 100644
index bb96d8e1daf1..000000000000
--- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/adapters/NotesAdapter.java
+++ /dev/null
@@ -1,398 +0,0 @@
-package org.wordpress.android.ui.notifications.adapters;
-
-import android.annotation.SuppressLint;
-import android.content.Context;
-import android.os.AsyncTask;
-import android.os.AsyncTask.Status;
-import android.text.Spanned;
-import android.text.TextUtils;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.view.ViewParent;
-import android.widget.TextView;
-
-import androidx.core.graphics.ColorUtils;
-import androidx.core.text.BidiFormatter;
-import androidx.core.view.ViewCompat;
-import androidx.recyclerview.widget.RecyclerView;
-
-import org.wordpress.android.R;
-import org.wordpress.android.WordPress;
-import org.wordpress.android.datasets.NotificationsTable;
-import org.wordpress.android.fluxc.model.CommentStatus;
-import org.wordpress.android.models.Note;
-import org.wordpress.android.models.NoticonUtils;
-import org.wordpress.android.ui.comments.CommentUtils;
-import org.wordpress.android.ui.notifications.NotificationsListFragmentPage.OnNoteClickListener;
-import org.wordpress.android.ui.notifications.blocks.NoteBlockClickableSpan;
-import org.wordpress.android.ui.notifications.utils.NotificationsUtilsWrapper;
-import org.wordpress.android.util.GravatarUtils;
-import org.wordpress.android.util.RtlUtils;
-import org.wordpress.android.util.extensions.ContextExtensionsKt;
-import org.wordpress.android.util.image.ImageManager;
-import org.wordpress.android.util.image.ImageType;
-import org.wordpress.android.widgets.BadgedImageView;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-
-import javax.inject.Inject;
-
-public class NotesAdapter extends RecyclerView.Adapter {
- private final int mAvatarSz;
- private final int mColorUnread;
- private final int mTextIndentSize;
-
- private final DataLoadedListener mDataLoadedListener;
- private final OnLoadMoreListener mOnLoadMoreListener;
- private final ArrayList mNotes = new ArrayList<>();
- private final ArrayList mFilteredNotes = new ArrayList<>();
- @Inject protected ImageManager mImageManager;
- @Inject protected NotificationsUtilsWrapper mNotificationsUtilsWrapper;
- @Inject protected NoticonUtils mNoticonUtils;
-
- public enum FILTERS {
- FILTER_ALL,
- FILTER_COMMENT,
- FILTER_FOLLOW,
- FILTER_LIKE,
- FILTER_UNREAD;
-
- public String toString() {
- switch (this) {
- case FILTER_ALL:
- return "all";
- case FILTER_COMMENT:
- return "comment";
- case FILTER_FOLLOW:
- return "follow";
- case FILTER_LIKE:
- return "like";
- case FILTER_UNREAD:
- return "unread";
- default:
- return "all";
- }
- }
- }
-
- private FILTERS mCurrentFilter = FILTERS.FILTER_ALL;
- private ReloadNotesFromDBTask mReloadNotesFromDBTask;
-
- public interface DataLoadedListener {
- void onDataLoaded(int itemsCount);
- }
-
- public interface OnLoadMoreListener {
- void onLoadMore(long timestamp);
- }
-
- private OnNoteClickListener mOnNoteClickListener;
-
- public NotesAdapter(Context context, DataLoadedListener dataLoadedListener, OnLoadMoreListener onLoadMoreListener) {
- super();
- ((WordPress) context.getApplicationContext()).component().inject(this);
- mDataLoadedListener = dataLoadedListener;
- mOnLoadMoreListener = onLoadMoreListener;
-
- // this is on purpose - we don't show more than a hundred or so notifications at a time so no need to set
- // stable IDs. This helps prevent crashes in case a note comes with no ID (we've code checking for that
- // elsewhere, but telling the RecyclerView.Adapter the notes have stable Ids and then failing to provide them
- // will make things go south as in https://github.com/wordpress-mobile/WordPress-Android/issues/8741
- setHasStableIds(false);
-
- mAvatarSz = (int) context.getResources().getDimension(R.dimen.notifications_avatar_sz);
- mColorUnread = ColorUtils.setAlphaComponent(
- ContextExtensionsKt.getColorFromAttribute(context, com.google.android.material.R.attr.colorOnSurface),
- context.getResources().getInteger(R.integer.selected_list_item_opacity)
- );
- mTextIndentSize = context.getResources().getDimensionPixelSize(R.dimen.notifications_text_indent_sz);
- }
-
- public void setFilter(FILTERS newFilter) {
- mCurrentFilter = newFilter;
- }
-
- public FILTERS getCurrentFilter() {
- return mCurrentFilter;
- }
-
- public void addAll(List notes, boolean clearBeforeAdding) {
- Collections.sort(notes, new Note.TimeStampComparator());
- try {
- if (clearBeforeAdding) {
- mNotes.clear();
- }
- mNotes.addAll(notes);
- } finally {
- myNotifyDatasetChanged();
- }
- }
-
- private void myNotifyDatasetChanged() {
- buildFilteredNotesList(mFilteredNotes, mNotes, mCurrentFilter);
- notifyDataSetChanged();
- if (mDataLoadedListener != null) {
- mDataLoadedListener.onDataLoaded(getItemCount());
- }
- }
-
- @Override
- public NoteViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
- View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.notifications_list_item, parent, false);
-
- return new NoteViewHolder(view);
- }
-
- // Instead of building the filtered notes list dynamically, create it once and re-use it.
- // Otherwise it's re-created so many times during layout.
- public static void buildFilteredNotesList(ArrayList filteredNotes, ArrayList notes, FILTERS filter) {
- filteredNotes.clear();
- if (notes.isEmpty() || filter == FILTERS.FILTER_ALL) {
- filteredNotes.addAll(notes);
- return;
- }
- for (Note currentNote : notes) {
- switch (filter) {
- case FILTER_COMMENT:
- if (currentNote.isCommentType()) {
- filteredNotes.add(currentNote);
- }
- break;
- case FILTER_FOLLOW:
- if (currentNote.isFollowType()) {
- filteredNotes.add(currentNote);
- }
- break;
- case FILTER_UNREAD:
- if (currentNote.isUnread()) {
- filteredNotes.add(currentNote);
- }
- break;
- case FILTER_LIKE:
- if (currentNote.isLikeType()) {
- filteredNotes.add(currentNote);
- }
- break;
- }
- }
- }
-
- private Note getNoteAtPosition(int position) {
- if (isValidPosition(position)) {
- return mFilteredNotes.get(position);
- }
-
- return null;
- }
-
- private boolean isValidPosition(int position) {
- return (position >= 0 && position < mFilteredNotes.size());
- }
-
- @Override
- public int getItemCount() {
- return mFilteredNotes.size();
- }
-
- @Override
- public void onBindViewHolder(NoteViewHolder noteViewHolder, int position) {
- final Note note = getNoteAtPosition(position);
- if (note == null) {
- return;
- }
- noteViewHolder.mContentView.setTag(note.getId());
-
- // Display group header
- Note.NoteTimeGroup timeGroup = Note.getTimeGroupForTimestamp(note.getTimestamp());
-
- Note.NoteTimeGroup previousTimeGroup = null;
- if (position > 0) {
- Note previousNote = getNoteAtPosition(position - 1);
- previousTimeGroup = Note.getTimeGroupForTimestamp(previousNote.getTimestamp());
- }
-
- if (previousTimeGroup != null && previousTimeGroup == timeGroup) {
- noteViewHolder.mHeaderText.setVisibility(View.GONE);
- noteViewHolder.mHeaderDivider.setVisibility(View.GONE);
- } else {
- noteViewHolder.mHeaderText.setVisibility(View.VISIBLE);
- noteViewHolder.mHeaderDivider.setVisibility(View.VISIBLE);
-
- if (timeGroup == Note.NoteTimeGroup.GROUP_TODAY) {
- noteViewHolder.mHeaderText.setText(R.string.stats_timeframe_today);
- } else if (timeGroup == Note.NoteTimeGroup.GROUP_YESTERDAY) {
- noteViewHolder.mHeaderText.setText(R.string.stats_timeframe_yesterday);
- } else if (timeGroup == Note.NoteTimeGroup.GROUP_OLDER_TWO_DAYS) {
- noteViewHolder.mHeaderText.setText(R.string.older_two_days);
- } else if (timeGroup == Note.NoteTimeGroup.GROUP_OLDER_WEEK) {
- noteViewHolder.mHeaderText.setText(R.string.older_last_week);
- } else {
- noteViewHolder.mHeaderText.setText(R.string.older_month);
- }
- }
-
- CommentStatus commentStatus = CommentStatus.ALL;
- if (note.getCommentStatus() == CommentStatus.UNAPPROVED) {
- commentStatus = CommentStatus.UNAPPROVED;
- }
-
- if (!TextUtils.isEmpty(note.getLocalStatus())) {
- commentStatus = CommentStatus.fromString(note.getLocalStatus());
- }
-
- // Subject is stored in db as html to preserve text formatting
- Spanned noteSubjectSpanned = note.getFormattedSubject(mNotificationsUtilsWrapper);
- // Trim the '\n\n' added by HtmlCompat.fromHtml(...)
- noteSubjectSpanned =
- (Spanned) noteSubjectSpanned.subSequence(0, TextUtils.getTrimmedLength(noteSubjectSpanned));
-
- NoteBlockClickableSpan[] spans =
- noteSubjectSpanned.getSpans(0, noteSubjectSpanned.length(), NoteBlockClickableSpan.class);
- for (NoteBlockClickableSpan span : spans) {
- span.enableColors(noteViewHolder.mContentView.getContext());
- }
-
- noteViewHolder.mTxtSubject.setText(noteSubjectSpanned);
-
- String noteSubjectNoticon = note.getCommentSubjectNoticon();
- if (!TextUtils.isEmpty(noteSubjectNoticon)) {
- ViewParent parent = noteViewHolder.mTxtSubject.getParent();
- // Fix position of the subject noticon in the RtL mode
- if (parent instanceof ViewGroup) {
- int textDirection = BidiFormatter.getInstance().isRtl(noteViewHolder.mTxtSubject.getText())
- ? ViewCompat.LAYOUT_DIRECTION_RTL : ViewCompat.LAYOUT_DIRECTION_LTR;
- ViewCompat.setLayoutDirection((ViewGroup) parent, textDirection);
- }
- // mirror noticon in the rtl mode
- if (RtlUtils.isRtl(noteViewHolder.itemView.getContext())) {
- noteViewHolder.mTxtSubjectNoticon.setScaleX(-1);
- }
- CommentUtils.indentTextViewFirstLine(noteViewHolder.mTxtSubject, mTextIndentSize);
- noteViewHolder.mTxtSubjectNoticon.setText(noteSubjectNoticon);
- noteViewHolder.mTxtSubjectNoticon.setVisibility(View.VISIBLE);
- } else {
- noteViewHolder.mTxtSubjectNoticon.setVisibility(View.GONE);
- }
-
- String noteSnippet = note.getCommentSubject();
- if (!TextUtils.isEmpty(noteSnippet)) {
- noteViewHolder.mTxtSubject.setMaxLines(2);
- noteViewHolder.mTxtDetail.setText(noteSnippet);
- noteViewHolder.mTxtDetail.setVisibility(View.VISIBLE);
- } else {
- noteViewHolder.mTxtSubject.setMaxLines(3);
- noteViewHolder.mTxtDetail.setVisibility(View.GONE);
- }
-
- String avatarUrl = GravatarUtils.fixGravatarUrl(note.getIconURL(), mAvatarSz);
- mImageManager.loadIntoCircle(noteViewHolder.mImgAvatar, ImageType.AVATAR_WITH_BACKGROUND, avatarUrl);
-
- boolean isUnread = note.isUnread();
-
- int gridicon = mNoticonUtils.noticonToGridicon(note.getNoticonCharacter());
- noteViewHolder.mImgAvatar.setBadgeIcon(gridicon);
- if (commentStatus == CommentStatus.UNAPPROVED) {
- noteViewHolder.mImgAvatar.setBadgeBackground(R.drawable.bg_oval_warning_dark);
- } else if (isUnread) {
- noteViewHolder.mImgAvatar.setBadgeBackground(R.drawable.bg_note_avatar_badge);
- } else {
- noteViewHolder.mImgAvatar.setBadgeBackground(R.drawable.bg_oval_neutral_20);
- }
-
- if (isUnread) {
- noteViewHolder.mContentView.setBackgroundColor(mColorUnread);
- } else {
- noteViewHolder.mContentView.setBackgroundColor(0);
- }
-
- // request to load more comments when we near the end
- if (mOnLoadMoreListener != null && position >= getItemCount() - 1) {
- mOnLoadMoreListener.onLoadMore(note.getTimestamp());
- }
- }
-
- private int getPositionForNoteUnfiltered(String noteId) {
- return getPositionForNoteInArray(noteId, mNotes);
- }
-
- private int getPositionForNoteInArray(String noteId, ArrayList notes) {
- if (notes != null && noteId != null) {
- for (int i = 0; i < notes.size(); i++) {
- String noteKey = notes.get(i).getId();
- if (noteKey != null && noteKey.equals(noteId)) {
- return i;
- }
- }
- }
- return RecyclerView.NO_POSITION;
- }
-
- public void setOnNoteClickListener(OnNoteClickListener mNoteClickListener) {
- mOnNoteClickListener = mNoteClickListener;
- }
-
- public void cancelReloadNotesTask() {
- if (mReloadNotesFromDBTask != null && mReloadNotesFromDBTask.getStatus() != Status.FINISHED) {
- mReloadNotesFromDBTask.cancel(true);
- mReloadNotesFromDBTask = null;
- }
- }
-
- @SuppressWarnings("deprecation")
- public void reloadNotesFromDBAsync() {
- cancelReloadNotesTask();
- mReloadNotesFromDBTask = new ReloadNotesFromDBTask();
- mReloadNotesFromDBTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
- }
-
- @SuppressWarnings("deprecation")
- @SuppressLint("StaticFieldLeak")
- private class ReloadNotesFromDBTask extends AsyncTask> {
- @Override
- protected ArrayList doInBackground(Void... voids) {
- return NotificationsTable.getLatestNotes();
- }
-
- @Override
- protected void onPostExecute(ArrayList notes) {
- mNotes.clear();
- mNotes.addAll(notes);
- myNotifyDatasetChanged();
- }
- }
-
- class NoteViewHolder extends RecyclerView.ViewHolder {
- private final View mContentView;
- private final TextView mHeaderText;
- private final View mHeaderDivider;
- private final TextView mTxtSubject;
- private final TextView mTxtSubjectNoticon;
- private final TextView mTxtDetail;
- private final BadgedImageView mImgAvatar;
-
- NoteViewHolder(View view) {
- super(view);
- mContentView = view.findViewById(R.id.note_content_container);
- mHeaderText = view.findViewById(R.id.header_text);
- mHeaderDivider = view.findViewById(R.id.header_divider);
- mTxtSubject = view.findViewById(R.id.note_subject);
- mTxtSubjectNoticon = view.findViewById(R.id.note_subject_noticon);
- mTxtDetail = view.findViewById(R.id.note_detail);
- mImgAvatar = view.findViewById(R.id.note_avatar);
-
- mContentView.setOnClickListener(mOnClickListener);
- }
- }
-
- private final View.OnClickListener mOnClickListener = new View.OnClickListener() {
- @Override
- public void onClick(View v) {
- if (mOnNoteClickListener != null && v.getTag() instanceof String) {
- mOnNoteClickListener.onClickNote((String) v.getTag());
- }
- }
- };
-}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/adapters/NotesAdapter.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/adapters/NotesAdapter.kt
new file mode 100644
index 000000000000..56589ee4369b
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/adapters/NotesAdapter.kt
@@ -0,0 +1,136 @@
+package org.wordpress.android.ui.notifications.adapters
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.wordpress.android.WordPress
+import org.wordpress.android.databinding.NotificationsListItemBinding
+import org.wordpress.android.datasets.NotificationsTable
+import org.wordpress.android.models.Note
+import org.wordpress.android.ui.notifications.NotificationsListViewModel.InlineActionEvent
+import org.wordpress.android.util.extensions.indexOrNull
+
+class NotesAdapter(context: Context, private val inlineActionEvents: MutableSharedFlow) :
+ RecyclerView.Adapter() {
+ private val coroutineScope = CoroutineScope(Dispatchers.IO)
+ private var reloadLocalNotesJob: Job? = null
+ val filteredNotes = ArrayList()
+ var onNoteClicked = { _: String -> }
+ var onNotesLoaded = { _: Int -> }
+ var onScrolledToBottom = { _: Long -> }
+ var currentFilter = Filter.ALL
+ private set
+
+ init {
+ (context.applicationContext as WordPress).component().inject(this)
+
+ // this is on purpose - we don't show more than a hundred or so notifications at a time so no need to set
+ // stable IDs. This helps prevent crashes in case a note comes with no ID (we've code checking for that
+ // elsewhere, but telling the RecyclerView.Adapter the notes have stable Ids and then failing to provide them
+ // will make things go south as in https://github.com/wordpress-mobile/WordPress-Android/issues/8741
+ setHasStableIds(false)
+ }
+
+ fun setFilter(newFilter: Filter) {
+ currentFilter = newFilter
+ }
+
+ /**
+ * Add notes to the adapter and notify the change
+ */
+ @SuppressLint("NotifyDataSetChanged")
+ fun addAll(notes: List) = coroutineScope.launch {
+ val newNotes = buildFilteredNotesList(notes, currentFilter)
+ filteredNotes.clear()
+ filteredNotes.addAll(newNotes)
+ withContext(Dispatchers.Main) {
+ notifyDataSetChanged()
+ onNotesLoaded(newNotes.size)
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NoteViewHolder =
+ NoteViewHolder(
+ NotificationsListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false),
+ inlineActionEvents,
+ coroutineScope
+ )
+
+ override fun getItemCount(): Int = filteredNotes.size
+
+ override fun onBindViewHolder(noteViewHolder: NoteViewHolder, position: Int) {
+ val note = filteredNotes.getOrNull(position) ?: return
+ val previousNote = filteredNotes.getOrNull(position - 1)
+
+ noteViewHolder.bindTimeGroupHeader(note, previousNote, position)
+ noteViewHolder.bindSubject(note)
+ noteViewHolder.bindSubjectNoticon(note)
+ noteViewHolder.bindContent(note)
+ noteViewHolder.bindAvatars(note)
+ noteViewHolder.bindInlineActions(note)
+ noteViewHolder.bindOthers(note, onNoteClicked)
+
+ // request to load more comments when we near the end
+ if (position >= itemCount - 1) {
+ onScrolledToBottom(note.timestamp)
+ }
+ }
+
+ fun cancelReloadLocalNotes() {
+ reloadLocalNotesJob?.cancel()
+ }
+
+ /**
+ * Reload the notes from local database and update the adapter
+ */
+ fun reloadLocalNotes() {
+ cancelReloadLocalNotes()
+ reloadLocalNotesJob = coroutineScope.launch {
+ addAll(NotificationsTable.getLatestNotes())
+ }
+ }
+
+ /**
+ * Update the note in the adapter and notify the change
+ */
+ fun updateNote(note: Note) {
+ filteredNotes.indexOrNull { it.id == note.id }?.let { notePosition ->
+ filteredNotes[notePosition] = note
+ notifyItemChanged(notePosition)
+ }
+ }
+
+ companion object {
+ // Instead of building the filtered notes list dynamically, create it once and re-use it.
+ // Otherwise it's re-created so many times during layout.
+ @JvmStatic
+ fun buildFilteredNotesList(
+ notes: List,
+ filter: Filter
+ ): ArrayList = notes.filter { note ->
+ when (filter) {
+ Filter.ALL -> true
+ Filter.COMMENT -> note.isCommentType
+ Filter.FOLLOW -> note.isFollowType
+ Filter.UNREAD -> note.isUnread
+ Filter.LIKE -> note.isLikeType
+ }
+ }.sortedByDescending { it.timestamp }.let { result -> ArrayList(result) }
+ }
+}
+
+enum class Filter(val value: String) {
+ ALL("all"),
+ COMMENT("comment"),
+ FOLLOW("follow"),
+ LIKE("like"),
+ UNREAD("unread");
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/NoteBlock.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/NoteBlock.java
index 6c92527da847..570821795d0c 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/NoteBlock.java
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/NoteBlock.java
@@ -251,7 +251,7 @@ public void onResourceReady(@NonNull Drawable resource, @Nullable Object model)
});
} else {
noteBlockHolder.getTextView().setTextSize(28);
- TypefaceSpan typefaceSpan = new TypefaceSpan("serif");
+ TypefaceSpan typefaceSpan = new TypefaceSpan("sans-serif");
noteText.setSpan(typefaceSpan, 0, noteText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/utils/NotificationsActions.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/utils/NotificationsActions.java
index 92417beb30d0..44c93cf53d80 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/utils/NotificationsActions.java
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/utils/NotificationsActions.java
@@ -1,5 +1,7 @@
package org.wordpress.android.ui.notifications.utils;
+import androidx.annotation.Nullable;
+
import com.android.volley.VolleyError;
import com.wordpress.rest.RestRequest;
@@ -58,7 +60,7 @@ public static List parseNotes(JSONObject response) throws JSONException {
return notes;
}
- public static void markNoteAsRead(final Note note) {
+ public static void markNoteAsRead(@Nullable final Note note) {
if (note == null) {
return;
}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/utils/NotificationsActionsWrapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/utils/NotificationsActionsWrapper.kt
index a1a8287d8dd2..086be53d281d 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/utils/NotificationsActionsWrapper.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/utils/NotificationsActionsWrapper.kt
@@ -1,6 +1,7 @@
package org.wordpress.android.ui.notifications.utils
import dagger.Reusable
+import org.wordpress.android.models.Note
import javax.inject.Inject
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@@ -14,4 +15,8 @@ class NotificationsActionsWrapper @Inject constructor() {
{ continuation.resume(true) },
{ continuation.resume(true) })
}
+
+ fun markNoteAsRead(note: Note?) {
+ NotificationsActions.markNoteAsRead(note)
+ }
}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/utils/NotificationsUtils.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/utils/NotificationsUtils.java
index 1cef320048c5..151916d79294 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/utils/NotificationsUtils.java
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/utils/NotificationsUtils.java
@@ -4,7 +4,6 @@
import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
-import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Bundle;
@@ -15,10 +14,10 @@
import android.text.TextUtils;
import android.text.style.AlignmentSpan;
import android.text.style.ImageSpan;
-import android.text.style.StyleSpan;
import android.view.View;
import android.widget.TextView;
+import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.core.app.NotificationManagerCompat;
import androidx.preference.PreferenceManager;
@@ -279,13 +278,6 @@ public void onClick(View widget) {
spannableStringBuilder
.setSpan(clickableSpan, indices.get(0), indices.get(1), Spanned.SPAN_INCLUSIVE_INCLUSIVE);
- // Add additional styling if the range wants it
- if (clickableSpan.getSpanStyle() != Typeface.NORMAL) {
- StyleSpan styleSpan = new StyleSpan(clickableSpan.getSpanStyle());
- spannableStringBuilder
- .setSpan(styleSpan, indices.get(0), indices.get(1), Spanned.SPAN_INCLUSIVE_INCLUSIVE);
- }
-
if (onNoteBlockTextClickListener != null && textView != null) {
textView.setLinksClickable(true);
textView.setMovementMethod(new NoteBlockLinkMovementMethod());
@@ -478,6 +470,11 @@ public static boolean buildNoteObjectFromBundleAndSaveIt(Bundle data) {
return false;
}
+ @Nullable
+ public static Note getNoteById(@Nullable String noteID) {
+ return NotificationsTable.getNoteById(noteID);
+ }
+
public static Note buildNoteObjectFromBundle(Bundle data) {
if (data == null) {
AppLog.e(T.NOTIFS, "Bundle is null! Cannot read '" + GCMMessageService.PUSH_ARG_NOTE_ID + "'.");
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/utils/NotificationsUtilsWrapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/utils/NotificationsUtilsWrapper.kt
index b2ccbf49a396..bef354abe943 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/utils/NotificationsUtilsWrapper.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/utils/NotificationsUtilsWrapper.kt
@@ -6,6 +6,7 @@ import org.json.JSONObject
import org.wordpress.android.fluxc.tools.FormattableContent
import org.wordpress.android.fluxc.tools.FormattableContentMapper
import org.wordpress.android.fluxc.tools.FormattableRange
+import org.wordpress.android.models.Note
import org.wordpress.android.ui.notifications.blocks.NoteBlock
import javax.inject.Inject
import javax.inject.Singleton
@@ -69,4 +70,6 @@ class NotificationsUtilsWrapper @Inject constructor(private val formattableConte
fun mapJsonToFormattableContent(blockObject: JSONObject): FormattableContent = NotificationsUtils
.mapJsonToFormattableContent(formattableContentMapper, blockObject)
+
+ fun getNoteById(noteId: String): Note? = NotificationsUtils.getNoteById(noteId)
}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderActivityLauncher.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderActivityLauncher.java
index 2aea5b62e6e1..32b2261e2527 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderActivityLauncher.java
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderActivityLauncher.java
@@ -13,7 +13,6 @@
import androidx.core.app.ActivityOptionsCompat;
import androidx.fragment.app.Fragment;
-import org.wordpress.android.R;
import org.wordpress.android.analytics.AnalyticsTracker;
import org.wordpress.android.models.ReaderPost;
import org.wordpress.android.models.ReaderTag;
@@ -403,13 +402,7 @@ public static void openPost(Context context, ReaderPost post) {
public static void sharePost(Context context, ReaderPost post) throws ActivityNotFoundException {
String url = (post.hasShortUrl() ? post.getShortUrl() : post.getUrl());
-
- Intent intent = new Intent(Intent.ACTION_SEND);
- intent.setType("text/plain");
- intent.putExtra(Intent.EXTRA_TEXT, url);
- intent.putExtra(Intent.EXTRA_SUBJECT, post.getTitle());
-
- context.startActivity(Intent.createChooser(intent, context.getString(R.string.share_link)));
+ ActivityLauncher.openShareIntent(context, url, post.getTitle());
}
public static void openUrl(Context context, String url, OpenUrlType openUrlType) {
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderPostActions.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderPostActions.java
index 4f325b9643dd..ee49b07f0a92 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderPostActions.java
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderPostActions.java
@@ -4,6 +4,7 @@
import android.text.TextUtils;
import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import com.android.volley.AuthFailureError;
import com.android.volley.Request;
@@ -82,43 +83,52 @@ public static boolean performLikeActionLocal(final ReaderPost post,
return true;
}
- public static void performLikeActionRemote(final ReaderPost post,
+ public static void performLikeActionRemote(@Nullable final ReaderPost post,
final boolean isAskingToLike,
final long wpComUserId,
- final ActionListener actionListener) {
+ @Nullable final ActionListener actionListener) {
+ if (post != null) {
+ performLikeActionRemote(post, post.blogId, post.postId, isAskingToLike, wpComUserId, actionListener);
+ }
+ }
+
+ public static void performLikeActionRemote(
+ @Nullable final ReaderPost post,
+ final long blogId,
+ final long postId,
+ final boolean isAskingToLike,
+ final long wpComUserId,
+ @Nullable final ActionListener actionListener
+ ) {
final String actionName = isAskingToLike ? "like" : "unlike";
- String path = "sites/" + post.blogId + "/posts/" + post.postId + "/likes/";
+ String path = "sites/" + blogId + "/posts/" + postId + "/likes/";
if (isAskingToLike) {
path += "new";
} else {
path += "mine/delete";
}
- com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() {
- @Override
- public void onResponse(JSONObject jsonObject) {
- AppLog.d(T.READER, String.format("post %s succeeded", actionName));
- if (actionListener != null) {
- ReaderActions.callActionListener(actionListener, true);
- }
+ com.wordpress.rest.RestRequest.Listener listener = jsonObject -> {
+ AppLog.d(T.READER, String.format("post %s succeeded", actionName));
+ if (actionListener != null) {
+ ReaderActions.callActionListener(actionListener, true);
}
};
- RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() {
- @Override
- public void onErrorResponse(VolleyError volleyError) {
- String error = VolleyUtils.errStringFromVolleyError(volleyError);
- if (TextUtils.isEmpty(error)) {
- AppLog.w(T.READER, String.format("post %s failed", actionName));
- } else {
- AppLog.w(T.READER, String.format("post %s failed (%s)", actionName, error));
- }
- AppLog.e(T.READER, volleyError);
+ RestRequest.ErrorListener errorListener = volleyError -> {
+ String error = VolleyUtils.errStringFromVolleyError(volleyError);
+ if (TextUtils.isEmpty(error)) {
+ AppLog.w(T.READER, String.format("post %s failed", actionName));
+ } else {
+ AppLog.w(T.READER, String.format("post %s failed (%s)", actionName, error));
+ }
+ AppLog.e(T.READER, volleyError);
+ if (post != null) {
ReaderPostTable.setLikesForPost(post, post.numLikes, post.isLikedByCurrentUser);
- ReaderLikeTable.setCurrentUserLikesPost(post, post.isLikedByCurrentUser, wpComUserId);
- if (actionListener != null) {
- ReaderActions.callActionListener(actionListener, false);
- }
+ }
+ ReaderLikeTable.setCurrentUserLikesPost(postId, blogId, isAskingToLike, wpComUserId);
+ if (actionListener != null) {
+ ReaderActions.callActionListener(actionListener, false);
}
};
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderPostActionsWrapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderPostActionsWrapper.kt
index 43d553259f26..b59b3fd5a933 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderPostActionsWrapper.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderPostActionsWrapper.kt
@@ -23,6 +23,16 @@ class ReaderPostActionsWrapper @Inject constructor(private val siteStore: SiteSt
actionListener: ActionListener
) = ReaderPostActions.performLikeActionRemote(post, isAskingToLike, wpComUserId, actionListener)
+ @Suppress("LongParameterList")
+ fun performLikeActionRemote(
+ post: ReaderPost?,
+ postId: Long,
+ blogId: Long,
+ isAskingToLike: Boolean,
+ wpComUserId: Long,
+ actionListener: ActionListener
+ ) = ReaderPostActions.performLikeActionRemote(post, blogId, postId, isAskingToLike, wpComUserId, actionListener)
+
fun bumpPageViewForPost(post: ReaderPost) = ReaderPostActions.bumpPageViewForPost(siteStore, post)
fun requestRelatedPosts(sourcePost: ReaderPost) = ReaderPostActions.requestRelatedPosts(sourcePost)
@@ -36,6 +46,6 @@ class ReaderPostActionsWrapper @Inject constructor(private val siteStore: SiteSt
fun requestBlogPost(
blogId: Long,
postId: Long,
- requestListener: ReaderActions.OnRequestListener
+ requestListener: ReaderActions.OnRequestListener?
) = ReaderPostActions.requestBlogPost(blogId, postId, requestListener)
}
diff --git a/WordPress/src/main/java/org/wordpress/android/widgets/BadgedImageView.kt b/WordPress/src/main/java/org/wordpress/android/widgets/BadgedImageView.kt
deleted file mode 100644
index 524c6619d9c0..000000000000
--- a/WordPress/src/main/java/org/wordpress/android/widgets/BadgedImageView.kt
+++ /dev/null
@@ -1,205 +0,0 @@
-package org.wordpress.android.widgets
-
-import android.content.Context
-import android.graphics.Bitmap
-import android.graphics.Bitmap.Config.ARGB_8888
-import android.graphics.Canvas
-import android.graphics.Color
-import android.graphics.Paint
-import android.graphics.Paint.ANTI_ALIAS_FLAG
-import android.graphics.Paint.Style
-import android.graphics.PorterDuff.Mode.CLEAR
-import android.graphics.PorterDuffXfermode
-import android.graphics.drawable.Drawable
-import android.util.AttributeSet
-import androidx.annotation.DrawableRes
-import androidx.appcompat.widget.AppCompatImageView
-import org.wordpress.android.R
-import org.wordpress.android.util.DisplayUtils
-
-/**
- * A ImageView that can draw a badge at the corner of its view.
- * The main difference between this implementation and others commonly found online, is that this one uses
- * Porter/Duff Compositing to create a transparent space between the badge background and the view.
- */
-class BadgedImageView @JvmOverloads constructor(
- context: Context,
- attrs: AttributeSet? = null,
- defStyleAttr: Int = 0
-) : AppCompatImageView(context, attrs, defStyleAttr) {
- companion object {
- const val DEFAULT_BADGE_BACKGROUND_SIZE = 16f
- const val DEFAULT_BADGE_BACKGROUND_BORDER_WIDTH = 0f
- const val DEFAULT_BADGE_ICON_SIZE = 16f
- const val DEFAULT_BADGE_HORIZONTAL_OFFSET = 0f
- const val DEFAULT_BADGE_VERTICAL_OFFSET = 0f
- }
-
- var badgeBackground: Drawable? = null
- set(value) {
- field = value
- invalidate()
- }
- var badgeBackgroundSize: Float = 0f
- set(value) {
- field = value
- invalidate()
- }
- var badgeBackgroundBorderWidth: Float = 0f
- set(value) {
- field = value
- invalidate()
- }
- var badgeIcon: Drawable? = null
- set(value) {
- field = value
- invalidate()
- }
- var badgeIconSize: Float = 0f
- set(value) {
- field = value
- invalidate()
- }
- var badgeHorizontalOffset: Float = 0f
- set(value) {
- field = value
- invalidate()
- }
- var badgeVerticalOffset: Float = 0f
- set(value) {
- field = value
- invalidate()
- }
-
- init {
- val styledAttributes = context.obtainStyledAttributes(attrs, R.styleable.BadgedImageView)
-
- badgeBackground = styledAttributes.getDrawable(
- R.styleable.BadgedImageView_badgeBackground
- )
-
- badgeBackgroundSize = styledAttributes.getDimension(
- R.styleable.BadgedImageView_badgeBackgroundSize,
- DisplayUtils.dpToPx(context, DEFAULT_BADGE_BACKGROUND_SIZE.toInt()).toFloat()
- )
-
- badgeBackgroundBorderWidth = styledAttributes.getDimension(
- R.styleable.BadgedImageView_badgeBackgroundBorderWidth,
- DisplayUtils.dpToPx(context, DEFAULT_BADGE_BACKGROUND_BORDER_WIDTH.toInt()).toFloat()
- )
-
- badgeIcon = styledAttributes.getDrawable(
- R.styleable.BadgedImageView_badgeIcon
- )
-
- badgeIconSize = styledAttributes.getDimension(
- R.styleable.BadgedImageView_badgeIconSize,
- DisplayUtils.dpToPx(context, DEFAULT_BADGE_ICON_SIZE.toInt()).toFloat()
- )
-
- badgeHorizontalOffset = styledAttributes.getDimension(
- R.styleable.BadgedImageView_badgeHorizontalOffset,
- DisplayUtils.dpToPx(context, DEFAULT_BADGE_HORIZONTAL_OFFSET.toInt()).toFloat()
- )
-
- badgeVerticalOffset = styledAttributes.getDimension(
- R.styleable.BadgedImageView_badgeVerticalOffset,
- DisplayUtils.dpToPx(context, DEFAULT_BADGE_VERTICAL_OFFSET.toInt()).toFloat()
- )
-
- styledAttributes.recycle()
- }
-
- private val paint = Paint(ANTI_ALIAS_FLAG)
- private val eraserPaint = Paint(ANTI_ALIAS_FLAG).apply {
- color = Color.TRANSPARENT
- style = Style.FILL_AND_STROKE
- strokeWidth = badgeBackgroundBorderWidth
- xfermode = PorterDuffXfermode(CLEAR)
- }
-
- private var tempCanvasBitmap: Bitmap? = null
- private var tempCanvas: Canvas? = null
- private var invalidated = true
-
- fun setBadgeBackground(@DrawableRes badgeBackgroundResId: Int) {
- badgeBackground = context.getDrawable(badgeBackgroundResId)
- }
-
- fun setBadgeIcon(@DrawableRes badgeIconResId: Int) {
- badgeIcon = context.getDrawable(badgeIconResId)
- }
-
- override fun invalidate() {
- invalidated = true
- super.invalidate()
- }
-
- override fun onSizeChanged(width: Int, height: Int, oldWidht: Int, oldHeight: Int) {
- super.onSizeChanged(width, height, oldWidht, oldHeight)
- val sizeChanged = width != oldWidht || height != oldHeight
- val isValid = width > 0 && height > 0
-
- if (isValid && (tempCanvas == null || sizeChanged)) {
- tempCanvasBitmap = Bitmap.createBitmap(
- width + badgeBackgroundSize.toInt() / 2,
- height + badgeBackgroundSize.toInt() / 2,
- ARGB_8888
- )
- tempCanvas = tempCanvasBitmap?.let { Canvas(it) }
- invalidated = true
- }
- }
-
- override fun onDraw(canvas: Canvas) {
- if (invalidated) {
- tempCanvas?.let {
- clearCanvas(it)
- super.onDraw(it)
- drawBadge(it)
- }
-
- invalidated = false
- }
- if (!invalidated) {
- tempCanvasBitmap?.let { canvas.drawBitmap(it, 0f, 0f, paint) }
- }
- }
-
- private fun clearCanvas(canvas: Canvas) {
- canvas.drawColor(Color.TRANSPARENT, CLEAR)
- }
-
- private fun drawBadge(canvas: Canvas) {
- val x = pivotX + width / 2f - badgeBackgroundSize / 2f + badgeHorizontalOffset
- val y = pivotY + height / 2f - badgeBackgroundSize / 2f + badgeVerticalOffset
-
- drawBadgeSpace(canvas, x, y)
- drawBadgeBackground(canvas, x, y)
- drawBadgeIcon(canvas, x, y)
- }
-
- private fun drawBadgeSpace(canvas: Canvas, x: Float, y: Float) {
- canvas.drawCircle(x, y, badgeBackgroundSize / 2f + badgeBackgroundBorderWidth, eraserPaint)
- }
-
- private fun drawBadgeBackground(canvas: Canvas, x: Float, y: Float) {
- if (badgeBackground != null) {
- badgeBackground?.setBounds(0, 0, badgeBackgroundSize.toInt(), badgeBackgroundSize.toInt())
- canvas.save()
- canvas.translate(x - badgeBackgroundSize / 2f, y - badgeBackgroundSize / 2f)
- badgeBackground?.draw(canvas)
- canvas.restore()
- }
- }
-
- private fun drawBadgeIcon(canvas: Canvas, x: Float, y: Float) {
- if (badgeIcon != null) {
- badgeIcon?.setBounds(0, 0, badgeIconSize.toInt(), badgeIconSize.toInt())
- canvas.save()
- canvas.translate(x - badgeIconSize / 2f, y - badgeIconSize / 2f)
- badgeIcon?.draw(canvas)
- canvas.restore()
- }
- }
-}
diff --git a/WordPress/src/main/res/drawable/bg_note_avatar_badge.xml b/WordPress/src/main/res/drawable/bg_note_avatar_badge.xml
deleted file mode 100644
index 11123c08e037..000000000000
--- a/WordPress/src/main/res/drawable/bg_note_avatar_badge.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
diff --git a/WordPress/src/main/res/drawable/block_share.xml b/WordPress/src/main/res/drawable/block_share.xml
new file mode 100644
index 000000000000..7cd1f17043f0
--- /dev/null
+++ b/WordPress/src/main/res/drawable/block_share.xml
@@ -0,0 +1,15 @@
+
+
+
+
diff --git a/WordPress/src/main/res/drawable/ic_cart_white_24dp.xml b/WordPress/src/main/res/drawable/ic_cart_white_24dp.xml
deleted file mode 100644
index 6d19749fc390..000000000000
--- a/WordPress/src/main/res/drawable/ic_cart_white_24dp.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
diff --git a/WordPress/src/main/res/drawable/ic_info_white_24dp.xml b/WordPress/src/main/res/drawable/ic_info_white_24dp.xml
deleted file mode 100644
index 0195e4a5dc59..000000000000
--- a/WordPress/src/main/res/drawable/ic_info_white_24dp.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
diff --git a/WordPress/src/main/res/drawable/ic_mention_white_24dp.xml b/WordPress/src/main/res/drawable/ic_mention_white_24dp.xml
deleted file mode 100644
index 17c26bdc1217..000000000000
--- a/WordPress/src/main/res/drawable/ic_mention_white_24dp.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
diff --git a/WordPress/src/main/res/drawable/bg_oval_warning_dark.xml b/WordPress/src/main/res/drawable/ic_notification_unread.xml
similarity index 71%
rename from WordPress/src/main/res/drawable/bg_oval_warning_dark.xml
rename to WordPress/src/main/res/drawable/ic_notification_unread.xml
index a9dc70ac3408..66b24b5d4542 100644
--- a/WordPress/src/main/res/drawable/bg_oval_warning_dark.xml
+++ b/WordPress/src/main/res/drawable/ic_notification_unread.xml
@@ -1,5 +1,5 @@
-
+
diff --git a/WordPress/src/main/res/drawable/star_empty.xml b/WordPress/src/main/res/drawable/star_empty.xml
new file mode 100644
index 000000000000..0cc3f17f470e
--- /dev/null
+++ b/WordPress/src/main/res/drawable/star_empty.xml
@@ -0,0 +1,11 @@
+
+
+
diff --git a/WordPress/src/main/res/drawable/star_filled.xml b/WordPress/src/main/res/drawable/star_filled.xml
new file mode 100644
index 000000000000..84ae3d2e18fd
--- /dev/null
+++ b/WordPress/src/main/res/drawable/star_filled.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/WordPress/src/main/res/layout/comment_detail_fragment.xml b/WordPress/src/main/res/layout/comment_detail_fragment.xml
index a46b6c529176..e7f39c3f815b 100644
--- a/WordPress/src/main/res/layout/comment_detail_fragment.xml
+++ b/WordPress/src/main/res/layout/comment_detail_fragment.xml
@@ -109,7 +109,7 @@
android:id="@+id/text_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:fontFamily="serif"
+ android:fontFamily="sans-serif"
android:paddingStart="@dimen/margin_extra_large"
android:paddingTop="@dimen/margin_large"
android:paddingEnd="@dimen/margin_extra_large"
diff --git a/WordPress/src/main/res/layout/note_block_comment_user.xml b/WordPress/src/main/res/layout/note_block_comment_user.xml
index 310b4b7ccde8..8b24a62ee478 100644
--- a/WordPress/src/main/res/layout/note_block_comment_user.xml
+++ b/WordPress/src/main/res/layout/note_block_comment_user.xml
@@ -100,7 +100,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_medium"
android:background="?android:selectableItemBackground"
- android:fontFamily="serif"
+ android:fontFamily="sans-serif"
android:paddingBottom="@dimen/margin_medium"
android:textAppearance="?attr/textAppearanceBody1"
tools:text="Thanks for stopping by my blog! I hope to see you again. " />
diff --git a/WordPress/src/main/res/layout/notification_action_menu.xml b/WordPress/src/main/res/layout/notification_action_menu.xml
new file mode 100644
index 000000000000..165f25309025
--- /dev/null
+++ b/WordPress/src/main/res/layout/notification_action_menu.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/WordPress/src/main/res/layout/notification_actions.xml b/WordPress/src/main/res/layout/notification_actions.xml
new file mode 100644
index 000000000000..d9beae2543ea
--- /dev/null
+++ b/WordPress/src/main/res/layout/notification_actions.xml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/WordPress/src/main/res/layout/notifications_list_double_avatar.xml b/WordPress/src/main/res/layout/notifications_list_double_avatar.xml
new file mode 100644
index 000000000000..47154885bbb9
--- /dev/null
+++ b/WordPress/src/main/res/layout/notifications_list_double_avatar.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
diff --git a/WordPress/src/main/res/layout/notifications_list_item.xml b/WordPress/src/main/res/layout/notifications_list_item.xml
index 4f2a38b62c9e..640df7fde81b 100644
--- a/WordPress/src/main/res/layout/notifications_list_item.xml
+++ b/WordPress/src/main/res/layout/notifications_list_item.xml
@@ -1,114 +1,129 @@
-
+ android:layout_height="wrap_content">
-
-
-
+ android:layout_height="wrap_content"
+ android:layout_marginTop="@dimen/margin_small"
+ android:background="?android:selectableItemBackground"
+ android:paddingVertical="@dimen/notifications_item_vertical_padding"
+ app:layout_constraintTop_toBottomOf="@+id/header_text"
+ app:layout_goneMarginTop="@dimen/margin_none">
-
+
-
+
-
+
-
+
-
+
-
+
-
+
+
+ android:textAlignment="viewStart"
+ android:textAppearance="@style/WordPress.TextAppearance.NotificationItemTitle"
+ tools:text="Bob Ross commented on your post Happy Trees" />
+
-
-
-
-
-
-
+
+
+
diff --git a/WordPress/src/main/res/layout/notifications_list_triple_avatar.xml b/WordPress/src/main/res/layout/notifications_list_triple_avatar.xml
new file mode 100644
index 000000000000..f26edc3d3943
--- /dev/null
+++ b/WordPress/src/main/res/layout/notifications_list_triple_avatar.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
diff --git a/WordPress/src/main/res/menu/notifications_list_menu.xml b/WordPress/src/main/res/menu/notifications_list_menu.xml
index d721396ead78..7f226358f32c 100644
--- a/WordPress/src/main/res/menu/notifications_list_menu.xml
+++ b/WordPress/src/main/res/menu/notifications_list_menu.xml
@@ -1,11 +1,9 @@
diff --git a/WordPress/src/main/res/values-night/colors.xml b/WordPress/src/main/res/values-night/colors.xml
index ff2b633e7bde..3e7342ee456f 100644
--- a/WordPress/src/main/res/values-night/colors.xml
+++ b/WordPress/src/main/res/values-night/colors.xml
@@ -115,8 +115,10 @@
@color/black
@color/white
+
+ @color/jetpack_green_30
+
#1C1C1E
#2C2C2E
-
diff --git a/WordPress/src/main/res/values-night/styles.xml b/WordPress/src/main/res/values-night/styles.xml
index 54e8280b64c1..87d5fc60b125 100644
--- a/WordPress/src/main/res/values-night/styles.xml
+++ b/WordPress/src/main/res/values-night/styles.xml
@@ -29,6 +29,8 @@
- ?attr/colorOnSurface
- @color/background_dark_elevated
- ?attr/colorSurface
+ - ?attr/wpColorOnSurfaceHigh
+ - @color/text_secondary
- @color/gravatar_info_banner
- @color/gravatar_sync_info_banner
diff --git a/WordPress/src/main/res/values/attrs.xml b/WordPress/src/main/res/values/attrs.xml
index 4c496caa5e9c..1ea27ccdd485 100644
--- a/WordPress/src/main/res/values/attrs.xml
+++ b/WordPress/src/main/res/values/attrs.xml
@@ -10,6 +10,8 @@
+
+
diff --git a/WordPress/src/main/res/values/colors.xml b/WordPress/src/main/res/values/colors.xml
index 4314ae121e4c..c75fad414336 100644
--- a/WordPress/src/main/res/values/colors.xml
+++ b/WordPress/src/main/res/values/colors.xml
@@ -150,8 +150,12 @@
#DEDEDE
+ #8A8A8E
+
+
+ @color/jetpack_green_50
+
#F2F2F7
#2C2C2E
-
diff --git a/WordPress/src/main/res/values/dimens.xml b/WordPress/src/main/res/values/dimens.xml
index f67241180b4f..e3ac76c2201e 100644
--- a/WordPress/src/main/res/values/dimens.xml
+++ b/WordPress/src/main/res/values/dimens.xml
@@ -168,6 +168,7 @@
10sp
12sp
14sp
+ 15sp
16sp
18sp
20sp
@@ -182,6 +183,7 @@
64dp
72dp
72dp
+ 28dp
26dp
22dp
1dp
@@ -264,19 +266,27 @@
72dp
- 16dp
6dp
240dp
10dp
0dp
48dp
+ 20dp
+ 17dp
22dp
- 4dp
- 2dp
- 12dp
14dp
6dp
- 1dp
+ 8dp
+ 12dp
+ 18dp
+ 12dp
+ 28dp
+ 16dp
+ 4dp
+ 4dp
+ 40dp
+ 44dp
+ 48dp
3dp
5dp
diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml
index dc4df0c3b0cc..07623a4c35c9 100644
--- a/WordPress/src/main/res/values/strings.xml
+++ b/WordPress/src/main/res/values/strings.xml
@@ -1511,6 +1511,7 @@
Comments
Follows
Likes
+ Unread
Fix
@@ -1577,6 +1578,8 @@
To see notifications on notifications tab for this site, turn Notifications for this site on.
Turning Notifications for this site off will disable notifications display on notifications tab for this site. You can fine-tune which kind of notification you see after turning Notifications for this site on.
+ Mark all as read
+
On
Off
diff --git a/WordPress/src/main/res/values/styles.xml b/WordPress/src/main/res/values/styles.xml
index de43d8f78c93..e422dcdc1729 100644
--- a/WordPress/src/main/res/values/styles.xml
+++ b/WordPress/src/main/res/values/styles.xml
@@ -48,6 +48,8 @@
- ?attr/colorOnSurface
- @color/neutral_5
- @color/blue_0
+ - ?attr/wpColorOnSurfaceHigh
+ - @color/text_secondary
- @color/gravatar_info_banner
- @color/gravatar_sync_info_banner
@@ -630,6 +632,28 @@
- @anim/activity_slide_out_to_right
+
+
+
+
+
+
+
+