Release chat folders to internal users.

This commit is contained in:
Michelle Tang
2024-10-11 09:38:53 -07:00
committed by Greyson Parrelli
parent e5c122d972
commit c4fc32988c
64 changed files with 3166 additions and 251 deletions

View File

@@ -20,9 +20,9 @@ import org.thoughtcrime.securesms.util.rx.RxStore
*/
class ContactChipViewModel : ViewModel() {
private val store = RxStore(emptyList<SelectedContacts.Model>())
private val store = RxStore(emptyList<SelectedContacts.Model<*>>())
val state: Flowable<List<SelectedContacts.Model>> = store.stateFlowable
val state: Flowable<List<SelectedContacts.Model<*>>> = store.stateFlowable
.distinctUntilChanged()
.observeOn(AndroidSchedulers.mainThread())
@@ -39,20 +39,27 @@ class ContactChipViewModel : ViewModel() {
}
fun add(selectedContact: SelectedContact) {
disposables += getOrCreateRecipientId(selectedContact).map { Recipient.resolved(it) }.observeOn(Schedulers.io()).subscribe { recipient ->
store.update { it + SelectedContacts.Model(selectedContact, recipient) }
disposableMap[recipient.id]?.dispose()
disposableMap[recipient.id] = store.update(recipient.live().observable().toFlowable(BackpressureStrategy.LATEST)) { changedRecipient, state ->
val index = state.indexOfFirst { it.selectedContact.matches(selectedContact) }
when {
index == 0 -> {
listOf(SelectedContacts.Model(selectedContact, changedRecipient)) + state.drop(index + 1)
}
index > 0 -> {
state.take(index) + SelectedContacts.Model(selectedContact, changedRecipient) + state.drop(index + 1)
}
else -> {
state
if (selectedContact.hasChatType()) {
store.update { it + SelectedContacts.ChatTypeModel(selectedContact) }
} else {
disposables += getOrCreateRecipientId(selectedContact).map { Recipient.resolved(it) }.observeOn(Schedulers.io()).subscribe { recipient ->
store.update { it + SelectedContacts.RecipientModel(selectedContact, recipient) }
disposableMap[recipient.id]?.dispose()
disposableMap[recipient.id] = store.update(recipient.live().observable().toFlowable(BackpressureStrategy.LATEST)) { changedRecipient, state ->
val index = state.indexOfFirst { it.selectedContact.matches(selectedContact) }
when {
index == 0 -> {
listOf(SelectedContacts.RecipientModel(selectedContact, changedRecipient)) + state.drop(index + 1)
}
index > 0 -> {
state.take(index) + SelectedContacts.RecipientModel(selectedContact, changedRecipient) + state.drop(index + 1)
}
else -> {
state
}
}
}
}

View File

@@ -5,6 +5,7 @@ import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.contacts.paged.ChatType;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
import org.thoughtcrime.securesms.recipients.Recipient;
@@ -19,23 +20,29 @@ public final class SelectedContact {
private final RecipientId recipientId;
private final String number;
private final String username;
private final ChatType chatType;
public static @NonNull SelectedContact forPhone(@Nullable RecipientId recipientId, @NonNull String number) {
return new SelectedContact(recipientId, number, null);
return new SelectedContact(recipientId, number, null, null);
}
public static @NonNull SelectedContact forUsername(@Nullable RecipientId recipientId, @NonNull String username) {
return new SelectedContact(recipientId, null, username);
return new SelectedContact(recipientId, null, username, null);
}
public static @NonNull SelectedContact forChatType(@NonNull ChatType chatType) {
return new SelectedContact(null, null, null, chatType);
}
public static @NonNull SelectedContact forRecipientId(@NonNull RecipientId recipientId) {
return new SelectedContact(recipientId, null, null);
return new SelectedContact(recipientId, null, null, null);
}
private SelectedContact(@Nullable RecipientId recipientId, @Nullable String number, @Nullable String username) {
private SelectedContact(@Nullable RecipientId recipientId, @Nullable String number, @Nullable String username, @Nullable ChatType chatType) {
this.recipientId = recipientId;
this.number = number;
this.username = username;
this.chatType = chatType;
}
public @NonNull RecipientId getOrCreateRecipientId(@NonNull Context context) {
@@ -60,6 +67,14 @@ public final class SelectedContact {
return username != null;
}
public boolean hasChatType() {
return chatType != null;
}
public ChatType getChatType() {
return chatType;
}
public @NonNull ContactSearchKey toContactSearchKey() {
if (recipientId != null) {
return new ContactSearchKey.RecipientSearchKey(recipientId, false);
@@ -67,6 +82,8 @@ public final class SelectedContact {
return new ContactSearchKey.UnknownRecipientKey(ContactSearchConfiguration.SectionKey.PHONE_NUMBER, number);
} else if (username != null) {
return new ContactSearchKey.UnknownRecipientKey(ContactSearchConfiguration.SectionKey.USERNAME, username);
} else if (chatType != null) {
return new ContactSearchKey.ChatTypeSearchKey(chatType);
} else {
throw new IllegalStateException("Nothing to map!");
}
@@ -86,6 +103,7 @@ public final class SelectedContact {
}
return number != null && number .equals(other.number) ||
username != null && username.equals(other.username);
username != null && username.equals(other.username) ||
chatType != null && chatType.equals(other.chatType);
}
}

View File

@@ -1,8 +1,12 @@
package org.thoughtcrime.securesms.contacts
import android.content.res.ColorStateList
import android.view.View
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat
import com.bumptech.glide.Glide
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ChatType
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@@ -11,25 +15,28 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
object SelectedContacts {
@JvmStatic
fun register(adapter: MappingAdapter, onCloseIconClicked: (Model) -> Unit) {
adapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it, onCloseIconClicked) }, R.layout.contact_selection_list_chip))
fun register(adapter: MappingAdapter, onCloseIconClicked: (Model<*>) -> Unit) {
adapter.registerFactory(RecipientModel::class.java, LayoutFactory({ RecipientViewHolder(it, onCloseIconClicked) }, R.layout.contact_selection_list_chip))
adapter.registerFactory(ChatTypeModel::class.java, LayoutFactory({ ChatTypeViewHolder(it, onCloseIconClicked) }, R.layout.contact_selection_list_chip))
}
class Model(val selectedContact: SelectedContact, val recipient: Recipient) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean {
sealed class Model<T : Any>(val selectedContact: SelectedContact) : MappingModel<T>
class RecipientModel(selectedContact: SelectedContact, val recipient: Recipient) : Model<RecipientModel>(selectedContact = selectedContact) {
override fun areItemsTheSame(newItem: RecipientModel): Boolean {
return newItem.selectedContact.matches(selectedContact) && recipient == newItem.recipient
}
override fun areContentsTheSame(newItem: Model): Boolean {
override fun areContentsTheSame(newItem: RecipientModel): Boolean {
return areItemsTheSame(newItem) && recipient.hasSameContent(newItem.recipient)
}
}
private class ViewHolder(itemView: View, private val onCloseIconClicked: (Model) -> Unit) : MappingViewHolder<Model>(itemView) {
private class RecipientViewHolder(itemView: View, private val onCloseIconClicked: (RecipientModel) -> Unit) : MappingViewHolder<RecipientModel>(itemView) {
private val chip: ContactChip = itemView.findViewById(R.id.contact_chip)
override fun bind(model: Model) {
override fun bind(model: RecipientModel) {
chip.text = model.recipient.getShortDisplayName(context)
chip.setContact(model.selectedContact)
chip.isCloseIconVisible = true
@@ -39,4 +46,36 @@ object SelectedContacts {
chip.setAvatar(Glide.with(itemView), model.recipient, null)
}
}
class ChatTypeModel(selectedContact: SelectedContact) : Model<ChatTypeModel>(selectedContact = selectedContact) {
override fun areItemsTheSame(newItem: ChatTypeModel): Boolean {
return newItem.selectedContact.matches(selectedContact) && newItem.selectedContact.chatType == selectedContact.chatType
}
override fun areContentsTheSame(newItem: ChatTypeModel): Boolean {
return areItemsTheSame(newItem)
}
}
private class ChatTypeViewHolder(itemView: View, private val onCloseIconClicked: (ChatTypeModel) -> Unit) : MappingViewHolder<ChatTypeModel>(itemView) {
private val chip: ContactChip = itemView.findViewById(R.id.contact_chip)
override fun bind(model: ChatTypeModel) {
if (model.selectedContact.chatType == ChatType.INDIVIDUAL) {
chip.text = context.getString(R.string.ChatFoldersFragment__one_on_one_chats)
chip.chipIcon = AppCompatResources.getDrawable(context, R.drawable.symbol_person_light_24)
chip.chipIconTint = ColorStateList.valueOf(ContextCompat.getColor(context, R.color.signal_colorOnSurface))
} else {
chip.text = context.getString(R.string.ChatFoldersFragment__groups)
chip.chipIcon = AppCompatResources.getDrawable(context, R.drawable.symbol_group_light_20)
chip.chipIconTint = ColorStateList.valueOf(ContextCompat.getColor(context, R.color.signal_colorOnSurface))
}
chip.setContact(model.selectedContact)
chip.isCloseIconVisible = true
chip.setOnCloseIconClickListener {
onCloseIconClicked(model)
}
}
}
}

View File

@@ -0,0 +1,9 @@
package org.thoughtcrime.securesms.contacts.paged
/**
* Enum class that represents the different chat types a chat folder can have
*/
enum class ChatType {
INDIVIDUAL,
GROUPS
}

View File

@@ -5,6 +5,7 @@ import android.text.SpannableStringBuilder
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.content.ContextCompat
@@ -57,6 +58,7 @@ open class ContactSearchAdapter(
registerKnownRecipientItems(this, fixedContacts, displayOptions, onClickCallbacks::onKnownRecipientClicked, longClickCallbacks::onKnownRecipientLongClick, callButtonClickCallbacks)
registerHeaders(this)
registerExpands(this, onClickCallbacks::onExpandClicked)
registerChatTypeItems(this, onClickCallbacks::onChatTypeClicked)
registerFactory(UnknownRecipientModel::class.java, LayoutFactory({ UnknownRecipientViewHolder(it, onClickCallbacks::onUnknownRecipientClicked, displayOptions.displayCheckBox) }, R.layout.contact_search_unknown_item))
}
@@ -117,6 +119,13 @@ open class ContactSearchAdapter(
)
}
fun registerChatTypeItems(mappingAdapter: MappingAdapter, chatTypeRowListener: OnClickedCallback<ContactSearchData.ChatTypeRow>) {
mappingAdapter.registerFactory(
ChatTypeModel::class.java,
LayoutFactory({ ChatTypeViewHolder(it, chatTypeRowListener) }, R.layout.contact_search_chat_type_item)
)
}
fun toMappingModelList(contactSearchData: List<ContactSearchData?>, selection: Set<ContactSearchKey>, arbitraryRepository: ArbitraryRepository?): MappingModelList {
return MappingModelList(
contactSearchData.filterNotNull().map {
@@ -132,6 +141,7 @@ open class ContactSearchAdapter(
is ContactSearchData.Empty -> EmptyModel(it)
is ContactSearchData.GroupWithMembers -> GroupWithMembersModel(it)
is ContactSearchData.UnknownRecipient -> UnknownRecipientModel(it)
is ContactSearchData.ChatTypeRow -> ChatTypeModel(it, selection.contains(it.contactSearchKey))
}
}
)
@@ -675,6 +685,7 @@ open class ContactSearchAdapter(
ContactSearchConfiguration.SectionKey.MESSAGES -> R.string.ContactsCursorLoader__messages
ContactSearchConfiguration.SectionKey.GROUPS_WITH_MEMBERS -> R.string.ContactsCursorLoader_group_members
ContactSearchConfiguration.SectionKey.CONTACTS_WITHOUT_THREADS -> R.string.ContactsCursorLoader_contacts
ContactSearchConfiguration.SectionKey.CHAT_TYPES -> R.string.ContactsCursorLoader__chat_types
else -> error("This section does not support HEADER")
}
)
@@ -712,6 +723,42 @@ open class ContactSearchAdapter(
}
}
/**
* Mapping Model for chat types.
*/
class ChatTypeModel(val data: ContactSearchData.ChatTypeRow, val isSelected: Boolean) : MappingModel<ChatTypeModel> {
override fun areItemsTheSame(newItem: ChatTypeModel): Boolean = data == newItem.data
override fun areContentsTheSame(newItem: ChatTypeModel): Boolean = data == newItem.data && isSelected == newItem.isSelected
}
/**
* View Holder for chat types
*/
private class ChatTypeViewHolder(
itemView: View,
val onClick: OnClickedCallback<ContactSearchData.ChatTypeRow>
) : MappingViewHolder<ChatTypeModel>(itemView) {
val image: ImageView = itemView.findViewById(R.id.image)
val name: TextView = itemView.findViewById(R.id.name)
val checkbox: CheckBox = itemView.findViewById(R.id.check_box)
override fun bind(model: ChatTypeModel) {
itemView.setOnClickListener { onClick.onClicked(itemView, model.data, model.isSelected) }
image.setImageResource(model.data.imageResId)
if (model.data.chatType == ChatType.INDIVIDUAL) {
name.text = context.getString(R.string.ChatFoldersFragment__one_on_one_chats)
}
if (model.data.chatType == ChatType.GROUPS) {
name.text = context.getString(R.string.ChatFoldersFragment__groups)
}
checkbox.isChecked = model.isSelected
}
}
private class IsSelfComparator : Comparator<Recipient> {
override fun compare(lhs: Recipient?, rhs: Recipient?): Int {
val isLeftSelf = lhs?.isSelf == true
@@ -764,6 +811,7 @@ open class ContactSearchAdapter(
fun onUnknownRecipientClicked(view: View, unknownRecipient: ContactSearchData.UnknownRecipient, isSelected: Boolean) {
throw NotImplementedError()
}
fun onChatTypeClicked(view: View, chatTypeRow: ContactSearchData.ChatTypeRow, isSelected: Boolean)
}
interface CallButtonClickCallbacks {

View File

@@ -193,6 +193,18 @@ class ContactSearchConfiguration private constructor(
override val includeHeader: Boolean = false
override val expandConfig: ExpandConfig? = null
}
/**
* Chat types that are displayed when creating a chat folder.
*
* Key: [ContactSearchKey.ChatType]
* Data: [ContactSearchData.ChatTypeRow]
* Model: [ContactSearchAdapter.ChatTypeModel]
*/
data class ChatTypes(
override val includeHeader: Boolean = true,
override val expandConfig: ExpandConfig? = null
) : Section(SectionKey.CHAT_TYPES)
}
/**
@@ -234,6 +246,11 @@ class ContactSearchConfiguration private constructor(
*/
CONTACTS_WITHOUT_THREADS,
/**
* Chat types (ie unreads, 1:1, groups) that are used to customize folders
*/
CHAT_TYPES,
/**
* Arbitrary row (think new group button, username row, etc)
*/

View File

@@ -69,6 +69,14 @@ sealed class ContactSearchData(val contactSearchKey: ContactSearchKey) {
val action: HeaderAction?
) : ContactSearchData(ContactSearchKey.Header(sectionKey))
/**
* A row containing a chat type (filters that can be applied to a chat folders)
*/
class ChatTypeRow(
val imageResId: Int,
val chatType: ChatType
) : ContactSearchData(ContactSearchKey.ChatTypeSearchKey(chatType))
/**
* A row which the user can click to view all entries for a given section.
*/

View File

@@ -76,5 +76,14 @@ sealed class ContactSearchKey {
*/
data class Message(val messageId: Long) : ContactSearchKey()
/**
* Search key for a ChatType
*/
data class ChatTypeSearchKey(val chatType: ChatType) : ContactSearchKey() {
override fun requireSelectedContact(): SelectedContact {
return SelectedContact.forChatType(chatType)
}
}
object Empty : ContactSearchKey()
}

View File

@@ -87,6 +87,11 @@ class ContactSearchMediator(
Log.d(TAG, "onExpandClicked()")
viewModel.expandSection(expand.sectionKey)
}
override fun onChatTypeClicked(view: View, chatTypeRow: ContactSearchData.ChatTypeRow, isSelected: Boolean) {
Log.d(TAG, "onChatTypeClicked() chatType $chatTypeRow")
toggleChatTypeSelection(view, chatTypeRow, isSelected)
}
},
longClickCallbacks = ContactSearchAdapter.LongClickCallbacksAdapter(),
storyContextMenuCallbacks = StoryContextMenuCallbacks(),
@@ -188,6 +193,16 @@ class ContactSearchMediator(
}
}
private fun toggleChatTypeSelection(view: View, contactSearchData: ContactSearchData, isSelected: Boolean) {
return if (isSelected) {
Log.d(TAG, "toggleSelection(OFF) ${contactSearchData.contactSearchKey}")
viewModel.setKeysNotSelected(setOf(contactSearchData.contactSearchKey))
} else {
Log.d(TAG, "toggleSelection(ON) ${contactSearchData.contactSearchKey}")
viewModel.setKeysSelected(callbacks.onBeforeContactsSelected(view, setOf(contactSearchData.contactSearchKey)))
}
}
private inner class StoryContextMenuCallbacks : ContactSearchAdapter.StoryContextMenuCallbacks {
override fun onOpenStorySettings(story: ContactSearchData.Story) {
if (story.recipient.isMyStory) {

View File

@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.contacts.paged
import android.database.Cursor
import org.signal.core.util.requireLong
import org.signal.paging.PagedDataSource
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.ContactRepository
import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchCollection
import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchIterator
@@ -142,6 +143,7 @@ class ContactSearchPagedDataSource(
is ContactSearchConfiguration.Section.PhoneNumber -> if (isPossiblyPhoneNumber(query)) 1 else 0
is ContactSearchConfiguration.Section.Username -> if (isPossiblyUsername(query)) 1 else 0
is ContactSearchConfiguration.Section.Empty -> 1
is ContactSearchConfiguration.Section.ChatTypes -> getChatTypesData(section).size
}
}
@@ -181,6 +183,7 @@ class ContactSearchPagedDataSource(
is ContactSearchConfiguration.Section.PhoneNumber -> getPossiblePhoneNumber(section, query)
is ContactSearchConfiguration.Section.Username -> getPossibleUsername(section, query)
is ContactSearchConfiguration.Section.Empty -> listOf(ContactSearchData.Empty(query))
is ContactSearchConfiguration.Section.ChatTypes -> getChatTypesData(section)
}
}
@@ -348,6 +351,22 @@ class ContactSearchPagedDataSource(
}
}
// TODO [michelle]: Replace hardcoding chat types after building db
private fun getChatTypesData(section: ContactSearchConfiguration.Section.ChatTypes): List<ContactSearchData> {
val data = mutableListOf<ContactSearchData>()
if (section.includeHeader) {
data.add(ContactSearchData.Header(section.sectionKey, section.headerAction))
}
data.addAll(
listOf(
ContactSearchData.ChatTypeRow(R.drawable.symbol_person_light_24, ChatType.INDIVIDUAL),
ContactSearchData.ChatTypeRow(R.drawable.symbol_group_light_20, ChatType.GROUPS)
)
)
return data
}
private fun getContactsWithoutThreadsContactData(section: ContactSearchConfiguration.Section.ContactsWithoutThreads, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
return getContactsWithoutThreadsIterator(query).use { records ->
readContactData(

View File

@@ -21,6 +21,7 @@ class ContactSearchRepository {
val isSelectable = when (it) {
is ContactSearchKey.RecipientSearchKey -> canSelectRecipient(it.recipientId)
is ContactSearchKey.UnknownRecipientKey -> it.sectionKey == ContactSearchConfiguration.SectionKey.PHONE_NUMBER
is ContactSearchKey.ChatTypeSearchKey -> true
else -> false
}
ContactSearchSelectionResult(it, isSelectable)