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 @@ -15,6 +15,7 @@ import android.net.Uri
import android.os.AsyncTask
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
Expand Down Expand Up @@ -459,6 +460,9 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate,

private var isKeyboardVisible = false

private var pendingRecyclerViewScrollState: Parcelable? = null
private var hasRestoredRecyclerViewScrollState: Boolean = false
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you need this variable? Isn't pendingRecyclerViewScrollState enough on its own when being not null?

Copy link
Collaborator Author

@jbsession jbsession Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not strictly. Restore is deferred (runWhenLaidOut) , so there’s a window where state is pending but not yet applied. The flag prevents possible double restore and other timing issues.


private lateinit var reactionDelegate: ConversationReactionDelegate
private val reactWithAnyEmojiStartPage = -1

Expand Down Expand Up @@ -529,6 +533,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate,
// Extras
private const val ADDRESS = "address"
private const val SCROLL_MESSAGE_ID = "scroll_message_id"
private const val CONVERSATION_SCROLL_STATE = "conversation_scroll_state"

const val SHOW_SEARCH = "show_search"

Expand Down Expand Up @@ -570,6 +575,9 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate,
override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) {
super.onCreate(savedInstanceState, isReady)

pendingRecyclerViewScrollState = savedInstanceState?.getParcelable(CONVERSATION_SCROLL_STATE)
hasRestoredRecyclerViewScrollState = false

// Check if address is null before proceeding with initialization
if (
IntentCompat.getParcelableExtra(
Expand Down Expand Up @@ -924,33 +932,27 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate,
unreadCount = data.threadUnreadCount
updateUnreadCountIndicator()

// If we have a saved RecyclerView scroll state (after rotation), restore it and skip first-load autoscroll.
if (!hasRestoredRecyclerViewScrollState && pendingRecyclerViewScrollState != null) {
val lm = binding.conversationRecyclerView.layoutManager
binding.conversationRecyclerView.runWhenLaidOut {
lm?.onRestoreInstanceState(pendingRecyclerViewScrollState)
hasRestoredRecyclerViewScrollState = true
pendingRecyclerViewScrollState = null
}
}

if (messageToScrollTo != null) {
if(gotoMessageById(messageToScrollTo!!.id, smoothScroll = messageToScrollTo!!.smoothScroll, highlight = firstLoad)){
messageToScrollTo = null
}
} else {
if (firstLoad) {
scrollToFirstUnreadMessageOrBottom()

// On the first load, check if there unread messages
if (unreadCount == 0 && adapter.itemCount > 0) {
lifecycleScope.launch(Dispatchers.Default) {
val isUnread = configFactory.withUserConfigs {
it.convoInfoVolatile.getConversationUnread(
viewModel.address,
)
}
val shouldAutoScrollOnFirstLoad = firstLoad &&
pendingRecyclerViewScrollState == null &&
!hasRestoredRecyclerViewScrollState

viewModel.threadId?.let { threadId ->
if (isUnread) {
storage.markConversationAsRead(
threadId,
clock.currentTimeMillis()
)
}
}
}
}
if (shouldAutoScrollOnFirstLoad) {
scrollToFirstUnreadMessageOrBottom()
} else {
// If there are new data updated, we'll try to stay scrolled at the bottom (if we were at the bottom).
// scrolled to bottom has a leniency of 50dp, so if we are within the 50dp but not fully at the bottom, scroll down
Expand All @@ -960,6 +962,26 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate,
}
}

// We should do this check regardless of whether we're restoring a saved scroll position.
if (firstLoad && unreadCount == 0 && adapter.itemCount > 0) {
lifecycleScope.launch(Dispatchers.Default) {
val isUnread = configFactory.withUserConfigs {
it.convoInfoVolatile.getConversationUnread(
viewModel.address,
)
}

viewModel.threadId?.let { threadId ->
if (isUnread) {
storage.markConversationAsRead(
threadId,
clock.currentTimeMillis()
)
}
}
}
}

handleRecyclerViewScrolled()
}
}
Expand All @@ -973,6 +995,17 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate,
}
}

override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)

// Persist the current scroll position so rotations (e.g., portrait <-> landscape) don't jump to bottom.
val lm = binding.conversationRecyclerView.layoutManager
val state = lm?.onSaveInstanceState()
if (state != null) {
outState.putParcelable(CONVERSATION_SCROLL_STATE, state)
}
}

override fun onLoaderReset(cursor: Loader<ConversationLoader.Data>) = adapter.changeCursor(null)

// called from onCreate
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,8 @@ class InputBar @JvmOverloads constructor(

// Edit text
binding.inputBarEditText.setOnEditorActionListener(this)
binding.inputBarEditText.imeOptions = EditorInfo.IME_ACTION_NONE
// Prevent some IMEs from switching to fullscreen/extracted text mode in landscape (shows IME-owned text field).
binding.inputBarEditText.imeOptions = EditorInfo.IME_ACTION_NONE or EditorInfo.IME_FLAG_NO_EXTRACT_UI

val incognitoFlag = if (TextSecurePreferences.isIncognitoKeyboardEnabled(context)) 16777216 else 0
binding.inputBarEditText.imeOptions = binding.inputBarEditText.imeOptions or incognitoFlag // Always use incognito keyboard if setting enabled
Expand Down