diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java index 7d8122f218d..5bb8a029f02 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java @@ -136,6 +136,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind private View uncheckedView; private View checkedView; private View unreadMentions; + private View unreadReactions; private View pinnedView; private int thumbSize; private GlideLiveDataTarget thumbTarget; @@ -173,6 +174,7 @@ protected void onFinishInflate() { this.uncheckedView = findViewById(R.id.conversation_list_item_unchecked); this.checkedView = findViewById(R.id.conversation_list_item_checked); this.unreadMentions = findViewById(R.id.conversation_list_item_unread_mentions_indicator); + this.unreadReactions = findViewById(R.id.conversation_list_item_unread_reactions_indicator); this.thumbSize = (int) DimensionUnit.SP.toPixels(16f); this.thumbTarget = new GlideLiveDataTarget(thumbSize, thumbSize); this.searchStyleFactory = () -> new CharacterStyle[] { new ForegroundColorSpan(ContextCompat.getColor(getContext(), org.signal.core.ui.R.color.signal_colorOnSurface)), SpanUtil.getBoldSpan() }; @@ -333,6 +335,7 @@ public void bindMessage(@NonNull LifecycleOwner lifecycleOwner, archivedView.setVisibility(GONE); unreadIndicator.setVisibility(GONE); unreadMentions.setVisibility(GONE); + unreadReactions.setVisibility(GONE); deliveryStatusIndicator.setNone(); alertView.setNone(); @@ -373,6 +376,7 @@ public void bindGroupWithMembers(@NonNull LifecycleOwner lifecycleOwner, archivedView.setVisibility(GONE); unreadIndicator.setVisibility(GONE); unreadMentions.setVisibility(GONE); + unreadReactions.setVisibility(GONE); deliveryStatusIndicator.setNone(); alertView.setNone(); @@ -561,15 +565,30 @@ private void setUnreadIndicator(ThreadRecord thread) { if (thread.isRead()) { unreadIndicator.setVisibility(View.GONE); unreadMentions.setVisibility(View.GONE); + unreadReactions.setVisibility(View.GONE); return; } if (thread.getUnreadSelfMentionsCount() > 0) { + // we have mentions, show those and unread indicator if there are multiple unread messages unreadMentions.setVisibility(View.VISIBLE); unreadIndicator.setVisibility(thread.getUnreadCount() == 1 ? View.GONE : View.VISIBLE); + unreadReactions.setVisibility(View.GONE); } else { + // no mentions, hide the mention indicator unreadMentions.setVisibility(View.GONE); - unreadIndicator.setVisibility(View.VISIBLE); + // check for unread messages and reactions separately since either could cause + // the thread to be unread + if (thread.getUnreadCount() > 0) { + unreadIndicator.setVisibility(View.VISIBLE); + } else { + unreadIndicator.setVisibility(View.GONE); + } + if (thread.getUnreadReactionToSelfCount() > 0) { + unreadReactions.setVisibility(View.VISIBLE); + } else { + unreadReactions.setVisibility(View.GONE); + } } unreadIndicator.setText(unreadCount > 0 ? String.valueOf(unreadCount) : " "); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index d329bd1e2ca..a271ef526fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -975,7 +975,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat ) val messageId = MessageId(db.insert(TABLE_NAME, null, values)) - threads.incrementUnread(threadId, 1, 0) + threads.incrementUnread(threadId, 1, 0, 0) threads.update(threadId, true) messageId @@ -2631,6 +2631,15 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat .readToSingleInt() } + fun getUnreadReactionsToSelfCount(threadId: Long): Int { + return readableDatabase + .count() + .from(TABLE_NAME) + .where("$THREAD_ID = ? AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $ORIGINAL_MESSAGE_ID IS NULL AND $SCHEDULED_DATE = -1 AND ($outgoingTypeClause) AND $REACTIONS_UNREAD = 1", threadId) + .run() + .readToSingleInt() + } + /** * Trims data related to expired messages. Only intended to be run after a backup restore. */ @@ -3033,7 +3042,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat editedMessage == null ) { val incrementUnreadMentions = retrieved.mentions.isNotEmpty() && retrieved.mentions.any { it.recipientId == Recipient.self().id } - threads.incrementUnread(threadId, 1, if (incrementUnreadMentions) 1 else 0) + threads.incrementUnread(threadId, 1, if (incrementUnreadMentions) 1 else 0, 0) ThreadUpdateJob.enqueue(threadId, true) } @@ -3080,7 +3089,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat ) .run() - threads.incrementUnread(threadId, 1, 0) + threads.incrementUnread(threadId, 1, 0, 0) threads.update(threadId, true) notifyConversationListeners(threadId) @@ -3109,7 +3118,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat ) .run() - threads.incrementUnread(threadId, 1, 0) + threads.incrementUnread(threadId, 1, 0, 0) threads.update(threadId, true) notifyConversationListeners(threadId) @@ -5678,12 +5687,24 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat fun updateReactionsUnread(db: SQLiteDatabase, messageId: Long, hasReactions: Boolean, isRemoval: Boolean) { try { - val isOutgoing = getMessageRecord(messageId).isOutgoing + val messageRecord = getMessageRecord(messageId) + val isOutgoing = messageRecord.isOutgoing + val threadId = messageRecord.threadId + val hasUnreadReactions = readableDatabase + .select(REACTIONS_UNREAD) + .from(TABLE_NAME) + .where("$ID = ?", messageId) + .run() + .readToSingleBoolean() val values = ContentValues() if (!hasReactions) { values.put(REACTIONS_UNREAD, 0) } else if (!isRemoval) { + // increment unread reactions to self only on first reaction + if (!hasUnreadReactions && isOutgoing) { + threads.incrementUnread(threadId, 0, 0, 1) + } values.put(REACTIONS_UNREAD, 1) } @@ -5697,6 +5718,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat .where("$ID = ?", messageId) .run() } + + ThreadUpdateJob.enqueue(threadId, false) + notifyConversationListeners(threadId) + notifyConversationListListeners() } catch (e: NoSuchMessageException) { Log.w(TAG, "Failed to find message $messageId") } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt index 608c196da94..e39a829ade8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt @@ -120,6 +120,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa const val LAST_SCROLLED = "last_scrolled" const val PINNED_ORDER = "pinned_order" const val UNREAD_SELF_MENTION_COUNT = "unread_self_mention_count" + const val UNREAD_REACTION_TO_SELF_COUNT = "unread_reaction_to_self_count" const val ACTIVE = "active" const val MAX_CACHE_SIZE = 1000 @@ -150,6 +151,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa $LAST_SCROLLED INTEGER DEFAULT 0, $PINNED_ORDER INTEGER UNIQUE DEFAULT NULL, $UNREAD_SELF_MENTION_COUNT INTEGER DEFAULT 0, + $UNREAD_REACTION_TO_SELF_COUNT INTEGER DEFAULT 0, $ACTIVE INTEGER DEFAULT 0, $SNIPPET_MESSAGE_EXTRAS BLOB DEFAULT NULL, $SNIPPET_MESSAGE_ID INTEGER DEFAULT 0 @@ -188,7 +190,8 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa HAS_READ_RECEIPT, LAST_SCROLLED, PINNED_ORDER, - UNREAD_SELF_MENTION_COUNT + UNREAD_SELF_MENTION_COUNT, + UNREAD_REACTION_TO_SELF_COUNT ) private val TYPED_THREAD_PROJECTION: List = THREAD_PROJECTION @@ -241,6 +244,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa readReceiptCount: Int, unreadCount: Int, unreadMentionCount: Int, + unreadReactionToSelfCount: Int, messageExtras: MessageExtras? ) { var extraSerialized: String? = null @@ -268,6 +272,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa ACTIVE to 1, UNREAD_COUNT to unreadCount, UNREAD_SELF_MENTION_COUNT to unreadMentionCount, + UNREAD_REACTION_TO_SELF_COUNT to unreadReactionToSelfCount, SNIPPET_MESSAGE_EXTRAS to messageExtras?.encode(), SNIPPET_MESSAGE_ID to messageId ) @@ -474,7 +479,8 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa .values( READ to ReadStatus.READ.serialize(), UNREAD_COUNT to 0, - UNREAD_SELF_MENTION_COUNT to 0 + UNREAD_SELF_MENTION_COUNT to 0, + UNREAD_REACTION_TO_SELF_COUNT to 0 ) .run() @@ -564,12 +570,14 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa val unreadCount = messages.getUnreadCount(threadId) val unreadMentionsCount = messages.getUnreadMentionCount(threadId) + val unreadReactionToSelfCount = messages.getUnreadReactionsToSelfCount(threadId) val lastSeenTimestamp = messages.getMostRecentReadMessageDateReceived(threadId) ?: System.currentTimeMillis() val contentValues = contentValuesOf( READ to ReadStatus.READ.serialize(), UNREAD_COUNT to unreadCount, UNREAD_SELF_MENTION_COUNT to unreadMentionsCount, + UNREAD_REACTION_TO_SELF_COUNT to unreadReactionToSelfCount, LAST_SEEN to lastSeenTimestamp ) @@ -771,17 +779,18 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa .use(mapCursorToType) } - fun incrementUnread(threadId: Long, unreadAmount: Int, unreadSelfMentionAmount: Int) { + fun incrementUnread(threadId: Long, unreadAmount: Int, unreadSelfMentionAmount: Int, unreadReactionToSelfAmount: Int) { writableDatabase.execSQL( """ UPDATE $TABLE_NAME SET $READ = ${ReadStatus.UNREAD.serialize()}, $UNREAD_COUNT = $UNREAD_COUNT + ?, $UNREAD_SELF_MENTION_COUNT = $UNREAD_SELF_MENTION_COUNT + ?, + $UNREAD_REACTION_TO_SELF_COUNT = $UNREAD_REACTION_TO_SELF_COUNT + ?, $LAST_SCROLLED = ? WHERE $ID = ? """, - SqlUtil.buildArgs(unreadAmount, unreadSelfMentionAmount, 0, threadId) + SqlUtil.buildArgs(unreadAmount, unreadSelfMentionAmount, unreadReactionToSelfAmount, 0, threadId) ) } @@ -1561,13 +1570,15 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa val previous = getThreadRecord(threadId) val unreadCount = messages.getUnreadCount(threadId) val unreadMentionsCount = messages.getUnreadMentionCount(threadId) + val unreadReactionToSelfCount = messages.getUnreadReactionsToSelfCount(threadId) writableDatabase .update(TABLE_NAME) .values( READ to if (unreadCount == 0) ReadStatus.READ.serialize() else ReadStatus.UNREAD.serialize(), UNREAD_COUNT to unreadCount, - UNREAD_SELF_MENTION_COUNT to unreadMentionsCount + UNREAD_SELF_MENTION_COUNT to unreadMentionsCount, + UNREAD_REACTION_TO_SELF_COUNT to unreadReactionToSelfCount ) .where("$ID = ?", threadId) .run() @@ -1656,10 +1667,12 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa } else if (threadId != null) { val unreadCount = messages.getUnreadCount(threadId) val unreadMentionsCount = messages.getUnreadMentionCount(threadId) + val unreadReactionToSelfCount = messages.getUnreadReactionsToSelfCount(threadId) values.put(READ, if (unreadCount == 0) ReadStatus.READ.serialize() else ReadStatus.UNREAD.serialize()) values.put(UNREAD_COUNT, unreadCount) values.put(UNREAD_SELF_MENTION_COUNT, unreadMentionsCount) + values.put(UNREAD_REACTION_TO_SELF_COUNT, unreadReactionToSelfCount) } writableDatabase @@ -1806,6 +1819,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa readReceiptCount = 0, unreadCount = 0, unreadMentionCount = 0, + unreadReactionToSelfCount = 0, messageExtras = null ) } @@ -1819,6 +1833,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa val threadBody: ThreadBody = ThreadBodyUtil.getFormattedBodyFor(context, record) val unreadCount: Int = messages.getUnreadCount(threadId) val unreadMentionCount: Int = messages.getUnreadMentionCount(threadId) + val unreadReactionToSelfCount: Int = messages.getUnreadReactionsToSelfCount(threadId) updateThread( threadId = threadId, @@ -1837,6 +1852,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa readReceiptCount = record.hasReadReceipt().toInt(), unreadCount = unreadCount, unreadMentionCount = unreadMentionCount, + unreadReactionToSelfCount = unreadReactionToSelfCount, messageExtras = record.messageExtras ) @@ -2017,6 +2033,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa LAST_SCROLLED to 0, PINNED_ORDER to null, UNREAD_SELF_MENTION_COUNT to 0, + UNREAD_REACTION_TO_SELF_COUNT to 0, ACTIVE to 0 ) @@ -2320,6 +2337,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa .setForcedUnread(cursor.requireInt(READ) == ReadStatus.FORCED_UNREAD.serialize()) .setPinned(cursor.requireBoolean(PINNED_ORDER)) .setUnreadSelfMentionsCount(cursor.requireInt(UNREAD_SELF_MENTION_COUNT)) + .setUnreadReactionToSelfCount(cursor.requireInt(UNREAD_REACTION_TO_SELF_COUNT)) .setExtra(extra) .setSnippetMessageExtras(messageExtras) .build() diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index 360992258e3..c10df0aefba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -161,6 +161,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V304_CallAndReplyNo import org.thoughtcrime.securesms.database.helpers.migration.V305_AddStoryArchivedColumn import org.thoughtcrime.securesms.database.helpers.migration.V306_AddRemoteDeletedColumn import org.thoughtcrime.securesms.database.helpers.migration.V308_AddBackRemoteDeletedColumn +import org.thoughtcrime.securesms.database.helpers.migration.V309_ThreadUnreadReactionToSelfCount import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase /** @@ -329,10 +330,11 @@ object SignalDatabaseMigrations { 305 to V305_AddStoryArchivedColumn, 306 to V306_AddRemoteDeletedColumn, // 307 to V307_RemoveRemoteDeletedColumn - Removed due to unsolvable OOM crashes. [TODO]: Attempt to fix in the future - 308 to V308_AddBackRemoteDeletedColumn + 308 to V308_AddBackRemoteDeletedColumn, + 309 to V309_ThreadUnreadReactionToSelfCount ) - const val DATABASE_VERSION = 308 + const val DATABASE_VERSION = 309 @JvmStatic fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V309_ThreadUnreadReactionToSelfCount.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V309_ThreadUnreadReactionToSelfCount.kt new file mode 100644 index 00000000000..266dc7e6279 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V309_ThreadUnreadReactionToSelfCount.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.database.helpers.migration + +import android.app.Application +import org.thoughtcrime.securesms.database.SQLiteDatabase + +@Suppress("ClassName") +object V309_ThreadUnreadReactionToSelfCount : SignalDatabaseMigration { + override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + db.execSQL("ALTER TABLE thread ADD COLUMN unread_reaction_to_self_count INTEGER DEFAULT 0") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java index b01e05a79cf..f455a21b29d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java @@ -58,30 +58,32 @@ public final class ThreadRecord { private final long lastSeen; private final boolean isPinned; private final int unreadSelfMentionsCount; + private final int unreadReactionToSelfCount; private final MessageExtras messageExtras; private ThreadRecord(@NonNull Builder builder) { - this.threadId = builder.threadId; - this.body = builder.body; - this.recipient = builder.recipient; - this.date = builder.date; - this.type = builder.type; - this.deliveryStatus = builder.deliveryStatus; - this.hasDeliveryReceipt = builder.hasDeliveryReceipt; - this.hasReadReceipt = builder.hasReadReceipt; - this.snippetUri = builder.snippetUri; - this.contentType = builder.contentType; - this.extra = builder.extra; - this.meaningfulMessages = builder.meaningfulMessages; - this.unreadCount = builder.unreadCount; - this.forcedUnread = builder.forcedUnread; - this.distributionType = builder.distributionType; - this.archived = builder.archived; - this.expiresIn = builder.expiresIn; - this.lastSeen = builder.lastSeen; - this.isPinned = builder.isPinned; - this.unreadSelfMentionsCount = builder.unreadSelfMentionsCount; - this.messageExtras = builder.messageExtras; + this.threadId = builder.threadId; + this.body = builder.body; + this.recipient = builder.recipient; + this.date = builder.date; + this.type = builder.type; + this.deliveryStatus = builder.deliveryStatus; + this.hasDeliveryReceipt = builder.hasDeliveryReceipt; + this.hasReadReceipt = builder.hasReadReceipt; + this.snippetUri = builder.snippetUri; + this.contentType = builder.contentType; + this.extra = builder.extra; + this.meaningfulMessages = builder.meaningfulMessages; + this.unreadCount = builder.unreadCount; + this.forcedUnread = builder.forcedUnread; + this.distributionType = builder.distributionType; + this.archived = builder.archived; + this.expiresIn = builder.expiresIn; + this.lastSeen = builder.lastSeen; + this.isPinned = builder.isPinned; + this.unreadSelfMentionsCount = builder.unreadSelfMentionsCount; + this.unreadReactionToSelfCount = builder.unreadReactionToSelfCount; + this.messageExtras = builder.messageExtras; } public long getThreadId() { @@ -125,7 +127,7 @@ public boolean isForcedUnread() { } public boolean isRead() { - return unreadCount == 0 && !forcedUnread; + return unreadCount == 0 && unreadReactionToSelfCount == 0 && !forcedUnread; } public long getDate() { @@ -251,6 +253,10 @@ public int getUnreadSelfMentionsCount() { return unreadSelfMentionsCount; } + public int getUnreadReactionToSelfCount() { + return unreadReactionToSelfCount; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -273,6 +279,7 @@ public boolean equals(Object o) { body.equals(that.body) && recipient.equals(that.recipient) && unreadSelfMentionsCount == that.unreadSelfMentionsCount && + unreadReactionToSelfCount == that.unreadReactionToSelfCount && Objects.equals(snippetUri, that.snippetUri) && Objects.equals(contentType, that.contentType) && Objects.equals(extra, that.extra); @@ -299,7 +306,8 @@ public int hashCode() { expiresIn, lastSeen, isPinned, - unreadSelfMentionsCount); + unreadSelfMentionsCount, + unreadReactionToSelfCount); } public static class Builder { @@ -323,6 +331,7 @@ public static class Builder { private long lastSeen; private boolean isPinned; private int unreadSelfMentionsCount; + private int unreadReactionToSelfCount; private MessageExtras messageExtras; public Builder(long threadId) { @@ -434,6 +443,11 @@ public Builder setUnreadSelfMentionsCount(int unreadSelfMentionsCount) { return this; } + public Builder setUnreadReactionToSelfCount(int unreadReactionToSelfCount) { + this.unreadReactionToSelfCount = unreadReactionToSelfCount; + return this; + } + public ThreadRecord build() { if (distributionType == ThreadTable.DistributionTypes.CONVERSATION) { Preconditions.checkArgument(threadId > 0); diff --git a/app/src/main/res/drawable/ic_heart_12.xml b/app/src/main/res/drawable/ic_heart_12.xml new file mode 100644 index 00000000000..5867077d32e --- /dev/null +++ b/app/src/main/res/drawable/ic_heart_12.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/layout/conversation_list_item_view.xml b/app/src/main/res/layout/conversation_list_item_view.xml index d017670cd70..1f3da7e7260 100644 --- a/app/src/main/res/layout/conversation_list_item_view.xml +++ b/app/src/main/res/layout/conversation_list_item_view.xml @@ -133,8 +133,8 @@ android:id="@+id/conversation_list_item_status_container" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginStart="12dp" android:layout_marginTop="6dp" + android:layout_marginStart="12dp" android:gravity="center" android:orientation="horizontal" app:layout_constraintEnd_toEndOf="parent" @@ -162,6 +162,21 @@ app:tint="@color/signal_colorOnPrimary" tools:visibility="visible" /> + +