From 64ddd982fe24af556d58ab45c4836d5fc0007db9 Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Fri, 26 May 2023 15:25:27 -0400 Subject: [PATCH] Add review banner to CFv2. --- .../conversation/v2/ConversationBannerView.kt | 199 ++++++++++-------- .../conversation/v2/ConversationFragment.kt | 49 +++-- .../conversation/v2/ConversationRepository.kt | 33 +++ .../conversation/v2/ConversationViewModel.kt | 13 +- .../conversation/v2/InputReadyState.kt | 2 +- .../conversation/v2/RequestReviewState.kt | 29 +++ .../MessageRequestRepository.java | 1 - .../profiles/spoofing/ReviewBannerView.java | 17 +- .../securesms/util/views/Stub.java | 4 + .../res/layout/v2_conversation_fragment.xml | 25 ++- 10 files changed, 261 insertions(+), 111 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/RequestReviewState.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationBannerView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationBannerView.kt index 28cb682473..0e3eb22a5c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationBannerView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationBannerView.kt @@ -6,15 +6,17 @@ package org.thoughtcrime.securesms.conversation.v2 import android.content.Context +import android.text.SpannableStringBuilder import android.transition.ChangeBounds import android.transition.Slide import android.transition.TransitionManager import android.transition.TransitionSet import android.util.AttributeSet import android.view.Gravity -import android.view.LayoutInflater import android.view.View import androidx.appcompat.widget.LinearLayoutCompat +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.DrawableCompat import androidx.core.transition.addListener import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.identity.UnverifiedBannerView @@ -22,7 +24,15 @@ import org.thoughtcrime.securesms.components.reminder.Reminder import org.thoughtcrime.securesms.components.reminder.ReminderView import org.thoughtcrime.securesms.database.identity.IdentityRecordList import org.thoughtcrime.securesms.database.model.IdentityRecord +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.profiles.spoofing.ReviewBannerView +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.ContextUtil import org.thoughtcrime.securesms.util.IdentityUtil +import org.thoughtcrime.securesms.util.SpanUtil +import org.thoughtcrime.securesms.util.ViewUtil +import org.thoughtcrime.securesms.util.views.Stub +import org.thoughtcrime.securesms.util.visible /** * Responsible for showing the various "banner" views at the top of a conversation @@ -39,11 +49,9 @@ class ConversationBannerView @JvmOverloads constructor( attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : LinearLayoutCompat(context, attrs, defStyleAttr) { - - private val inflater: LayoutInflater by lazy { LayoutInflater.from(context) } - - private var reminderView: ReminderView? = null - private var unverifiedBannerView: UnverifiedBannerView? = null + private val unverifiedBannerStub: Stub by lazy { ViewUtil.findStubById(this, R.id.unverified_banner_stub) } + private val reminderStub: Stub by lazy { ViewUtil.findStubById(this, R.id.reminder_stub) } + private val reviewBannerStub: Stub by lazy { ViewUtil.findStubById(this, R.id.review_banner_stub) } var listener: Listener? = null @@ -52,114 +60,125 @@ class ConversationBannerView @JvmOverloads constructor( } fun showReminder(reminder: Reminder) { - reminderView = show( - position = -1, - existingView = reminderView, - create = { ReminderView(context) }, - bind = { - showReminder(reminder) - setOnActionClickListener { - when (it) { - R.id.reminder_action_update_now -> listener?.updateAppAction() - R.id.reminder_action_re_register -> listener?.reRegisterAction() - R.id.reminder_action_review_join_requests -> listener?.reviewJoinRequestsAction() - R.id.reminder_action_gv1_suggestion_no_thanks -> listener?.gv1SuggestionsAction(it) - R.id.reminder_action_bubble_not_now, R.id.reminder_action_bubble_turn_off -> { - listener?.changeBubbleSettingAction(disableSetting = it == R.id.reminder_action_bubble_turn_off) - } + show( + stub = reminderStub + ) { + showReminder(reminder) + setOnActionClickListener { + when (it) { + R.id.reminder_action_update_now -> listener?.updateAppAction() + R.id.reminder_action_re_register -> listener?.reRegisterAction() + R.id.reminder_action_review_join_requests -> listener?.reviewJoinRequestsAction() + R.id.reminder_action_gv1_suggestion_no_thanks -> listener?.gv1SuggestionsAction(it) + R.id.reminder_action_bubble_not_now, R.id.reminder_action_bubble_turn_off -> { + listener?.changeBubbleSettingAction(disableSetting = it == R.id.reminder_action_bubble_turn_off) } } - setOnHideListener { - clearReminder() - true - } } - ) + setOnHideListener { + clearReminder() + true + } + } } fun clearReminder() { - removeIfNotNull(reminderView) - reminderView = null + hide(reminderStub) } fun showUnverifiedBanner(identityRecords: IdentityRecordList) { - unverifiedBannerView = show( - position = 0, - existingView = null, - create = { UnverifiedBannerView(context) }, - bind = { - setOnHideListener { - clearUnverifiedBanner() - true - } - display( - IdentityUtil.getUnverifiedBannerDescription(context, identityRecords.unverifiedRecipients)!!, - identityRecords.unverifiedRecords, - { listener?.onUnverifiedBannerClicked(identityRecords.unverifiedRecords) }, - { listener?.onUnverifiedBannerDismissed(identityRecords.unverifiedRecords) } - ) + show( + stub = unverifiedBannerStub + ) { + setOnHideListener { + clearUnverifiedBanner() + true } - ) + display( + IdentityUtil.getUnverifiedBannerDescription(context, identityRecords.unverifiedRecipients)!!, + identityRecords.unverifiedRecords, + { listener?.onUnverifiedBannerClicked(identityRecords.unverifiedRecords) }, + { listener?.onUnverifiedBannerDismissed(identityRecords.unverifiedRecords) } + ) + } } fun clearUnverifiedBanner() { - removeIfNotNull(unverifiedBannerView) - unverifiedBannerView = null + hide(unverifiedBannerStub) } - private fun show(position: Int, existingView: V?, create: () -> V, bind: V.() -> Unit = {}): V { - val view: V = if (existingView != null) { - existingView - } else { - val newView: V = create() + fun showReviewBanner(requestReviewState: RequestReviewState) { + show( + stub = reviewBannerStub + ) { + if (requestReviewState.individualReviewState != null) { + val message: CharSequence = SpannableStringBuilder() + .append(SpanUtil.bold(context.getString(R.string.ConversationFragment__review_requests_carefully))) + .append(" ") + .append(context.getString(R.string.ConversationFragment__signal_found_another_contact_with_the_same_name)) - TransitionManager.beginDelayedTransition(this, Slide(Gravity.TOP)) - if (position in 0..childCount) { - addView(newView, position, defaultLayoutParams()) - } else { - addView(newView, defaultLayoutParams()) + setBannerMessage(message) + + val drawable = ContextUtil.requireDrawable(context, R.drawable.symbol_info_24).mutate() + DrawableCompat.setTint(drawable, ContextCompat.getColor(context, R.color.signal_icon_tint_primary)) + setBannerIcon(drawable) + setOnClickListener { listener?.onRequestReviewIndividual(requestReviewState.individualReviewState.recipient.id) } + } else if (requestReviewState.groupReviewState != null) { + setBannerMessage(context.getString(R.string.ConversationFragment__d_group_members_have_the_same_name, requestReviewState.groupReviewState.count)) + setBannerRecipient(requestReviewState.groupReviewState.recipient) + setOnClickListener { listener?.onReviewGroupMembers(requestReviewState.groupReviewState.groupId) } } - newView + + setOnHideListener { + clearRequestReview() + true + } + } + } + + fun clearRequestReview() { + hide(reviewBannerStub) + } + + private fun show(stub: Stub, bind: V.() -> Unit = {}) { + TransitionManager.beginDelayedTransition(this, Slide(Gravity.TOP)) + stub.get().bind() + stub.get().visible = true + } + + private fun hide(stub: Stub<*>) { + if (!stub.isVisible) { + return } - view.bind() - - return view - } - - private fun defaultLayoutParams(): LayoutParams { - return LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) - } - - private fun removeIfNotNull(view: View?) { - if (view != null) { - val slideTransition = Slide(Gravity.TOP).apply { - addListener( - onEnd = { - layoutParams = layoutParams.apply { height = LayoutParams.WRAP_CONTENT } - } - ) + val slideTransition = Slide(Gravity.TOP) + val changeTransition = ChangeBounds().apply { + if (reminderStub.isVisible) { + addTarget(reminderStub.get()) } - val changeTransition = ChangeBounds().apply { - if (reminderView != null) { - addTarget(reminderView) - } - - if (unverifiedBannerView != null) { - addTarget(unverifiedBannerView) - } + if (unverifiedBannerStub.isVisible) { + addTarget(unverifiedBannerStub.get()) } - val transition = TransitionSet().apply { - addTransition(slideTransition) - addTransition(changeTransition) + if (reviewBannerStub.isVisible) { + addTarget(reviewBannerStub.get()) } - - layoutParams = layoutParams.apply { height = this@ConversationBannerView.height } - TransitionManager.beginDelayedTransition(this, transition) - removeView(view) } + + val transition = TransitionSet().apply { + addTransition(slideTransition) + addTransition(changeTransition) + addListener( + onEnd = { + layoutParams = layoutParams.apply { height = LayoutParams.WRAP_CONTENT } + } + ) + } + + layoutParams = layoutParams.apply { height = this@ConversationBannerView.height } + TransitionManager.beginDelayedTransition(this, transition) + stub.get().visible = false } interface Listener { @@ -170,5 +189,7 @@ class ConversationBannerView @JvmOverloads constructor( fun changeBubbleSettingAction(disableSetting: Boolean) fun onUnverifiedBannerClicked(unverifiedIdentities: List) fun onUnverifiedBannerDismissed(unverifiedIdentities: List) + fun onRequestReviewIndividual(recipientId: RecipientId) + fun onReviewGroupMembers(groupId: GroupId.V2) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index a91a37754a..ce5a20c5d5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -175,6 +175,7 @@ import org.thoughtcrime.securesms.mms.SlideDeck import org.thoughtcrime.securesms.notifications.v2.ConversationId import org.thoughtcrime.securesms.payments.preferences.PaymentsActivity import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.profiles.spoofing.ReviewCardDialogFragment import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment import org.thoughtcrime.securesms.reactions.ReactionsBottomSheetDialogFragment import org.thoughtcrime.securesms.recipients.Recipient @@ -381,11 +382,6 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) EventBus.getDefault().unregister(this) } - override fun onStop() { - super.onStop() - EventBus.getDefault().unregister(this) - } - private fun observeConversationThread() { var firstRender = true disposables += viewModel @@ -523,18 +519,11 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) .observeOn(AndroidSchedulers.mainThread()) .subscribeBy { presentIdentityRecordsState(it) } .addTo(disposables) - } - private fun presentIdentityRecordsState(identityRecordsState: IdentityRecordsState) { - if (!identityRecordsState.isGroup) { - binding.conversationTitleView.root.setVerified(identityRecordsState.isVerified) - } - - if (identityRecordsState.isUnverified) { - binding.conversationBanner.showUnverifiedBanner(identityRecordsState.identityRecords) - } else { - binding.conversationBanner.clearUnverifiedBanner() - } + viewModel + .getRequestReviewState() + .subscribeBy { presentRequestReviewState(it) } + .addTo(disposables) } private fun presentInputReadyState(inputReadyState: InputReadyState) { @@ -561,6 +550,26 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) } } + private fun presentIdentityRecordsState(identityRecordsState: IdentityRecordsState) { + if (!identityRecordsState.isGroup) { + binding.conversationTitleView.root.setVerified(identityRecordsState.isVerified) + } + + if (identityRecordsState.isUnverified) { + binding.conversationBanner.showUnverifiedBanner(identityRecordsState.identityRecords) + } else { + binding.conversationBanner.clearUnverifiedBanner() + } + } + + private fun presentRequestReviewState(requestReviewState: RequestReviewState) { + if (requestReviewState.shouldShowReviewBanner()) { + binding.conversationBanner.showReviewBanner(requestReviewState) + } else { + binding.conversationBanner.clearRequestReview() + } + } + private fun calculateCharactersRemaining() { val messageBody: String = binding.conversationInputPanel.embeddedTextEditor.textTrimmed.toString() val charactersLeftView: TextView = binding.conversationInputSpaceLeft @@ -1969,6 +1978,14 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) override fun onUnverifiedBannerDismissed(unverifiedIdentities: List) { viewModel.resetVerifiedStatusToDefault(unverifiedIdentities) } + + override fun onRequestReviewIndividual(recipientId: RecipientId) { + ReviewCardDialogFragment.createForReviewRequest(recipientId).show(childFragmentManager, null) + } + + override fun onReviewGroupMembers(groupId: GroupId.V2) { + ReviewCardDialogFragment.createForReviewMembers(groupId).show(childFragmentManager, null) + } } //endregion diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt index ff53143169..afb72dd135 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt @@ -33,6 +33,8 @@ import org.thoughtcrime.securesms.conversation.ConversationMessage import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper import org.thoughtcrime.securesms.conversation.colors.NameColor import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart +import org.thoughtcrime.securesms.conversation.v2.RequestReviewState.GroupReviewState +import org.thoughtcrime.securesms.conversation.v2.RequestReviewState.IndividualReviewState import org.thoughtcrime.securesms.conversation.v2.data.ConversationDataSource import org.thoughtcrime.securesms.crypto.ReentrantSessionLock import org.thoughtcrime.securesms.database.GroupTable @@ -53,10 +55,12 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.jobs.MultiDeviceViewOnceOpenJob import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.messagerequests.MessageRequestState import org.thoughtcrime.securesms.mms.OutgoingMessage import org.thoughtcrime.securesms.mms.PartAuthority import org.thoughtcrime.securesms.mms.QuoteModel import org.thoughtcrime.securesms.mms.SlideDeck +import org.thoughtcrime.securesms.profiles.spoofing.ReviewUtil import org.thoughtcrime.securesms.providers.BlobProvider import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientFormattingException @@ -285,6 +289,35 @@ class ConversationRepository( }.subscribeOn(Schedulers.io()) } + fun getRequestReviewState(recipient: Recipient, group: GroupRecord?, messageRequest: MessageRequestState): Single { + return Single.fromCallable { + if (group == null && messageRequest != MessageRequestState.INDIVIDUAL) { + return@fromCallable RequestReviewState() + } + + if (group == null && ReviewUtil.isRecipientReviewSuggested(recipient.id)) { + return@fromCallable RequestReviewState(individualReviewState = IndividualReviewState(recipient)) + } + + if (group != null && group.isV2Group) { + val groupId = group.id.requireV2() + val duplicateRecipients: List = ReviewUtil.getDuplicatedRecipients(groupId).map { it.recipient } + + if (duplicateRecipients.isNotEmpty()) { + return@fromCallable RequestReviewState( + groupReviewState = GroupReviewState( + groupId, + duplicateRecipients[0], + duplicateRecipients.size + ) + ) + } + } + + RequestReviewState() + }.subscribeOn(Schedulers.io()) + } + fun getTemporaryViewOnceUri(mmsMessageRecord: MmsMessageRecord): Maybe { return Maybe.fromCallable { Log.i(TAG, "Copying the view-once photo to temp storage and deleting underlying media.") diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 9970eda32f..4ff511e927 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -94,6 +94,7 @@ class ConversationViewModel( val wallpaperSnapshot: ChatWallpaper? get() = recipientSnapshot?.wallpaper + private val _inputReadyState: Observable val inputReadyState: Observable private val hasMessageRequestStateSubject: BehaviorSubject = BehaviorSubject.createDefault(false) @@ -157,7 +158,7 @@ class ConversationViewModel( ) } - inputReadyState = Observable.combineLatest( + _inputReadyState = Observable.combineLatest( recipientRepository.conversationRecipient, recipientRepository.groupRecord ) { recipient, groupRecord -> @@ -170,7 +171,8 @@ class ConversationViewModel( ) }.doOnNext { hasMessageRequestStateSubject.onNext(it.messageRequestState != MessageRequestState.NONE) - }.observeOn(AndroidSchedulers.mainThread()) + } + inputReadyState = _inputReadyState.observeOn(AndroidSchedulers.mainThread()) recipientRepository.conversationRecipient.map { Unit }.subscribeWithSubject(refreshReminder, disposables) @@ -264,4 +266,11 @@ class ConversationViewModel( fun copyToClipboard(context: Context, messageParts: Set): Maybe { return repository.copyToClipboard(context, messageParts) } + + fun getRequestReviewState(): Observable { + return _inputReadyState + .flatMapSingle { (recipient, messageRequestState, group) -> repository.getRequestReviewState(recipient, group, messageRequestState) } + .distinctUntilChanged() + .observeOn(AndroidSchedulers.mainThread()) + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/InputReadyState.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/InputReadyState.kt index 6e03230793..9f73be68c0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/InputReadyState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/InputReadyState.kt @@ -17,7 +17,7 @@ import org.thoughtcrime.securesms.recipients.Recipient data class InputReadyState( val conversationRecipient: Recipient, val messageRequestState: MessageRequestState, - private val groupRecord: GroupRecord?, + val groupRecord: GroupRecord?, val isClientExpired: Boolean, val isUnauthorized: Boolean ) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/RequestReviewState.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/RequestReviewState.kt new file mode 100644 index 0000000000..f279ee727e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/RequestReviewState.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2 + +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.recipients.Recipient + +/** + * Indicates if we should present an additional review warning banner + * for an individual or group. + */ +data class RequestReviewState( + val individualReviewState: IndividualReviewState? = null, + val groupReviewState: GroupReviewState? = null +) { + + fun shouldShowReviewBanner(): Boolean { + return individualReviewState != null || groupReviewState != null + } + + /** Recipient is in message request state and has similar name as someone else */ + data class IndividualReviewState(val recipient: Recipient) + + /** Group has multiple members with similar names */ + data class GroupReviewState(val groupId: GroupId.V2, val recipient: Recipient, val count: Int) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java index 0e874fe3eb..bb9f121245 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java @@ -38,7 +38,6 @@ 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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewBannerView.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewBannerView.java index 93de2de2be..596525842b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewBannerView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewBannerView.java @@ -30,6 +30,7 @@ public class ReviewBannerView extends LinearLayout { private AvatarImageView topLeftAvatar; private AvatarImageView bottomRightAvatar; private View stroke; + private OnHideListener onHideListener; public ReviewBannerView(@NonNull Context context, @Nullable AttributeSet attrs) { super(context, attrs); @@ -55,7 +56,17 @@ public class ReviewBannerView extends LinearLayout { topLeftAvatar.setFallbackPhotoProvider(provider); bottomRightAvatar.setFallbackPhotoProvider(provider); - bannerClose.setOnClickListener(v -> setVisibility(GONE)); + bannerClose.setOnClickListener(v -> { + if (onHideListener != null && onHideListener.onHide()) { + return; + } + + setVisibility(GONE); + }); + } + + public void setOnHideListener(@Nullable OnHideListener onHideListener) { + this.onHideListener = onHideListener; } public void setBannerMessage(@Nullable CharSequence charSequence) { @@ -121,4 +132,8 @@ public class ReviewBannerView extends LinearLayout { return new FallbackPhoto20dp(getFallbackResId()).asDrawable(context, color, inverted); } } + + public interface OnHideListener { + boolean onHide(); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/views/Stub.java b/app/src/main/java/org/thoughtcrime/securesms/util/views/Stub.java index 4605d3ccf8..e8c4838582 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/views/Stub.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/views/Stub.java @@ -43,4 +43,8 @@ public class Stub { } } + public boolean isVisible() { + return getVisibility() == View.VISIBLE; + } + } diff --git a/app/src/main/res/layout/v2_conversation_fragment.xml b/app/src/main/res/layout/v2_conversation_fragment.xml index 0c86f286f6..647d7b4d68 100644 --- a/app/src/main/res/layout/v2_conversation_fragment.xml +++ b/app/src/main/res/layout/v2_conversation_fragment.xml @@ -114,7 +114,30 @@ android:layout_height="wrap_content" app:layout_constraintEnd_toEndOf="@id/parent_end_guideline" app:layout_constraintStart_toStartOf="@id/parent_start_guideline" - app:layout_constraintTop_toBottomOf="@+id/toolbar" /> + app:layout_constraintTop_toBottomOf="@+id/toolbar"> + + + + + + + +