Add viewer count and list to 'All Signal Connections'.

This commit is contained in:
Alex Hart
2022-10-07 09:40:10 -03:00
committed by Greyson Parrelli
parent c239ba1e35
commit 842626e96c
20 changed files with 408 additions and 27 deletions

View File

@@ -11,6 +11,7 @@ import android.util.AttributeSet;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import org.signal.core.util.BreakIteratorCompat;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.SimpleEmojiTextView;
@@ -19,6 +20,7 @@ import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.SpanUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.Iterator;
import java.util.Objects;
public class FromTextView extends SimpleEmojiTextView {

View File

@@ -52,7 +52,8 @@ class ContactSearchConfiguration private constructor(
val includeSelf: Boolean,
val transportType: TransportType,
override val includeHeader: Boolean,
override val expandConfig: ExpandConfig? = null
override val expandConfig: ExpandConfig? = null,
val includeLetterHeaders: Boolean = false
) : Section(SectionKey.INDIVIDUALS)
/**

View File

@@ -23,7 +23,11 @@ sealed class ContactSearchData(val contactSearchKey: ContactSearchKey) {
/**
* A row displaying a known recipient.
*/
data class KnownRecipient(val recipient: Recipient, val shortSummary: Boolean = false) : ContactSearchData(ContactSearchKey.RecipientSearchKey.KnownRecipient(recipient.id))
data class KnownRecipient(
val recipient: Recipient,
val shortSummary: Boolean = false,
val headerLetter: String? = null
) : ContactSearchData(ContactSearchKey.RecipientSearchKey.KnownRecipient(recipient.id))
/**
* A row containing a title for a given section

View File

@@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.FromTextView
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
@@ -226,7 +227,10 @@ object ContactSearchItems {
}
}
private class KnownRecipientViewHolder(itemView: View, displayCheckBox: Boolean, onClick: RecipientClickListener) : BaseRecipientViewHolder<RecipientModel, ContactSearchData.KnownRecipient>(itemView, displayCheckBox, onClick) {
private class KnownRecipientViewHolder(itemView: View, displayCheckBox: Boolean, onClick: RecipientClickListener) : BaseRecipientViewHolder<RecipientModel, ContactSearchData.KnownRecipient>(itemView, displayCheckBox, onClick), LetterHeaderDecoration.LetterHeaderItem {
private var headerLetter: String? = null
override fun isSelected(model: RecipientModel): Boolean = model.isSelected
override fun getData(model: RecipientModel): ContactSearchData.KnownRecipient = model.knownRecipient
override fun getRecipient(model: RecipientModel): Recipient = model.knownRecipient.recipient
@@ -235,10 +239,16 @@ object ContactSearchItems {
if (model.shortSummary && recipient.isGroup) {
val count = recipient.participantIds.size
number.setText(context.resources.getQuantityString(R.plurals.ContactSearchItems__group_d_members, count, count))
number.text = context.resources.getQuantityString(R.plurals.ContactSearchItems__group_d_members, count, count)
} else {
super.bindNumberField(model)
}
headerLetter = model.knownRecipient.headerLetter
}
override fun getHeaderLetter(): String? {
return headerLetter
}
}

View File

@@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.StorySend
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import java.util.concurrent.TimeUnit
/**
@@ -129,6 +130,13 @@ class ContactSearchPagedDataSource(
}
}
private fun getNonGroupHeaderLetterMap(section: ContactSearchConfiguration.Section.Individuals, query: String?): Map<RecipientId, String> {
return when (section.transportType) {
ContactSearchConfiguration.TransportType.PUSH -> contactSearchPagedDataSourceRepository.querySignalContactLetterHeaders(query, section.includeSelf)
else -> error("This has only been implemented for push recipients.")
}
}
private fun getStoriesSearchIterator(query: String?): ContactSearchIterator<Cursor> {
return CursorSearchIterator(contactSearchPagedDataSourceRepository.getStories(query))
}
@@ -193,6 +201,12 @@ class ContactSearchPagedDataSource(
}
private fun getNonGroupContactsData(section: ContactSearchConfiguration.Section.Individuals, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
val headerMap: Map<RecipientId, String> = if (section.includeLetterHeaders) {
getNonGroupHeaderLetterMap(section, query)
} else {
emptyMap()
}
return getNonGroupSearchIterator(section, query).use { records ->
readContactData(
records = records,
@@ -201,7 +215,8 @@ class ContactSearchPagedDataSource(
startIndex = startIndex,
endIndex = endIndex,
recordMapper = {
ContactSearchData.KnownRecipient(contactSearchPagedDataSourceRepository.getRecipientFromRecipientCursor(it))
val recipient = contactSearchPagedDataSourceRepository.getRecipientFromRecipientCursor(it)
ContactSearchData.KnownRecipient(recipient, headerLetter = headerMap[recipient.id])
}
)
}

View File

@@ -36,6 +36,10 @@ open class ContactSearchPagedDataSourceRepository(
return contactRepository.querySignalContacts(query ?: "", includeSelf)
}
open fun querySignalContactLetterHeaders(query: String?, includeSelf: Boolean): Map<RecipientId, String> {
return SignalDatabase.recipients.querySignalContactLetterHeaders(query ?: "", includeSelf)
}
open fun queryNonSignalContacts(query: String?): Cursor? {
return contactRepository.queryNonSignalContacts(query ?: "")
}

View File

@@ -3112,6 +3112,44 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
return readableDatabase.query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, orderBy)
}
fun querySignalContactLetterHeaders(inputQuery: String, includeSelf: Boolean): Map<RecipientId, String> {
val searchSelection = ContactSearchSelection.Builder()
.withRegistered(true)
.withGroups(false)
.excludeId(if (includeSelf) null else Recipient.self().id)
.withSearchQuery(inputQuery)
.build()
return readableDatabase.query(
"""
SELECT
_id,
UPPER(SUBSTR($SORT_NAME, 0, 2)) AS letter_header
FROM (
SELECT ${SEARCH_PROJECTION.joinToString(", ")}
FROM recipient
WHERE ${searchSelection.where}
ORDER BY $SORT_NAME, $SYSTEM_JOINED_NAME, $SEARCH_PROFILE_NAME, $PHONE
)
GROUP BY letter_header
""".trimIndent(),
searchSelection.args
).use { cursor ->
if (cursor.count == 0) {
emptyMap()
} else {
val resultsMap = mutableMapOf<RecipientId, String>()
while (cursor.moveToNext()) {
cursor.requireString("letter_header")?.let {
resultsMap[RecipientId.from(cursor.requireLong(ID))] = it
}
}
resultsMap
}
}
}
fun getNonSignalContacts(): Cursor? {
val searchSelection = ContactSearchSelection.Builder().withNonRegistered(true)
.withGroups(false)

View File

@@ -0,0 +1,60 @@
package org.thoughtcrime.securesms.stories.settings.connections
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.WrapperDialogFragment
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator
import org.thoughtcrime.securesms.databinding.ViewAllSignalConnectionsFragmentBinding
import org.thoughtcrime.securesms.groups.SelectionLimits
class ViewAllSignalConnectionsFragment : Fragment(R.layout.view_all_signal_connections_fragment) {
private val binding by ViewBinderDelegate(ViewAllSignalConnectionsFragmentBinding::bind)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.recycler.addItemDecoration(LetterHeaderDecoration(requireContext()) { false })
binding.toolbar.setNavigationOnClickListener {
requireActivity().onBackPressedDispatcher.onBackPressed()
}
ContactSearchMediator(
fragment = this,
recyclerView = binding.recycler,
selectionLimits = SelectionLimits(0, 0),
displayCheckBox = false,
mapStateToConfiguration = { getConfiguration() },
performSafetyNumberChecks = false
)
}
private fun getConfiguration(): ContactSearchConfiguration {
return ContactSearchConfiguration.build {
addSection(
ContactSearchConfiguration.Section.Individuals(
includeHeader = false,
includeSelf = false,
includeLetterHeaders = true,
transportType = ContactSearchConfiguration.TransportType.PUSH
)
)
}
}
class Dialog : WrapperDialogFragment() {
override fun getWrappedFragment(): Fragment {
return ViewAllSignalConnectionsFragment()
}
companion object {
fun show(fragmentManager: FragmentManager) {
Dialog().show(fragmentManager, null)
}
}
}
}

View File

@@ -0,0 +1,70 @@
package org.thoughtcrime.securesms.stories.settings.my
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.databinding.AllSignalConnectionsRowItemBinding
import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.visible
/**
* AllSignalConnections privacy setting row item with "View" support
*/
object AllSignalConnectionsRowItem {
private const val IS_CHECKED = 0
private const val IS_COUNT = 1
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(Model::class.java, BindingFactory(::ViewHolder, AllSignalConnectionsRowItemBinding::inflate))
}
class Model(
val isChecked: Boolean,
val count: Int,
val onRowClicked: () -> Unit,
val onViewClicked: () -> Unit
) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean = true
override fun areContentsTheSame(newItem: Model): Boolean = isChecked == newItem.isChecked && count == newItem.count
override fun getChangePayload(newItem: Model): Any? {
val isCheckedDifferent = isChecked != newItem.isChecked
val isCountDifferent = count != newItem.count
return when {
isCheckedDifferent && !isCountDifferent -> IS_CHECKED
!isCheckedDifferent && isCountDifferent -> IS_COUNT
else -> null
}
}
}
private class ViewHolder(binding: AllSignalConnectionsRowItemBinding) : BindingViewHolder<Model, AllSignalConnectionsRowItemBinding>(binding) {
override fun bind(model: Model) {
binding.root.setOnClickListener { model.onRowClicked() }
binding.view.setOnClickListener { model.onViewClicked() }
when {
payload.contains(IS_COUNT) -> presentCount(model.count)
payload.contains(IS_CHECKED) -> presentSelected(model.isChecked)
else -> {
presentCount(model.count)
presentSelected(model.isChecked)
}
}
}
private fun presentCount(count: Int) {
binding.count.visible = count > 0
binding.count.text = context.resources.getQuantityString(R.plurals.MyStorySettingsFragment__viewers, count, count)
}
private fun presentSelected(isChecked: Boolean) {
binding.radio.isChecked = isChecked
}
}
}

View File

@@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.stories.settings.connections.ViewAllSignalConnectionsFragment
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -38,6 +39,7 @@ class MyStorySettingsFragment : DSLSettingsFragment(
}
override fun bindAdapter(adapter: MappingAdapter) {
AllSignalConnectionsRowItem.register(adapter)
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
@@ -47,14 +49,18 @@ class MyStorySettingsFragment : DSLSettingsFragment(
return configure {
sectionHeaderPref(R.string.MyStorySettingsFragment__who_can_view_this_story)
radioPref(
title = DSLSettingsText.from(R.string.MyStorySettingsFragment__all_signal_connections),
summary = DSLSettingsText.from(R.string.MyStorySettingsFragment__share_with_all_connections),
isChecked = state.myStoryPrivacyState.privacyMode == DistributionListPrivacyMode.ALL,
onClick = {
lifecycleDisposable += viewModel.setMyStoryPrivacyMode(DistributionListPrivacyMode.ALL)
.subscribe()
}
customPref(
AllSignalConnectionsRowItem.Model(
isChecked = state.myStoryPrivacyState.privacyMode == DistributionListPrivacyMode.ALL,
count = state.allSignalConnectionsCount,
onRowClicked = {
lifecycleDisposable += viewModel.setMyStoryPrivacyMode(DistributionListPrivacyMode.ALL)
.subscribe()
},
onViewClicked = {
ViewAllSignalConnectionsFragment.Dialog.show(parentFragmentManager)
}
)
)
val exceptText = if (state.myStoryPrivacyState.privacyMode == DistributionListPrivacyMode.ALL_EXCEPT) {

View File

@@ -22,11 +22,15 @@ class MyStorySettingsRepository {
}
fun observeChooseInitialPrivacy(): Observable<ChooseInitialMyStoryMembershipState> {
return Single.fromCallable { SignalDatabase.distributionLists.getRecipientId(DistributionListId.MY_STORY)!! }
return Single
.fromCallable { SignalDatabase.distributionLists.getRecipientId(DistributionListId.MY_STORY)!! }
.subscribeOn(Schedulers.io())
.flatMapObservable { recipientId ->
Recipient.observable(recipientId)
val allSignalConnectionsCount = getAllSignalConnectionsCount().toObservable()
val stateWithoutCount = Recipient.observable(recipientId)
.flatMap { Observable.just(ChooseInitialMyStoryMembershipState(recipientId = recipientId, privacyState = getStoryPrivacyState())) }
Observable.combineLatest(allSignalConnectionsCount, stateWithoutCount) { count, state -> state.copy(allSignalConnectionsCount = count) }
}
}
@@ -50,6 +54,12 @@ class MyStorySettingsRepository {
}.subscribeOn(Schedulers.io())
}
fun getAllSignalConnectionsCount(): Single<Int> {
return Single.fromCallable {
SignalDatabase.recipients.getSignalContactsCount(false)
}.subscribeOn(Schedulers.io())
}
@WorkerThread
private fun getStoryPrivacyState(): MyStoryPrivacyState {
val privacyData: DistributionListPrivacyData = SignalDatabase.distributionLists.getPrivacyData(DistributionListId.MY_STORY)

View File

@@ -2,5 +2,6 @@ package org.thoughtcrime.securesms.stories.settings.my
data class MyStorySettingsState(
val myStoryPrivacyState: MyStoryPrivacyState = MyStoryPrivacyState(),
val areRepliesAndReactionsEnabled: Boolean = false
val areRepliesAndReactionsEnabled: Boolean = false,
val allSignalConnectionsCount: Int = 0
)

View File

@@ -25,6 +25,8 @@ class MyStorySettingsViewModel @JvmOverloads constructor(private val repository:
.subscribe { myStoryPrivacyState -> store.update { it.copy(myStoryPrivacyState = myStoryPrivacyState) } }
disposables += repository.getRepliesAndReactionsEnabled()
.subscribe { repliesAndReactionsEnabled -> store.update { it.copy(areRepliesAndReactionsEnabled = repliesAndReactionsEnabled) } }
disposables += repository.getAllSignalConnectionsCount()
.subscribe { allSignalConnectionsCount -> store.update { it.copy(allSignalConnectionsCount = allSignalConnectionsCount) } }
}
fun setRepliesAndReactionsEnabled(repliesAndReactionsEnabled: Boolean) {

View File

@@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialog
import org.thoughtcrime.securesms.components.WrapperDialogFragment
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stories.settings.connections.ViewAllSignalConnectionsFragment
import org.thoughtcrime.securesms.stories.settings.select.BaseStoryRecipientSelectionFragment
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.LifecycleDisposable
@@ -42,9 +43,12 @@ class ChooseInitialMyStoryMembershipBottomSheetDialogFragment :
private lateinit var allExceptRadio: MaterialRadioButton
private lateinit var onlyWitRadio: MaterialRadioButton
private lateinit var allCount: TextView
private lateinit var allExceptCount: TextView
private lateinit var onlyWithCount: TextView
private lateinit var allView: View
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.choose_initial_my_story_membership_fragment, container, false)
}
@@ -58,9 +62,15 @@ class ChooseInitialMyStoryMembershipBottomSheetDialogFragment :
allExceptRadio = view.findViewById(R.id.choose_initial_my_story_all_signal_connnections_except_radio)
onlyWitRadio = view.findViewById(R.id.choose_initial_my_story_only_share_with_radio)
allCount = view.findViewById(R.id.choose_initial_my_story_all_signal_connnections_count)
allExceptCount = view.findViewById(R.id.choose_initial_my_story_all_signal_connnections_except_count)
onlyWithCount = view.findViewById(R.id.choose_initial_my_story_only_share_with_count)
allView = view.findViewById(R.id.choose_initial_my_story_all_signal_connnections_view)
allView.setOnClickListener {
ViewAllSignalConnectionsFragment.Dialog.show(parentFragmentManager)
}
val save = view.findViewById<View>(R.id.choose_initial_my_story_save).apply {
isEnabled = false
}
@@ -76,6 +86,9 @@ class ChooseInitialMyStoryMembershipBottomSheetDialogFragment :
allExceptCount.visible = allExceptRadio.isChecked
onlyWithCount.visible = onlyWitRadio.isChecked
allCount.visible = state.allSignalConnectionsCount > 0
allCount.text = resources.getQuantityString(R.plurals.MyStorySettingsFragment__viewers, state.allSignalConnectionsCount, state.allSignalConnectionsCount)
when (state.privacyState.privacyMode) {
DistributionListPrivacyMode.ALL_EXCEPT -> allExceptCount.text = resources.getQuantityString(R.plurals.MyStorySettingsFragment__d_people_excluded, state.privacyState.connectionCount, state.privacyState.connectionCount)
DistributionListPrivacyMode.ONLY_WITH -> onlyWithCount.text = resources.getQuantityString(R.plurals.MyStorySettingsFragment__d_people, state.privacyState.connectionCount, state.privacyState.connectionCount)

View File

@@ -3,4 +3,8 @@ package org.thoughtcrime.securesms.stories.settings.privacy
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stories.settings.my.MyStoryPrivacyState
data class ChooseInitialMyStoryMembershipState(val recipientId: RecipientId? = null, val privacyState: MyStoryPrivacyState = MyStoryPrivacyState())
data class ChooseInitialMyStoryMembershipState(
val recipientId: RecipientId? = null,
val privacyState: MyStoryPrivacyState = MyStoryPrivacyState(),
val allSignalConnectionsCount: Int = 0
)