diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index ada4ceda66..c063f30538 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -16,7 +16,6 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collect @@ -28,7 +27,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.databinding.ActivityHomeBinding -import network.loki.messenger.databinding.ViewMessageRequestBannerBinding import network.loki.messenger.libsession_util.ConfigBase import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe @@ -74,13 +72,11 @@ import org.thoughtcrime.securesms.preferences.SettingsActivity import org.thoughtcrime.securesms.showMuteDialog import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities -import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.IP2Country import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.show import java.io.IOException -import java.util.Locale import javax.inject.Inject @AndroidEntryPoint @@ -113,7 +109,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), get() = textSecurePreferences.getLocalNumber()!! private val homeAdapter: HomeAdapter by lazy { - HomeAdapter(context = this, configFactory = configFactory, listener = this) + HomeAdapter(context = this, configFactory = configFactory, listener = this, ::showMessageRequests, ::hideMessageRequests) } private val globalSearchAdapter = GlobalSearchAdapter { model -> @@ -185,7 +181,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), binding.seedReminderView.isVisible = false } } - setupMessageRequestsBanner() // Set up recycler view binding.globalSearchInputLayout.listener = this homeAdapter.setHasStableIds(true) @@ -218,9 +213,9 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), // Subscribe to threads and update the UI lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { - homeViewModel.threads + homeViewModel.data .filterNotNull() // We don't actually want the null value here as it indicates a loading state (maybe we need a loading state?) - .collectLatest { threads -> + .collectLatest { data -> val manager = binding.recyclerView.layoutManager as LinearLayoutManager val firstPos = manager.findFirstCompletelyVisibleItemPosition() val offsetTop = if(firstPos >= 0) { @@ -228,9 +223,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), manager.getDecoratedTop(view) - manager.getTopDecorationHeight(view) } ?: 0 } else 0 - homeAdapter.data = threads + homeAdapter.data = data if(firstPos >= 0) { manager.scrollToPositionWithOffset(firstPos, offsetTop) } - setupMessageRequestsBanner() updateEmptyState() } } @@ -341,34 +335,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), binding.newConversationButton.isVisible = !isShown } - private fun setupMessageRequestsBanner() { - val messageRequestCount = threadDb.unapprovedConversationCount - // Set up message requests - if (messageRequestCount > 0 && !textSecurePreferences.hasHiddenMessageRequests()) { - with(ViewMessageRequestBannerBinding.inflate(layoutInflater)) { - unreadCountTextView.text = messageRequestCount.toString() - timestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString( - this@HomeActivity, - Locale.getDefault(), - threadDb.latestUnapprovedConversationTimestamp - ) - root.setOnClickListener { showMessageRequests() } - root.setOnLongClickListener { hideMessageRequests(); true } - root.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT) - val hadHeader = homeAdapter.hasHeaderView() - homeAdapter.header = root - if (hadHeader) homeAdapter.notifyItemChanged(0) - else homeAdapter.notifyItemInserted(0) - } - } else { - val hadHeader = homeAdapter.hasHeaderView() - homeAdapter.header = null - if (hadHeader) { - homeAdapter.notifyItemRemoved(0) - } - } - } - private fun updateLegacyConfigView() { binding.configOutdatedView.isVisible = ConfigBase.isNewConfigEnabled(textSecurePreferences.hasForcedNewConfig(), SnodeAPI.nowWithOffset) && textSecurePreferences.getHasLegacyConfig() @@ -664,7 +630,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), text("Hide message requests?") button(R.string.yes) { textSecurePreferences.setHasHiddenMessageRequests() - setupMessageRequestsBanner() homeViewModel.tryReload() } button(R.string.no) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt index 63e0fed5b6..571adb7358 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt @@ -9,13 +9,18 @@ import androidx.recyclerview.widget.ListUpdateCallback import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.NO_ID import network.loki.messenger.R +import network.loki.messenger.databinding.ViewMessageRequestBannerBinding import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.mms.GlideRequests +import org.thoughtcrime.securesms.util.DateUtils +import java.util.Locale class HomeAdapter( private val context: Context, private val configFactory: ConfigFactory, - private val listener: ConversationClickListener + private val listener: ConversationClickListener, + private val showMessageRequests: () -> Unit, + private val hideMessageRequests: () -> Unit, ) : RecyclerView.Adapter(), ListUpdateCallback { companion object { @@ -23,13 +28,21 @@ class HomeAdapter( private const val ITEM = 1 } - var header: View? = null + var messageRequests: HomeViewModel.MessageRequests? = null + set(value) { + if (field == value) return + val hadHeader = hasHeaderView() + field = value + if (value != null) { + if (hadHeader) notifyItemChanged(0) else notifyItemInserted(0) + } else if (hadHeader) notifyItemRemoved(0) + } - var data: HomeViewModel.Data = HomeViewModel.Data(emptyList(), emptySet()) + var data: HomeViewModel.Data = HomeViewModel.Data() set(newData) { - if (field === newData) { - return - } + if (field === newData) return + + messageRequests = newData.messageRequests val diff = HomeDiffUtil(field, newData, context, configFactory) val diffResult = DiffUtil.calculateDiff(diff) @@ -37,10 +50,10 @@ class HomeAdapter( diffResult.dispatchUpdatesTo(this as ListUpdateCallback) } - fun hasHeaderView(): Boolean = header != null + fun hasHeaderView(): Boolean = messageRequests != null private val headerCount: Int - get() = if (header == null) 0 else 1 + get() = if (messageRequests == null) 0 else 1 override fun onInserted(position: Int, count: Int) { notifyItemRangeInserted(position + headerCount, count) @@ -69,7 +82,11 @@ class HomeAdapter( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = when (viewType) { HEADER -> { - HeaderFooterViewHolder(header!!) + ViewMessageRequestBannerBinding.inflate(LayoutInflater.from(parent.context)).apply { + root.setOnClickListener { showMessageRequests() } + root.setOnLongClickListener { hideMessageRequests(); true } + root.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT) + }.let(::HeaderFooterViewHolder) } ITEM -> { val conversationView = LayoutInflater.from(parent.context).inflate(R.layout.view_conversation, parent, false) as ConversationView @@ -85,19 +102,27 @@ class HomeAdapter( } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - if (holder is ConversationViewHolder) { - val offset = if (hasHeaderView()) position - 1 else position - val thread = data.threads[offset] - val isTyping = data.typingThreadIDs.contains(thread.threadId) - holder.view.bind(thread, isTyping, glide) + when (holder) { + is HeaderFooterViewHolder -> { + holder.binding.run { + messageRequests?.let { + unreadCountTextView.text = it.count + timestampTextView.text = it.timestamp + } + } + } + is ConversationViewHolder -> { + val offset = if (hasHeaderView()) position - 1 else position + val thread = data.threads[offset] + val isTyping = data.typingThreadIDs.contains(thread.threadId) + holder.view.bind(thread, isTyping, glide) + } } } override fun onViewRecycled(holder: RecyclerView.ViewHolder) { if (holder is ConversationViewHolder) { holder.view.recycle() - } else { - super.onViewRecycled(holder) } } @@ -109,6 +134,5 @@ class HomeAdapter( class ConversationViewHolder(val view: ConversationView) : RecyclerView.ViewHolder(view) - class HeaderFooterViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) - -} \ No newline at end of file + class HeaderFooterViewHolder(val binding: ViewMessageRequestBannerBinding) : RecyclerView.ViewHolder(binding.root) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt index dccebd4156..fa18a995b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -2,11 +2,13 @@ package org.thoughtcrime.securesms.home import android.content.ContentResolver import android.content.Context +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -15,24 +17,31 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.withContext +import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.ThreadRecord +import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.observeChanges +import java.util.Locale import javax.inject.Inject import dagger.hilt.android.qualifiers.ApplicationContext as ApplicationContextQualifier @HiltViewModel class HomeViewModel @Inject constructor( - private val threadDb: ThreadDatabase, - private val contentResolver: ContentResolver, - @ApplicationContextQualifier private val context: Context, + private val threadDb: ThreadDatabase, + private val contentResolver: ContentResolver, + private val prefs: TextSecurePreferences, + @ApplicationContextQualifier private val context: Context, ) : ViewModel() { // SharedFlow that emits whenever the user asks us to reload the conversation private val manualReloadTrigger = MutableSharedFlow( @@ -46,8 +55,19 @@ class HomeViewModel @Inject constructor( * This flow will emit whenever the user asks us to reload the conversation list or * whenever the conversation list changes. */ - val threads: StateFlow = combine(observeConversationList(), observeTypingStatus(), ::Data) - .stateIn(viewModelScope, SharingStarted.Eagerly, null) + val data: StateFlow = combine( + observeConversationList(), + observeTypingStatus(), + messageRequests(), + ::Data + ) + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + private fun hasHiddenMessageRequests() = TextSecurePreferences.events + .filter { it == TextSecurePreferences.HAS_HIDDEN_MESSAGE_REQUESTS } + .flowOn(Dispatchers.IO) + .map { prefs.hasHiddenMessageRequests() } + .onStart { emit(prefs.hasHiddenMessageRequests()) } private fun observeTypingStatus(): Flow> = ApplicationContext.getInstance(context).typingStatusRepository @@ -56,32 +76,55 @@ class HomeViewModel @Inject constructor( .onStart { emit(emptySet()) } .distinctUntilChanged() + private fun messageRequests() = combine( + unapprovedConversationCount(), + hasHiddenMessageRequests(), + latestUnapprovedConversationTimestamp(), + ::createMessageRequests + ) + + private fun unapprovedConversationCount() = reloadTriggersAndContentChanges() + .map { threadDb.unapprovedConversationCount } + + private fun latestUnapprovedConversationTimestamp() = reloadTriggersAndContentChanges() + .map { threadDb.latestUnapprovedConversationTimestamp } + @Suppress("OPT_IN_USAGE") - private fun observeConversationList(): Flow> = merge( - manualReloadTrigger, - contentResolver.observeChanges(DatabaseContentProviders.ConversationList.CONTENT_URI)) - .debounce(CHANGE_NOTIFICATION_DEBOUNCE_MILLS) - .onStart { emit(Unit) } - .mapLatest { _ -> - withContext(Dispatchers.IO) { - threadDb.approvedConversationList.use { openCursor -> - val reader = threadDb.readerFor(openCursor) - buildList(reader.count) { - while (true) { - add(reader.next ?: break) - } - } - } - } + private fun observeConversationList(): Flow> = reloadTriggersAndContentChanges() + .mapLatest { _ -> + threadDb.approvedConversationList.use { openCursor -> + threadDb.readerFor(openCursor).run { generateSequence { next }.toList() } } + } + + @OptIn(FlowPreview::class) + private fun reloadTriggersAndContentChanges() = merge( + manualReloadTrigger, + contentResolver.observeChanges(DatabaseContentProviders.ConversationList.CONTENT_URI) + ) + .flowOn(Dispatchers.IO) + .debounce(CHANGE_NOTIFICATION_DEBOUNCE_MILLS) + .onStart { emit(Unit) } fun tryReload() = manualReloadTrigger.tryEmit(Unit) data class Data( - val threads: List, - val typingThreadIDs: Set + val threads: List = emptyList(), + val typingThreadIDs: Set = emptySet(), + val messageRequests: MessageRequests? = null ) + fun createMessageRequests( + count: Int, + hidden: Boolean, + timestamp: Long + ) = if (count > 0 && !hidden) MessageRequests( + count.toString(), + DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), timestamp) + ) else null + + data class MessageRequests(val count: String, val timestamp: String) + companion object { private const val CHANGE_NOTIFICATION_DEBOUNCE_MILLS = 100L }