feat: add unread badge count for conversations (#560)

* feat: add unread badge count for conversations

* fix: mark all messages read on thread open

* fix: address some lint issues

* fix: mark as read/unread in the local db as well

* docs: update changelog

Refs: https://github.com/FossifyOrg/Messages/issues/264, https://github.com/FossifyOrg/Messages/issues/177
This commit is contained in:
Naveen Singh 2025-10-15 20:33:54 +05:30 committed by GitHub
parent 627f19471e
commit 0a66c46f0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 110 additions and 16 deletions

View file

@ -138,6 +138,7 @@ import org.fossify.messages.extensions.isGifMimeType
import org.fossify.messages.extensions.isImageMimeType
import org.fossify.messages.extensions.launchConversationDetails
import org.fossify.messages.extensions.markMessageRead
import org.fossify.messages.extensions.markThreadMessagesRead
import org.fossify.messages.extensions.markThreadMessagesUnread
import org.fossify.messages.extensions.messagesDB
import org.fossify.messages.extensions.moveMessageToRecycleBin
@ -302,6 +303,8 @@ class ThreadActivity : SimpleActivity() {
binding.messageHolder.threadTypeMessage.setSelection(smsDraft.length)
}
}
markThreadMessagesRead(threadId)
}
val bottomBarColor = getBottomBarColor()

View file

@ -6,6 +6,7 @@ import android.os.Parcelable
import android.util.TypedValue
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
@ -14,6 +15,7 @@ import org.fossify.commons.adapters.MyRecyclerViewListAdapter
import org.fossify.commons.extensions.applyColorFilter
import org.fossify.commons.extensions.beVisibleIf
import org.fossify.commons.extensions.formatDateOrTime
import org.fossify.commons.extensions.getContrastColor
import org.fossify.commons.extensions.getTextSize
import org.fossify.commons.extensions.setupViewBackground
import org.fossify.commons.helpers.SimpleContactsHelper
@ -171,21 +173,23 @@ abstract class BaseConversationsAdapter(
setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * 0.8f)
}
val style = if (conversation.read) {
conversationBodyShort.alpha = 0.7f
if (conversation.isScheduled) Typeface.ITALIC else Typeface.NORMAL
} else {
val isUnread = !conversation.read
val style = if (isUnread) {
conversationBodyShort.alpha = 1f
if (conversation.isScheduled) Typeface.BOLD_ITALIC else Typeface.BOLD
} else {
conversationBodyShort.alpha = 0.7f
if (conversation.isScheduled) Typeface.ITALIC else Typeface.NORMAL
}
conversationAddress.setTypeface(null, style)
conversationBodyShort.setTypeface(null, style)
conversationDate.setTypeface(null, style)
arrayListOf(conversationAddress, conversationBodyShort, conversationDate).forEach {
it.setTextColor(textColor)
}
setupBadgeCount(unreadCountBadge, isUnread, conversation.unreadCount)
// at group conversations we use an icon as the placeholder, not any letter
val placeholder = if (conversation.isGroupConversation) {
SimpleContactsHelper(activity).getColoredGroupIcon(conversation.title)
@ -202,6 +206,21 @@ abstract class BaseConversationsAdapter(
}
}
private fun setupBadgeCount(view: TextView, isUnread: Boolean, count: Int) {
view.apply {
beVisibleIf(isUnread)
if (isUnread) {
text = when {
count > MAX_UNREAD_BADGE_COUNT -> "$MAX_UNREAD_BADGE_COUNT+"
count == 0 -> ""
else -> count.toString()
}
setTextColor(properPrimaryColor.getContrastColor())
background?.applyColorFilter(properPrimaryColor)
}
}
}
override fun onChange(position: Int) = currentList.getOrNull(position)?.title ?: ""
private fun saveRecyclerViewState() {
@ -221,4 +240,8 @@ abstract class BaseConversationsAdapter(
return Conversation.areContentsTheSame(oldItem, newItem)
}
}
companion object {
private const val MAX_UNREAD_BADGE_COUNT = 99
}
}

View file

@ -1,3 +1,4 @@
@file:Suppress("MagicNumber")
package org.fossify.messages.databases
import android.content.Context
@ -29,7 +30,7 @@ import org.fossify.messages.models.RecycleBinMessage
RecycleBinMessage::class,
Draft::class
],
version = 9
version = 10
)
@TypeConverters(Converters::class)
abstract class MessagesDatabase : RoomDatabase() {
@ -65,6 +66,7 @@ abstract class MessagesDatabase : RoomDatabase() {
.addMigrations(MIGRATION_6_7)
.addMigrations(MIGRATION_7_8)
.addMigrations(MIGRATION_8_9)
.addMigrations(MIGRATION_9_10)
.build()
}
}
@ -154,5 +156,13 @@ abstract class MessagesDatabase : RoomDatabase() {
}
}
}
private val MIGRATION_9_10 = object : Migration(9, 10) {
override fun migrate(db: SupportSQLiteDatabase) {
db.apply {
execSQL("ALTER TABLE conversations ADD COLUMN unread_count INTEGER NOT NULL DEFAULT 0")
}
}
}
}
}

View file

@ -318,6 +318,34 @@ fun Context.getMMSSender(msgId: Long): String {
return ""
}
fun Context.getUnreadCountsByThread(): Map<Long, Int> {
val result = HashMap<Long, Int>(128)
fun bump(id: Long) {
result[id] = (result[id] ?: 0) + 1
}
// Unread SMS
queryCursor(
uri = Sms.CONTENT_URI,
projection = arrayOf(Sms.THREAD_ID),
selection = "${Sms.READ}=0 AND ${Sms.TYPE}=${Sms.MESSAGE_TYPE_INBOX}",
selectionArgs = null,
showErrors = false
) { bump(it.getLongValue(Sms.THREAD_ID)) }
// Unread MMS
queryCursor(
uri = Mms.CONTENT_URI,
projection = arrayOf(Mms.THREAD_ID),
selection = "${Mms.READ}=0 AND ${Mms.MESSAGE_BOX}=${Mms.MESSAGE_BOX_INBOX}",
selectionArgs = null,
showErrors = false
) { bump(it.getLongValue(Mms.THREAD_ID)) }
return result
}
fun Context.getConversations(
threadId: Long? = null,
privateContacts: ArrayList<SimpleContact> = ArrayList(),
@ -349,6 +377,7 @@ fun Context.getConversations(
val conversations = ArrayList<Conversation>()
val simpleContactHelper = SimpleContactsHelper(this)
val blockedNumbers = getBlockedNumbers()
val unreadMap = getUnreadCountsByThread()
try {
queryCursorUnsafe(
uri,
@ -397,6 +426,7 @@ fun Context.getConversations(
val read = cursor.getIntValue(Threads.READ) == 1
val archived =
if (archiveAvailable) cursor.getIntValue(Threads.ARCHIVED) == 1 else false
val unreadCount = if (!read) unreadMap[id] ?: 0 else 0
val conversation = Conversation(
threadId = id,
snippet = snippet,
@ -406,7 +436,8 @@ fun Context.getConversations(
photoUri = photoUri,
isGroupConversation = isGroupConversation,
phoneNumber = phoneNumbers.first(),
isArchived = archived
isArchived = archived,
unreadCount = unreadCount,
)
conversations.add(conversation)
}
@ -973,6 +1004,7 @@ fun Context.markThreadMessagesRead(threadId: Long) {
contentResolver.update(uri, contentValues, selection, selectionArgs)
}
messagesDB.markThreadRead(threadId)
conversationsDB.markRead(threadId)
}
fun Context.markThreadMessagesUnread(threadId: Long) {
@ -985,7 +1017,8 @@ fun Context.markThreadMessagesUnread(threadId: Long) {
val selectionArgs = arrayOf(threadId.toString())
contentResolver.update(uri, contentValues, selection, selectionArgs)
}
}
conversationsDB.markUnread(threadId)
}
@SuppressLint("NewApi")
fun Context.getThreadId(address: String): Long {
@ -1258,7 +1291,8 @@ fun Context.createTemporaryThread(
phoneNumber = addresses.first(),
isScheduled = true,
usesCustomTitle = cachedConv?.usesCustomTitle == true,
isArchived = false
isArchived = false,
unreadCount = 0,
)
try {
conversationsDB.insertOrUpdate(conversation)

View file

@ -17,7 +17,8 @@ data class Conversation(
@ColumnInfo(name = "phone_number") var phoneNumber: String,
@ColumnInfo(name = "is_scheduled") var isScheduled: Boolean = false,
@ColumnInfo(name = "uses_custom_title") var usesCustomTitle: Boolean = false,
@ColumnInfo(name = "archived") var isArchived: Boolean = false
@ColumnInfo(name = "archived") var isArchived: Boolean = false,
@ColumnInfo(name = "unread_count") var unreadCount: Int = 0,
) {
companion object {
@ -32,7 +33,8 @@ data class Conversation(
old.title == new.title &&
old.photoUri == new.photoUri &&
old.isGroupConversation == new.isGroupConversation &&
old.phoneNumber == new.phoneNumber
old.phoneNumber == new.phoneNumber &&
old.unreadCount == new.unreadCount
}
}
}

View file

@ -88,12 +88,28 @@
<ImageView
android:id="@+id/pin_indicator"
android:layout_width="@dimen/pin_icon_size"
android:layout_height="@dimen/pin_icon_size"
android:layout_width="@dimen/small_icon_size"
android:layout_height="@dimen/small_icon_size"
android:alpha="0.7"
android:padding="@dimen/tiny_margin"
android:src="@drawable/ic_pin_filled_vector"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/conversation_body_short"
app:layout_constraintEnd_toStartOf="@id/unread_count_badge"
app:layout_constraintTop_toTopOf="@id/conversation_body_short"
tools:visibility="visible" />
<TextView
android:id="@+id/unread_count_badge"
android:layout_width="@dimen/small_icon_size"
android:layout_height="@dimen/small_icon_size"
android:background="@drawable/circle_background"
android:gravity="center"
android:textSize="@dimen/list_tertiary_text_size"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/conversation_body_short"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/conversation_body_short" />
app:layout_constraintTop_toTopOf="@id/conversation_body_short"
tools:text="42"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>