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:
parent
627f19471e
commit
0a66c46f0d
8 changed files with 110 additions and 16 deletions
|
|
@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
### Added
|
||||||
|
- Unread badge count for conversations ([#177])
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Optimized loading messages in conversations
|
- Optimized loading messages in conversations
|
||||||
- Updated conversation item design to be more compact ([#376])
|
- Updated conversation item design to be more compact ([#376])
|
||||||
|
|
@ -13,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
- Fixed position reset when opening attachments in conversations ([#82])
|
- Fixed position reset when opening attachments in conversations ([#82])
|
||||||
- Fixed automatic scroll to searched message in conversations ([#350])
|
- Fixed automatic scroll to searched message in conversations ([#350])
|
||||||
- Fixed non-standard text and avatar sizes in list items
|
- Fixed non-standard text and avatar sizes in list items
|
||||||
|
- Fixed "Mark as read" not working in some cases ([#264])
|
||||||
|
|
||||||
## [1.4.0] - 2025-10-12
|
## [1.4.0] - 2025-10-12
|
||||||
### Added
|
### Added
|
||||||
|
|
@ -157,12 +161,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
[#135]: https://github.com/FossifyOrg/Messages/issues/135
|
[#135]: https://github.com/FossifyOrg/Messages/issues/135
|
||||||
[#153]: https://github.com/FossifyOrg/Messages/issues/153
|
[#153]: https://github.com/FossifyOrg/Messages/issues/153
|
||||||
[#165]: https://github.com/FossifyOrg/Messages/issues/165
|
[#165]: https://github.com/FossifyOrg/Messages/issues/165
|
||||||
|
[#177]: https://github.com/FossifyOrg/Messages/issues/177
|
||||||
[#180]: https://github.com/FossifyOrg/Messages/issues/180
|
[#180]: https://github.com/FossifyOrg/Messages/issues/180
|
||||||
[#209]: https://github.com/FossifyOrg/Messages/issues/209
|
[#209]: https://github.com/FossifyOrg/Messages/issues/209
|
||||||
[#217]: https://github.com/FossifyOrg/Messages/issues/217
|
[#217]: https://github.com/FossifyOrg/Messages/issues/217
|
||||||
[#225]: https://github.com/FossifyOrg/Messages/issues/225
|
[#225]: https://github.com/FossifyOrg/Messages/issues/225
|
||||||
[#243]: https://github.com/FossifyOrg/Messages/issues/243
|
[#243]: https://github.com/FossifyOrg/Messages/issues/243
|
||||||
[#262]: https://github.com/FossifyOrg/Messages/issues/262
|
[#262]: https://github.com/FossifyOrg/Messages/issues/262
|
||||||
|
[#264]: https://github.com/FossifyOrg/Messages/issues/264
|
||||||
[#274]: https://github.com/FossifyOrg/Messages/issues/274
|
[#274]: https://github.com/FossifyOrg/Messages/issues/274
|
||||||
[#279]: https://github.com/FossifyOrg/Messages/issues/279
|
[#279]: https://github.com/FossifyOrg/Messages/issues/279
|
||||||
[#287]: https://github.com/FossifyOrg/Messages/issues/287
|
[#287]: https://github.com/FossifyOrg/Messages/issues/287
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,7 @@ import org.fossify.messages.extensions.isGifMimeType
|
||||||
import org.fossify.messages.extensions.isImageMimeType
|
import org.fossify.messages.extensions.isImageMimeType
|
||||||
import org.fossify.messages.extensions.launchConversationDetails
|
import org.fossify.messages.extensions.launchConversationDetails
|
||||||
import org.fossify.messages.extensions.markMessageRead
|
import org.fossify.messages.extensions.markMessageRead
|
||||||
|
import org.fossify.messages.extensions.markThreadMessagesRead
|
||||||
import org.fossify.messages.extensions.markThreadMessagesUnread
|
import org.fossify.messages.extensions.markThreadMessagesUnread
|
||||||
import org.fossify.messages.extensions.messagesDB
|
import org.fossify.messages.extensions.messagesDB
|
||||||
import org.fossify.messages.extensions.moveMessageToRecycleBin
|
import org.fossify.messages.extensions.moveMessageToRecycleBin
|
||||||
|
|
@ -302,6 +303,8 @@ class ThreadActivity : SimpleActivity() {
|
||||||
binding.messageHolder.threadTypeMessage.setSelection(smsDraft.length)
|
binding.messageHolder.threadTypeMessage.setSelection(smsDraft.length)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
markThreadMessagesRead(threadId)
|
||||||
}
|
}
|
||||||
|
|
||||||
val bottomBarColor = getBottomBarColor()
|
val bottomBarColor = getBottomBarColor()
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import android.os.Parcelable
|
||||||
import android.util.TypedValue
|
import android.util.TypedValue
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.widget.TextView
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.bumptech.glide.Glide
|
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.applyColorFilter
|
||||||
import org.fossify.commons.extensions.beVisibleIf
|
import org.fossify.commons.extensions.beVisibleIf
|
||||||
import org.fossify.commons.extensions.formatDateOrTime
|
import org.fossify.commons.extensions.formatDateOrTime
|
||||||
|
import org.fossify.commons.extensions.getContrastColor
|
||||||
import org.fossify.commons.extensions.getTextSize
|
import org.fossify.commons.extensions.getTextSize
|
||||||
import org.fossify.commons.extensions.setupViewBackground
|
import org.fossify.commons.extensions.setupViewBackground
|
||||||
import org.fossify.commons.helpers.SimpleContactsHelper
|
import org.fossify.commons.helpers.SimpleContactsHelper
|
||||||
|
|
@ -171,21 +173,23 @@ abstract class BaseConversationsAdapter(
|
||||||
setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * 0.8f)
|
setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * 0.8f)
|
||||||
}
|
}
|
||||||
|
|
||||||
val style = if (conversation.read) {
|
val isUnread = !conversation.read
|
||||||
conversationBodyShort.alpha = 0.7f
|
val style = if (isUnread) {
|
||||||
if (conversation.isScheduled) Typeface.ITALIC else Typeface.NORMAL
|
|
||||||
} else {
|
|
||||||
conversationBodyShort.alpha = 1f
|
conversationBodyShort.alpha = 1f
|
||||||
if (conversation.isScheduled) Typeface.BOLD_ITALIC else Typeface.BOLD
|
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)
|
conversationAddress.setTypeface(null, style)
|
||||||
conversationBodyShort.setTypeface(null, style)
|
conversationBodyShort.setTypeface(null, style)
|
||||||
|
conversationDate.setTypeface(null, style)
|
||||||
|
|
||||||
arrayListOf(conversationAddress, conversationBodyShort, conversationDate).forEach {
|
arrayListOf(conversationAddress, conversationBodyShort, conversationDate).forEach {
|
||||||
it.setTextColor(textColor)
|
it.setTextColor(textColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupBadgeCount(unreadCountBadge, isUnread, conversation.unreadCount)
|
||||||
// at group conversations we use an icon as the placeholder, not any letter
|
// at group conversations we use an icon as the placeholder, not any letter
|
||||||
val placeholder = if (conversation.isGroupConversation) {
|
val placeholder = if (conversation.isGroupConversation) {
|
||||||
SimpleContactsHelper(activity).getColoredGroupIcon(conversation.title)
|
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 ?: ""
|
override fun onChange(position: Int) = currentList.getOrNull(position)?.title ?: ""
|
||||||
|
|
||||||
private fun saveRecyclerViewState() {
|
private fun saveRecyclerViewState() {
|
||||||
|
|
@ -221,4 +240,8 @@ abstract class BaseConversationsAdapter(
|
||||||
return Conversation.areContentsTheSame(oldItem, newItem)
|
return Conversation.areContentsTheSame(oldItem, newItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val MAX_UNREAD_BADGE_COUNT = 99
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
@file:Suppress("MagicNumber")
|
||||||
package org.fossify.messages.databases
|
package org.fossify.messages.databases
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
|
@ -29,7 +30,7 @@ import org.fossify.messages.models.RecycleBinMessage
|
||||||
RecycleBinMessage::class,
|
RecycleBinMessage::class,
|
||||||
Draft::class
|
Draft::class
|
||||||
],
|
],
|
||||||
version = 9
|
version = 10
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
abstract class MessagesDatabase : RoomDatabase() {
|
abstract class MessagesDatabase : RoomDatabase() {
|
||||||
|
|
@ -65,6 +66,7 @@ abstract class MessagesDatabase : RoomDatabase() {
|
||||||
.addMigrations(MIGRATION_6_7)
|
.addMigrations(MIGRATION_6_7)
|
||||||
.addMigrations(MIGRATION_7_8)
|
.addMigrations(MIGRATION_7_8)
|
||||||
.addMigrations(MIGRATION_8_9)
|
.addMigrations(MIGRATION_8_9)
|
||||||
|
.addMigrations(MIGRATION_9_10)
|
||||||
.build()
|
.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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -318,6 +318,34 @@ fun Context.getMMSSender(msgId: Long): String {
|
||||||
return ""
|
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(
|
fun Context.getConversations(
|
||||||
threadId: Long? = null,
|
threadId: Long? = null,
|
||||||
privateContacts: ArrayList<SimpleContact> = ArrayList(),
|
privateContacts: ArrayList<SimpleContact> = ArrayList(),
|
||||||
|
|
@ -349,6 +377,7 @@ fun Context.getConversations(
|
||||||
val conversations = ArrayList<Conversation>()
|
val conversations = ArrayList<Conversation>()
|
||||||
val simpleContactHelper = SimpleContactsHelper(this)
|
val simpleContactHelper = SimpleContactsHelper(this)
|
||||||
val blockedNumbers = getBlockedNumbers()
|
val blockedNumbers = getBlockedNumbers()
|
||||||
|
val unreadMap = getUnreadCountsByThread()
|
||||||
try {
|
try {
|
||||||
queryCursorUnsafe(
|
queryCursorUnsafe(
|
||||||
uri,
|
uri,
|
||||||
|
|
@ -397,6 +426,7 @@ fun Context.getConversations(
|
||||||
val read = cursor.getIntValue(Threads.READ) == 1
|
val read = cursor.getIntValue(Threads.READ) == 1
|
||||||
val archived =
|
val archived =
|
||||||
if (archiveAvailable) cursor.getIntValue(Threads.ARCHIVED) == 1 else false
|
if (archiveAvailable) cursor.getIntValue(Threads.ARCHIVED) == 1 else false
|
||||||
|
val unreadCount = if (!read) unreadMap[id] ?: 0 else 0
|
||||||
val conversation = Conversation(
|
val conversation = Conversation(
|
||||||
threadId = id,
|
threadId = id,
|
||||||
snippet = snippet,
|
snippet = snippet,
|
||||||
|
|
@ -406,7 +436,8 @@ fun Context.getConversations(
|
||||||
photoUri = photoUri,
|
photoUri = photoUri,
|
||||||
isGroupConversation = isGroupConversation,
|
isGroupConversation = isGroupConversation,
|
||||||
phoneNumber = phoneNumbers.first(),
|
phoneNumber = phoneNumbers.first(),
|
||||||
isArchived = archived
|
isArchived = archived,
|
||||||
|
unreadCount = unreadCount,
|
||||||
)
|
)
|
||||||
conversations.add(conversation)
|
conversations.add(conversation)
|
||||||
}
|
}
|
||||||
|
|
@ -973,6 +1004,7 @@ fun Context.markThreadMessagesRead(threadId: Long) {
|
||||||
contentResolver.update(uri, contentValues, selection, selectionArgs)
|
contentResolver.update(uri, contentValues, selection, selectionArgs)
|
||||||
}
|
}
|
||||||
messagesDB.markThreadRead(threadId)
|
messagesDB.markThreadRead(threadId)
|
||||||
|
conversationsDB.markRead(threadId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Context.markThreadMessagesUnread(threadId: Long) {
|
fun Context.markThreadMessagesUnread(threadId: Long) {
|
||||||
|
|
@ -985,6 +1017,7 @@ fun Context.markThreadMessagesUnread(threadId: Long) {
|
||||||
val selectionArgs = arrayOf(threadId.toString())
|
val selectionArgs = arrayOf(threadId.toString())
|
||||||
contentResolver.update(uri, contentValues, selection, selectionArgs)
|
contentResolver.update(uri, contentValues, selection, selectionArgs)
|
||||||
}
|
}
|
||||||
|
conversationsDB.markUnread(threadId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("NewApi")
|
@SuppressLint("NewApi")
|
||||||
|
|
@ -1258,7 +1291,8 @@ fun Context.createTemporaryThread(
|
||||||
phoneNumber = addresses.first(),
|
phoneNumber = addresses.first(),
|
||||||
isScheduled = true,
|
isScheduled = true,
|
||||||
usesCustomTitle = cachedConv?.usesCustomTitle == true,
|
usesCustomTitle = cachedConv?.usesCustomTitle == true,
|
||||||
isArchived = false
|
isArchived = false,
|
||||||
|
unreadCount = 0,
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
conversationsDB.insertOrUpdate(conversation)
|
conversationsDB.insertOrUpdate(conversation)
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ data class Conversation(
|
||||||
@ColumnInfo(name = "phone_number") var phoneNumber: String,
|
@ColumnInfo(name = "phone_number") var phoneNumber: String,
|
||||||
@ColumnInfo(name = "is_scheduled") var isScheduled: Boolean = false,
|
@ColumnInfo(name = "is_scheduled") var isScheduled: Boolean = false,
|
||||||
@ColumnInfo(name = "uses_custom_title") var usesCustomTitle: 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 {
|
companion object {
|
||||||
|
|
@ -32,7 +33,8 @@ data class Conversation(
|
||||||
old.title == new.title &&
|
old.title == new.title &&
|
||||||
old.photoUri == new.photoUri &&
|
old.photoUri == new.photoUri &&
|
||||||
old.isGroupConversation == new.isGroupConversation &&
|
old.isGroupConversation == new.isGroupConversation &&
|
||||||
old.phoneNumber == new.phoneNumber
|
old.phoneNumber == new.phoneNumber &&
|
||||||
|
old.unreadCount == new.unreadCount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,12 +88,28 @@
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:id="@+id/pin_indicator"
|
android:id="@+id/pin_indicator"
|
||||||
android:layout_width="@dimen/pin_icon_size"
|
android:layout_width="@dimen/small_icon_size"
|
||||||
android:layout_height="@dimen/pin_icon_size"
|
android:layout_height="@dimen/small_icon_size"
|
||||||
android:alpha="0.7"
|
android:alpha="0.7"
|
||||||
|
android:padding="@dimen/tiny_margin"
|
||||||
android:src="@drawable/ic_pin_filled_vector"
|
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_constraintBottom_toBottomOf="@id/conversation_body_short"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
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>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ style:
|
||||||
active: true
|
active: true
|
||||||
ignoreAnnotated: ["Composable"]
|
ignoreAnnotated: ["Composable"]
|
||||||
ignoreEnums: true
|
ignoreEnums: true
|
||||||
ignoreNumbers: ["-1", "0", "1", "2", "42", "1000"]
|
ignoreNumbers: ["-1", "0", "1", "2", "42", "128", "256", "1000"]
|
||||||
MaxLineLength:
|
MaxLineLength:
|
||||||
active: true
|
active: true
|
||||||
maxLineLength: 120
|
maxLineLength: 120
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue