Add rendering and handling for various disabled input states in CFv2.

This commit is contained in:
Cody Henthorne
2023-05-22 20:33:31 -04:00
committed by Nicholas
parent ad50c81a6b
commit 1099128513
17 changed files with 668 additions and 245 deletions

View File

@@ -4275,24 +4275,10 @@ public class ConversationParentFragment extends Fragment
return;
}
AlertDialog.Builder builder = new MaterialAlertDialogBuilder(requireContext())
.setNeutralButton(R.string.ConversationActivity_cancel, (d, w) -> d.dismiss());
if (recipient.isGroup() && recipient.isBlocked()) {
builder.setTitle(R.string.ConversationActivity_delete_conversation);
builder.setMessage(R.string.ConversationActivity_this_conversation_will_be_deleted_from_all_of_your_devices);
builder.setPositiveButton(R.string.ConversationActivity_delete, (d, w) -> requestModel.onDelete());
} else if (recipient.isGroup()) {
builder.setTitle(R.string.ConversationActivity_delete_and_leave_group);
builder.setMessage(R.string.ConversationActivity_you_will_leave_this_group_and_it_will_be_deleted_from_all_of_your_devices);
builder.setNegativeButton(R.string.ConversationActivity_delete_and_leave, (d, w) -> requestModel.onDelete());
} else {
builder.setTitle(R.string.ConversationActivity_delete_conversation);
builder.setMessage(R.string.ConversationActivity_this_conversation_will_be_deleted_from_all_of_your_devices);
builder.setNegativeButton(R.string.ConversationActivity_delete, (d, w) -> requestModel.onDelete());
}
builder.show();
ConversationDialogs.displayDeleteDialog(requireContext(), recipient, () -> {
requestModel.onDelete();
return Unit.INSTANCE;
});
}
private void onMessageRequestBlockClicked(@NonNull MessageRequestViewModel requestModel) {

View File

@@ -112,4 +112,26 @@ object ConversationDialogs {
}
.show()
}
@JvmStatic
fun displayDeleteDialog(context: Context, recipient: Recipient, onDelete: () -> Unit) {
MaterialAlertDialogBuilder(context)
.setNeutralButton(R.string.ConversationActivity_cancel, null)
.apply {
if (recipient.isGroup && recipient.isBlocked) {
setTitle(R.string.ConversationActivity_delete_conversation)
setMessage(R.string.ConversationActivity_this_conversation_will_be_deleted_from_all_of_your_devices)
setPositiveButton(R.string.ConversationActivity_delete) { _, _ -> onDelete() }
} else if (recipient.isGroup) {
setTitle(R.string.ConversationActivity_delete_and_leave_group)
setMessage(R.string.ConversationActivity_you_will_leave_this_group_and_it_will_be_deleted_from_all_of_your_devices)
setNegativeButton(R.string.ConversationActivity_delete_and_leave) { _, _ -> onDelete() }
} else {
setTitle(R.string.ConversationActivity_delete_conversation)
setMessage(R.string.ConversationActivity_this_conversation_will_be_deleted_from_all_of_your_devices)
setNegativeButton(R.string.ConversationActivity_delete) { _, _ -> onDelete() }
}
}
.show()
}
}

View File

@@ -46,14 +46,18 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import org.greenrobot.eventbus.EventBus
import org.signal.core.util.Result
import org.signal.core.util.ThreadUtil
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.concurrent.addTo
import org.signal.core.util.logging.Log
import org.signal.core.util.orNull
import org.signal.libsignal.protocol.InvalidMessageException
import org.thoughtcrime.securesms.BlockUnblockDialog
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
@@ -86,6 +90,7 @@ import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.ConversationOptionsMenu
import org.thoughtcrime.securesms.conversation.MarkReadHelper
import org.thoughtcrime.securesms.conversation.MessageSendType
import org.thoughtcrime.securesms.conversation.ShowAdminsBottomSheetDialog
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
@@ -112,10 +117,12 @@ import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionPlayerHolder
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionRecycler
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason
import org.thoughtcrime.securesms.groups.ui.GroupErrors
import org.thoughtcrime.securesms.groups.ui.invitesandrequests.invite.GroupLinkInviteFriendsBottomSheetDialogFragment
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupDescriptionDialog
import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationInfoBottomSheetDialogFragment
import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationInitiationBottomSheetDialogFragment
import org.thoughtcrime.securesms.groups.v2.GroupBlockJoinRequestResult
import org.thoughtcrime.securesms.invites.InviteActions
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -127,6 +134,8 @@ import org.thoughtcrime.securesms.mediapreview.MediaPreviewV2Activity
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.messagedetails.MessageDetailsFragment
import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository
import org.thoughtcrime.securesms.messagerequests.MessageRequestState
import org.thoughtcrime.securesms.mms.AttachmentManager
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.SlideDeck
@@ -139,6 +148,7 @@ import org.thoughtcrime.securesms.recipients.RecipientExporter
import org.thoughtcrime.securesms.recipients.RecipientFormattingException
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.thoughtcrime.securesms.stickers.StickerPackPreviewActivity
@@ -149,11 +159,13 @@ import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.ContextUtil
import org.thoughtcrime.securesms.util.DrawableUtil
import org.thoughtcrime.securesms.util.FullscreenHelper
import org.thoughtcrime.securesms.util.PlayStoreUtil
import org.thoughtcrime.securesms.util.SignalLocalMetrics
import org.thoughtcrime.securesms.util.WindowUtil
import org.thoughtcrime.securesms.util.doAfterNextLayout
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.hasGiftBadge
import org.thoughtcrime.securesms.util.viewModel
import org.thoughtcrime.securesms.util.visible
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
import org.thoughtcrime.securesms.wallpaper.ChatWallpaperDimLevelUtil
@@ -176,17 +188,21 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
ConversationRecipientRepository(args.threadId)
}
private val messageRequestRepository: MessageRequestRepository by lazy {
MessageRequestRepository(requireContext())
}
private val disposables = LifecycleDisposable()
private val binding by ViewBinderDelegate(V2ConversationFragmentBinding::bind)
private val viewModel: ConversationViewModel by viewModels(
factoryProducer = {
ConversationViewModel.Factory(
args,
ConversationRepository(requireContext()),
conversationRecipientRepository
)
}
)
private val viewModel: ConversationViewModel by viewModel {
ConversationViewModel(
args.threadId,
args.startingPosition,
ConversationRepository(requireContext()),
conversationRecipientRepository,
messageRequestRepository
)
}
private val groupCallViewModel: ConversationGroupCallViewModel by viewModels(
factoryProducer = {
@@ -200,6 +216,10 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
}
)
private val messageRequestViewModel: MessageRequestViewModel by viewModel {
MessageRequestViewModel(args.threadId, conversationRecipientRepository, messageRequestRepository)
}
private val conversationTooltips = ConversationTooltips(this)
private val colorizer = Colorizer()
@@ -264,6 +284,13 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
observeConversationThread()
viewModel
.inputReadyState
.subscribeBy(
onNext = this::presentInputReadyState
)
.addTo(disposables)
container.fragmentManager = childFragmentManager
}
@@ -346,6 +373,9 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
adapter.notifyItemRangeChanged(0, adapter.itemCount)
})
val disabledInputListener = DisabledInputListener()
binding.conversationDisabledInput.listener = disabledInputListener
val sendButtonListener = SendButtonListener()
val composeTextEventsListener = ComposeTextEventsListener()
@@ -397,6 +427,28 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
childFragmentManager.setFragmentResultListener(AttachmentKeyboardFragment.RESULT_KEY, viewLifecycleOwner, AttachmentKeyboardFragmentListener())
}
private fun presentInputReadyState(inputReadyState: InputReadyState) {
val disabledInputView = binding.conversationDisabledInput
var inputDisabled = true
when {
inputReadyState.isClientExpired || inputReadyState.isUnauthorized -> disabledInputView.showAsExpiredOrUnauthorized(inputReadyState.isClientExpired, inputReadyState.isUnauthorized)
inputReadyState.messageRequestState != MessageRequestState.NONE -> disabledInputView.showAsMessageRequest(inputReadyState.conversationRecipient, inputReadyState.messageRequestState)
inputReadyState.isActiveGroup == false -> disabledInputView.showAsNoLongerAMember()
inputReadyState.isRequestingMember == true -> disabledInputView.showAsRequestingMember()
inputReadyState.isAnnouncementGroup == true && inputReadyState.isAdmin == false -> disabledInputView.showAsAnnouncementGroupAdminsOnly()
else -> inputDisabled = false
}
inputPanel.setHideForMessageRequestState(inputDisabled)
if (inputDisabled) {
WindowUtil.setNavigationBarColor(requireActivity(), disabledInputView.color)
} else {
disabledInputView.clear()
}
}
private fun calculateCharactersRemaining() {
val messageBody: String = binding.conversationInputPanel.embeddedTextEditor.textTrimmed.toString()
val charactersLeftView: TextView = binding.conversationInputSpaceLeft
@@ -473,10 +525,13 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
binding.conversationWallpaperDim.visible = false
}
binding.conversationWallpaper.visible = chatWallpaper != null
binding.scrollToBottom.setWallpaperEnabled(chatWallpaper != null)
binding.scrollToMention.setWallpaperEnabled(chatWallpaper != null)
adapter.onHasWallpaperChanged(chatWallpaper != null)
val wallpaperEnabled = chatWallpaper != null
binding.conversationWallpaper.visible = wallpaperEnabled
binding.scrollToBottom.setWallpaperEnabled(wallpaperEnabled)
binding.scrollToMention.setWallpaperEnabled(wallpaperEnabled)
binding.conversationDisabledInput.setWallpaperEnabled(wallpaperEnabled)
adapter.onHasWallpaperChanged(wallpaperEnabled)
}
private fun presentChatColors(chatColors: ChatColors) {
@@ -1238,6 +1293,128 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
}
}
//region Disabled Input Callbacks
private inner class DisabledInputListener : DisabledInputView.Listener {
override fun onUpdateAppClicked() {
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext())
}
override fun onReRegisterClicked() {
startActivity(RegistrationNavigationActivity.newIntentForReRegistration(requireContext()))
}
override fun onCancelGroupRequestClicked() {
conversationGroupViewModel
.cancelJoinRequest()
.subscribeBy { result ->
when (result) {
is Result.Success -> Log.d(TAG, "Cancel request complete")
is Result.Failure -> {
Log.d(TAG, "Cancel join request failed ${result.failure}")
toast(GroupErrors.getUserDisplayMessage(result.failure))
}
}
}
.addTo(disposables)
}
override fun onShowAdminsBottomSheetDialog() {
viewModel.recipientSnapshot?.let { recipient ->
ShowAdminsBottomSheetDialog.show(childFragmentManager, recipient.requireGroupId().requireV2())
}
}
override fun onAcceptMessageRequestClicked() {
messageRequestViewModel
.onAccept()
.subscribeWithShowProgress("accept message request")
.addTo(disposables)
}
override fun onDeleteGroupClicked() {
val recipient = viewModel.recipientSnapshot
if (recipient == null) {
Log.w(TAG, "[onDeleteGroupClicked] No recipient!")
return
}
ConversationDialogs.displayDeleteDialog(requireContext(), recipient) {
messageRequestViewModel
.onDelete()
.subscribeWithShowProgress("delete message request")
}
}
override fun onBlockClicked() {
val recipient = viewModel.recipientSnapshot
if (recipient == null) {
Log.w(TAG, "[onBlockClicked] No recipient!")
return
}
BlockUnblockDialog.showBlockAndReportSpamFor(
requireContext(),
lifecycle,
recipient,
{
messageRequestViewModel
.onBlock()
.subscribeWithShowProgress("block")
},
{
messageRequestViewModel
.onBlockAndReportSpam()
.subscribeWithShowProgress("block")
}
)
}
override fun onUnblockClicked() {
val recipient = viewModel.recipientSnapshot
if (recipient == null) {
Log.w(TAG, "[onUnblockClicked] No recipient!")
return
}
BlockUnblockDialog.showUnblockFor(
requireContext(),
lifecycle,
recipient
) {
messageRequestViewModel
.onUnblock()
.subscribeWithShowProgress("unblock")
}
}
override fun onGroupV1MigrationClicked() {
val recipient = viewModel.recipientSnapshot
if (recipient == null) {
Log.w(TAG, "[onGroupV1MigrationClicked] No recipient!")
return
}
GroupsV1MigrationInitiationBottomSheetDialogFragment.showForInitiation(childFragmentManager, recipient.id)
}
private fun Single<Result<Unit, GroupChangeFailureReason>>.subscribeWithShowProgress(logMessage: String): Disposable {
return doOnSubscribe { binding.conversationDisabledInput.showBusy() }
.doOnTerminate { binding.conversationDisabledInput.hideBusy() }
.subscribeBy { result ->
when (result) {
is Result.Success -> Log.d(TAG, "$logMessage complete")
is Result.Failure -> {
Log.d(TAG, "$logMessage failed ${result.failure}")
toast(GroupErrors.getUserDisplayMessage(result.failure))
}
}
}
}
}
//endregion
//region Compose + Send Callbacks
private inner class SendButtonListener : View.OnClickListener, OnEditorActionListener {

View File

@@ -4,7 +4,9 @@ import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.GroupRecord
import org.thoughtcrime.securesms.recipients.Recipient
import java.util.Optional
class ConversationRecipientRepository(threadId: Long) {
@@ -17,7 +19,22 @@ class ConversationRecipientRepository(threadId: Long) {
.flatMapObservable { Recipient.observable(it) }
.subscribeOn(Schedulers.io())
.observeOn(Schedulers.io())
.distinctUntilChanged { previous, next -> previous === next || previous.hasSameContent(next) }
.replay(1)
.refCount()
.observeOn(Schedulers.io())
}
val groupRecord: Observable<Optional<GroupRecord>> by lazy {
conversationRecipient
.switchMapSingle {
Single.fromCallable {
if (it.isGroup) {
SignalDatabase.groups.getGroup(it.id)
} else {
Optional.empty()
}
}
}
.replay(1)
.refCount()
.observeOn(Schedulers.io())

View File

@@ -6,7 +6,6 @@
package org.thoughtcrime.securesms.conversation.v2
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
@@ -19,8 +18,8 @@ import io.reactivex.rxjava3.processors.PublishProcessor
import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.BehaviorSubject
import io.reactivex.rxjava3.subjects.Subject
import org.signal.core.util.orNull
import org.signal.paging.ProxyPagingController
import org.thoughtcrime.securesms.conversation.ConversationIntents.Args
import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper
import org.thoughtcrime.securesms.conversation.colors.NameColor
import org.thoughtcrime.securesms.conversation.v2.data.ConversationElementKey
@@ -31,10 +30,13 @@ import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.Quote
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.mms.SlideDeck
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.hasGiftBadge
import org.thoughtcrime.securesms.util.rx.RxStore
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
@@ -46,7 +48,8 @@ class ConversationViewModel(
private val threadId: Long,
requestedStartingPosition: Int,
private val repository: ConversationRepository,
recipientRepository: ConversationRecipientRepository
recipientRepository: ConversationRecipientRepository,
messageRequestRepository: MessageRequestRepository
) : ViewModel() {
private val disposables = CompositeDisposable()
@@ -78,6 +81,8 @@ class ConversationViewModel(
val wallpaperSnapshot: ChatWallpaper?
get() = _recipient.value?.wallpaper
val inputReadyState: Observable<InputReadyState>
init {
disposables += recipientRepository
.conversationRecipient
@@ -129,6 +134,19 @@ class ConversationViewModel(
hasMentions = counts.mentions != 0
)
}
inputReadyState = Observable.combineLatest(
recipientRepository.conversationRecipient,
recipientRepository.groupRecord
) { recipient, groupRecord ->
InputReadyState(
conversationRecipient = recipient,
messageRequestState = messageRequestRepository.getMessageRequestState(recipient, threadId),
groupRecord = groupRecord.orNull(),
isClientExpired = SignalStore.misc().isClientDeprecated,
isUnauthorized = TextSecurePreferences.isUnauthorizedReceived(ApplicationDependencies.getApplication())
)
}.observeOn(AndroidSchedulers.mainThread())
}
override fun onCleared() {
@@ -188,21 +206,4 @@ class ConversationViewModel(
bodyRanges = bodyRanges
).observeOn(AndroidSchedulers.mainThread())
}
class Factory(
private val args: Args,
private val repository: ConversationRepository,
private val recipientRepository: ConversationRecipientRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(
ConversationViewModel(
args.threadId,
args.startingPosition,
repository,
recipientRepository
)
) as T
}
}
}

View File

@@ -0,0 +1,197 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.v2
import android.content.Context
import android.text.method.LinkMovementMethod
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
import com.google.android.material.button.MaterialButton
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.messagerequests.MessageRequestState
import org.thoughtcrime.securesms.messagerequests.MessageRequestViewModel
import org.thoughtcrime.securesms.messagerequests.MessageRequestsBottomView
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.SpanUtil
/**
* A one-stop-view for all your conversation input disabled needs.
*
* - Expired client
* - No longer registered
* - Message Request/Blocked
* - Requesting group member
* - No longer group member
*/
class DisabledInputView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private val inflater: LayoutInflater by lazy { LayoutInflater.from(context) }
private var expiredOrUnauthorized: View? = null
private var messageRequestView: MessageRequestsBottomView? = null
private var noLongerAMember: View? = null
private var requestingGroup: View? = null
private var announcementGroupOnly: TextView? = null
private var currentView: View? = null
var color: Int = 0
private set
var listener: Listener? = null
fun showAsExpiredOrUnauthorized(clientExpired: Boolean, unauthorized: Boolean) {
expiredOrUnauthorized = show(
existingView = expiredOrUnauthorized,
create = { inflater.inflate(R.layout.conversation_activity_logged_out_stub, this, false) },
bind = {
val message = findViewById<TextView>(R.id.logged_out_message)
val actionButton = findViewById<MaterialButton>(R.id.logged_out_button)
message.setText(if (clientExpired) R.string.ExpiredBuildReminder_this_version_of_signal_has_expired else R.string.UnauthorizedReminder_this_is_likely_because_you_registered_your_phone_number_with_Signal_on_a_different_device)
actionButton.setText(if (clientExpired) R.string.ConversationFragment__update_build else R.string.ConversationFragment__reregister_signal)
actionButton.setOnClickListener {
if (clientExpired) {
listener?.onUpdateAppClicked()
} else {
listener?.onReRegisterClicked()
}
}
}
)
}
fun showAsMessageRequest(recipient: Recipient, messageRequestState: MessageRequestState) {
messageRequestView = show(
existingView = messageRequestView,
create = { MessageRequestsBottomView(context) },
bind = {
setMessageData(MessageRequestViewModel.MessageData(recipient, messageRequestState))
setWallpaperEnabled(recipient.hasWallpaper())
setAcceptOnClickListener { listener?.onAcceptMessageRequestClicked() }
setDeleteOnClickListener { listener?.onDeleteGroupClicked() }
setBlockOnClickListener { listener?.onBlockClicked() }
setUnblockOnClickListener { listener?.onUnblockClicked() }
setGroupV1MigrationContinueListener { listener?.onGroupV1MigrationClicked() }
}
)
}
fun showAsNoLongerAMember() {
noLongerAMember = show(
existingView = noLongerAMember,
create = { inflater.inflate(R.layout.conversation_no_longer_a_member, this, false) }
)
}
fun showAsRequestingMember() {
requestingGroup = show(
existingView = requestingGroup,
create = { inflater.inflate(R.layout.conversation_requesting_bottom_banner, this, false) },
bind = {
findViewById<View>(R.id.conversation_cancel_request).setOnClickListener {
listener?.onCancelGroupRequestClicked()
}
}
)
}
fun showAsAnnouncementGroupAdminsOnly() {
announcementGroupOnly = show(
existingView = announcementGroupOnly,
create = { inflater.inflate(R.layout.conversation_cannot_send_announcement_group, this, false) as TextView },
bind = {
movementMethod = LinkMovementMethod.getInstance()
text = SpanUtil.clickSubstring(
context,
R.string.ConversationActivity_only_s_can_send_messages,
R.string.ConversationActivity_admins
) {
listener?.onShowAdminsBottomSheetDialog()
}
}
)
}
fun setWallpaperEnabled(wallpaperEnabled: Boolean) {
color = ContextCompat.getColor(context, if (wallpaperEnabled) R.color.wallpaper_bubble_color else R.color.signal_colorBackground)
setBackgroundColor(color)
}
fun showBusy() {
if (currentView == messageRequestView) {
messageRequestView?.showBusy()
}
}
fun hideBusy() {
if (currentView == messageRequestView) {
messageRequestView?.hideBusy()
}
}
fun clear() {
removeAllViews()
currentView = null
expiredOrUnauthorized = null
messageRequestView?.hideBusy()
messageRequestView = null
noLongerAMember = null
requestingGroup = null
announcementGroupOnly = null
}
private fun <VIEW : View> show(existingView: VIEW?, create: () -> VIEW, bind: VIEW.() -> Unit = {}): VIEW {
if (existingView != currentView) {
removeIfNotNull(currentView)
}
val view: VIEW = if (existingView != null) {
existingView
} else {
val newView: VIEW = create()
addView(newView, defaultLayoutParams())
newView
}
view.bind()
currentView = view
return view
}
private fun defaultLayoutParams(): LayoutParams {
return LayoutParams(LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
private fun removeIfNotNull(view: View?) {
if (view != null) {
removeView(view)
}
}
interface Listener {
fun onUpdateAppClicked()
fun onReRegisterClicked()
fun onCancelGroupRequestClicked()
fun onShowAdminsBottomSheetDialog()
fun onAcceptMessageRequestClicked()
fun onDeleteGroupClicked()
fun onBlockClicked()
fun onUnblockClicked()
fun onGroupV1MigrationClicked()
}
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.v2
import org.thoughtcrime.securesms.database.GroupTable
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.model.GroupRecord
import org.thoughtcrime.securesms.messagerequests.MessageRequestState
import org.thoughtcrime.securesms.recipients.Recipient
/**
* Information necessary for rendering compose input.
*/
data class InputReadyState(
val conversationRecipient: Recipient,
val messageRequestState: MessageRequestState,
private val groupRecord: GroupRecord?,
val isClientExpired: Boolean,
val isUnauthorized: Boolean
) {
private val selfMemberLevel: GroupTable.MemberLevel? = groupRecord?.memberLevel(Recipient.self())
val isSignalConversation: Boolean = conversationRecipient.registered == RecipientTable.RegisteredState.REGISTERED && Recipient.self().isRegistered
val isAnnouncementGroup: Boolean? = groupRecord?.isAnnouncementGroup
val isActiveGroup: Boolean? = if (selfMemberLevel == null) null else selfMemberLevel != GroupTable.MemberLevel.NOT_A_MEMBER
val isAdmin: Boolean? = selfMemberLevel?.equals(GroupTable.MemberLevel.ADMINISTRATOR)
val isRequestingMember: Boolean? = selfMemberLevel?.equals(GroupTable.MemberLevel.REQUESTING_MEMBER)
}

View File

@@ -1,27 +1,15 @@
package org.thoughtcrime.securesms.conversation.v2
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.subjects.BehaviorSubject
import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.concurrent.subscribeWithSubject
import org.signal.core.util.Result
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason
import org.thoughtcrime.securesms.messagerequests.GroupInfo
import org.thoughtcrime.securesms.messagerequests.MessageRequestRecipientInfo
import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository
import org.thoughtcrime.securesms.messagerequests.MessageRequestState
import org.thoughtcrime.securesms.messagerequests.MessageRequestViewModel.MessageData
import org.thoughtcrime.securesms.messagerequests.MessageRequestViewModel.RequestReviewDisplayState
import org.thoughtcrime.securesms.messagerequests.MessageRequestViewModel.Status
import org.thoughtcrime.securesms.profiles.spoofing.ReviewUtil
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* MessageRequestViewModel for ConversationFragment V2
* View model for interacting with a message request displayed in ConversationFragment V2
*/
class MessageRequestViewModel(
private val threadId: Long,
@@ -29,152 +17,51 @@ class MessageRequestViewModel(
private val messageRequestRepository: MessageRequestRepository
) : ViewModel() {
private val disposables = CompositeDisposable()
private val statusSubject = PublishSubject.create<Status>()
val status: Observable<Status> = statusSubject
private val failureSubject = PublishSubject.create<GroupChangeFailureReason>()
val failure: Observable<GroupChangeFailureReason> = failureSubject
private val groupInfo: Observable<GroupInfo> = recipientRepository
.conversationRecipient
.flatMap { recipient ->
Single.create { emitter ->
messageRequestRepository.getGroupInfo(recipient.id, emitter::onSuccess)
}.toObservable()
private val recipientId: Single<RecipientId>
get() {
return recipientRepository
.conversationRecipient
.map { it.id }
.firstOrError()
}
private val groups: Observable<List<String>> = recipientRepository
.conversationRecipient
.flatMap { recipient ->
Single.create<List<String>> { emitter ->
messageRequestRepository.getGroups(recipient.id, emitter::onSuccess)
}.toObservable()
}
private val messageDataSubject: BehaviorSubject<MessageData> = recipientRepository.conversationRecipient.map {
val state = messageRequestRepository.getMessageRequestState(it, threadId)
MessageData(it, state)
}.subscribeWithSubject(BehaviorSubject.create(), disposables)
private val requestReviewDisplayStateSubject: BehaviorSubject<RequestReviewDisplayState> = messageDataSubject.map { holder ->
if (holder.messageState == MessageRequestState.INDIVIDUAL) {
if (ReviewUtil.isRecipientReviewSuggested(holder.recipient.id)) {
RequestReviewDisplayState.SHOWN
} else {
RequestReviewDisplayState.HIDDEN
fun onAccept(): Single<Result<Unit, GroupChangeFailureReason>> {
return recipientId
.flatMap { recipientId ->
messageRequestRepository.acceptMessageRequest(recipientId, threadId)
}
} else {
RequestReviewDisplayState.NONE
}
}.subscribeWithSubject(BehaviorSubject.create(), disposables)
val recipientInfo: Observable<MessageRequestRecipientInfo> = Observable.combineLatest(
recipientRepository.conversationRecipient,
groupInfo,
groups,
messageDataSubject.map { it.messageState },
::MessageRequestRecipientInfo
)
override fun onCleared() {
disposables.clear()
.observeOn(AndroidSchedulers.mainThread())
}
fun shouldShowMessageRequest(): Boolean {
val messageData = messageDataSubject.value
return messageData != null && messageData.messageState != MessageRequestState.NONE
}
fun onAccept() {
statusSubject.onNext(Status.ACCEPTING)
disposables += recipientRepository
.conversationRecipient
.firstOrError()
.map { it.id }
.subscribeBy { recipientId ->
messageRequestRepository.acceptMessageRequest(
recipientId,
threadId,
{ statusSubject.onNext(Status.ACCEPTED) },
this::onGroupChangeError
)
}
}
fun onDelete() {
statusSubject.onNext(Status.DELETING)
disposables += recipientRepository
.conversationRecipient
.firstOrError()
.map { it.id }
.subscribeBy { recipientId ->
messageRequestRepository.deleteMessageRequest(
recipientId,
threadId,
{ statusSubject.onNext(Status.DELETED) },
this::onGroupChangeError
)
}
}
fun onBlock() {
statusSubject.onNext(Status.BLOCKING)
disposables += recipientRepository
.conversationRecipient
.firstOrError()
.map { it.id }
.subscribeBy { recipientId ->
messageRequestRepository.blockMessageRequest(
recipientId,
{ statusSubject.onNext(Status.BLOCKED) },
this::onGroupChangeError
)
}
}
fun onUnblock() {
disposables += recipientRepository
.conversationRecipient
.firstOrError()
.map { it.id }
.subscribeBy { recipientId ->
messageRequestRepository.unblockAndAccept(
recipientId
) { statusSubject.onNext(Status.ACCEPTED) }
}
}
fun onBlockAndReportSpam() {
disposables += recipientRepository
.conversationRecipient
.firstOrError()
.map { it.id }
.subscribeBy { recipientId ->
messageRequestRepository.blockAndReportSpamMessageRequest(
recipientId,
threadId,
{ statusSubject.onNext(Status.BLOCKED_AND_REPORTED) },
this::onGroupChangeError
)
fun onDelete(): Single<Result<Unit, GroupChangeFailureReason>> {
return recipientId
.flatMap { recipientId ->
messageRequestRepository.deleteMessageRequest(recipientId, threadId)
}
.observeOn(AndroidSchedulers.mainThread())
}
private fun onGroupChangeError(error: GroupChangeFailureReason) {
statusSubject.onNext(Status.IDLE)
failureSubject.onNext(error)
fun onBlock(): Single<Result<Unit, GroupChangeFailureReason>> {
return recipientId
.flatMap { recipientId ->
messageRequestRepository.blockMessageRequest(recipientId)
}
.observeOn(AndroidSchedulers.mainThread())
}
class Factory(
private val threadId: Long,
private val recipientRepository: ConversationRecipientRepository,
private val messageRequestRepository: MessageRequestRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(
MessageRequestViewModel(
threadId,
recipientRepository,
messageRequestRepository
)
) as T
}
fun onUnblock(): Single<Result<Unit, GroupChangeFailureReason>> {
return recipientId
.flatMap { recipientId ->
messageRequestRepository.unblockAndAccept(recipientId)
}
.observeOn(AndroidSchedulers.mainThread())
}
fun onBlockAndReportSpam(): Single<Result<Unit, GroupChangeFailureReason>> {
return recipientId
.flatMap { recipientId ->
messageRequestRepository.blockAndReportSpamMessageRequest(recipientId, threadId)
}
.observeOn(AndroidSchedulers.mainThread())
}
}

View File

@@ -7,14 +7,15 @@ import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.subjects.BehaviorSubject
import io.reactivex.rxjava3.subjects.Subject
import org.signal.core.util.Result
import org.signal.core.util.concurrent.subscribeWithSubject
import org.thoughtcrime.securesms.conversation.v2.ConversationRecipientRepository
import org.thoughtcrime.securesms.database.GroupTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.GroupRecord
import org.thoughtcrime.securesms.groups.GroupsV1MigrationUtil
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason
import org.thoughtcrime.securesms.groups.v2.GroupBlockJoinRequestResult
import org.thoughtcrime.securesms.groups.v2.GroupManagementRepository
import org.thoughtcrime.securesms.profiles.spoofing.ReviewUtil
@@ -31,43 +32,36 @@ class ConversationGroupViewModel(
) : ViewModel() {
private val disposables = CompositeDisposable()
private val _recipient: Subject<Recipient> = BehaviorSubject.create()
private val _groupRecord: Subject<GroupRecord> = BehaviorSubject.create()
private val _groupRecord: Subject<GroupRecord>
private val _reviewState: Subject<ConversationGroupReviewState>
private val _groupActiveState: Subject<ConversationGroupActiveState> = BehaviorSubject.create()
private val _memberLevel: BehaviorSubject<ConversationGroupMemberLevel> = BehaviorSubject.create()
private val _actionableRequestingMembersCount: Subject<Int> = BehaviorSubject.create()
private val _gv1MigrationSuggestions: Subject<List<RecipientId>> = BehaviorSubject.create()
private val _reviewState: Subject<ConversationGroupReviewState> = BehaviorSubject.create()
init {
disposables += recipientRepository
.conversationRecipient
.filter { it.isGroup }
.subscribeBy(onNext = _recipient::onNext)
_groupRecord = recipientRepository
.groupRecord
.filter { it.isPresent }
.map { it.get() }
.subscribeWithSubject(BehaviorSubject.create(), disposables)
disposables += _recipient
.switchMap {
Observable.fromCallable {
SignalDatabase.groups.getGroup(it.id).get()
}
}
.subscribeBy(onNext = _groupRecord::onNext)
val duplicates = _groupRecord.map {
if (it.isV2Group) {
ReviewUtil.getDuplicatedRecipients(it.id.requireV2()).map { it.recipient }
val duplicates = _groupRecord.map { groupRecord ->
if (groupRecord.isV2Group) {
ReviewUtil.getDuplicatedRecipients(groupRecord.id.requireV2()).map { it.recipient }
} else {
emptyList()
}
}
disposables += Observable.combineLatest(_groupRecord, duplicates) { record, dupes ->
_reviewState = Observable.combineLatest(_groupRecord, duplicates) { record, dupes ->
if (dupes.isEmpty()) {
ConversationGroupReviewState.EMPTY
} else {
ConversationGroupReviewState(record.id.requireV2(), dupes[0], dupes.size)
}
}.subscribeBy(onNext = _reviewState::onNext)
}.subscribeWithSubject(BehaviorSubject.create(), disposables)
disposables += _groupRecord.subscribe { groupRecord ->
_groupActiveState.onNext(ConversationGroupActiveState(groupRecord.isActive, groupRecord.isV2Group))
@@ -87,9 +81,12 @@ class ConversationGroupViewModel(
}
fun blockJoinRequests(recipient: Recipient): Single<GroupBlockJoinRequestResult> {
return _recipient.firstOrError().flatMap {
groupManagementRepository.blockJoinRequests(it.requireGroupId().requireV2(), recipient)
}.observeOn(AndroidSchedulers.mainThread())
return _groupRecord
.firstOrError()
.flatMap {
groupManagementRepository.blockJoinRequests(it.id.requireV2(), recipient)
}
.observeOn(AndroidSchedulers.mainThread())
}
private fun getActionableRequestingMembersCount(groupRecord: GroupRecord): Int {
@@ -114,6 +111,15 @@ class ConversationGroupViewModel(
}
}
fun cancelJoinRequest(): Single<Result<Unit, GroupChangeFailureReason>> {
return _groupRecord
.firstOrError()
.flatMap { group ->
groupManagementRepository.cancelJoinRequest(group.id.requireV2())
}
.observeOn(AndroidSchedulers.mainThread())
}
class Factory(private val threadId: Long, private val recipientRepository: ConversationRecipientRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(ConversationGroupViewModel(threadId, recipientRepository = recipientRepository)) as T

View File

@@ -4,11 +4,14 @@ import android.content.Context
import androidx.core.util.Consumer
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.Result
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.groups.GroupChangeBusyException
import org.thoughtcrime.securesms.groups.GroupChangeException
import org.thoughtcrime.securesms.groups.GroupChangeFailedException
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupManager
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason
@@ -77,4 +80,22 @@ class GroupManagementRepository @JvmOverloads constructor(private val context: C
}
}.subscribeOn(Schedulers.io())
}
fun cancelJoinRequest(groupId: GroupId.V2): Single<Result<Unit, GroupChangeFailureReason>> {
return Single.create { emitter ->
try {
GroupManager.cancelJoinRequest(context, groupId)
emitter.onSuccess(Result.success(Unit))
} catch (gcfe: GroupChangeFailedException) {
Log.i(TAG, "Unable to cancel request", gcfe)
emitter.onSuccess(Result.failure(GroupChangeFailureReason.fromException(gcfe)))
} catch (ioe: IOException) {
Log.i(TAG, "Unable to cancel request", ioe)
emitter.onSuccess(Result.failure(GroupChangeFailureReason.fromException(ioe)))
} catch (gcbe: GroupChangeBusyException) {
Log.i(TAG, "Unable to cancel request", gcbe)
emitter.onSuccess(Result.failure(GroupChangeFailureReason.fromException(gcbe)))
}
}.subscribeOn(Schedulers.io())
}
}

View File

@@ -6,6 +6,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import androidx.core.util.Consumer;
import org.signal.core.util.Result;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
@@ -24,7 +25,6 @@ import org.thoughtcrime.securesms.jobs.MultiDeviceMessageRequestResponseJob;
import org.thoughtcrime.securesms.jobs.ReportSpamJob;
import org.thoughtcrime.securesms.jobs.SendViewedReceiptJob;
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
@@ -38,6 +38,11 @@ import java.util.List;
import java.util.Optional;
import java.util.concurrent.Executor;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
import kotlin.Unit;
public final class MessageRequestRepository {
private static final String TAG = Log.tag(MessageRequestRepository.class);
@@ -149,6 +154,19 @@ public final class MessageRequestRepository {
}
}
@SuppressWarnings("unchecked")
public @NonNull Single<Result<Unit, GroupChangeFailureReason>> acceptMessageRequest(@NonNull RecipientId recipientId, long threadId) {
//noinspection CodeBlock2Expr
return Single.<Result<Unit, GroupChangeFailureReason>>create(emitter -> {
acceptMessageRequest(
recipientId,
threadId,
() -> emitter.onSuccess(Result.success(Unit.INSTANCE)),
reason -> emitter.onSuccess(Result.failure(reason))
);
}).subscribeOn(Schedulers.io());
}
public void acceptMessageRequest(@NonNull RecipientId recipientId,
long threadId,
@NonNull Runnable onMessageRequestAccepted,
@@ -192,6 +210,19 @@ public final class MessageRequestRepository {
});
}
@SuppressWarnings("unchecked")
public @NonNull Single<Result<Unit, GroupChangeFailureReason>> deleteMessageRequest(@NonNull RecipientId recipientId, long threadId) {
//noinspection CodeBlock2Expr
return Single.<Result<Unit, GroupChangeFailureReason>>create(emitter -> {
deleteMessageRequest(
recipientId,
threadId,
() -> emitter.onSuccess(Result.success(Unit.INSTANCE)),
reason -> emitter.onSuccess(Result.failure(reason))
);
}).subscribeOn(Schedulers.io());
}
public void deleteMessageRequest(@NonNull RecipientId recipientId,
long threadId,
@NonNull Runnable onMessageRequestDeleted,
@@ -229,6 +260,18 @@ public final class MessageRequestRepository {
});
}
@SuppressWarnings("unchecked")
public @NonNull Single<Result<Unit, GroupChangeFailureReason>> blockMessageRequest(@NonNull RecipientId recipientId) {
//noinspection CodeBlock2Expr
return Single.<Result<Unit, GroupChangeFailureReason>>create(emitter -> {
blockMessageRequest(
recipientId,
() -> emitter.onSuccess(Result.success(Unit.INSTANCE)),
reason -> emitter.onSuccess(Result.failure(reason))
);
}).subscribeOn(Schedulers.io());
}
public void blockMessageRequest(@NonNull RecipientId recipientId,
@NonNull Runnable onMessageRequestBlocked,
@NonNull GroupChangeErrorCallback error)
@@ -252,6 +295,19 @@ public final class MessageRequestRepository {
});
}
@SuppressWarnings("unchecked")
public @NonNull Single<Result<Unit, GroupChangeFailureReason>> blockAndReportSpamMessageRequest(@NonNull RecipientId recipientId, long threadId) {
//noinspection CodeBlock2Expr
return Single.<Result<Unit, GroupChangeFailureReason>>create(emitter -> {
blockAndReportSpamMessageRequest(
recipientId,
threadId,
() -> emitter.onSuccess(Result.success(Unit.INSTANCE)),
reason -> emitter.onSuccess(Result.failure(reason))
);
}).subscribeOn(Schedulers.io());
}
public void blockAndReportSpamMessageRequest(@NonNull RecipientId recipientId,
long threadId,
@NonNull Runnable onMessageRequestBlocked,
@@ -278,6 +334,17 @@ public final class MessageRequestRepository {
});
}
@SuppressWarnings("unchecked")
public @NonNull Single<Result<Unit, GroupChangeFailureReason>> unblockAndAccept(@NonNull RecipientId recipientId) {
//noinspection CodeBlock2Expr
return Single.<Result<Unit, GroupChangeFailureReason>>create(emitter -> {
unblockAndAccept(
recipientId,
() -> emitter.onSuccess(Result.success(Unit.INSTANCE))
);
}).subscribeOn(Schedulers.io());
}
public void unblockAndAccept(@NonNull RecipientId recipientId, @NonNull Runnable onMessageRequestUnblocked) {
executor.execute(() -> {
Recipient recipient = Recipient.resolved(recipientId);

View File

@@ -43,6 +43,7 @@ public class MessageRequestsBottomView extends ConstraintLayout {
public MessageRequestsBottomView(Context context) {
super(context);
onFinishInflate();
}
public MessageRequestsBottomView(Context context, AttributeSet attrs) {

View File

@@ -88,7 +88,7 @@ public final class LiveRecipient {
* @return An rx-flavored {@link Observable}.
*/
public @NonNull Observable<Recipient> observable() {
return subject.distinctUntilChanged(Recipient::hasSameContent);
return subject;
}
/**

View File

@@ -155,17 +155,7 @@ public class Recipient {
@AnyThread
public static @NonNull Observable<Recipient> observable(@NonNull RecipientId id) {
Preconditions.checkNotNull(id, "ID cannot be null");
return Observable.<Recipient>create(emitter -> {
LiveRecipient live = live(id);
emitter.onNext(live.resolve());
RecipientForeverObserver observer = emitter::onNext;
live.observeForever(observer);
emitter.setCancellable(() -> {
live.removeForeverObserver(observer);
});
}).subscribeOn(Schedulers.io());
return live(id).observable().subscribeOn(Schedulers.io());
}
/**
@@ -1355,6 +1345,7 @@ public class Recipient {
Objects.equals(extras, other.extras) &&
hasGroupsInCommon == other.hasGroupsInCommon &&
Objects.equals(badges, other.badges) &&
isActiveGroup == other.isActiveGroup &&
Objects.equals(callLinkRoomId, other.callLinkRoomId);
}

View File

@@ -1,5 +1,8 @@
package org.thoughtcrime.securesms.util
import androidx.annotation.MainThread
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
@@ -23,3 +26,12 @@ class ViewModelFactory<MODEL : ViewModel>(private val create: () -> MODEL) : Vie
}
}
}
@MainThread
inline fun <reified VM : ViewModel> Fragment.viewModel(
noinline create: () -> VM
): Lazy<VM> {
return viewModels(
factoryProducer = ViewModelFactory.factoryProducer(create)
)
}

View File

@@ -8,7 +8,6 @@
android:background="@color/signal_transparent_80"
android:gravity="center"
android:padding="17dp"
android:visibility="gone"
style="@style/Signal.Text.Preview"
tools:text="Only admins can send messages."
android:textColor="@color/signal_text_secondary" />

View File

@@ -129,7 +129,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="top"
app:constraint_referenced_ids="conversation_input_panel,attachment_editor_stub" />
app:constraint_referenced_ids="conversation_input_panel,attachment_editor_stub,conversation_disabled_input " />
<ViewStub
android:id="@+id/attachment_editor_stub"
@@ -150,6 +150,14 @@
app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"
app:layout_constraintStart_toStartOf="@id/parent_start_guideline" />
<org.thoughtcrime.securesms.conversation.v2.DisabledInputView
android:id="@+id/conversation_disabled_input"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintBottom_toTopOf="@id/keyboard_guideline"
app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"
app:layout_constraintStart_toStartOf="@id/parent_start_guideline" />
<TextView
android:id="@+id/conversation_input_space_left"
android:layout_width="0dp"