Add review banner to CFv2.

This commit is contained in:
Cody Henthorne 2023-05-26 15:25:27 -04:00
parent b785b3f887
commit 64ddd982fe
10 changed files with 261 additions and 111 deletions

View file

@ -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<UnverifiedBannerView> by lazy { ViewUtil.findStubById(this, R.id.unverified_banner_stub) }
private val reminderStub: Stub<ReminderView> by lazy { ViewUtil.findStubById(this, R.id.reminder_stub) }
private val reviewBannerStub: Stub<ReviewBannerView> 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 <V : View> 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 <V : View> show(stub: Stub<V>, 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<IdentityRecord>)
fun onUnverifiedBannerDismissed(unverifiedIdentities: List<IdentityRecord>)
fun onRequestReviewIndividual(recipientId: RecipientId)
fun onReviewGroupMembers(groupId: GroupId.V2)
}
}

View file

@ -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<IdentityRecord>) {
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

View file

@ -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<RequestReviewState> {
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<Recipient> = 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<Uri> {
return Maybe.fromCallable<Uri> {
Log.i(TAG, "Copying the view-once photo to temp storage and deleting underlying media.")

View file

@ -94,6 +94,7 @@ class ConversationViewModel(
val wallpaperSnapshot: ChatWallpaper?
get() = recipientSnapshot?.wallpaper
private val _inputReadyState: Observable<InputReadyState>
val inputReadyState: Observable<InputReadyState>
private val hasMessageRequestStateSubject: BehaviorSubject<Boolean> = 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<MultiselectPart>): Maybe<CharSequence> {
return repository.copyToClipboard(context, messageParts)
}
fun getRequestReviewState(): Observable<RequestReviewState> {
return _inputReadyState
.flatMapSingle { (recipient, messageRequestState, group) -> repository.getRequestReviewState(recipient, group, messageRequestState) }
.distinctUntilChanged()
.observeOn(AndroidSchedulers.mainThread())
}
}

View file

@ -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
) {

View file

@ -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)
}

View file

@ -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;

View file

@ -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();
}
}

View file

@ -43,4 +43,8 @@ public class Stub<T extends View> {
}
}
public boolean isVisible() {
return getVisibility() == View.VISIBLE;
}
}

View file

@ -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">
<ViewStub
android:id="@+id/unverified_banner_stub"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inflatedId="@+id/unverified_banner"
android:layout="@layout/conversation_activity_unverified_banner_stub" />
<ViewStub
android:id="@+id/reminder_stub"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inflatedId="@+id/reminder"
android:layout="@layout/conversation_activity_reminderview_stub" />
<ViewStub
android:id="@+id/review_banner_stub"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inflatedId="@+id/review_banner"
android:layout="@layout/review_banner_view" />
</org.thoughtcrime.securesms.conversation.v2.ConversationBannerView>
<org.thoughtcrime.securesms.components.ConversationScrollToView
android:id="@+id/scroll_to_mention"