Update spam UX and reporting flows.

This commit is contained in:
Cody Henthorne
2024-02-09 15:25:31 -05:00
committed by Clark Chen
parent a4fde60c1c
commit aa76cefb1c
66 changed files with 1578 additions and 894 deletions

View File

@@ -120,6 +120,12 @@ public class ConversationHeaderView extends ConstraintLayout {
return binding.messageRequestDescription;
}
public void setButton(@NonNull CharSequence button, Runnable onClick) {
binding.messageRequestButton.setText(button);
binding.messageRequestButton.setOnClickListener(v -> onClick.run());
binding.messageRequestButton.setVisibility(View.VISIBLE);
}
public void showBackgroundBubble(boolean enabled) {
if (enabled) {
setBackgroundResource(R.drawable.wallpaper_bubble_background_18);
@@ -146,6 +152,10 @@ public class ConversationHeaderView extends ConstraintLayout {
binding.messageRequestDescription.setVisibility(View.GONE);
}
public void hideButton() {
binding.messageRequestButton.setVisibility(View.GONE);
}
public void setLinkifyDescription(boolean enable) {
binding.messageRequestDescription.setMovementMethod(enable ? LongClickMovementMethod.getInstance(getContext()) : null);
}

View File

@@ -16,6 +16,7 @@ import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.messagerequests.MessageRequestState
import org.thoughtcrime.securesms.recipients.Recipient
/**
@@ -53,7 +54,7 @@ internal object ConversationOptionsMenu {
hasActiveGroupCall,
distributionType,
threadId,
isInMessageRequest,
messageRequestState,
isInBubble
) = callback.getSnapshot()
@@ -62,6 +63,23 @@ internal object ConversationOptionsMenu {
return
}
if (!messageRequestState.isAccepted) {
menuInflater.inflate(R.menu.conversation_message_request, menu)
if (messageRequestState.isBlocked) {
hideMenuItem(menu, R.id.menu_block)
hideMenuItem(menu, R.id.menu_accept)
} else {
hideMenuItem(menu, R.id.menu_unblock)
}
if (messageRequestState.reportedAsSpam) {
hideMenuItem(menu, R.id.menu_report_spam)
}
return
}
if (!afterFirstRenderMode) {
createdPreRenderMenu = true
if (recipient.isSelf) {
@@ -83,12 +101,6 @@ internal object ConversationOptionsMenu {
return
}
if (isInMessageRequest && !recipient.isBlocked) {
if (isActiveGroup) {
menuInflater.inflate(R.menu.conversation_message_requests_group, menu)
}
}
if (isPushAvailable) {
if (recipient.expiresInSeconds > 0) {
if (!isInActiveGroup) {
@@ -120,10 +132,6 @@ internal object ConversationOptionsMenu {
menuInflater.inflate(R.menu.conversation, menu)
if (isInMessageRequest && !recipient.isBlocked) {
hideMenuItem(menu, R.id.menu_conversation_settings)
}
if (!recipient.isGroup && !isPushAvailable && !recipient.isReleaseNotes) {
menuInflater.inflate(R.menu.conversation_insecure, menu)
}
@@ -208,6 +216,11 @@ internal object ConversationOptionsMenu {
R.id.menu_expiring_messages_off, R.id.menu_expiring_messages -> callback.handleSelectMessageExpiration()
R.id.menu_create_bubble -> callback.handleCreateBubble()
R.id.home -> callback.handleGoHome()
R.id.menu_block -> callback.handleBlock()
R.id.menu_unblock -> callback.handleUnblock()
R.id.menu_report_spam -> callback.handleReportSpam()
R.id.menu_accept -> callback.handleMessageRequestAccept()
R.id.menu_delete_chat -> callback.handleDeleteConversation()
R.id.edittext_bold,
R.id.edittext_italic,
R.id.edittext_strikethrough,
@@ -244,7 +257,7 @@ internal object ConversationOptionsMenu {
val hasActiveGroupCall: Boolean,
val distributionType: Int,
val threadId: Long,
val isInMessageRequest: Boolean,
val messageRequestState: MessageRequestState,
val isInBubble: Boolean
)
@@ -276,5 +289,10 @@ internal object ConversationOptionsMenu {
fun showExpiring(recipient: Recipient)
fun clearExpiring()
fun handleFormatText(@IdRes id: Int)
fun handleBlock()
fun handleUnblock()
fun handleReportSpam()
fun handleMessageRequestAccept()
fun handleDeleteConversation()
}
}

View File

@@ -566,6 +566,22 @@ public final class ConversationUpdateItem extends FrameLayout
eventListener.onSendPaymentClicked(conversationMessage.getMessageRecord().getFromRecipient().getId());
}
});
} else if (conversationMessage.getMessageRecord().isReportedSpam()) {
actionButton.setText(R.string.ConversationUpdateItem_learn_more);
actionButton.setVisibility(VISIBLE);
actionButton.setOnClickListener(v -> {
if (batchSelected.isEmpty() && eventListener != null) {
eventListener.onReportSpamLearnMoreClicked();
}
});
} else if (conversationMessage.getMessageRecord().isMessageRequestAccepted()) {
actionButton.setText(R.string.ConversationUpdateItem_options);
actionButton.setVisibility(VISIBLE);
actionButton.setOnClickListener(v -> {
if (batchSelected.isEmpty() && eventListener != null) {
eventListener.onMessageRequestAcceptOptionsClicked();
}
});
} else{
actionButton.setVisibility(GONE);
actionButton.setOnClickListener(null);

View File

@@ -274,6 +274,9 @@ class ScheduledMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment
override fun onActivatePaymentsClicked() = Unit
override fun onSendPaymentClicked(recipientId: RecipientId) = Unit
override fun onEditedIndicatorClicked(messageRecord: MessageRecord) = Unit
override fun onShowSafetyTips(forGroup: Boolean) = Unit
override fun onReportSpamLearnMoreClicked() = Unit
override fun onMessageRequestAcceptOptionsClicked() = Unit
}
companion object {

View File

@@ -257,6 +257,10 @@ class MessageQuotesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() {
dismiss()
getAdapterListener().onEditedIndicatorClicked(messageRecord)
}
override fun onShowSafetyTips(forGroup: Boolean) = Unit
override fun onReportSpamLearnMoreClicked() = Unit
override fun onMessageRequestAcceptOptionsClicked() = Unit
}
companion object {

View File

@@ -165,6 +165,9 @@ class EditMessageHistoryDialog : FixedRoundedCornerBottomSheetDialogFragment() {
override fun onActivatePaymentsClicked() = Unit
override fun onSendPaymentClicked(recipientId: RecipientId) = Unit
override fun onEditedIndicatorClicked(messageRecord: MessageRecord) = Unit
override fun onShowSafetyTips(forGroup: Boolean) = Unit
override fun onReportSpamLearnMoreClicked() = Unit
override fun onMessageRequestAcceptOptionsClicked() = Unit
}
companion object {

View File

@@ -50,7 +50,6 @@ import org.thoughtcrime.securesms.databinding.V2ConversationItemTextOnlyOutgoing
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer
import org.thoughtcrime.securesms.groups.v2.GroupDescriptionUtil
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.messagerequests.MessageRequestState
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.CachedInflater
@@ -594,13 +593,25 @@ class ConversationAdapterV2(
}
}
if (sharedGroups.isEmpty() || isSelf) {
conversationBanner.hideButton()
if (messageRequestState?.isAccepted == false && sharedGroups.isEmpty() && !isSelf && !recipient.isGroup) {
conversationBanner.setDescription(context.getString(R.string.ConversationUpdateItem_no_groups_in_common_review_requests_carefully), R.drawable.symbol_error_circle_24)
conversationBanner.setButton(context.getString(R.string.ConversationFragment_safety_tips)) {
clickListener.onShowSafetyTips(false)
}
} else if (messageRequestState?.isAccepted == false && recipient.isGroup && !groupInfo.hasExistingContacts) {
conversationBanner.setDescription(context.getString(R.string.ConversationUpdateItem_no_contacts_in_this_group_review_requests_carefully), R.drawable.symbol_error_circle_24)
conversationBanner.setButton(context.getString(R.string.ConversationFragment_safety_tips)) {
clickListener.onShowSafetyTips(true)
}
} else if (sharedGroups.isEmpty() || isSelf) {
if (TextUtils.isEmpty(groupInfo.description)) {
conversationBanner.setLinkifyDescription(false)
conversationBanner.hideDescription()
} else {
conversationBanner.setLinkifyDescription(true)
val linkifyWebLinks = messageRequestState == MessageRequestState.NONE
val linkifyWebLinks = messageRequestState?.isAccepted == true
conversationBanner.showDescription()
GroupDescriptionUtil.setText(

View File

@@ -10,12 +10,10 @@ import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.concurrent.SimpleTask
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord.NoGroupsInCommon
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.sms.MessageSender
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.verify.VerifyIdentityActivity
/**
@@ -82,24 +80,7 @@ object ConversationDialogs {
dialog.show()
}
fun displayInMemoryMessageDialog(context: Context, messageRecord: MessageRecord) {
if (messageRecord is NoGroupsInCommon) {
val isGroup = messageRecord.isGroup
MaterialAlertDialogBuilder(context, R.style.ThemeOverlay_Signal_MaterialAlertDialog)
.setMessage(
if (isGroup) {
R.string.GroupsInCommonMessageRequest__none_of_your_contacts_or_people_you_chat_with_are_in_this_group
} else {
R.string.GroupsInCommonMessageRequest__you_have_no_groups_in_common_with_this_person
}
)
.setNeutralButton(R.string.GroupsInCommonMessageRequest__about_message_requests) { _, _ ->
CommunicationActions.openBrowserLink(context, context.getString(R.string.GroupsInCommonMessageRequest__support_article))
}
.setPositiveButton(R.string.GroupsInCommonMessageRequest__okay, null)
.show()
}
}
fun displayInMemoryMessageDialog(context: Context, messageRecord: MessageRecord) = Unit
fun displayMessageCouldNotBeSentDialog(context: Context, messageRecord: MessageRecord) {
MaterialAlertDialogBuilder(context)

View File

@@ -243,7 +243,6 @@ import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
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.AudioSlide
import org.thoughtcrime.securesms.mms.GifSlide
@@ -1141,7 +1140,7 @@ class ConversationFragment :
var inputDisabled = true
when {
inputReadyState.isClientExpired || inputReadyState.isUnauthorized -> disabledInputView.showAsExpiredOrUnauthorized(inputReadyState.isClientExpired, inputReadyState.isUnauthorized)
inputReadyState.messageRequestState != MessageRequestState.NONE && inputReadyState.messageRequestState != MessageRequestState.NONE_HIDDEN -> disabledInputView.showAsMessageRequest(inputReadyState.conversationRecipient, inputReadyState.messageRequestState)
!inputReadyState.messageRequestState.isAccepted -> disabledInputView.showAsMessageRequest(inputReadyState.conversationRecipient, inputReadyState.messageRequestState)
inputReadyState.isActiveGroup == false -> disabledInputView.showAsNoLongerAMember()
inputReadyState.isRequestingMember == true -> disabledInputView.showAsRequestingMember()
inputReadyState.isAnnouncementGroup == true && inputReadyState.isAdmin == false -> disabledInputView.showAsAnnouncementGroupAdminsOnly()
@@ -2099,6 +2098,128 @@ class ConversationFragment :
composeText.clearFocus()
}
//region Message Request Helpers
@SuppressLint("CheckResult")
private fun onReportSpam() {
val recipient = viewModel.recipientSnapshot
if (recipient == null) {
Log.w(TAG, "[onBlockClicked] No recipient!")
return
}
BlockUnblockDialog.showReportSpamFor(
requireContext(),
lifecycle,
recipient,
{
messageRequestViewModel
.onReportSpam()
.doOnSubscribe { binding.conversationDisabledInput.showBusy() }
.doOnTerminate { binding.conversationDisabledInput.hideBusy() }
.subscribeBy {
Log.d(TAG, "report spam complete")
toast(R.string.ConversationFragment_reported_as_spam)
}
},
if (recipient.isBlocked) {
null
} else {
Runnable {
messageRequestViewModel
.onBlockAndReportSpam()
.doOnSubscribe { binding.conversationDisabledInput.showBusy() }
.doOnTerminate { binding.conversationDisabledInput.hideBusy() }
.subscribeBy { result ->
when (result) {
is Result.Success -> {
Log.d(TAG, "report spam complete")
toast(R.string.ConversationFragment_reported_as_spam_and_blocked)
}
is Result.Failure -> {
Log.d(TAG, "report spam failed ${result.failure}")
toast(GroupErrors.getUserDisplayMessage(result.failure))
}
}
}
}
}
)
}
@SuppressLint("CheckResult")
private fun onBlock() {
val recipient = viewModel.recipientSnapshot
if (recipient == null) {
Log.w(TAG, "[onBlockClicked] No recipient!")
return
}
BlockUnblockDialog.showBlockFor(
requireContext(),
lifecycle,
recipient
) {
messageRequestViewModel
.onBlock()
.subscribeWithShowProgress("block")
}
}
@SuppressLint("CheckResult")
private fun onUnblock() {
val recipient = viewModel.recipientSnapshot
if (recipient == null) {
Log.w(TAG, "[onUnblockClicked] No recipient!")
return
}
BlockUnblockDialog.showUnblockFor(
requireContext(),
lifecycle,
recipient
) {
messageRequestViewModel
.onUnblock()
.subscribeWithShowProgress("unblock")
}
}
private fun onMessageRequestAccept() {
messageRequestViewModel
.onAccept()
.subscribeWithShowProgress("accept message request")
.addTo(disposables)
}
private fun onDeleteConversation() {
val recipient = viewModel.recipientSnapshot
if (recipient == null) {
Log.w(TAG, "[onDeleteConversation] No recipient!")
return
}
ConversationDialogs.displayDeleteDialog(requireContext(), recipient) {
messageRequestViewModel
.onDelete()
.subscribeWithShowProgress("delete message request")
}
}
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))
}
}
}
}
private inner class BackPressedDelegate : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
Log.d(TAG, "onBackPressed()")
@@ -2115,6 +2236,8 @@ class ConversationFragment :
}
}
// endregion
//region Message action handling
private fun handleReplyToMessage(conversationMessage: ConversationMessage) {
@@ -2983,6 +3106,31 @@ class ConversationFragment :
CommunicationActions.startVideoCall(this@ConversationFragment, callLinkRootKey)
}
override fun onShowSafetyTips(forGroup: Boolean) {
SafetyTipsBottomSheetDialog.show(childFragmentManager, forGroup)
}
override fun onReportSpamLearnMoreClicked() {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.ConversationFragment_reported_spam)
.setMessage(R.string.ConversationFragment_reported_spam_message)
.setPositiveButton(android.R.string.ok, null)
.show()
}
override fun onMessageRequestAcceptOptionsClicked() {
val recipient: Recipient? = viewModel.recipientSnapshot
if (recipient != null) {
MaterialAlertDialogBuilder(requireContext())
.setMessage(getString(R.string.ConversationFragment_you_accepted_a_message_request_from_s, recipient.getDisplayName(requireContext())))
.setPositiveButton(R.string.ConversationFragment_block) { _, _ -> onBlock() }
.setNegativeButton(R.string.ConversationFragment_report_spam) { _, _ -> onReportSpam() }
.setNeutralButton(R.string.ConversationFragment__cancel, null)
.show()
}
}
private fun MessageRecord.getAudioUriForLongClick(): Uri? {
val playbackState = getVoiceNoteMediaController().voiceNotePlaybackState.value
if (playbackState == null || !playbackState.isPlaying) {
@@ -3012,7 +3160,7 @@ class ConversationFragment :
hasActiveGroupCall = groupCallViewModel.hasOngoingGroupCallSnapshot,
distributionType = args.distributionType,
threadId = args.threadId,
isInMessageRequest = viewModel.hasMessageRequestState,
messageRequestState = viewModel.messageRequestState,
isInBubble = args.conversationScreenType.isInBubble
)
}
@@ -3240,6 +3388,26 @@ class ConversationFragment :
override fun handleFormatText(id: Int) {
composeText.handleFormatText(id)
}
override fun handleBlock() {
onBlock()
}
override fun handleUnblock() {
onUnblock()
}
override fun handleReportSpam() {
onReportSpam()
}
override fun handleMessageRequestAccept() {
onMessageRequestAccept()
}
override fun handleDeleteConversation() {
onDeleteConversation()
}
}
private inner class OnReactionsSelectedListener : ConversationReactionOverlay.OnReactionSelectedListener {
@@ -3551,66 +3719,23 @@ class ConversationFragment :
}
override fun onAcceptMessageRequestClicked() {
messageRequestViewModel
.onAccept()
.subscribeWithShowProgress("accept message request")
.addTo(disposables)
onMessageRequestAccept()
}
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 onDeleteClicked() {
onDeleteConversation()
}
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")
}
)
onBlock()
}
override fun onUnblockClicked() {
val recipient = viewModel.recipientSnapshot
if (recipient == null) {
Log.w(TAG, "[onUnblockClicked] No recipient!")
return
}
onUnblock()
}
BlockUnblockDialog.showUnblockFor(
requireContext(),
lifecycle,
recipient
) {
messageRequestViewModel
.onUnblock()
.subscribeWithShowProgress("unblock")
}
override fun onReportSpamClicked() {
onReportSpam()
}
override fun onInviteToSignal(recipient: Recipient) {
@@ -3625,20 +3750,6 @@ class ConversationFragment :
override fun onUnmuteReleaseNotesChannel() {
viewModel.muteConversation(0L)
}
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

View File

@@ -366,7 +366,7 @@ class ConversationRepository(
fun getRequestReviewState(recipient: Recipient, group: GroupRecord?, messageRequest: MessageRequestState): Single<RequestReviewState> {
return Single.fromCallable {
if (group == null && messageRequest != MessageRequestState.INDIVIDUAL) {
if (group == null && messageRequest.state != MessageRequestState.State.INDIVIDUAL) {
return@fromCallable RequestReviewState()
}

View File

@@ -132,9 +132,11 @@ class ConversationViewModel(
private val _inputReadyState: Observable<InputReadyState>
val inputReadyState: Observable<InputReadyState>
private val hasMessageRequestStateSubject: BehaviorSubject<Boolean> = BehaviorSubject.createDefault(false)
private val hasMessageRequestStateSubject: BehaviorSubject<MessageRequestState> = BehaviorSubject.createDefault(MessageRequestState())
val hasMessageRequestState: Boolean
get() = hasMessageRequestStateSubject.value ?: false
get() = hasMessageRequestStateSubject.value?.state != MessageRequestState.State.NONE
val messageRequestState: MessageRequestState
get() = hasMessageRequestStateSubject.value ?: MessageRequestState()
private val refreshReminder: Subject<Unit> = PublishSubject.create()
val reminder: Observable<Optional<Reminder>>
@@ -239,7 +241,7 @@ class ConversationViewModel(
isUnauthorized = TextSecurePreferences.isUnauthorizedReceived(ApplicationDependencies.getApplication())
)
}.doOnNext {
hasMessageRequestStateSubject.onNext(it.messageRequestState != MessageRequestState.NONE)
hasMessageRequestStateSubject.onNext(it.messageRequestState)
}
inputReadyState = _inputReadyState.observeOn(AndroidSchedulers.mainThread())

View File

@@ -17,7 +17,6 @@ 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
@@ -80,13 +79,14 @@ class DisabledInputView @JvmOverloads constructor(
existingView = messageRequestView,
create = { MessageRequestsBottomView(context) },
bind = {
setMessageData(MessageRequestViewModel.MessageData(recipient, messageRequestState))
setMessageRequestData(recipient, messageRequestState)
setWallpaperEnabled(recipient.hasWallpaper())
setAcceptOnClickListener { listener?.onAcceptMessageRequestClicked() }
setDeleteOnClickListener { listener?.onDeleteGroupClicked() }
setDeleteOnClickListener { listener?.onDeleteClicked() }
setBlockOnClickListener { listener?.onBlockClicked() }
setUnblockOnClickListener { listener?.onUnblockClicked() }
setReportOnClickListener { listener?.onReportSpamClicked() }
}
)
}
@@ -226,10 +226,11 @@ class DisabledInputView @JvmOverloads constructor(
fun onCancelGroupRequestClicked()
fun onShowAdminsBottomSheetDialog()
fun onAcceptMessageRequestClicked()
fun onDeleteGroupClicked()
fun onDeleteClicked()
fun onBlockClicked()
fun onUnblockClicked()
fun onInviteToSignal(recipient: Recipient)
fun onUnmuteReleaseNotesChannel()
fun onReportSpamClicked()
}
}

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.conversation.v2
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single
import org.signal.core.util.Result
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason
@@ -57,6 +58,14 @@ class MessageRequestViewModel(
.observeOn(AndroidSchedulers.mainThread())
}
fun onReportSpam(): Completable {
return recipientId
.flatMapCompletable { recipientId ->
messageRequestRepository.reportSpamMessageRequest(recipientId, threadId)
}
.observeOn(AndroidSchedulers.mainThread())
}
fun onBlockAndReportSpam(): Single<Result<Unit, GroupChangeFailureReason>> {
return recipientId
.flatMap { recipientId ->

View File

@@ -0,0 +1,277 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.v2
import android.content.res.Configuration
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import kotlinx.coroutines.launch
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
/**
* Shows tips about typical spam and fraud messages.
*/
class SafetyTipsBottomSheetDialog : ComposeBottomSheetDialogFragment() {
companion object {
private const val FOR_GROUP_ARG = "for_group"
fun show(fragmentManager: FragmentManager, forGroup: Boolean) {
SafetyTipsBottomSheetDialog()
.apply {
arguments = bundleOf(
FOR_GROUP_ARG to forGroup
)
}
.show(fragmentManager, "SAFETY_TIPS")
}
}
override val peekHeightPercentage: Float = 1f
@Composable
override fun SheetContent() {
val nestedScrollInterop = rememberNestedScrollInteropConnection()
SafetyTipsContent(
forGroup = requireArguments().getBoolean(FOR_GROUP_ARG, false),
modifier = Modifier.nestedScroll(nestedScrollInterop)
)
}
}
data class SafetyTipData(
@DrawableRes val heroImage: Int,
@StringRes val titleText: Int,
@StringRes val messageText: Int
)
private val tips = listOf(
SafetyTipData(heroImage = R.drawable.safety_tip1, titleText = R.string.SafetyTips_tip1_title, messageText = R.string.SafetyTips_tip1_message),
SafetyTipData(heroImage = R.drawable.safety_tip2, titleText = R.string.SafetyTips_tip2_title, messageText = R.string.SafetyTips_tip2_message),
SafetyTipData(heroImage = R.drawable.safety_tip3, titleText = R.string.SafetyTips_tip3_title, messageText = R.string.SafetyTips_tip3_message),
SafetyTipData(heroImage = R.drawable.safety_tip4, titleText = R.string.SafetyTips_tip4_title, messageText = R.string.SafetyTips_tip4_message)
)
@Preview
@Composable
private fun SafetyTipsContentPreview() {
SignalTheme {
Surface {
SafetyTipsContent()
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SafetyTipsContent(forGroup: Boolean = false, modifier: Modifier = Modifier) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth()
) {
BottomSheets.Handle()
}
val size = remember { tips.size }
val pagerState = rememberPagerState(
pageCount = { size }
)
val scrollState = rememberScrollState()
Column(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = modifier
.fillMaxWidth()
.weight(weight = 1f, fill = false)
.padding(top = 22.dp)
.verticalScroll(state = scrollState)
) {
Text(
text = stringResource(id = R.string.SafetyTips_title),
style = MaterialTheme.typography.headlineMedium.copy(textAlign = TextAlign.Center),
modifier = Modifier
.padding(start = 24.dp, end = 24.dp, bottom = 4.dp, top = 26.dp)
.fillMaxWidth()
)
Text(
text = if (forGroup) stringResource(id = R.string.SafetyTips_subtitle_group) else stringResource(id = R.string.SafetyTips_subtitle_individual),
style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurfaceVariant),
modifier = Modifier
.padding(start = 36.dp, end = 36.dp)
.fillMaxWidth()
)
HorizontalPager(
state = pagerState,
beyondBoundsPageCount = size,
modifier = Modifier.padding(top = 24.dp)
) {
SafetyTip(tips[it])
}
Row(
Modifier
.fillMaxWidth()
.padding(top = 20.dp),
horizontalArrangement = Arrangement.Center
) {
repeat(pagerState.pageCount) { iteration ->
val color = if (pagerState.currentPage == iteration) {
MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f)
} else {
MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.3f)
}
Box(
modifier = Modifier
.padding(3.dp)
.clip(CircleShape)
.background(color)
.size(8.dp)
)
}
}
}
Surface(
shadowElevation = if (scrollState.canScrollForward) 8.dp else 0.dp,
modifier = Modifier.fillMaxWidth(),
color = SignalTheme.colors.colorSurface1,
contentColor = MaterialTheme.colorScheme.onSurface
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.padding(start = 24.dp, end = 24.dp, bottom = 36.dp, top = 24.dp)
.fillMaxWidth()
) {
val coroutineScope = rememberCoroutineScope()
TextButton(
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(pagerState.currentPage - 1)
}
},
enabled = pagerState.currentPage > 0,
modifier = Modifier
) {
Text(text = stringResource(id = R.string.SafetyTips_previous_tip))
}
Buttons.LargeTonal(
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
},
enabled = pagerState.currentPage + 1 < pagerState.pageCount
) {
Text(text = stringResource(id = R.string.SafetyTips_next_tip))
}
}
}
}
}
@Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun SafetyTipPreview() {
SignalTheme {
Surface {
SafetyTip(tips[0])
}
}
}
@Composable
private fun SafetyTip(safetyTip: SafetyTipData) {
Surface(
shape = RoundedCornerShape(18.dp),
color = colorResource(id = R.color.safety_tip_background),
contentColor = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.fillMaxWidth()
.padding(start = 24.dp, end = 24.dp)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = colorResource(id = R.color.safety_tip_image_background),
modifier = Modifier
.padding(12.dp)
.fillMaxWidth()
) {
Image(
painter = painterResource(id = safetyTip.heroImage),
contentDescription = null,
modifier = Modifier
.padding(16.dp)
)
}
Text(
text = stringResource(id = safetyTip.titleText),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 4.dp)
)
Text(
text = stringResource(id = safetyTip.messageText),
style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center),
modifier = Modifier
.padding(start = 24.dp, end = 24.dp, bottom = 24.dp)
)
}
}
}

View File

@@ -15,7 +15,6 @@ import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord.NoGroupsInCommon
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord.RemovedContactHidden
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord.UniversalExpireTimerUpdate
import org.thoughtcrime.securesms.database.model.MessageRecord
@@ -72,7 +71,6 @@ class ConversationDataSource(
val startTime = System.currentTimeMillis()
val size: Int = getSizeInternal() +
THREAD_HEADER_COUNT +
messageRequestData.includeWarningUpdateMessage().toInt() +
messageRequestData.isHidden.toInt() +
showUniversalExpireTimerUpdate.toInt()
@@ -108,10 +106,6 @@ class ConversationDataSource(
}
}
if (messageRequestData.includeWarningUpdateMessage() && (start + length >= totalSize)) {
records.add(NoGroupsInCommon(threadId, messageRequestData.isGroup))
}
if (messageRequestData.isHidden && (start + length >= totalSize)) {
records.add(RemovedContactHidden(threadId))
}