Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,14 @@ interface StorageProtocol {
runThreadUpdate: Boolean
): MessageId?
fun markConversationAsRead(threadId: Long, lastSeenTime: Long, force: Boolean = false, updateNotification: Boolean = true)

/**
* Marks the conversation as read up to and including the message with [messageId]. It will
* take the reactions associated with messages prior to and including that message into account.
*
* It will not do anything if the last seen of this thread is already set in the future.
*/
fun markConversationAsReadUpToMessage(messageId: MessageId)
fun markConversationAsUnread(threadId: Long)
fun getLastSeen(threadId: Long): Long
fun ensureMessageHashesAreSender(hashes: Set<String>, sender: String, closedGroupId: String): Boolean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
Expand Down Expand Up @@ -324,7 +325,11 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate,
// Search
val searchViewModel: SearchViewModel by viewModels()

private val bufferedLastSeenChannel = Channel<Long>(capacity = 512, onBufferOverflow = BufferOverflow.DROP_OLDEST)
// The channel where we buffer the last seen data to be saved in the db
// The data can be:
// 1. A `MessageId`, which indicates what message we should mark the last seen until (inclusive of reactions)
// 2. A `Long` (timestamp), which indicates we should mark all messages until that timestamp as seen
private val bufferedLastSeenChannel = Channel<Any>(capacity = 10, onBufferOverflow = BufferOverflow.DROP_OLDEST)

private var emojiPickerVisible = false

Expand Down Expand Up @@ -598,21 +603,32 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate,
reactionDelegate = ConversationReactionDelegate(reactionOverlayStub)
reactionDelegate.setOnReactionSelectedListener(this)
lifecycleScope.launch {
// only update the conversation every 3 seconds maximum
// channel is rendezvous and shouldn't block on try send calls as often as we want
bufferedLastSeenChannel.receiveAsFlow()
.flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED)
.collectLatest {
withContext(Dispatchers.IO) {
try {
if (it > storage.getLastSeen(viewModel.threadId)) {
storage.markConversationAsRead(viewModel.threadId, it)
@Suppress("OPT_IN_USAGE")
bufferedLastSeenChannel.receiveAsFlow()
.flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED)
.distinctUntilChanged()
.debounce(500L)
.collectLatest {
withContext(Dispatchers.Default) {
try {
when (it) {
is Long -> {
if (storage.getLastSeen(viewModel.threadId) < it) {
storage.markConversationAsRead(viewModel.threadId, it)
}
}
} catch (e: Exception) {
Log.e(TAG, "bufferedLastSeenChannel collectLatest", e)

is MessageId -> {
storage.markConversationAsReadUpToMessage(it)
}

else -> error("Unsupported type sent to bufferedLastSeenChannel: $it")
}
} catch (e: Exception) {
Log.e(TAG, "Error handling last seen", e)
}
}
}
}

lifecycleScope.launch {
Expand Down Expand Up @@ -1376,19 +1392,16 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate,
val maybeTargetVisiblePosition = layoutManager?.findLastVisibleItemPosition()
val targetVisiblePosition = maybeTargetVisiblePosition ?: RecyclerView.NO_POSITION
if (!firstLoad.get() && targetVisiblePosition != RecyclerView.NO_POSITION) {
val timestampToSend: Long? = if (binding.conversationRecyclerView.isFullyScrolled) {
// We are at the bottom, so mark "now" as the last seen time
clock.currentTimeMills()
if (binding.conversationRecyclerView.isFullyScrolled) {
adapter.getMessageIdAt(targetVisiblePosition)?.let { lastSeenMessageId ->
bufferedLastSeenChannel.trySend(lastSeenMessageId)
}
} else {
// We are not at the bottom, so just mark the timestamp of the last visible message
adapter.getTimestampForItemAt(targetVisiblePosition)
}

timestampToSend?.let {
bufferedLastSeenChannel.trySend(it).apply {
if (isFailure) Log.e(TAG, "trySend failed", exceptionOrNull())
adapter.getMessageTimestampAt(targetVisiblePosition)?.let { timestamp ->
bufferedLastSeenChannel.trySend(timestamp)
}
}

}

val layoutUnreadCount = layoutManager?.let { (it.itemCount - 1) - it.findLastVisibleItemPosition() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import java.util.concurrent.atomic.AtomicLong
import kotlin.math.max
import kotlin.math.min

class ConversationAdapter(
Expand Down Expand Up @@ -261,18 +260,20 @@ class ConversationAdapter(
notifyDataSetChanged()
}

fun getTimestampForItemAt(firstVisiblePosition: Int): Long? {
fun getMessageIdAt(position: Int): MessageId? {
val cursor = this.cursor ?: return null
if (!cursor.moveToPosition(firstVisiblePosition)) return null
val message = messageDB.readerFor(cursor).current ?: return null
if (message.reactions.isEmpty()) {
// If the message has no reactions, we can use the timestamp directly
return message.timestamp
}
if (!cursor.moveToPosition(position)) return null

val id = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.ID))
val isMms = cursor.getString(cursor.getColumnIndexOrThrow(MmsSmsDatabase.TRANSPORT)) == MmsSmsDatabase.MMS_TRANSPORT
return MessageId(id, isMms)
}

fun getMessageTimestampAt(position: Int): Long? {
val cursor = this.cursor ?: return null
if (!cursor.moveToPosition(position)) return null

// Otherwise, we will need to take the reaction timestamp into account
val maxReactionTimestamp = message.reactions.maxOf { it.dateReceived }
return max(message.timestamp, maxReactionTimestamp)
return cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.NORMALIZED_DATE_SENT))
}

companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,28 @@ public MessageRecord getLastMessage(long threadId, boolean includeReactions, boo
}
}

/**
* Get the maximum timestamp in a thread up to (and including) the message with the given ID.
* Useful for determining the last read timestamp in a thread.
* <p>
* This method will also consider the reactions associated with messages in the thread.
* If a reaction has a timestamp greater than the message timestamp, it will be taken into account.
*
* @param messageId The message ID up to which to search.
* @return A pair of maximum timestamp in mills and thread ID, or null if no messages are found.
*/
@Nullable
public Pair<Long, Long> getMaxTimestampInThreadUpTo(@NonNull final MessageId messageId) {
Pair<String, Object[]> query = MmsSmsDatabaseSQLKt.buildMaxTimestampInThreadUpToQuery(messageId);
try (Cursor cursor = getReadableDatabase().rawQuery(query.getFirst(), query.getSecond())) {
if (cursor != null && cursor.moveToFirst()) {
return new Pair<>(cursor.getLong(0), cursor.getLong(1));
} else {
return null;
}
}
}

private String buildOutgoingConditionForNotifications() {
return "(" + TRANSPORT + " = '" + MMS_TRANSPORT + "' AND " +
"(" + MESSAGE_BOX + " & " + MmsSmsColumns.Types.BASE_TYPE_MASK + ") IN (" + buildOutgoingTypesList() + "))" +
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package org.thoughtcrime.securesms.database

import org.thoughtcrime.securesms.database.model.MessageId

/**
* Build a combined query to fetch both MMS and SMS messages in one go, the high level idea is to
* use a UNION between two SELECT statements, one for MMS and one for SMS. And they will need
Expand Down Expand Up @@ -236,3 +238,57 @@ fun buildMmsSmsCombinedQuery(
$limitStatement
"""
}

/**
* Build a query to get the maximum timestamp (date sent) in a thread up to and including
* the timestamp of the given message ID.
*
* This query will also look at reactions associated with messages in the thread
* to ensure that if there are reactions with later timestamps, they are considered
* as well.
*
* @return A pair containing the SQL query string and an array of parameters to bind.
* The query will return at most one row of "maxTimestamp", "threadId".
*/
fun buildMaxTimestampInThreadUpToQuery(id: MessageId): Pair<String, Array<Any>> {
val msgTable = if (id.mms) MmsDatabase.TABLE_NAME else SmsDatabase.TABLE_NAME
val dateSentColumn = if (id.mms) MmsDatabase.DATE_SENT else SmsDatabase.DATE_SENT
val threadIdColumn = if (id.mms) MmsSmsColumns.THREAD_ID else SmsDatabase.THREAD_ID

// The query below does this:
// 1. Query the given message, find out its thread id and its date sent
// 2. Find all the messages in this thread before this messages (using result from step 1)
// 3. With this message + earlier messages, grab all the reactions associated with them
// 4. Look at the max date among the reactions returned from step 3
// 5. Return the max between this message's date and the max reaction date
return """
SELECT
MAX(
mainMessage.$dateSentColumn,
IFNULL(
(
SELECT MAX(r.${ReactionDatabase.DATE_SENT})
FROM ${ReactionDatabase.TABLE_NAME} r
INDEXED BY reaction_message_id_is_mms_index
WHERE (r.${ReactionDatabase.MESSAGE_ID}, r.${ReactionDatabase.IS_MMS}) IN (
SELECT s.${MmsSmsColumns.ID}, FALSE
FROM ${SmsDatabase.TABLE_NAME} s
WHERE s.${SmsDatabase.THREAD_ID} = mainMessage.${threadIdColumn} AND
s.${SmsDatabase.DATE_SENT} <= mainMessage.$dateSentColumn

UNION ALL

SELECT m.${MmsSmsColumns.ID}, TRUE
FROM ${MmsDatabase.TABLE_NAME} m
WHERE m.${MmsSmsColumns.THREAD_ID} = mainMessage.${threadIdColumn} AND
m.${MmsDatabase.DATE_SENT} <= mainMessage.$dateSentColumn
)
),
0
)
) AS maxTimestamp,
mainMessage.$threadIdColumn AS threadId
FROM $msgTable mainMessage
WHERE mainMessage.${MmsSmsColumns.ID} = ?
""" to arrayOf(id.id)
}
Loading