Ensures chat folder is remembered when we leave page.

This commit is contained in:
Alex Hart
2025-04-23 10:43:46 -03:00
committed by Cody Henthorne
parent 252a4afa79
commit be035456f7
5 changed files with 74 additions and 36 deletions
@@ -32,6 +32,7 @@ import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.fragment.app.viewModels
import org.signal.core.ui.compose.BottomSheets import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview import org.signal.core.ui.compose.SignalPreview
@@ -40,7 +41,6 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.viewModel
/** /**
* Bottom sheet shown when choosing to add a chat to a folder * Bottom sheet shown when choosing to add a chat to a folder
@@ -59,7 +59,11 @@ class AddToFolderBottomSheet private constructor(private val onDismissListener:
OTHER(3) OTHER(3)
} }
private val viewModel by viewModel { ConversationListViewModel(isArchived = false) } private val viewModel: ConversationListViewModel by viewModels(
factoryProducer = {
ConversationListViewModel.Factory(isArchived = false)
}
)
companion object { companion object {
private const val ARG_FOLDERS = "argument.folders" private const val ARG_FOLDERS = "argument.folders"
@@ -1,17 +1,20 @@
package org.thoughtcrime.securesms.conversationlist package org.thoughtcrime.securesms.conversationlist
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
/** /**
* Mapping model of folders used in [ChatFolderAdapter] * Mapping model of folders used in [ChatFolderAdapter]
*/ */
@Parcelize
data class ChatFolderMappingModel( data class ChatFolderMappingModel(
val chatFolder: ChatFolderRecord, val chatFolder: ChatFolderRecord,
val unreadCount: Int, val unreadCount: Int,
val isMuted: Boolean, val isMuted: Boolean,
val isSelected: Boolean val isSelected: Boolean
) : MappingModel<ChatFolderMappingModel> { ) : MappingModel<ChatFolderMappingModel>, Parcelable {
override fun areItemsTheSame(newItem: ChatFolderMappingModel): Boolean { override fun areItemsTheSame(newItem: ChatFolderMappingModel): Boolean {
return chatFolder.id == newItem.chatFolder.id return chatFolder.id == newItem.chatFolder.id
} }
@@ -146,7 +146,6 @@ import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.search.MessageResult; import org.thoughtcrime.securesms.search.MessageResult;
import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.AppForegroundObserver; import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup; import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.BottomSheetUtil; import org.thoughtcrime.securesms.util.BottomSheetUtil;
@@ -1,17 +1,25 @@
package org.thoughtcrime.securesms.conversationlist package org.thoughtcrime.securesms.conversationlist
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.BackpressureStrategy import io.reactivex.rxjava3.core.BackpressureStrategy
import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.addTo import io.reactivex.rxjava3.kotlin.addTo
import io.reactivex.rxjava3.kotlin.combineLatest
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.asFlowable
import kotlinx.parcelize.Parcelize
import org.signal.paging.PagedData import org.signal.paging.PagedData
import org.signal.paging.PagingConfig import org.signal.paging.PagingConfig
import org.signal.paging.ProxyPagingController import org.signal.paging.ProxyPagingController
@@ -35,15 +43,24 @@ import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class ConversationListViewModel( class ConversationListViewModel(
private val isArchived: Boolean private val isArchived: Boolean,
private val savedStateHandle: SavedStateHandle
) : ViewModel() { ) : ViewModel() {
companion object { companion object {
private const val STATE = "state"
private var coldStart = true private var coldStart = true
} }
private val disposables: CompositeDisposable = CompositeDisposable() private val disposables: CompositeDisposable = CompositeDisposable()
private var saveableState: SaveableState
get() = savedStateHandle[STATE] ?: SaveableState()
set(value) {
savedStateHandle[STATE] = value
}
private val store = RxStore(ConversationListState()).addTo(disposables) private val store = RxStore(ConversationListState()).addTo(disposables)
private val conversationListDataSource: Flowable<ConversationListDataSource> private val conversationListDataSource: Flowable<ConversationListDataSource>
private val pagingConfig = PagingConfig.Builder() private val pagingConfig = PagingConfig.Builder()
@@ -53,18 +70,18 @@ class ConversationListViewModel(
val conversationsState: Flowable<List<Conversation>> = store.mapDistinctForUi { it.conversations } val conversationsState: Flowable<List<Conversation>> = store.mapDistinctForUi { it.conversations }
val selectedState: Flowable<ConversationSet> = store.mapDistinctForUi { it.selectedConversations } val selectedState: Flowable<ConversationSet> = store.mapDistinctForUi { it.selectedConversations }
val filterRequestState: Flowable<ConversationFilterRequest> = store.mapDistinctForUi { it.filterRequest } val filterRequestState: Flowable<ConversationFilterRequest> = savedStateHandle.getStateFlow(STATE, SaveableState()).map { it.filterRequest }.asFlowable()
val chatFolderState: Flowable<List<ChatFolderMappingModel>> = store.mapDistinctForUi { it.chatFolders } val chatFolderState: Flowable<List<ChatFolderMappingModel>> = savedStateHandle.getStateFlow(STATE, SaveableState()).map { it.chatFolders }.asFlowable()
val hasNoConversations: Flowable<Boolean> val hasNoConversations: Flowable<Boolean>
val controller = ProxyPagingController<Long>() val controller = ProxyPagingController<Long>()
val folders: List<ChatFolderMappingModel> val folders: List<ChatFolderMappingModel>
get() = store.state.chatFolders get() = saveableState.chatFolders
val currentFolder: ChatFolderRecord val currentFolder: ChatFolderRecord
get() = store.state.currentFolder get() = saveableState.currentFolder
val conversationFilterRequest: ConversationFilterRequest val conversationFilterRequest: ConversationFilterRequest
get() = store.state.filterRequest get() = saveableState.filterRequest
val pinnedCount: Int val pinnedCount: Int
get() = store.state.pinnedCount get() = store.state.pinnedCount
val webSocketState: Observable<WebSocketConnectionState> val webSocketState: Observable<WebSocketConnectionState>
@@ -75,8 +92,9 @@ class ConversationListViewModel(
get() = store.state.internalSelection get() = store.state.internalSelection
init { init {
conversationListDataSource = store val saveableStateFlowable = savedStateHandle.getStateFlow(STATE, SaveableState()).asFlowable()
.stateFlowable
conversationListDataSource = saveableStateFlowable
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.filter { it.currentFolder.id != -1L } .filter { it.currentFolder.id != -1L }
.map { it.filterRequest to it.currentFolder } .map { it.filterRequest to it.currentFolder }
@@ -128,7 +146,8 @@ class ConversationListViewModel(
hasNoConversations = store hasNoConversations = store
.stateFlowable .stateFlowable
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.map { it.filterRequest to it.conversations } .combineLatest(saveableStateFlowable.map { it.filterRequest })
.map { (state, filterRequest) -> filterRequest to state.conversations }
.distinctUntilChanged() .distinctUntilChanged()
.map { (filterRequest, conversations) -> .map { (filterRequest, conversations) ->
if (conversations.isNotEmpty()) { if (conversations.isNotEmpty()) {
@@ -187,9 +206,12 @@ class ConversationListViewModel(
} }
fun setFiltered(isFiltered: Boolean, conversationFilterSource: ConversationFilterSource) { fun setFiltered(isFiltered: Boolean, conversationFilterSource: ConversationFilterSource) {
store.update { saveableState = saveableState.copy(
it.copy(filterRequest = ConversationFilterRequest(if (isFiltered) ConversationFilter.UNREAD else ConversationFilter.OFF, conversationFilterSource)) filterRequest = ConversationFilterRequest(
} filter = if (isFiltered) ConversationFilter.UNREAD else ConversationFilter.OFF,
source = conversationFilterSource
)
)
} }
private fun loadCurrentFolders() { private fun loadCurrentFolders() {
@@ -211,12 +233,10 @@ class ConversationListViewModel(
) )
} }
store.update { saveableState = saveableState.copy(
it.copy( currentFolder = folders.find { folder -> folder.id == selectedFolderId } ?: ChatFolderRecord(),
currentFolder = folders.find { folder -> folder.id == selectedFolderId } ?: ChatFolderRecord(), chatFolders = chatFolders
chatFolders = chatFolders )
)
}
} }
} }
@@ -228,14 +248,12 @@ class ConversationListViewModel(
} }
fun select(chatFolder: ChatFolderRecord) { fun select(chatFolder: ChatFolderRecord) {
store.update { saveableState = saveableState.copy(
it.copy( currentFolder = chatFolder,
currentFolder = chatFolder, chatFolders = folders.map { model ->
chatFolders = folders.map { model -> model.copy(isSelected = chatFolder.id == model.chatFolder.id)
model.copy(isSelected = chatFolder.id == model.chatFolder.id) }
} )
)
}
} }
fun onUpdateMute(chatFolder: ChatFolderRecord, until: Long) { fun onUpdateMute(chatFolder: ChatFolderRecord, until: Long) {
@@ -282,19 +300,30 @@ class ConversationListViewModel(
StorageSyncHelper.scheduleSyncForDataChange() StorageSyncHelper.scheduleSyncForDataChange()
} }
private data class ConversationListState( /**
* Easily persistable state to ensure proper restoration upon VM recreation.
*/
@Parcelize
private data class SaveableState(
val chatFolders: List<ChatFolderMappingModel> = emptyList(), val chatFolders: List<ChatFolderMappingModel> = emptyList(),
val currentFolder: ChatFolderRecord = ChatFolderRecord(), val currentFolder: ChatFolderRecord = ChatFolderRecord(),
val filterRequest: ConversationFilterRequest = ConversationFilterRequest(ConversationFilter.OFF, ConversationFilterSource.DRAG)
) : Parcelable
private data class ConversationListState(
val conversations: List<Conversation> = emptyList(), val conversations: List<Conversation> = emptyList(),
val selectedConversations: ConversationSet = ConversationSet(), val selectedConversations: ConversationSet = ConversationSet(),
val internalSelection: Set<Conversation> = emptySet(), val internalSelection: Set<Conversation> = emptySet(),
val filterRequest: ConversationFilterRequest = ConversationFilterRequest(ConversationFilter.OFF, ConversationFilterSource.DRAG),
val pinnedCount: Int = 0 val pinnedCount: Int = 0
) )
class Factory(private val isArchived: Boolean) : ViewModelProvider.Factory { class Factory(
override fun <T : ViewModel> create(modelClass: Class<T>): T { private val isArchived: Boolean
return modelClass.cast(ConversationListViewModel(isArchived))!! ) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
val savedStateHandle = extras.createSavedStateHandle()
return modelClass.cast(ConversationListViewModel(isArchived, savedStateHandle))!!
} }
} }
} }
@@ -1,8 +1,11 @@
package org.thoughtcrime.securesms.conversationlist.chatfilter package org.thoughtcrime.securesms.conversationlist.chatfilter
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
@Parcelize
data class ConversationFilterRequest( data class ConversationFilterRequest(
val filter: ConversationFilter, val filter: ConversationFilter,
val source: ConversationFilterSource val source: ConversationFilterSource
) ) : Parcelable