Allow replies to maintain state on orientation change

Signed-off-by: rapterjet2004 <juliuslinus1@gmail.com>
This commit is contained in:
rapterjet2004 2025-06-05 16:38:35 -05:00
parent 7e72032738
commit 71bd381828
No known key found for this signature in database
GPG Key ID: 3AA5FDFED7944099
11 changed files with 222 additions and 79 deletions

View File

@ -35,7 +35,6 @@ import android.widget.LinearLayout
import android.widget.PopupMenu
import android.widget.RelativeLayout
import android.widget.SeekBar
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.view.ContextThemeWrapper
import androidx.core.content.ContextCompat
@ -81,15 +80,15 @@ import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew
import com.nextcloud.talk.utils.message.MessageUtils
import com.nextcloud.talk.utils.text.Spans
import com.otaliastudios.autocomplete.Autocomplete
import com.stfalcon.chatkit.commons.models.IMessage
import com.vanniktech.emoji.EmojiPopup
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.util.Objects
import javax.inject.Inject
@Suppress("LongParameterList", "TooManyFunctions")
@Suppress("LongParameterList", "TooManyFunctions", "LargeClass", "LongMethod")
@AutoInjector(NextcloudTalkApplication::class)
class MessageInputFragment : Fragment() {
@ -112,6 +111,10 @@ class MessageInputFragment : Fragment() {
private const val CONNECTION_ESTABLISHED_ANIM_DURATION: Long = 3000
private const val FULLY_OPAQUE: Float = 1.0f
private const val FULLY_TRANSPARENT: Float = 0.0f
const val QUOTED_MESSAGE_TEXT = "QUOTED_MESSAGE_TEXT"
const val QUOTED_MESSAGE_ID = "QUOTED_MESSAGE_ID"
const val QUOTED_MESSAGE_URL = "QUOTED_MESSAGE_URL"
const val QUOTED_MESSAGE_NAME = "QUOTED_MESSAGE_NAME"
}
@Inject
@ -163,7 +166,6 @@ class MessageInputFragment : Fragment() {
override fun onPause() {
super.onPause()
saveState()
}
override fun onDestroyView() {
@ -172,7 +174,6 @@ class MessageInputFragment : Fragment() {
mentionAutocomplete?.dismissPopup()
}
clearEditUI()
cancelReply()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@ -183,7 +184,13 @@ class MessageInputFragment : Fragment() {
private fun initObservers() {
Log.d(TAG, "LifeCyclerOwner is: ${viewLifecycleOwner.lifecycle}")
chatActivity.messageInputViewModel.getReplyChatMessage.observe(viewLifecycleOwner) { message ->
message?.let { replyToMessage(message) }
(message as ChatMessage?)?.let {
chatActivity.chatViewModel.messageDraft.quotedMessageText = message.text
chatActivity.chatViewModel.messageDraft.quotedDisplayName = message.actorDisplayName
chatActivity.chatViewModel.messageDraft.quotedImageUrl = message.imageUrl
chatActivity.chatViewModel.messageDraft.quotedJsonId = message.jsonMessageId
replyToMessage(message.text, message.actorDisplayName, message.imageUrl, message.jsonMessageId)
}
}
chatActivity.messageInputViewModel.getEditChatMessage.observe(viewLifecycleOwner) { message ->
@ -299,34 +306,24 @@ class MessageInputFragment : Fragment() {
}
private fun restoreState() {
if (binding.fragmentMessageInputView.inputEditText.text.isEmpty()) {
requireContext().getSharedPreferences(chatActivity.localClassName, AppCompatActivity.MODE_PRIVATE).apply {
val text = getString(chatActivity.roomToken, "")
val cursor = getInt(chatActivity.roomToken + CURSOR_KEY, 0)
binding.fragmentMessageInputView.messageInput.setText(text)
binding.fragmentMessageInputView.messageInput.setSelection(cursor)
}
runBlocking {
chatActivity.chatViewModel.updateMessageDraft()
}
}
private fun saveState() {
val text = binding.fragmentMessageInputView.messageInput.text.toString()
val cursor = binding.fragmentMessageInputView.messageInput.selectionStart
val previous = requireContext().getSharedPreferences(
chatActivity.localClassName,
AppCompatActivity
.MODE_PRIVATE
).getString(chatActivity.roomToken, "null")
val draft = chatActivity.chatViewModel.messageDraft
binding.fragmentMessageInputView.messageInput.setText(draft.messageText)
binding.fragmentMessageInputView.messageInput.setSelection(draft.messageCursor)
if (draft.messageText != "") {
binding.fragmentMessageInputView.messageInput.requestFocus()
}
if (text != previous) {
requireContext().getSharedPreferences(
chatActivity.localClassName,
AppCompatActivity.MODE_PRIVATE
).edit().apply {
putString(chatActivity.roomToken, text)
putInt(chatActivity.roomToken + CURSOR_KEY, cursor)
apply()
}
if (isInReplyState()) {
replyToMessage(
chatActivity.chatViewModel.messageDraft.quotedMessageText,
chatActivity.chatViewModel.messageDraft.quotedDisplayName,
chatActivity.chatViewModel.messageDraft.quotedImageUrl,
chatActivity.chatViewModel.messageDraft.quotedJsonId ?: 0
)
}
}
@ -388,7 +385,10 @@ class MessageInputFragment : Fragment() {
}
override fun afterTextChanged(s: Editable) {
// unused atm
val cursor = binding.fragmentMessageInputView.messageInput.selectionStart
val text = binding.fragmentMessageInputView.messageInput.text.toString()
chatActivity.chatViewModel.messageDraft.messageCursor = cursor
chatActivity.chatViewModel.messageDraft.messageText = text
}
})
@ -615,7 +615,7 @@ class MessageInputFragment : Fragment() {
}
}
}
v?.onTouchEvent(event) ?: true
v?.onTouchEvent(event) != false
}
}
@ -717,52 +717,54 @@ class MessageInputFragment : Fragment() {
}
}
private fun replyToMessage(message: IMessage?) {
private fun replyToMessage(
quotedMessageText: String?,
quotedActorDisplayName: String?,
quotedImageUrl: String?,
quotedJsonId: Int
) {
Log.d(TAG, "Reply")
val chatMessage = message as ChatMessage?
chatMessage?.let {
val view = binding.fragmentMessageInputView
view.findViewById<ImageButton>(R.id.attachmentButton)?.visibility =
View.GONE
view.findViewById<ImageButton>(R.id.cancelReplyButton)?.visibility =
View.VISIBLE
val view = binding.fragmentMessageInputView
view.findViewById<ImageButton>(R.id.attachmentButton)?.visibility =
View.GONE
view.findViewById<ImageButton>(R.id.cancelReplyButton)?.visibility =
View.VISIBLE
val quotedMessage = view.findViewById<EmojiTextView>(R.id.quotedMessage)
val quotedMessage = view.findViewById<EmojiTextView>(R.id.quotedMessage)
quotedMessage?.maxLines = 2
quotedMessage?.ellipsize = TextUtils.TruncateAt.END
quotedMessage?.text = it.text
view.findViewById<EmojiTextView>(R.id.quotedMessageAuthor)?.text =
it.actorDisplayName ?: requireContext().getText(R.string.nc_nick_guest)
quotedMessage?.maxLines = 2
quotedMessage?.ellipsize = TextUtils.TruncateAt.END
quotedMessage?.text = quotedMessageText
view.findViewById<EmojiTextView>(R.id.quotedMessageAuthor)?.text =
quotedActorDisplayName ?: requireContext().getText(R.string.nc_nick_guest)
chatActivity.conversationUser?.let {
val quotedMessageImage = view.findViewById<ImageView>(R.id.quotedMessageImage)
chatMessage.imageUrl?.let { previewImageUrl ->
quotedMessageImage?.visibility = View.VISIBLE
chatActivity.conversationUser?.let {
val quotedMessageImage = view.findViewById<ImageView>(R.id.quotedMessageImage)
quotedImageUrl?.let { previewImageUrl ->
quotedMessageImage?.visibility = View.VISIBLE
val px = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
QUOTED_MESSAGE_IMAGE_MAX_HEIGHT,
resources.displayMetrics
)
val px = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
QUOTED_MESSAGE_IMAGE_MAX_HEIGHT,
resources.displayMetrics
)
quotedMessageImage?.maxHeight = px.toInt()
val layoutParams = quotedMessageImage?.layoutParams as FlexboxLayout.LayoutParams
layoutParams.flexGrow = 0f
quotedMessageImage.layoutParams = layoutParams
quotedMessageImage.load(previewImageUrl) {
addHeader("Authorization", chatActivity.credentials!!)
}
} ?: run {
view.findViewById<ImageView>(R.id.quotedMessageImage)?.visibility = View.GONE
quotedMessageImage?.maxHeight = px.toInt()
val layoutParams = quotedMessageImage?.layoutParams as FlexboxLayout.LayoutParams
layoutParams.flexGrow = 0f
quotedMessageImage.layoutParams = layoutParams
quotedMessageImage.load(previewImageUrl) {
addHeader("Authorization", chatActivity.credentials!!)
}
} ?: run {
view.findViewById<ImageView>(R.id.quotedMessageImage)?.visibility = View.GONE
}
val quotedChatMessageView =
view.findViewById<RelativeLayout>(R.id.quotedChatMessageView)
quotedChatMessageView?.tag = message?.jsonMessageId
quotedChatMessageView?.visibility = View.VISIBLE
}
val quotedChatMessageView =
view.findViewById<RelativeLayout>(R.id.quotedChatMessageView)
quotedChatMessageView?.tag = quotedJsonId
quotedChatMessageView?.visibility = View.VISIBLE
}
fun updateOwnTypingStatus(typedText: CharSequence) {
@ -1051,5 +1053,15 @@ class MessageInputFragment : Fragment() {
quote.tag = null
binding.fragmentMessageInputView.findViewById<ImageButton>(R.id.attachmentButton)?.visibility = View.VISIBLE
chatActivity.messageInputViewModel.reply(null)
chatActivity.chatViewModel.messageDraft.quotedMessageText = null
chatActivity.chatViewModel.messageDraft.quotedDisplayName = null
chatActivity.chatViewModel.messageDraft.quotedImageUrl = null
chatActivity.chatViewModel.messageDraft.quotedJsonId = null
}
private fun isInReplyState(): Boolean {
val jsonId = chatActivity.chatViewModel.messageDraft.quotedJsonId
return jsonId != null
}
}

View File

@ -27,6 +27,7 @@ import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository
import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.extensions.toIntOrZero
import com.nextcloud.talk.jobs.UploadAndShareFilesWorker
import com.nextcloud.talk.models.MessageDraft
import com.nextcloud.talk.models.domain.ConversationModel
import com.nextcloud.talk.models.domain.ReactionAddedModel
import com.nextcloud.talk.models.domain.ReactionDeletedModel
@ -60,6 +61,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.io.File
import javax.inject.Inject
@ -89,6 +91,8 @@ class ChatViewModel @Inject constructor(
val disposableSet = mutableSetOf<Disposable>()
var mediaPlayerDuration = mediaPlayerManager.mediaPlayerDuration
val mediaPlayerPosition = mediaPlayerManager.mediaPlayerPosition
var chatRoomToken: String = ""
var messageDraft: MessageDraft = MessageDraft()
fun getChatRepository(): ChatMessageRepository = chatRepository
@ -108,6 +112,14 @@ class ChatViewModel @Inject constructor(
mediaRecorderManager.handleOnPause()
chatRepository.handleOnPause()
mediaPlayerManager.handleOnPause()
runBlocking {
val model = conversationRepository.getLocallyStoredConversation(chatRoomToken)
model?.let {
it.messageDraft = messageDraft
conversationRepository.updateConversation(it)
}
}
}
override fun onStop(owner: LifecycleOwner) {
@ -889,6 +901,11 @@ class ChatViewModel @Inject constructor(
}
}
suspend fun updateMessageDraft() {
val model = conversationRepository.getLocallyStoredConversation(chatRoomToken)
messageDraft = model?.messageDraft!!
}
companion object {
private val TAG = ChatViewModel::class.simpleName
const val JOIN_ROOM_RETRY_COUNT: Long = 3

View File

@ -307,6 +307,16 @@ class ConversationsListActivity :
showNotificationWarning()
showShareToScreen = hasActivityActionSendIntent()
// context.getSharedPreferences(
// CHAT_ACTIVITY_LOCAL_NAME,
// MODE_PRIVATE
// ).edit().apply {
// putInt(QUOTED_MESSAGE_ID, -1)
// putString(QUOTED_MESSAGE_NAME, null)
// putString(QUOTED_MESSAGE_TEXT, "")
// putString(QUOTED_MESSAGE_URL, null)
// apply()
// }
if (!eventBus.isRegistered(this)) {
eventBus.register(this)
@ -2216,6 +2226,7 @@ class ConversationsListActivity :
const val UNREAD_BUBBLE_DELAY = 2500
const val BOTTOM_SHEET_DELAY: Long = 2500
private const val KEY_SEARCH_QUERY = "ConversationsListActivity.searchQuery"
private const val CHAT_ACTIVITY_LOCAL_NAME = "com.nextcloud.talk.chat.ChatActivity"
const val SEARCH_DEBOUNCE_INTERVAL_MS = 300
const val SEARCH_MIN_CHARS = 1
const val HTTP_UNAUTHORIZED = 401

View File

@ -36,4 +36,8 @@ interface OfflineConversationsRepository {
* to be handled asynchronously.
*/
fun getRoom(roomToken: String): Job
suspend fun updateConversation(conversationModel: ConversationModel)
suspend fun getLocallyStoredConversation(roomToken: String): ConversationModel?
}

View File

@ -98,12 +98,22 @@ class OfflineFirstConversationsRepository @Inject constructor(
runBlocking {
_conversationFlow.emit(model)
val entityList = listOf(model.asEntity())
dao.upsertConversations(entityList)
dao.upsertConversations(user.id!!, entityList)
}
}
})
}
override suspend fun updateConversation(conversationModel: ConversationModel) {
val entity = conversationModel.asEntity()
dao.updateConversation(entity)
}
override suspend fun getLocallyStoredConversation(roomToken: String): ConversationModel? {
val id = user.id!!
return getConversation(id, roomToken)
}
@Suppress("Detekt.TooGenericExceptionCaught")
private suspend fun getRoomsFromServer(): List<ConversationEntity>? {
var conversationsFromSync: List<ConversationEntity>? = null
@ -126,7 +136,7 @@ class OfflineFirstConversationsRepository @Inject constructor(
}
deleteLeftConversations(conversationsFromSync)
dao.upsertConversations(conversationsFromSync)
dao.upsertConversations(user.id!!, conversationsFromSync)
} catch (e: Exception) {
Log.e(TAG, "Something went wrong when fetching conversations", e)
}

View File

@ -8,11 +8,15 @@
package com.nextcloud.talk.data.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy.Companion.REPLACE
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import androidx.room.Upsert
import com.nextcloud.talk.data.database.model.ConversationEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
@Dao
interface ConversationsDao {
@ -22,9 +26,27 @@ interface ConversationsDao {
@Query("SELECT * FROM Conversations where accountId = :accountId AND token = :token")
fun getConversationForUser(accountId: Long, token: String): Flow<ConversationEntity?>
@Upsert
@Upsert()
fun upsertConversations(conversationEntities: List<ConversationEntity>)
@Insert(onConflict = REPLACE)
suspend fun insertOrUpdate(item: ConversationEntity)
@Transaction
suspend fun upsertConversations(accountId: Long, serverItems: List<ConversationEntity>) {
serverItems.forEach { serverItem ->
val existingItem = getConversationForUser(accountId, serverItem.token).first()
if (existingItem != null) {
val mergedItem = serverItem
mergedItem.messageDraft = existingItem.messageDraft
insertOrUpdate(mergedItem)
} else {
// Insert new item directly (local-only fields will be default)
insertOrUpdate(serverItem)
}
}
}
/**
* Deletes rows in the db matching the specified [conversationIds]
*/
@ -36,7 +58,7 @@ interface ConversationsDao {
)
fun deleteConversations(conversationIds: List<String>)
@Update
@Update(onConflict = REPLACE)
fun updateConversation(conversationEntity: ConversationEntity)
@Query(

View File

@ -1,7 +1,7 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Your Name <your@email.com>
* SPDX-FileCopyrightText: 2024 Julius Linus <juliuslinus1@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
@ -63,7 +63,8 @@ fun ConversationModel.asEntity() =
remoteToken = remoteToken,
hasArchived = hasArchived,
hasSensitive = hasSensitive,
hasImportant = hasImportant
hasImportant = hasImportant,
messageDraft = messageDraft
)
fun ConversationEntity.asModel() =
@ -117,7 +118,8 @@ fun ConversationEntity.asModel() =
remoteToken = remoteToken,
hasArchived = hasArchived,
hasSensitive = hasSensitive,
hasImportant = hasImportant
hasImportant = hasImportant,
messageDraft = messageDraft
)
fun Conversation.asEntity(accountId: Long) =

View File

@ -13,6 +13,7 @@ import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import com.nextcloud.talk.data.user.model.UserEntity
import com.nextcloud.talk.models.MessageDraft
import com.nextcloud.talk.models.json.conversations.ConversationEnums
import com.nextcloud.talk.models.json.participants.Participant
@ -96,7 +97,8 @@ data class ConversationEntity(
@ColumnInfo(name = "unreadMessages") var unreadMessages: Int = 0,
@ColumnInfo(name = "hasArchived") var hasArchived: Boolean = false,
@ColumnInfo(name = "hasSensitive") var hasSensitive: Boolean = false,
@ColumnInfo(name = "hasImportant") var hasImportant: Boolean = false
@ColumnInfo(name = "hasImportant") var hasImportant: Boolean = false,
@ColumnInfo(name = "messageDraft") var messageDraft: MessageDraft? = MessageDraft()
// missing/not needed: attendeeId
// missing/not needed: attendeePin
// missing/not needed: attendeePermissions

View File

@ -0,0 +1,57 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Julius Linus <juliuslinus1@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.models
import android.os.Parcelable
import androidx.room.TypeConverter
import com.bluelinelabs.logansquare.LoganSquare
import com.bluelinelabs.logansquare.annotation.JsonField
import com.bluelinelabs.logansquare.annotation.JsonObject
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Parcelize
@JsonObject
@Serializable
data class MessageDraft(
@JsonField(name = ["messageText"])
var messageText: String = "",
@JsonField(name = ["messageCursor"])
var messageCursor: Int = 0,
@JsonField(name = ["quotedJsonId"])
var quotedJsonId: Int? = null,
@JsonField(name = ["quotedDisplayName"])
var quotedDisplayName: String? = null,
@JsonField(name = ["quotedMessageText"])
var quotedMessageText: String? = null,
@JsonField(name = ["quoteImageUrl"])
var quotedImageUrl: String? = null
) : Parcelable {
constructor() : this("", 0, null, null, null, null)
}
class MessageDraftConverter {
@TypeConverter
fun fromMessageDraftToString(messageDraft: MessageDraft?): String {
return if (messageDraft == null) {
""
} else {
LoganSquare.serialize(messageDraft)
}
}
@TypeConverter
fun fromStringToMessageDraft(value: String): MessageDraft? {
return if (value.isBlank()) {
null
} else {
return LoganSquare.parse(value, MessageDraft::class.java)
}
}
}

View File

@ -8,6 +8,7 @@
package com.nextcloud.talk.models.domain
import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.models.MessageDraft
import com.nextcloud.talk.models.json.chat.ChatMessageJson
import com.nextcloud.talk.models.json.conversations.Conversation
import com.nextcloud.talk.models.json.conversations.ConversationEnums
@ -65,7 +66,8 @@ class ConversationModel(
var hasImportant: Boolean = false,
// attributes that don't come from API. This should be changed?!
var password: String? = null
var password: String? = null,
var messageDraft: MessageDraft? = MessageDraft()
) {
companion object {

View File

@ -194,6 +194,10 @@ class DummyConversationDaoImpl : ConversationsDao {
override fun upsertConversations(conversationEntities: List<ConversationEntity>) { /* */ }
override suspend fun insertOrUpdate(item: ConversationEntity) {
/**/
}
override fun deleteConversations(conversationIds: List<String>) { /* */ }
override fun updateConversation(conversationEntity: ConversationEntity) { /* */ }