mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-03 23:15:44 +01:00
Update conversation header and message request UI.
This commit is contained in:
committed by
jeffrey-signal
parent
c2d927029a
commit
4756b8d70b
@@ -718,7 +718,7 @@ class ChatItemArchiveImporter(
|
||||
when {
|
||||
itemStandardMessage != null -> contentValues.addStandardMessage(itemStandardMessage)
|
||||
itemRemoteDeletedMessage != null -> contentValues.put(MessageTable.DELETED_BY, fromRecipientId.toLong())
|
||||
itemUpdateMessage != null -> contentValues.addUpdateMessage(itemUpdateMessage, fromRecipientId, toRecipientId)
|
||||
itemUpdateMessage != null -> contentValues.addUpdateMessage(itemUpdateMessage, fromRecipientId, toRecipientId, chatRecipientId)
|
||||
itemPaymentNotification != null -> contentValues.addPaymentNotification(this, chatRecipientId)
|
||||
itemGiftBadge != null -> contentValues.addGiftBadge(itemGiftBadge)
|
||||
itemViewOnceMessage != null -> contentValues.addViewOnce(itemViewOnceMessage)
|
||||
@@ -866,7 +866,7 @@ class ChatItemArchiveImporter(
|
||||
}
|
||||
}
|
||||
|
||||
private fun ContentValues.addUpdateMessage(updateMessage: ChatUpdateMessage, fromRecipientId: RecipientId, toRecipientId: RecipientId) {
|
||||
private fun ContentValues.addUpdateMessage(updateMessage: ChatUpdateMessage, fromRecipientId: RecipientId, toRecipientId: RecipientId, chatRecipientId: RecipientId) {
|
||||
var typeFlags: Long = 0
|
||||
val simpleUpdate = updateMessage.simpleUpdate
|
||||
val expirationTimerChange = updateMessage.expirationTimerChange
|
||||
@@ -907,6 +907,11 @@ class ChatItemArchiveImporter(
|
||||
put(MessageTable.FROM_RECIPIENT_ID, toRecipientId.serialize())
|
||||
put(MessageTable.TO_RECIPIENT_ID, fromRecipientId.serialize())
|
||||
}
|
||||
|
||||
// directionless 1:1 message requests expect to recipient to be the other recipient not self
|
||||
if (simpleUpdate.type == SimpleChatUpdate.Type.MESSAGE_REQUEST_ACCEPTED) {
|
||||
put(MessageTable.TO_RECIPIENT_ID, chatRecipientId.serialize())
|
||||
}
|
||||
}
|
||||
expirationTimerChange != null -> {
|
||||
typeFlags = getAsLong(MessageTable.TYPE) or MessageTypes.EXPIRATION_TIMER_UPDATE_BIT
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.animation.AnimatorListenerAdapter;
|
||||
import android.animation.ObjectAnimator;
|
||||
import android.content.Context;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Handler;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.TextUtils;
|
||||
import android.text.method.LinkMovementMethod;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.DrawableRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.view.ViewKt;
|
||||
|
||||
import com.bumptech.glide.RequestManager;
|
||||
|
||||
import org.signal.core.util.DimensionUnit;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarGradientColors;
|
||||
import org.thoughtcrime.securesms.conversation.v2.data.AvatarDownloadStateCache;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.databinding.ConversationHeaderViewBinding;
|
||||
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.LongClickMovementMethod;
|
||||
import org.thoughtcrime.securesms.util.SpanUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.whispersystems.signalservice.api.util.Preconditions;
|
||||
|
||||
public class ConversationHeaderView extends ConstraintLayout {
|
||||
|
||||
private static final String TAG = Log.tag(ConversationHeaderView.class);
|
||||
private static final int FADE_DURATION = 150;
|
||||
private static final int LOADING_DELAY = 800;
|
||||
|
||||
private final ConversationHeaderViewBinding binding;
|
||||
|
||||
private boolean inProgress = false;
|
||||
private Handler handler = new Handler();
|
||||
|
||||
public ConversationHeaderView(Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public ConversationHeaderView(Context context, AttributeSet attrs) {
|
||||
this(context, attrs, 0);
|
||||
}
|
||||
|
||||
public ConversationHeaderView(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
|
||||
inflate(getContext(), R.layout.conversation_header_view, this);
|
||||
|
||||
binding = ConversationHeaderViewBinding.bind(this);
|
||||
}
|
||||
|
||||
public void showProgressBar(@NonNull Recipient recipient) {
|
||||
if (!inProgress) {
|
||||
inProgress = true;
|
||||
animateAvatarLoading(recipient);
|
||||
binding.messageRequestAvatarTapToView.setVisibility(GONE);
|
||||
binding.messageRequestAvatarTapToView.setOnClickListener(null);
|
||||
handler.postDelayed(() -> {
|
||||
boolean isDownloading = AvatarDownloadStateCache.getDownloadState(recipient) == AvatarDownloadStateCache.DownloadState.IN_PROGRESS;
|
||||
binding.progressBar.setVisibility(isDownloading ? View.VISIBLE : View.GONE);
|
||||
}, LOADING_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
public void hideProgressBar() {
|
||||
inProgress = false;
|
||||
binding.progressBar.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
public void showFailedAvatarDownload(@NonNull Recipient recipient) {
|
||||
AvatarDownloadStateCache.set(recipient, AvatarDownloadStateCache.DownloadState.NONE);
|
||||
binding.progressBar.setVisibility(View.GONE);
|
||||
binding.messageRequestAvatar.setImageDrawable(AvatarGradientColors.getGradientDrawable(recipient));
|
||||
}
|
||||
|
||||
public void setBadge(@Nullable Recipient recipient) {
|
||||
if (recipient == null || recipient.isSelf()) {
|
||||
binding.messageRequestBadge.setBadge(null);
|
||||
} else {
|
||||
binding.messageRequestBadge.setBadgeFromRecipient(recipient);
|
||||
}
|
||||
}
|
||||
|
||||
public void setAvatar(@NonNull RequestManager requestManager, @Nullable Recipient recipient) {
|
||||
if (recipient == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (AvatarDownloadStateCache.getDownloadState(recipient) != AvatarDownloadStateCache.DownloadState.IN_PROGRESS) {
|
||||
binding.messageRequestAvatar.setAvatar(requestManager, recipient, false, false, true);
|
||||
hideProgressBar();
|
||||
}
|
||||
|
||||
if (recipient.getShouldBlurAvatar() && recipient.getHasAvatar()) {
|
||||
binding.messageRequestAvatarTapToView.setVisibility(VISIBLE);
|
||||
binding.messageRequestAvatarTapToView.setOnClickListener(v -> {
|
||||
AvatarDownloadStateCache.set(recipient, AvatarDownloadStateCache.DownloadState.IN_PROGRESS);
|
||||
SignalExecutors.BOUNDED.execute(() -> SignalDatabase.recipients().manuallyUpdateShowAvatar(recipient.getId(), true));
|
||||
if (recipient.isPushV2Group()) {
|
||||
AvatarGroupsV2DownloadJob.enqueueUnblurredAvatar(recipient.requireGroupId().requireV2());
|
||||
} else {
|
||||
RetrieveProfileAvatarJob.enqueueUnblurredAvatar(recipient);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
binding.messageRequestAvatarTapToView.setVisibility(GONE);
|
||||
binding.messageRequestAvatarTapToView.setOnClickListener(null);
|
||||
}
|
||||
}
|
||||
|
||||
public String setTitle(@NonNull Recipient recipient, @NonNull Runnable onTitleClicked) {
|
||||
CharSequence title = recipient.getDisplayNameForHeadline(getContext());
|
||||
|
||||
if (recipient.isIndividual() && !recipient.isSelf()) {
|
||||
binding.messageRequestTitle.setOnClickListener(v -> onTitleClicked.run());
|
||||
} else {
|
||||
binding.messageRequestTitle.setOnClickListener(null);
|
||||
}
|
||||
|
||||
binding.messageRequestTitle.setText(title);
|
||||
return title.toString();
|
||||
}
|
||||
|
||||
public void showReleaseNoteHeader() {
|
||||
binding.messageRequestInfo.setVisibility(View.GONE);
|
||||
binding.releaseHeaderContainer.setVisibility(View.VISIBLE);
|
||||
binding.releaseHeaderDescription1.setText(prependIcon(getContext().getString(R.string.ReleaseNotes__this_is_official_chat_period), R.drawable.symbol_official_20));
|
||||
binding.releaseHeaderDescription2.setText(prependIcon(getContext().getString(R.string.ReleaseNotes__keep_up_to_date_period), R.drawable.symbol_bell_20));
|
||||
}
|
||||
|
||||
public void setAbout(@NonNull Recipient recipient) {
|
||||
String about = recipient.getCombinedAboutAndEmoji();
|
||||
binding.messageRequestAbout.setText(about);
|
||||
binding.messageRequestAbout.setVisibility(TextUtils.isEmpty(about) || recipient.isReleaseNotes() ? GONE : VISIBLE);
|
||||
}
|
||||
|
||||
public void setSubtitle(@NonNull CharSequence subtitle, @DrawableRes int iconRes, @Nullable String substring, @Nullable Runnable onClick) {
|
||||
if (TextUtils.isEmpty(subtitle)) {
|
||||
hideSubtitle();
|
||||
return;
|
||||
}
|
||||
|
||||
if (onClick != null && substring != null) {
|
||||
binding.messageRequestSubtitle.setMovementMethod(LinkMovementMethod.getInstance());
|
||||
CharSequence builder = SpanUtil.clickSubstring(
|
||||
subtitle,
|
||||
substring,
|
||||
listener -> onClick.run(),
|
||||
ContextCompat.getColor(getContext(), org.signal.core.ui.R.color.signal_colorOnSurface),
|
||||
true
|
||||
);
|
||||
binding.messageRequestSubtitle.setText(prependIcon(builder, iconRes));
|
||||
} else {
|
||||
binding.messageRequestSubtitle.setText(prependIcon(subtitle, iconRes));
|
||||
}
|
||||
|
||||
binding.messageRequestSubtitle.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
public void setDescription(@Nullable CharSequence description, @DrawableRes int iconRes) {
|
||||
if (TextUtils.isEmpty(description)) {
|
||||
hideDescription();
|
||||
return;
|
||||
}
|
||||
|
||||
binding.messageRequestDescription.setText(prependIcon(description, iconRes));
|
||||
binding.messageRequestDescription.setVisibility(View.VISIBLE);
|
||||
updateOutlineVisibility();
|
||||
}
|
||||
|
||||
public @NonNull EmojiTextView getDescription() {
|
||||
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 showWarningSubtitle() {
|
||||
binding.messageRequestReviewCarefully.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
public void hideWarningSubtitle() {
|
||||
binding.messageRequestReviewCarefully.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
public void setUnverifiedNameSubtitle(@DrawableRes int iconRes, boolean forGroup, @NonNull Runnable onClick) {
|
||||
binding.messageRequestProfileNameUnverified.setVisibility(View.VISIBLE);
|
||||
binding.messageRequestProfileNameUnverified.setOnClickListener(view -> onClick.run());
|
||||
|
||||
String substring = forGroup ? getContext().getString(R.string.ConversationFragment_group_names)
|
||||
: getContext().getString(R.string.ConversationFragment_profile_names);
|
||||
|
||||
String fullString = forGroup ? getContext().getString(R.string.ConversationFragment_group_names_not_verified, substring)
|
||||
: getContext().getString(R.string.ConversationFragment_profile_names_not_verified, substring);
|
||||
|
||||
CharSequence builder = SpanUtil.underlineSubstring(fullString, substring);
|
||||
binding.messageRequestProfileNameUnverified.setText(prependIcon(builder, iconRes, forGroup));
|
||||
}
|
||||
|
||||
public void hideUnverifiedNameSubtitle() {
|
||||
binding.messageRequestProfileNameUnverified.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
public void showBackgroundBubble(boolean enabled) {
|
||||
if (enabled) {
|
||||
setBackgroundResource(R.drawable.wallpaper_bubble_background_18);
|
||||
} else {
|
||||
setBackground(null);
|
||||
}
|
||||
|
||||
updateOutlineVisibility();
|
||||
}
|
||||
|
||||
public void hideSubtitle() {
|
||||
binding.messageRequestSubtitle.setVisibility(View.GONE);
|
||||
updateOutlineVisibility();
|
||||
}
|
||||
|
||||
public void showDescription() {
|
||||
binding.messageRequestDescription.setVisibility(View.VISIBLE);
|
||||
updateOutlineVisibility();
|
||||
}
|
||||
|
||||
public void hideDescription() {
|
||||
binding.messageRequestDescription.setVisibility(View.GONE);
|
||||
updateOutlineVisibility();
|
||||
}
|
||||
|
||||
public void hideButton() {
|
||||
binding.messageRequestButton.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
public void setLinkifyDescription(boolean enable) {
|
||||
binding.messageRequestDescription.setMovementMethod(enable ? LongClickMovementMethod.getInstance(getContext()) : null);
|
||||
}
|
||||
|
||||
private void animateAvatarLoading(@NonNull Recipient recipient) {
|
||||
Drawable loadingProfile = AppCompatResources.getDrawable(getContext(), R.drawable.circle_profile_photo);
|
||||
ObjectAnimator animator = ObjectAnimator.ofFloat(binding.messageRequestAvatar, "alpha", 1f, 0f).setDuration(FADE_DURATION);
|
||||
animator.addListener(new AnimatorListenerAdapter() {
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation) {
|
||||
if (AvatarDownloadStateCache.getDownloadState(recipient) == AvatarDownloadStateCache.DownloadState.IN_PROGRESS) {
|
||||
binding.messageRequestAvatar.setImageDrawable(loadingProfile);
|
||||
}
|
||||
ObjectAnimator.ofFloat(binding.messageRequestAvatar, "alpha", 0f, 1f).setDuration(FADE_DURATION).start();
|
||||
}
|
||||
});
|
||||
|
||||
animator.start();
|
||||
}
|
||||
|
||||
private void updateOutlineVisibility() {
|
||||
if (ViewKt.isVisible(binding.messageRequestSubtitle) || ViewKt.isVisible(binding.messageRequestDescription)) {
|
||||
if (getBackground() != null) {
|
||||
binding.messageRequestInfoOutline.setVisibility(View.GONE);
|
||||
binding.messageRequestDivider.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
binding.messageRequestInfoOutline.setVisibility(View.VISIBLE);
|
||||
binding.messageRequestDivider.setVisibility(View.GONE);
|
||||
}
|
||||
} else {
|
||||
binding.messageRequestInfoOutline.setVisibility(View.GONE);
|
||||
binding.messageRequestDivider.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
public void updateOutlineBoxSize() {
|
||||
int visibleCount = 0;
|
||||
for (int i = 0; i < binding.messageRequestInfo.getChildCount(); i++) {
|
||||
if (ViewKt.isVisible(binding.messageRequestInfo.getChildAt(i))) {
|
||||
visibleCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (getBackground() != null) {
|
||||
ViewUtil.setPaddingTop(binding.messageRequestInfo, 0);
|
||||
ViewUtil.setPaddingBottom(binding.messageRequestInfo, getContext().getResources().getDimensionPixelOffset(R.dimen.conversation_header_padding));
|
||||
int margin = getContext().getResources().getDimensionPixelOffset(R.dimen.conversation_header_margin);
|
||||
ViewUtil.setLeftMargin(this, margin);
|
||||
ViewUtil.setRightMargin(this, margin);
|
||||
}
|
||||
|
||||
int padding = visibleCount == 1 ? getContext().getResources().getDimensionPixelOffset(R.dimen.conversation_header_padding) : getContext().getResources().getDimensionPixelOffset(R.dimen.conversation_header_padding_expanded);
|
||||
ViewUtil.setPaddingStart(binding.messageRequestInfo, padding);
|
||||
ViewUtil.setPaddingEnd(binding.messageRequestInfo, padding);
|
||||
}
|
||||
|
||||
private @NonNull CharSequence prependIcon(@NonNull CharSequence input, @DrawableRes int iconRes) {
|
||||
return prependIcon(input, iconRes, false);
|
||||
}
|
||||
|
||||
|
||||
private @NonNull CharSequence prependIcon(@NonNull CharSequence input, @DrawableRes int iconRes, boolean useIntrinsicWidth) {
|
||||
Drawable drawable = ContextCompat.getDrawable(getContext(), iconRes);
|
||||
Preconditions.checkNotNull(drawable);
|
||||
int width = useIntrinsicWidth ? drawable.getIntrinsicWidth() : (int) DimensionUnit.SP.toPixels(16);
|
||||
drawable.setBounds(0, 0, width, (int) DimensionUnit.SP.toPixels(16));
|
||||
drawable.setColorFilter(ContextCompat.getColor(getContext(), org.signal.core.ui.R.color.signal_colorOnSurface), PorterDuff.Mode.SRC_ATOP);
|
||||
|
||||
return new SpannableStringBuilder()
|
||||
.append(SpanUtil.buildCenteredImageSpan(drawable))
|
||||
.append(SpanUtil.space(8, DimensionUnit.SP))
|
||||
.append(input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.Gravity
|
||||
import android.widget.ImageView
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SnapshotMutationPolicy
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.AbstractComposeView
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import kotlinx.coroutines.delay
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.BidiUtil
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.avatar.AvatarImage
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImageLarge
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarGradientColors
|
||||
import org.thoughtcrime.securesms.conversation.v2.data.AvatarDownloadStateCache
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols.buildSignalSymbolAnnotatedString
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols.signalSymbolText
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupDescriptionUtil
|
||||
import org.thoughtcrime.securesms.messagerequests.GroupInfo
|
||||
import org.thoughtcrime.securesms.messagerequests.MessageRequestRecipientInfo
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.LongClickMovementMethod
|
||||
import org.thoughtcrime.securesms.util.SignalE164Util
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
private val AvatarSize = 74.dp
|
||||
private val AvatarOverlapAbove = 16.dp
|
||||
private val AvatarOverlapBelow = AvatarSize - AvatarOverlapAbove
|
||||
private val BorderShape = RoundedCornerShape(40.dp)
|
||||
|
||||
class ConversationHeaderView : AbstractComposeView {
|
||||
constructor(context: Context) : super(context)
|
||||
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
|
||||
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
|
||||
|
||||
init {
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||
}
|
||||
|
||||
var callbacks: ConversationHeaderCallbacks = ConversationHeaderCallbacks.Empty
|
||||
var recipientInfo: MessageRequestRecipientInfo? by mutableStateOf(null, policy = RecipientInfoContentPolicy)
|
||||
var avatarDownloadState: AvatarDownloadStateCache.DownloadState by mutableStateOf(AvatarDownloadStateCache.DownloadState.NONE)
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val info = recipientInfo ?: return
|
||||
val recipient = info.recipient
|
||||
val groupInfo = info.groupInfo
|
||||
val isSelf = recipient.isSelf
|
||||
val isReleaseNotes = recipient.isReleaseNotes
|
||||
val isOfficialAccount = recipient.showVerified
|
||||
|
||||
val showUnverifiedName = if (recipient.isGroup) {
|
||||
!groupInfo.hasExistingContacts && !(groupInfo.fullMemberCount == 1 && groupInfo.isMember)
|
||||
} else if (!isOfficialAccount) {
|
||||
recipient.nickname.isEmpty && !recipient.isSystemContact
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
val displayName = if (isSelf) BidiUtil.isolateBidi(context.getString(R.string.note_to_self)) else recipient.getDisplayName(context)
|
||||
val phoneNumber = if (!recipient.isGroup && !isOfficialAccount && recipient.shouldShowE164) {
|
||||
recipient.e164.map { SignalE164Util.prettyPrint(it) }.orElse(null)?.takeIf { it != displayName }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
SignalTheme {
|
||||
ConversationHeaderContent(
|
||||
recipientId = recipient.id,
|
||||
displayName = displayName,
|
||||
showVerified = isOfficialAccount,
|
||||
isSystemContact = recipient.isSystemContact,
|
||||
showChevron = recipient.isIndividual && !isOfficialAccount,
|
||||
isSelf = isSelf,
|
||||
isReleaseNotes = isReleaseNotes,
|
||||
badge = if (!isOfficialAccount) recipient.featuredBadge else null,
|
||||
showUnverifiedName = showUnverifiedName,
|
||||
isGroup = recipient.isGroup,
|
||||
hasWallpaper = recipient.hasWallpaper,
|
||||
phoneNumber = phoneNumber,
|
||||
groupInfo = if (recipient.isGroup) groupInfo else null,
|
||||
groupDescription = if (recipient.isGroup) groupInfo.description else null,
|
||||
linkifyGroupDescription = info.messageRequestState?.isAccepted == true,
|
||||
sharedGroups = info.sharedGroups,
|
||||
showSafetyTips = info.messageRequestState?.isAccepted == false,
|
||||
avatarDownloadState = avatarDownloadState,
|
||||
shouldBlurAvatar = recipient.shouldBlurAvatar && recipient.hasAvatar,
|
||||
callbacks = callbacks
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ConversationHeaderCallbacks {
|
||||
fun onSafetyTipsClicked(forGroup: Boolean) = Unit
|
||||
fun onUnverifiedNameClicked(forGroup: Boolean) = Unit
|
||||
fun onTitleClicked() = Unit
|
||||
fun onGroupSettingsClicked() = Unit
|
||||
fun onShowGroupDescriptionClicked(groupName: String, description: String, linkifyWebLinks: Boolean) = Unit
|
||||
fun onAvatarTapToViewClicked() = Unit
|
||||
|
||||
companion object Empty : ConversationHeaderCallbacks
|
||||
}
|
||||
|
||||
private object RecipientInfoContentPolicy : SnapshotMutationPolicy<MessageRequestRecipientInfo?> {
|
||||
override fun equivalent(a: MessageRequestRecipientInfo?, b: MessageRequestRecipientInfo?): Boolean {
|
||||
if (a === b) return true
|
||||
if (a == null || b == null) return false
|
||||
return a.recipient.hasSameContent(b.recipient) &&
|
||||
a.groupInfo == b.groupInfo &&
|
||||
a.sharedGroups == b.sharedGroups &&
|
||||
a.messageRequestState == b.messageRequestState
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConversationHeaderContent(
|
||||
recipientId: RecipientId,
|
||||
displayName: String,
|
||||
showVerified: Boolean = false,
|
||||
isSystemContact: Boolean = false,
|
||||
showChevron: Boolean = false,
|
||||
isSelf: Boolean = false,
|
||||
isReleaseNotes: Boolean = false,
|
||||
badge: Badge?,
|
||||
showUnverifiedName: Boolean,
|
||||
isGroup: Boolean,
|
||||
hasWallpaper: Boolean = false,
|
||||
phoneNumber: String? = null,
|
||||
groupInfo: GroupInfo? = null,
|
||||
groupDescription: String? = null,
|
||||
linkifyGroupDescription: Boolean = false,
|
||||
sharedGroups: List<String> = emptyList(),
|
||||
showSafetyTips: Boolean = false,
|
||||
avatarDownloadState: AvatarDownloadStateCache.DownloadState,
|
||||
shouldBlurAvatar: Boolean = false,
|
||||
callbacks: ConversationHeaderCallbacks = ConversationHeaderCallbacks.Empty
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.TopCenter
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.padding(top = AvatarOverlapAbove)
|
||||
.width(277.dp)
|
||||
.then(
|
||||
if (hasWallpaper) {
|
||||
Modifier
|
||||
.clip(BorderShape)
|
||||
.background(if (isSystemInDarkTheme()) SignalTheme.colors.colorTransparentInverse5 else SignalTheme.colors.colorTransparent5)
|
||||
} else {
|
||||
Modifier.border(width = 2.5.dp, color = SignalTheme.colors.colorSurface3, shape = BorderShape)
|
||||
}
|
||||
)
|
||||
.padding(top = AvatarOverlapBelow + 12.dp, bottom = 24.dp, start = 24.dp, end = 24.dp)
|
||||
) {
|
||||
HeadlineDisplayName(
|
||||
displayName = displayName,
|
||||
showVerified = showVerified,
|
||||
isSystemContact = isSystemContact,
|
||||
showChevron = showChevron,
|
||||
modifier = Modifier.clickable { callbacks.onTitleClicked() }
|
||||
)
|
||||
|
||||
if (isSelf) {
|
||||
OfficialChatPill()
|
||||
Text(
|
||||
text = stringResource(R.string.ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (isReleaseNotes) {
|
||||
OfficialChatPill()
|
||||
Text(
|
||||
text = stringResource(R.string.ConversationFragment_release_notes_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (showUnverifiedName) {
|
||||
UnverifiedNamePill(
|
||||
onClick = { callbacks.onUnverifiedNameClicked(isGroup) },
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (phoneNumber != null) {
|
||||
Text(
|
||||
text = signalSymbolText(
|
||||
text = phoneNumber,
|
||||
glyphStart = SignalSymbols.Glyph.PHONE
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (!groupDescription.isNullOrEmpty()) {
|
||||
GroupDescription(
|
||||
description = groupDescription,
|
||||
linkify = linkifyGroupDescription,
|
||||
onMoreClicked = { callbacks.onShowGroupDescriptionClicked(displayName.toString(), groupDescription, linkifyGroupDescription) },
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (groupInfo != null) {
|
||||
GroupMemberSubtitle(
|
||||
groupInfo = groupInfo,
|
||||
onGroupSettingsClicked = { callbacks.onGroupSettingsClicked() },
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (!isSelf && !isReleaseNotes && (sharedGroups.isNotEmpty() || !isGroup)) {
|
||||
SharedGroupsDescription(
|
||||
sharedGroups = sharedGroups,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (showSafetyTips) {
|
||||
Buttons.Small(
|
||||
onClick = { callbacks.onSafetyTipsClicked(isGroup) },
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.ConversationFragment_safety_tips))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AvatarWithBadge(
|
||||
recipientId = recipientId,
|
||||
badge = badge,
|
||||
useProfile = !isSelf,
|
||||
avatarDownloadState = avatarDownloadState,
|
||||
shouldBlurAvatar = shouldBlurAvatar,
|
||||
onTapToView = callbacks::onAvatarTapToViewClicked
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AvatarWithBadge(
|
||||
recipientId: RecipientId,
|
||||
badge: Badge?,
|
||||
useProfile: Boolean = true,
|
||||
avatarDownloadState: AvatarDownloadStateCache.DownloadState,
|
||||
shouldBlurAvatar: Boolean = false,
|
||||
onTapToView: () -> Unit = {}
|
||||
) {
|
||||
val showBlur = shouldBlurAvatar && avatarDownloadState != AvatarDownloadStateCache.DownloadState.IN_PROGRESS
|
||||
val showProgress = avatarDownloadState == AvatarDownloadStateCache.DownloadState.IN_PROGRESS
|
||||
val showGradient = showBlur || showProgress || avatarDownloadState == AvatarDownloadStateCache.DownloadState.FAILED
|
||||
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Crossfade(
|
||||
targetState = showGradient,
|
||||
animationSpec = tween(durationMillis = 220),
|
||||
label = "avatar-crossfade"
|
||||
) { gradient ->
|
||||
if (gradient) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
ImageView(context).apply {
|
||||
scaleType = ImageView.ScaleType.CENTER_CROP
|
||||
}
|
||||
},
|
||||
update = { view ->
|
||||
view.setImageDrawable(AvatarGradientColors.getGradientDrawable(Recipient.resolved(recipientId)))
|
||||
},
|
||||
modifier = Modifier
|
||||
.size(AvatarSize)
|
||||
.clip(CircleShape)
|
||||
)
|
||||
} else {
|
||||
AvatarImage(
|
||||
recipientId = recipientId,
|
||||
useProfile = useProfile,
|
||||
modifier = Modifier.size(AvatarSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showProgress,
|
||||
enter = fadeIn(tween(durationMillis = 220)),
|
||||
exit = fadeOut(tween(durationMillis = 220))
|
||||
) {
|
||||
var showSpinner by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(800)
|
||||
showSpinner = AvatarDownloadStateCache.getDownloadState(recipientId) == AvatarDownloadStateCache.DownloadState.IN_PROGRESS
|
||||
}
|
||||
|
||||
if (showSpinner) {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 3.dp,
|
||||
color = Color.White,
|
||||
modifier = Modifier.size(36.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showBlur,
|
||||
enter = fadeIn(tween(durationMillis = 220)),
|
||||
exit = fadeOut(tween(durationMillis = 220))
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.size(AvatarSize)
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onTapToView)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_tap_outline_24),
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
Spacer(Modifier.size(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.MessageRequestProfileView_view),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (badge != null) {
|
||||
BadgeImageLarge(
|
||||
badge = badge,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.align(Alignment.BottomEnd)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UnverifiedNamePill(
|
||||
onClick: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Text(
|
||||
text = signalSymbolText(
|
||||
text = stringResource(R.string.ConversationFragment_name_not_verified),
|
||||
glyphStart = SignalSymbols.Glyph.PERSON_QUESTION,
|
||||
glyphStartWeight = SignalSymbols.Weight.BOLD,
|
||||
glyphStartSize = 14.sp
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = SignalTheme.colors.colorOnWarning,
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(26.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.background(SignalTheme.colors.colorWarning)
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedGroupsDescription(
|
||||
sharedGroups: List<String>,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val description = when (sharedGroups.size) {
|
||||
0 -> stringResource(R.string.ConversationUpdateItem_no_groups_in_common_review_requests_carefully)
|
||||
1 -> stringResource(R.string.MessageRequestProfileView_member_of_one_group, sharedGroups[0])
|
||||
2 -> stringResource(R.string.MessageRequestProfileView_member_of_two_groups, sharedGroups[0], sharedGroups[1])
|
||||
else -> {
|
||||
val others = sharedGroups.size - 2
|
||||
stringResource(
|
||||
R.string.MessageRequestProfileView_member_of_many_groups,
|
||||
sharedGroups[0],
|
||||
sharedGroups[1],
|
||||
context.resources.getQuantityString(R.plurals.MessageRequestProfileView_member_of_d_additional_groups, others, others)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = signalSymbolText(
|
||||
text = description,
|
||||
glyphStart = SignalSymbols.Glyph.GROUP
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupMemberSubtitle(
|
||||
groupInfo: GroupInfo,
|
||||
onGroupSettingsClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val memberCount = groupInfo.fullMemberCount
|
||||
|
||||
val styledText = if (groupInfo.isMember) {
|
||||
val names = groupInfo.membersPreview.map { it.getDisplayName(context) }
|
||||
val othersCount = memberCount - 3
|
||||
val othersText = if (othersCount > 0) pluralStringResource(R.plurals.MessageRequestProfileView_other_members, othersCount, othersCount) else null
|
||||
|
||||
val fullText = when (names.size) {
|
||||
0 -> stringResource(R.string.MessageRequestProfileView_group_members_zero)
|
||||
1 -> stringResource(R.string.MessageRequestProfileView_group_members_one_and_you, names[0])
|
||||
2 -> stringResource(R.string.MessageRequestProfileView_group_members_two_and_you, names[0], names[1])
|
||||
else -> stringResource(R.string.MessageRequestProfileView_group_members_other, names[0], names[1], names[2], othersText ?: "")
|
||||
}
|
||||
|
||||
buildSignalSymbolAnnotatedString(glyphStart = SignalSymbols.Glyph.GROUP) {
|
||||
if (othersText != null) {
|
||||
val othersStart = fullText.indexOf(othersText)
|
||||
if (othersStart >= 0) {
|
||||
append(fullText.take(othersStart))
|
||||
withLink(LinkAnnotation.Clickable(tag = "group_settings", styles = TextLinkStyles(style = SpanStyle(color = MaterialTheme.colorScheme.onSurface))) { onGroupSettingsClicked() }) {
|
||||
withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) {
|
||||
append(othersText)
|
||||
}
|
||||
}
|
||||
append(fullText.substring(othersStart + othersText.length))
|
||||
} else {
|
||||
append(fullText)
|
||||
}
|
||||
} else {
|
||||
append(fullText)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
buildSignalSymbolAnnotatedString(glyphStart = SignalSymbols.Glyph.GROUP) {
|
||||
withLink(LinkAnnotation.Clickable(tag = "group_settings", styles = TextLinkStyles(style = SpanStyle(color = MaterialTheme.colorScheme.onSurface))) { onGroupSettingsClicked() }) {
|
||||
withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) {
|
||||
append(pluralStringResource(R.plurals.ConversationFragment_group_member_count, memberCount, memberCount))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = styledText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupDescription(
|
||||
description: String,
|
||||
linkify: Boolean,
|
||||
onMoreClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
EmojiTextView(context).apply {
|
||||
layoutParams = android.view.ViewGroup.LayoutParams(
|
||||
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
setTextAppearance(CoreUiR.style.Signal_Text_BodyMedium)
|
||||
gravity = Gravity.CENTER
|
||||
movementMethod = LongClickMovementMethod.getInstance(context)
|
||||
}
|
||||
},
|
||||
update = { view ->
|
||||
GroupDescriptionUtil.setText(view.context, view, description, linkify) {
|
||||
onMoreClicked()
|
||||
}
|
||||
},
|
||||
modifier = modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OfficialChatPill() {
|
||||
val pillShape = RoundedCornerShape(26.dp)
|
||||
|
||||
Text(
|
||||
text = signalSymbolText(
|
||||
text = stringResource(R.string.ConversationFragment_official_chat),
|
||||
glyphStart = SignalSymbols.Glyph.OFFICIAL_BADGE,
|
||||
glyphStartWeight = SignalSymbols.Weight.BOLD
|
||||
),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp)
|
||||
.clip(pillShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun ConversationHeaderPreview() {
|
||||
Previews.Preview {
|
||||
ConversationHeaderContent(
|
||||
recipientId = RecipientId.from(1),
|
||||
displayName = "Katie Hall",
|
||||
showChevron = true,
|
||||
badge = null,
|
||||
showUnverifiedName = true,
|
||||
isGroup = false,
|
||||
phoneNumber = "+1 (555) 867-5309",
|
||||
sharedGroups = emptyList(),
|
||||
showSafetyTips = true,
|
||||
avatarDownloadState = AvatarDownloadStateCache.DownloadState.NONE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun ConversationHeaderWithGroupsPreview() {
|
||||
Previews.Preview {
|
||||
ConversationHeaderContent(
|
||||
recipientId = RecipientId.from(1),
|
||||
displayName = "Katie Hall",
|
||||
showChevron = true,
|
||||
badge = null,
|
||||
showUnverifiedName = false,
|
||||
isGroup = false,
|
||||
sharedGroups = listOf("NYC Rock Climbers", "Dinner Party"),
|
||||
avatarDownloadState = AvatarDownloadStateCache.DownloadState.NONE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun ConversationHeaderGroupPreview() {
|
||||
Previews.Preview {
|
||||
ConversationHeaderContent(
|
||||
recipientId = RecipientId.from(1),
|
||||
displayName = "Trail Crew",
|
||||
badge = null,
|
||||
showUnverifiedName = true,
|
||||
isGroup = true,
|
||||
groupInfo = GroupInfo(fullMemberCount = 12, isMember = false),
|
||||
avatarDownloadState = AvatarDownloadStateCache.DownloadState.NONE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun ConversationHeaderNoteToSelfPreview() {
|
||||
Previews.Preview {
|
||||
ConversationHeaderContent(
|
||||
recipientId = RecipientId.from(1),
|
||||
displayName = "Note to Self",
|
||||
showVerified = true,
|
||||
isSelf = true,
|
||||
badge = null,
|
||||
showUnverifiedName = false,
|
||||
isGroup = false,
|
||||
avatarDownloadState = AvatarDownloadStateCache.DownloadState.NONE
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -199,13 +199,14 @@ public class ConversationTitleView extends ConstraintLayout {
|
||||
|
||||
private void setSelfTitle() {
|
||||
this.title.setText(R.string.note_to_self);
|
||||
this.subtitle.setText(R.string.ConversationFragment_official_chat);
|
||||
updateSubtitleVisibility();
|
||||
}
|
||||
|
||||
private void setReleaseNotesTitle(@NonNull Recipient recipient) {
|
||||
final String displayName = recipient.getDisplayName(getContext());
|
||||
this.title.setText(displayName);
|
||||
this.subtitle.setText(R.string.ReleaseNotes__official_only_chat);
|
||||
this.subtitle.setText(R.string.ConversationFragment_official_chat);
|
||||
updateSubtitleVisibility();
|
||||
}
|
||||
|
||||
@@ -221,7 +222,7 @@ public class ConversationTitleView extends ConstraintLayout {
|
||||
}
|
||||
|
||||
private void updateSubtitleVisibility() {
|
||||
subtitle.setVisibility(!isSelf && expirationBadgeContainer.getVisibility() != VISIBLE && !TextUtils.isEmpty(subtitle.getText()) ? VISIBLE : GONE);
|
||||
subtitle.setVisibility(expirationBadgeContainer.getVisibility() != VISIBLE && !TextUtils.isEmpty(subtitle.getText()) ? VISIBLE : GONE);
|
||||
updateVerifiedSubtitleVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -729,7 +729,7 @@ public final class ConversationUpdateItem extends FrameLayout
|
||||
}
|
||||
});
|
||||
} else if (conversationMessage.getMessageRecord().isMessageRequestAccepted()) {
|
||||
actionButton.setText(R.string.ConversationUpdateItem_options);
|
||||
actionButton.setText(R.string.ConversationUpdateItem_block_report);
|
||||
actionButton.setVisibility(VISIBLE);
|
||||
actionButton.setOnClickListener(v -> {
|
||||
if (batchSelected.isEmpty() && eventListener != null) {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.BaselineShift
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.LargeFontPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.emoji.Emojifier
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols.SignalSymbol
|
||||
|
||||
private const val VERIFIED_BADGE_ID = "verified_badge"
|
||||
|
||||
/**
|
||||
* Compose-native version of [org.thoughtcrime.securesms.recipients.Recipient.getDisplayNameForHeadline].
|
||||
*/
|
||||
@Composable
|
||||
fun HeadlineDisplayName(
|
||||
displayName: String,
|
||||
showVerified: Boolean,
|
||||
isSystemContact: Boolean,
|
||||
showChevron: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
|
||||
val chevronGlyph = if (isLtr) SignalSymbols.Glyph.CHEVRON_RIGHT else SignalSymbols.Glyph.CHEVRON_LEFT
|
||||
val outlineColor = MaterialTheme.colorScheme.outline
|
||||
val badgeOffset = with(LocalDensity.current) { (-1).sp.toDp() }
|
||||
|
||||
Emojifier(text = displayName) { emojiText, emojiInlineContent ->
|
||||
val styledText = buildAnnotatedString {
|
||||
if (!isLtr) {
|
||||
if (showChevron) {
|
||||
SignalSymbol(chevronGlyph, fontSize = 18.sp, color = outlineColor, baselineShift = BaselineShift(0.1f))
|
||||
append("\u00A0")
|
||||
}
|
||||
if (showVerified) {
|
||||
appendInlineContent(VERIFIED_BADGE_ID)
|
||||
append("\u00A0")
|
||||
} else if (isSystemContact) {
|
||||
SignalSymbol(SignalSymbols.Glyph.PERSON_CIRCLE, fontSize = 18.sp, baselineShift = BaselineShift(0.1f))
|
||||
append("\u00A0")
|
||||
}
|
||||
}
|
||||
|
||||
append(emojiText)
|
||||
|
||||
if (isLtr) {
|
||||
if (showVerified) {
|
||||
append("\u00A0")
|
||||
appendInlineContent(VERIFIED_BADGE_ID)
|
||||
} else if (isSystemContact) {
|
||||
append("\u00A0")
|
||||
SignalSymbol(SignalSymbols.Glyph.PERSON_CIRCLE, fontSize = 18.sp, baselineShift = BaselineShift(0.1f))
|
||||
}
|
||||
if (showChevron) {
|
||||
append("\u00A0")
|
||||
SignalSymbol(chevronGlyph, fontSize = 18.sp, color = outlineColor, baselineShift = BaselineShift(0.1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val inlineContent = if (showVerified) {
|
||||
emojiInlineContent + mapOf(
|
||||
VERIFIED_BADGE_ID to InlineTextContent(
|
||||
placeholder = Placeholder(width = 22.sp, height = 22.sp, placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_official_28),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize().offset(y = badgeOffset)
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
emojiInlineContent
|
||||
}
|
||||
|
||||
Text(
|
||||
text = styledText,
|
||||
inlineContent = inlineContent,
|
||||
style = MaterialTheme.typography.titleLarge.copy(textDirection = TextDirection.Ltr),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun HeadlineDisplayNamePreview() = Previews.Preview {
|
||||
HeadlineDisplayName(
|
||||
displayName = "Katie Hall",
|
||||
showVerified = false,
|
||||
isSystemContact = false,
|
||||
showChevron = true
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun HeadlineDisplayNameVerifiedPreview() = Previews.Preview {
|
||||
HeadlineDisplayName(
|
||||
displayName = "Katie Hall",
|
||||
showVerified = true,
|
||||
isSystemContact = false,
|
||||
showChevron = true
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun HeadlineDisplayNameSystemContactPreview() = Previews.Preview {
|
||||
HeadlineDisplayName(
|
||||
displayName = "Katie Hall",
|
||||
showVerified = false,
|
||||
isSystemContact = true,
|
||||
showChevron = true
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun HeadlineDisplayNameLongTextChevronPreview() = Previews.Preview {
|
||||
HeadlineDisplayName(
|
||||
displayName = "J. Jonah Jameson Jr.",
|
||||
showVerified = false,
|
||||
isSystemContact = false,
|
||||
showChevron = true,
|
||||
modifier = Modifier.width(120.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun HeadlineDisplayNameLongTextSystemContactPreview() = Previews.Preview {
|
||||
HeadlineDisplayName(
|
||||
displayName = "J. Jonah Jameson Jr.",
|
||||
showVerified = false,
|
||||
isSystemContact = true,
|
||||
showChevron = true,
|
||||
modifier = Modifier.width(120.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@LargeFontPreviews
|
||||
@Composable
|
||||
private fun HeadlineDisplayNameLargeFontChevronPreview() = Previews.Preview {
|
||||
HeadlineDisplayName(
|
||||
displayName = "Katie Hall",
|
||||
showVerified = false,
|
||||
isSystemContact = false,
|
||||
showChevron = true
|
||||
)
|
||||
}
|
||||
|
||||
@LargeFontPreviews
|
||||
@Composable
|
||||
private fun HeadlineDisplayNameLargeFontSystemContactPreview() = Previews.Preview {
|
||||
HeadlineDisplayName(
|
||||
displayName = "Katie Hall",
|
||||
showVerified = true,
|
||||
isSystemContact = true,
|
||||
showChevron = true
|
||||
)
|
||||
}
|
||||
@@ -154,7 +154,7 @@ class PinnedMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment()
|
||||
val callback = GiphyMp4ProjectionRecycler(holders)
|
||||
|
||||
GiphyMp4PlaybackController.attach(list, callback, maxPlayback)
|
||||
list.addItemDecoration(GiphyMp4ItemDecoration(callback) {}, 0)
|
||||
list.addItemDecoration(GiphyMp4ItemDecoration(callback), 0)
|
||||
|
||||
return callback
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ class ScheduledMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment
|
||||
val callback = GiphyMp4ProjectionRecycler(holders)
|
||||
|
||||
GiphyMp4PlaybackController.attach(list, callback, maxPlayback)
|
||||
list.addItemDecoration(GiphyMp4ItemDecoration(callback) {}, 0)
|
||||
list.addItemDecoration(GiphyMp4ItemDecoration(callback), 0)
|
||||
|
||||
return callback
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ class MessageQuotesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
val callback = GiphyMp4ProjectionRecycler(holders)
|
||||
|
||||
GiphyMp4PlaybackController.attach(list, callback, maxPlayback)
|
||||
list.addItemDecoration(GiphyMp4ItemDecoration(callback) {}, 0)
|
||||
list.addItemDecoration(GiphyMp4ItemDecoration(callback), 0)
|
||||
|
||||
return callback
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ class EditMessageHistoryDialog : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
val callback = GiphyMp4ProjectionRecycler(holders)
|
||||
|
||||
GiphyMp4PlaybackController.attach(binding.editHistoryList, callback, maxPlayback)
|
||||
binding.editHistoryList.addItemDecoration(GiphyMp4ItemDecoration(callback) {}, 0)
|
||||
binding.editHistoryList.addItemDecoration(GiphyMp4ItemDecoration(callback), 0)
|
||||
|
||||
return callback
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
|
||||
package org.thoughtcrime.securesms.conversation.v2
|
||||
|
||||
import android.content.Context
|
||||
import android.text.TextUtils
|
||||
import android.view.GestureDetector
|
||||
import android.view.GestureDetector.SimpleOnGestureListener
|
||||
import android.view.MotionEvent
|
||||
@@ -18,6 +16,7 @@ import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.RequestManager
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.toOptional
|
||||
import org.thoughtcrime.securesms.BindableConversationItem
|
||||
@@ -26,6 +25,7 @@ import org.thoughtcrime.securesms.Unbindable
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
|
||||
import org.thoughtcrime.securesms.conversation.ConversationAdapter.ItemClickListener
|
||||
import org.thoughtcrime.securesms.conversation.ConversationAdapterBridge
|
||||
import org.thoughtcrime.securesms.conversation.ConversationHeaderCallbacks
|
||||
import org.thoughtcrime.securesms.conversation.ConversationHeaderView
|
||||
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage
|
||||
@@ -48,20 +48,20 @@ import org.thoughtcrime.securesms.conversation.v2.items.V2ConversationItemMediaV
|
||||
import org.thoughtcrime.securesms.conversation.v2.items.V2ConversationItemTextOnlyViewHolder
|
||||
import org.thoughtcrime.securesms.conversation.v2.items.V2Payload
|
||||
import org.thoughtcrime.securesms.conversation.v2.items.bridge
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.databinding.V2ConversationItemMediaIncomingBinding
|
||||
import org.thoughtcrime.securesms.databinding.V2ConversationItemMediaOutgoingBinding
|
||||
import org.thoughtcrime.securesms.databinding.V2ConversationItemTextOnlyIncomingBinding
|
||||
import org.thoughtcrime.securesms.databinding.V2ConversationItemTextOnlyOutgoingBinding
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupDescriptionUtil
|
||||
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.ui.about.AboutSheet
|
||||
import org.thoughtcrime.securesms.util.CachedInflater
|
||||
import org.thoughtcrime.securesms.util.Projection
|
||||
import org.thoughtcrime.securesms.util.ProjectionList
|
||||
import org.thoughtcrime.securesms.util.SignalE164Util
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter
|
||||
import java.util.Locale
|
||||
@@ -565,165 +565,44 @@ class ConversationAdapterV2(
|
||||
inner class ThreadHeaderViewHolder(itemView: View) : MappingViewHolder<ThreadHeader>(itemView) {
|
||||
private val conversationBanner: ConversationHeaderView = itemView as ConversationHeaderView
|
||||
|
||||
init {
|
||||
conversationBanner.callbacks = object : ConversationHeaderCallbacks {
|
||||
override fun onSafetyTipsClicked(forGroup: Boolean) = clickListener.onShowSafetyTips(forGroup)
|
||||
|
||||
override fun onUnverifiedNameClicked(forGroup: Boolean) = clickListener.onShowUnverifiedProfileSheet(forGroup)
|
||||
|
||||
override fun onTitleClicked() {
|
||||
val recipient = conversationBanner.recipientInfo?.recipient ?: return
|
||||
if (recipient.isIndividual && !recipient.isSelf) {
|
||||
displayDialogFragment(AboutSheet.create(recipient))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onGroupSettingsClicked() {
|
||||
val recipient = conversationBanner.recipientInfo?.recipient ?: return
|
||||
context.startActivity(ConversationSettingsActivity.forGroup(context, recipient.requireGroupId()))
|
||||
}
|
||||
|
||||
override fun onShowGroupDescriptionClicked(groupName: String, description: String, linkifyWebLinks: Boolean) {
|
||||
clickListener.onShowGroupDescriptionClicked(groupName, description, linkifyWebLinks)
|
||||
}
|
||||
|
||||
override fun onAvatarTapToViewClicked() {
|
||||
val recipient = conversationBanner.recipientInfo?.recipient ?: return
|
||||
AvatarDownloadStateCache.set(recipient, AvatarDownloadStateCache.DownloadState.IN_PROGRESS)
|
||||
SignalExecutors.BOUNDED.execute { SignalDatabase.recipients.manuallyUpdateShowAvatar(recipient.id, true) }
|
||||
if (recipient.isPushV2Group) {
|
||||
AvatarGroupsV2DownloadJob.enqueueUnblurredAvatar(recipient.requireGroupId().requireV2())
|
||||
} else {
|
||||
RetrieveProfileAvatarJob.enqueueUnblurredAvatar(recipient)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun bind(model: ThreadHeader) {
|
||||
val (recipient, groupInfo, sharedGroups, messageRequestState) = model.recipientInfo
|
||||
val isSelf = recipient.id == Recipient.self().id
|
||||
|
||||
when (model.avatarDownloadState) {
|
||||
AvatarDownloadStateCache.DownloadState.NONE,
|
||||
AvatarDownloadStateCache.DownloadState.FINISHED -> {
|
||||
conversationBanner.setAvatar(requestManager, recipient)
|
||||
}
|
||||
AvatarDownloadStateCache.DownloadState.IN_PROGRESS -> {
|
||||
conversationBanner.showProgressBar(recipient)
|
||||
}
|
||||
AvatarDownloadStateCache.DownloadState.FAILED -> {
|
||||
conversationBanner.showFailedAvatarDownload(recipient)
|
||||
}
|
||||
}
|
||||
|
||||
conversationBanner.showBackgroundBubble(recipient.hasWallpaper)
|
||||
val title: String = conversationBanner.setTitle(recipient) {
|
||||
displayDialogFragment(AboutSheet.create(recipient))
|
||||
}
|
||||
|
||||
if (recipient.isReleaseNotes) {
|
||||
conversationBanner.showReleaseNoteHeader()
|
||||
}
|
||||
|
||||
conversationBanner.setAbout(recipient)
|
||||
|
||||
if (recipient.isGroup) {
|
||||
if (!groupInfo.hasExistingContacts) {
|
||||
conversationBanner.setUnverifiedNameSubtitle(R.drawable.symbol_group_question_16, true) {
|
||||
clickListener.onShowUnverifiedProfileSheet(true)
|
||||
}
|
||||
} else {
|
||||
conversationBanner.hideUnverifiedNameSubtitle()
|
||||
}
|
||||
|
||||
if (groupInfo.fullMemberCount > 0 || groupInfo.pendingMemberCount > 0) {
|
||||
if (groupInfo.fullMemberCount == 1 && groupInfo.isMember) {
|
||||
conversationBanner.hideUnverifiedNameSubtitle()
|
||||
}
|
||||
setSubtitle(context, groupInfo.pendingMemberCount, groupInfo.fullMemberCount, groupInfo.membersPreview, groupInfo.isMember, recipient)
|
||||
} else {
|
||||
conversationBanner.hideSubtitle()
|
||||
}
|
||||
} else if (isSelf) {
|
||||
conversationBanner.setSubtitle(context.getString(R.string.ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation), R.drawable.symbol_note_compact_16, null, null)
|
||||
} else {
|
||||
if ((recipient.profileName.toString() == recipient.getDisplayName(context)) && recipient.nickname.isEmpty && !recipient.isSystemContact) {
|
||||
conversationBanner.setUnverifiedNameSubtitle(R.drawable.symbol_person_question_16, false) {
|
||||
clickListener.onShowUnverifiedProfileSheet(false)
|
||||
}
|
||||
} else {
|
||||
conversationBanner.hideUnverifiedNameSubtitle()
|
||||
}
|
||||
|
||||
val subtitle: String? = recipient.takeIf { it.shouldShowE164 }?.e164?.map { e164: String? -> SignalE164Util.prettyPrint(e164!!) }?.orElse(null)
|
||||
if (subtitle == null || subtitle == title) {
|
||||
conversationBanner.hideSubtitle()
|
||||
} else {
|
||||
conversationBanner.setSubtitle(subtitle, R.drawable.symbol_phone_compact_16, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
conversationBanner.hideButton()
|
||||
|
||||
if (messageRequestState?.isAccepted == false && !isSelf && !recipient.isGroup) {
|
||||
if (sharedGroups.size < MIN_GROUPS_THRESHOLD) {
|
||||
conversationBanner.showWarningSubtitle()
|
||||
}
|
||||
conversationBanner.setButton(context.getString(R.string.ConversationFragment_safety_tips)) {
|
||||
clickListener.onShowSafetyTips(false)
|
||||
}
|
||||
conversationBanner.setDescription(getDescription(context, sharedGroups), R.drawable.symbol_group_compact_16)
|
||||
} else if (messageRequestState?.isAccepted == false && recipient.isGroup) {
|
||||
conversationBanner.showWarningSubtitle()
|
||||
conversationBanner.setButton(context.getString(R.string.ConversationFragment_safety_tips)) {
|
||||
clickListener.onShowSafetyTips(true)
|
||||
}
|
||||
} else if ((recipient.isGroup && sharedGroups.isEmpty()) || isSelf) {
|
||||
conversationBanner.hideWarningSubtitle()
|
||||
if (TextUtils.isEmpty(groupInfo.description)) {
|
||||
conversationBanner.setLinkifyDescription(false)
|
||||
conversationBanner.hideDescription()
|
||||
} else {
|
||||
conversationBanner.setLinkifyDescription(true)
|
||||
val linkifyWebLinks = messageRequestState?.isAccepted == true
|
||||
conversationBanner.showDescription()
|
||||
|
||||
GroupDescriptionUtil.setText(
|
||||
context,
|
||||
conversationBanner.description,
|
||||
groupInfo.description,
|
||||
linkifyWebLinks
|
||||
) {
|
||||
clickListener.onShowGroupDescriptionClicked(recipient.getDisplayName(context), groupInfo.description, linkifyWebLinks)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
conversationBanner.hideWarningSubtitle()
|
||||
conversationBanner.setDescription(getDescription(context, sharedGroups), R.drawable.symbol_group_compact_16)
|
||||
}
|
||||
conversationBanner.updateOutlineBoxSize()
|
||||
}
|
||||
|
||||
private fun setSubtitle(context: Context, pendingMemberCount: Int, size: Int, members: List<Recipient>, isMember: Boolean, recipient: Recipient) {
|
||||
val names = members.map { member -> member.getDisplayName(context) }
|
||||
val otherMembers = if (size > 3) context.resources.getQuantityString(R.plurals.MessageRequestProfileView_other_members, size - 3, size - 3) else null
|
||||
val membersSubtitle = if (isMember) {
|
||||
when (names.size) {
|
||||
0 -> context.getString(R.string.MessageRequestProfileView_group_members_zero)
|
||||
1 -> context.getString(R.string.MessageRequestProfileView_group_members_one_and_you, names[0])
|
||||
2 -> context.getString(R.string.MessageRequestProfileView_group_members_two_and_you, names[0], names[1])
|
||||
else -> context.getString(R.string.MessageRequestProfileView_group_members_other, names[0], names[1], names[2], otherMembers)
|
||||
}
|
||||
} else {
|
||||
when (names.size) {
|
||||
0 -> context.getString(R.string.MessageRequestProfileView_group_members_zero)
|
||||
1 -> context.getString(R.string.MessageRequestProfileView_group_members_one, names[0])
|
||||
2 -> context.getString(R.string.MessageRequestProfileView_group_members_two, names[0], names[1])
|
||||
3 -> context.getString(R.string.MessageRequestProfileView_group_members_three, names[0], names[1], names[2])
|
||||
else -> context.getString(R.string.MessageRequestProfileView_group_members_other, names[0], names[1], names[2], otherMembers)
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingMemberCount > 0) {
|
||||
val invited = context.resources.getQuantityString(R.plurals.MessageRequestProfileView_invited, pendingMemberCount, pendingMemberCount)
|
||||
val subtitle = context.getString(R.string.MessageRequestProfileView_member_names_and_invited, membersSubtitle, invited)
|
||||
conversationBanner.setSubtitle(subtitle, R.drawable.symbol_group_compact_16, otherMembers) { goToGroupSettings(recipient) }
|
||||
} else {
|
||||
conversationBanner.setSubtitle(membersSubtitle, R.drawable.symbol_group_compact_16, otherMembers) { goToGroupSettings(recipient) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDescription(context: Context, sharedGroups: List<String>): String {
|
||||
return when (sharedGroups.size) {
|
||||
0 -> context.getString(R.string.ConversationUpdateItem_no_groups_in_common_review_requests_carefully)
|
||||
1 -> context.getString(R.string.MessageRequestProfileView_member_of_one_group, sharedGroups[0])
|
||||
2 -> context.getString(R.string.MessageRequestProfileView_member_of_two_groups, sharedGroups[0], sharedGroups[1])
|
||||
3 -> context.getString(R.string.MessageRequestProfileView_member_of_many_groups, sharedGroups[0], sharedGroups[1], sharedGroups[2])
|
||||
else -> {
|
||||
val others: Int = sharedGroups.size - 2
|
||||
context.getString(
|
||||
R.string.MessageRequestProfileView_member_of_many_groups,
|
||||
sharedGroups[0],
|
||||
sharedGroups[1],
|
||||
context.resources.getQuantityString(R.plurals.MessageRequestProfileView_member_of_d_additional_groups, others, others)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun goToGroupSettings(recipient: Recipient) {
|
||||
val intent = ConversationSettingsActivity.forGroup(getContext(), recipient.requireGroupId())
|
||||
val bundle = ConversationSettingsActivity.createTransitionBundle(
|
||||
getContext(),
|
||||
conversationBanner.getViewById(R.id.message_request_avatar)
|
||||
)
|
||||
getContext().startActivity(intent, bundle)
|
||||
conversationBanner.recipientInfo = model.recipientInfo
|
||||
conversationBanner.avatarDownloadState = model.avatarDownloadState
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -687,7 +687,9 @@ class ConversationFragment :
|
||||
)
|
||||
conversationToolbarOnScrollHelper.attach(binding.conversationItemRecycler)
|
||||
presentConversationTitle(viewModel.recipientSnapshot)
|
||||
presentGroupConversationSubtitle(createGroupSubtitleString(viewModel.titleViewParticipantsSnapshot))
|
||||
if (viewModel.recipientSnapshot?.isGroup == true) {
|
||||
presentGroupConversationSubtitle(createGroupSubtitleString(viewModel.titleViewParticipantsSnapshot))
|
||||
}
|
||||
presentActionBarMenu()
|
||||
presentStoryRing()
|
||||
|
||||
@@ -2204,6 +2206,7 @@ class ConversationFragment :
|
||||
val statusBarInset = ViewCompat.getRootWindowInsets(binding.root)?.getInsets(WindowInsetsCompat.Type.systemBars())?.top ?: 0
|
||||
threadHeaderMarginDecoration.toolbarMargin = statusBarInset + resources.getDimensionPixelSize(R.dimen.signal_m3_toolbar_height) + 16.dp
|
||||
binding.conversationItemRecycler.addItemDecoration(threadHeaderMarginDecoration)
|
||||
binding.conversationItemRecycler.addItemDecoration(ConversationHeaderPositionDecoration())
|
||||
|
||||
conversationItemDecorations = ConversationItemDecorations(hasWallpaper = args.hasWallpaper)
|
||||
binding.conversationItemRecycler.addItemDecoration(conversationItemDecorations, 0)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.conversation.v2
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import androidx.core.view.children
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.conversation.ConversationHeaderView
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Adjusts the Conversation's recycler view translationY so that the conversation header
|
||||
* is pinned to the top of the visible area when content is too short to
|
||||
* fill the screen.
|
||||
*/
|
||||
class ConversationHeaderPositionDecoration : RecyclerView.ItemDecoration() {
|
||||
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
if (parent.childCount == 0 || parent.canScrollVertically(-1) || parent.canScrollVertically(1)) {
|
||||
parent.translationY = 0f
|
||||
} else {
|
||||
val threadHeaderView: ConversationHeaderView = parent.children
|
||||
.filterIsInstance<ConversationHeaderView>()
|
||||
.firstOrNull() ?: run {
|
||||
parent.translationY = 0f
|
||||
return
|
||||
}
|
||||
|
||||
// A decorator adds the margin for the toolbar, margin is the difference of the bounds "height" and the view height
|
||||
val bounds = Rect()
|
||||
parent.getDecoratedBoundsWithMargins(threadHeaderView, bounds)
|
||||
val toolbarMargin = bounds.bottom - bounds.top - threadHeaderView.height
|
||||
|
||||
val childTop: Int = threadHeaderView.top - toolbarMargin
|
||||
parent.translationY = min(0, -childTop).toFloat()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,10 +94,12 @@ class DisabledInputView @JvmOverloads constructor(
|
||||
setWallpaperEnabled(recipient.hasWallpaper)
|
||||
|
||||
setAcceptOnClickListener {
|
||||
if (messageRequestState.isFewConnectionsIndividual) {
|
||||
if (messageRequestState.isIndividual) {
|
||||
val signalWillNever = context.getString(R.string.MessageRequestBottomView_signal_will_never)
|
||||
val body = context.getString(R.string.MessageRequestBottomView_accept_request_body, signalWillNever)
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle(R.string.MessageRequestBottomView_accept_request)
|
||||
.setMessage(R.string.MessageRequestBottomView_review_requests_carefully)
|
||||
.setMessage(SpanUtil.boldSubstring(body, signalWillNever))
|
||||
.setPositiveButton(R.string.MessageRequestBottomView_accept) { _, _ -> listener?.onAcceptMessageRequestClicked() }
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
package org.thoughtcrime.securesms.conversation.v2
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
@@ -15,32 +14,39 @@ 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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
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.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
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.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.FragmentManager
|
||||
@@ -50,7 +56,6 @@ import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
@@ -83,84 +88,196 @@ class SafetyTipsBottomSheetDialog : ComposeBottomSheetDialogFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
data class SafetyTipData(
|
||||
private data class SafetyTipSummary(
|
||||
@DrawableRes val icon: Int,
|
||||
@StringRes val titleText: Int,
|
||||
@StringRes val messageText: Int
|
||||
)
|
||||
|
||||
private data class SafetyTipDetail(
|
||||
@DrawableRes val heroImage: Int,
|
||||
@StringRes val titleText: Int,
|
||||
@StringRes val messageText: Int
|
||||
)
|
||||
|
||||
private val tips = listOf(
|
||||
SafetyTipData(heroImage = R.drawable.safety_tip0, titleText = R.string.SafetyTips_tip0_title, messageText = R.string.SafetyTips_tip0_message),
|
||||
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)
|
||||
private val summaryTips = listOf(
|
||||
SafetyTipSummary(icon = R.drawable.safetytip_48_01, titleText = R.string.SafetyTips_summary_tip0_title, messageText = R.string.SafetyTips_summary_tip0_message),
|
||||
SafetyTipSummary(icon = R.drawable.safetytip_48_02, titleText = R.string.SafetyTips_summary_tip1_title, messageText = R.string.SafetyTips_summary_tip1_message),
|
||||
SafetyTipSummary(icon = R.drawable.safetytip_48_03, titleText = R.string.SafetyTips_summary_tip2_title, messageText = R.string.SafetyTips_summary_tip2_message)
|
||||
)
|
||||
|
||||
private val detailTips = listOf(
|
||||
SafetyTipDetail(heroImage = R.drawable.safetytip_240_01, titleText = R.string.SafetyTips_detail_tip0_title, messageText = R.string.SafetyTips_detail_tip0_message),
|
||||
SafetyTipDetail(heroImage = R.drawable.safetytip_240_02, titleText = R.string.SafetyTips_detail_tip1_title, messageText = R.string.SafetyTips_detail_tip1_message),
|
||||
SafetyTipDetail(heroImage = R.drawable.safetytip_240_03, titleText = R.string.SafetyTips_detail_tip2_title, messageText = R.string.SafetyTips_detail_tip2_message),
|
||||
SafetyTipDetail(heroImage = R.drawable.safetytip_240_04, titleText = R.string.SafetyTips_detail_tip3_title, messageText = R.string.SafetyTips_detail_tip3_message),
|
||||
SafetyTipDetail(heroImage = R.drawable.safetytip_240_05, titleText = R.string.SafetyTips_detail_tip4_title, messageText = R.string.SafetyTips_detail_tip4_message),
|
||||
SafetyTipDetail(heroImage = R.drawable.safetytip_240_06, titleText = R.string.SafetyTips_detail_tip5_title, messageText = R.string.SafetyTips_detail_tip5_message)
|
||||
)
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun SafetyTipsContentPreview() {
|
||||
Previews.Preview {
|
||||
Surface {
|
||||
SafetyTipsContent()
|
||||
private fun SafetyTipsContent(forGroup: Boolean = false, modifier: Modifier = Modifier) {
|
||||
var showDetails by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
if (showDetails) {
|
||||
SafetyTipsDetailContent(modifier = modifier)
|
||||
} else {
|
||||
SafetyTipsSummaryContent(
|
||||
onViewMore = { showDetails = true },
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SafetyTipsSummaryContent(
|
||||
onViewMore: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.weight(weight = 1f, fill = false)
|
||||
.verticalScroll(state = scrollState)
|
||||
.padding(horizontal = 36.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.SafetyTips_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier
|
||||
.padding(top = 28.dp, bottom = 34.dp)
|
||||
.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
|
||||
summaryTips.forEach { tip ->
|
||||
SafetyTipSummaryRow(tip)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = onViewMore,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.SafetyTips_view_more))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(36.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SafetyTipSummaryRow(tip: SafetyTipSummary) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 40.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = tip.icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 24.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = tip.titleText),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(id = tip.messageText),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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()
|
||||
private fun SafetyTipsDetailContent(modifier: Modifier = Modifier) {
|
||||
val size = remember { detailTips.size }
|
||||
val pagerState = rememberPagerState(pageCount = { size })
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
}
|
||||
|
||||
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,
|
||||
beyondViewportPageCount = size,
|
||||
modifier = Modifier.padding(top = 24.dp)
|
||||
) {
|
||||
SafetyTip(tips[it])
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier.weight(weight = 1f, fill = false)
|
||||
) { page ->
|
||||
SafetyTipDetailPage(detailTips[page])
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 24.dp, end = 24.dp, bottom = 36.dp, top = 16.dp)
|
||||
) {
|
||||
if (pagerState.currentPage > 0) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage(pagerState.currentPage - 1)
|
||||
}
|
||||
},
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
),
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_arrow_right_24),
|
||||
contentDescription = stringResource(R.string.SafetyTips_previous_tip),
|
||||
modifier = Modifier.graphicsLayer(scaleX = -1f)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.size(48.dp))
|
||||
}
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 20.dp),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
repeat(pagerState.pageCount) { iteration ->
|
||||
@@ -178,103 +295,92 @@ private fun SafetyTipsContent(forGroup: Boolean = false, modifier: Modifier = Mo
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
if (pagerState.currentPage < pagerState.pageCount - 1) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage(pagerState.currentPage + 1)
|
||||
}
|
||||
},
|
||||
enabled = pagerState.currentPage + 1 < pagerState.pageCount
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
),
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.SafetyTips_next_tip))
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_arrow_right_24),
|
||||
contentDescription = stringResource(R.string.SafetyTips_next_tip)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.size(48.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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() {
|
||||
Previews.Preview {
|
||||
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,
|
||||
private fun SafetyTipDetailPage(tip: SafetyTipDetail) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 24.dp, end = 24.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 36.dp)
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
Image(
|
||||
painter = painterResource(id = tip.heroImage),
|
||||
contentDescription = null,
|
||||
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)
|
||||
)
|
||||
}
|
||||
.padding(top = 16.dp, bottom = 16.dp)
|
||||
.height(160.dp)
|
||||
.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
|
||||
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 = tip.titleText),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
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)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(id = tip.messageText),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun SafetyTipsSummaryPreview() {
|
||||
Previews.Preview {
|
||||
Surface {
|
||||
SafetyTipsSummaryContent(onViewMore = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun SafetyTipsDetailPreview() {
|
||||
Previews.Preview {
|
||||
Surface {
|
||||
SafetyTipsDetailContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun SafetyTipDetailPagePreview() {
|
||||
Previews.Preview {
|
||||
Surface {
|
||||
SafetyTipDetailPage(detailTips[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -37,6 +32,7 @@ import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
@@ -70,7 +66,7 @@ class UnverifiedProfileNameBottomSheet : ComposeBottomSheetDialogFragment() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileNameSheet(forGroup: Boolean = true) {
|
||||
private fun ProfileNameSheet(forGroup: Boolean = false) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
@@ -82,13 +78,13 @@ private fun ProfileNameSheet(forGroup: Boolean = true) {
|
||||
val (imageVector, placeholder, text) =
|
||||
if (forGroup) {
|
||||
Triple(
|
||||
R.drawable.symbol_group_question_55,
|
||||
R.drawable.symbol_group_questionmark_bold_40,
|
||||
stringResource(R.string.ConversationFragment_group_names),
|
||||
stringResource(id = R.string.ProfileNameBottomSheet__group_names_on_signal, stringResource(R.string.ConversationFragment_group_names))
|
||||
)
|
||||
} else {
|
||||
Triple(
|
||||
R.drawable.symbol_person_question_40,
|
||||
R.drawable.symbol_person_questionmark_bold_40,
|
||||
stringResource(R.string.ConversationFragment_profile_names),
|
||||
stringResource(id = R.string.ProfileNameBottomSheet__profile_names_on_signal, stringResource(R.string.ConversationFragment_profile_names))
|
||||
)
|
||||
@@ -97,12 +93,16 @@ private fun ProfileNameSheet(forGroup: Boolean = true) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(imageVector),
|
||||
contentDescription = null,
|
||||
tint = SignalTheme.colors.colorOnWarning,
|
||||
modifier = Modifier
|
||||
.padding(top = 38.dp, bottom = 24.dp)
|
||||
.size(height = 56.dp, width = 72.dp)
|
||||
.padding(top = 30.dp, bottom = 20.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(SignalTheme.colors.colorWarning)
|
||||
.padding(horizontal = 18.dp, vertical = 8.dp)
|
||||
.size(32.dp)
|
||||
)
|
||||
|
||||
val annotatedText = remember {
|
||||
val annotatedText = remember(text, placeholder) {
|
||||
buildAnnotatedString {
|
||||
val start = text.indexOf(placeholder)
|
||||
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
@@ -114,43 +114,41 @@ private fun ProfileNameSheet(forGroup: Boolean = true) {
|
||||
|
||||
Text(
|
||||
text = annotatedText,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(bottom = 20.dp)
|
||||
)
|
||||
|
||||
BulletRow(stringResource(R.string.ProfileNameBottomSheet__signal_cant_verify))
|
||||
BulletRow(stringResource(R.string.ProfileNameBottomSheet__signal_will_never_contact))
|
||||
|
||||
if (forGroup) {
|
||||
InfoRow(stringResource(R.string.ProfileNameBottomSheet__be_cautious_of_groups))
|
||||
InfoRow(stringResource(R.string.ProfileNameBottomSheet__profile_names_in_groups))
|
||||
BulletRow(stringResource(R.string.ProfileNameBottomSheet__be_cautious_of_groups))
|
||||
} else {
|
||||
InfoRow(stringResource(R.string.ProfileNameBottomSheet__profile_names_arent_verified))
|
||||
InfoRow(stringResource(R.string.ProfileNameBottomSheet__be_cautious_of_accounts))
|
||||
BulletRow(stringResource(R.string.ProfileNameBottomSheet__be_cautious_of_accounts))
|
||||
}
|
||||
|
||||
InfoRow(stringResource(R.string.ProfileNameBottomSheet__dont_share_personal))
|
||||
BulletRow(stringResource(R.string.ProfileNameBottomSheet__dont_share_personal))
|
||||
|
||||
Spacer(Modifier.size(55.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InfoRow(text: String) {
|
||||
private fun BulletRow(text: String) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(IntrinsicSize.Min)
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, bottom = 12.dp)
|
||||
.padding(bottom = 12.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(4.dp)
|
||||
.padding(vertical = 5.dp)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(color = MaterialTheme.colorScheme.outline.copy(.4f))
|
||||
Text(
|
||||
text = "\u2022",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
@@ -163,3 +161,11 @@ private fun ProfileNameSheetPreview() {
|
||||
ProfileNameSheet()
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun ProfileNameSheetGroupPreview() {
|
||||
Previews.BottomSheetContentPreview {
|
||||
ProfileNameSheet(forGroup = true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,12 @@ object AvatarDownloadStateCache {
|
||||
|
||||
@JvmStatic
|
||||
fun getDownloadState(recipient: Recipient): DownloadState {
|
||||
return cache[recipient.id]?.value ?: DownloadState.NONE
|
||||
return getDownloadState(recipient.id)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getDownloadState(recipientId: RecipientId): DownloadState {
|
||||
return cache[recipientId]?.value ?: DownloadState.NONE
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
|
||||
@@ -310,7 +310,8 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
} else if (isReportedSpam()) {
|
||||
return staticUpdateDescription(context.getString(R.string.MessageRecord_reported_as_spam), Glyph.SPAM);
|
||||
} else if (isMessageRequestAccepted()) {
|
||||
return staticUpdateDescription(context.getString(R.string.MessageRecord_you_accepted_the_message_request), Glyph.THREAD);
|
||||
return isGroupV2() ? staticUpdateDescription(context.getString(R.string.MessageRecord_you_accepted_the_group_request), Glyph.THREAD)
|
||||
: fromRecipient(getToRecipient(), r -> context.getString(R.string.MessageRecord_you_accepted_s_message_request, r.getDisplayName(context)), Glyph.THREAD);
|
||||
} else if (isBlocked()) {
|
||||
return staticUpdateDescription(context.getString(isGroupV2() ? R.string.MessageRecord_you_blocked_this_group : R.string.MessageRecord_you_blocked_this_person), Glyph.BLOCK);
|
||||
} else if (isUnblocked()) {
|
||||
|
||||
@@ -12,13 +12,18 @@ import android.text.TextPaint
|
||||
import android.text.style.MetricAffectingSpan
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.BaselineShift
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.text.buildSpannedString
|
||||
import org.signal.core.util.BidiUtil
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
|
||||
@@ -162,6 +167,7 @@ object SignalSymbols {
|
||||
PERSON_X('\uE060'),
|
||||
PERSON_PLUS('\uE061'),
|
||||
PERSON_MINUS('\uE062'),
|
||||
PERSON_QUESTION('\uE06A'),
|
||||
PHONE('\uE063'),
|
||||
PHONE_FILL('\uE064'),
|
||||
PHOTO('\uE065'),
|
||||
@@ -192,7 +198,6 @@ object SignalSymbols {
|
||||
X('\u00D7'),
|
||||
X_CIRCLE('\uE1EE'),
|
||||
X_SQUARE('\u2327'),
|
||||
|
||||
REFRESH('\uE000'),
|
||||
ACTIVATE_PAYMENTS('\uE000'),
|
||||
CALENDAR('\uE0A2')
|
||||
@@ -235,7 +240,7 @@ object SignalSymbols {
|
||||
glyphEndWeight: Weight = Weight.REGULAR,
|
||||
glyphStartSizeSp: Int = 16,
|
||||
glyphEndSizeSp: Int = 16
|
||||
): AnnotatedString {
|
||||
): CharSequence {
|
||||
val isLtr = ViewUtil.isLtr(context)
|
||||
val leftGlyph = if (isLtr) glyphStart else glyphEnd
|
||||
val leftGlyphWeight = if (isLtr) glyphStartWeight else glyphEndWeight
|
||||
@@ -244,7 +249,7 @@ object SignalSymbols {
|
||||
val rightGlyphWeight = if (isLtr) glyphEndWeight else glyphStartWeight
|
||||
val rightGlyphSizeSp = if (isLtr) glyphEndSizeSp else glyphStartSizeSp
|
||||
|
||||
return buildAnnotatedString {
|
||||
return buildSpannedString {
|
||||
if (leftGlyph != null) {
|
||||
val symbol = SpanUtil.ofSize(
|
||||
getSpannedString(context, leftGlyphWeight, leftGlyph),
|
||||
@@ -266,16 +271,60 @@ object SignalSymbols {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AnnotatedString.Builder.SignalSymbol(weight: Weight, glyph: Glyph) {
|
||||
fun AnnotatedString.Builder.SignalSymbol(
|
||||
glyph: Glyph,
|
||||
weight: Weight = Weight.REGULAR,
|
||||
fontSize: TextUnit = TextUnit.Unspecified,
|
||||
color: Color = Color.Unspecified,
|
||||
baselineShift: BaselineShift? = null
|
||||
) {
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
fontFamily = FontFamily(getTypeface(LocalContext.current, weight))
|
||||
fontFamily = FontFamily(getTypeface(LocalContext.current, weight)),
|
||||
fontSize = fontSize,
|
||||
color = color,
|
||||
baselineShift = baselineShift
|
||||
)
|
||||
) {
|
||||
append(glyph.unicode.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun buildSignalSymbolAnnotatedString(
|
||||
glyphStart: Glyph? = null,
|
||||
glyphEnd: Glyph? = null,
|
||||
glyphStartWeight: Weight = Weight.REGULAR,
|
||||
glyphEndWeight: Weight = Weight.REGULAR,
|
||||
glyphStartSize: TextUnit = TextUnit.Unspecified,
|
||||
glyphEndSize: TextUnit = TextUnit.Unspecified,
|
||||
content: @Composable AnnotatedString.Builder.() -> Unit
|
||||
): AnnotatedString {
|
||||
val context = LocalContext.current
|
||||
|
||||
return buildAnnotatedString {
|
||||
if (glyphStart != null) {
|
||||
append(BidiUtil.BidiCodepoint.LRI)
|
||||
withStyle(SpanStyle(fontFamily = FontFamily(getTypeface(context, glyphStartWeight)), fontSize = glyphStartSize)) {
|
||||
append(glyphStart.unicode.toString())
|
||||
}
|
||||
append(BidiUtil.BidiCodepoint.PDI)
|
||||
append(" ")
|
||||
}
|
||||
|
||||
content()
|
||||
|
||||
if (glyphEnd != null) {
|
||||
append(" ")
|
||||
append(BidiUtil.BidiCodepoint.LRI)
|
||||
withStyle(SpanStyle(fontFamily = FontFamily(getTypeface(context, glyphEndWeight)), fontSize = glyphEndSize)) {
|
||||
append(glyphEnd.unicode.toString())
|
||||
}
|
||||
append(BidiUtil.BidiCodepoint.PDI)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun signalSymbolText(
|
||||
text: String,
|
||||
@@ -283,19 +332,12 @@ object SignalSymbols {
|
||||
glyphEnd: Glyph? = null,
|
||||
glyphStartWeight: Weight = Weight.REGULAR,
|
||||
glyphEndWeight: Weight = Weight.REGULAR,
|
||||
glyphStartSizeSp: Int = 16,
|
||||
glyphEndSizeSp: Int = 16
|
||||
glyphStartSize: TextUnit = TextUnit.Unspecified,
|
||||
glyphEndSize: TextUnit = TextUnit.Unspecified
|
||||
): AnnotatedString {
|
||||
return getSignalSymbolText(
|
||||
context = LocalContext.current,
|
||||
text = text,
|
||||
glyphStart = glyphStart,
|
||||
glyphEnd = glyphEnd,
|
||||
glyphStartWeight = glyphStartWeight,
|
||||
glyphEndWeight = glyphEndWeight,
|
||||
glyphStartSizeSp = glyphStartSizeSp,
|
||||
glyphEndSizeSp = glyphEndSizeSp
|
||||
)
|
||||
return buildSignalSymbolAnnotatedString(glyphStart, glyphEnd, glyphStartWeight, glyphEndWeight, glyphStartSize, glyphEndSize) {
|
||||
append(text)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getTypeface(context: Context, weight: Weight): Typeface {
|
||||
|
||||
@@ -1,50 +1,18 @@
|
||||
package org.thoughtcrime.securesms.giph.mp4
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import androidx.core.view.children
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.conversation.ConversationHeaderView
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Decoration that will make the video display params update on each recycler redraw.
|
||||
*/
|
||||
class GiphyMp4ItemDecoration(
|
||||
private val callback: GiphyMp4PlaybackController.Callback,
|
||||
private val onRecyclerVerticalTranslationSet: ((Float) -> Unit)? = null
|
||||
private val callback: GiphyMp4PlaybackController.Callback
|
||||
) : RecyclerView.ItemDecoration() {
|
||||
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
setParentRecyclerTranslationY(parent)
|
||||
|
||||
parent.children.map { parent.getChildViewHolder(it) }.filterIsInstance(GiphyMp4Playable::class.java).forEach {
|
||||
callback.updateVideoDisplayPositionAndSize(parent, it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setParentRecyclerTranslationY(parent: RecyclerView) {
|
||||
if (parent.childCount == 0 || parent.canScrollVertically(-1) || parent.canScrollVertically(1)) {
|
||||
parent.translationY = 0f
|
||||
onRecyclerVerticalTranslationSet?.invoke(parent.translationY)
|
||||
} else {
|
||||
val threadHeaderView: ConversationHeaderView? = parent.children
|
||||
.filterIsInstance<ConversationHeaderView>()
|
||||
.firstOrNull()
|
||||
|
||||
if (threadHeaderView == null) {
|
||||
parent.translationY = 0f
|
||||
onRecyclerVerticalTranslationSet?.invoke(parent.translationY)
|
||||
return
|
||||
}
|
||||
|
||||
// A decorator adds the margin for the toolbar, margin is difference of the bounds "height" and the view height
|
||||
val bounds = Rect()
|
||||
parent.getDecoratedBoundsWithMargins(threadHeaderView, bounds)
|
||||
val toolbarMargin = bounds.bottom - bounds.top - threadHeaderView.height
|
||||
|
||||
val childTop: Int = threadHeaderView.top - toolbarMargin
|
||||
parent.translationY = min(0, -childTop).toFloat()
|
||||
onRecyclerVerticalTranslationSet?.invoke(parent.translationY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,11 +144,8 @@ public final class MessageRequestRepository {
|
||||
} else {
|
||||
Recipient.HiddenState hiddenState = RecipientUtil.getRecipientHiddenState(threadId);
|
||||
boolean reportedAsSpam = reportedAsSpam(threadId);
|
||||
List<String> sharedGroups = SignalDatabase.groups().getPushGroupNamesContainingMember(recipient.getId());
|
||||
|
||||
if (hiddenState == Recipient.HiddenState.NOT_HIDDEN && sharedGroups.size() < MIN_GROUPS_THRESHOLD) {
|
||||
return new MessageRequestState(MessageRequestState.State.INDIVIDUAL_FEW_CONNECTIONS, reportedAsSpam);
|
||||
} else if (hiddenState == Recipient.HiddenState.NOT_HIDDEN) {
|
||||
if (hiddenState == Recipient.HiddenState.NOT_HIDDEN) {
|
||||
return new MessageRequestState(MessageRequestState.State.INDIVIDUAL, reportedAsSpam);
|
||||
} else if (hiddenState == Recipient.HiddenState.HIDDEN) {
|
||||
return new MessageRequestState(MessageRequestState.State.NONE_HIDDEN, reportedAsSpam);
|
||||
|
||||
@@ -19,8 +19,8 @@ data class MessageRequestState @JvmOverloads constructor(val state: State = Stat
|
||||
val isBlocked: Boolean
|
||||
get() = state == State.INDIVIDUAL_BLOCKED || state == State.BLOCKED_GROUP
|
||||
|
||||
val isFewConnectionsIndividual: Boolean
|
||||
get() = state == State.INDIVIDUAL_FEW_CONNECTIONS
|
||||
val isIndividual: Boolean
|
||||
get() = state == State.INDIVIDUAL || state == State.INDIVIDUAL_HIDDEN
|
||||
|
||||
val isGroupV2Add: Boolean
|
||||
get() = state == State.GROUP_V2_ADD
|
||||
@@ -56,9 +56,6 @@ data class MessageRequestState @JvmOverloads constructor(val state: State = Stat
|
||||
/** A user is blocked */
|
||||
INDIVIDUAL_BLOCKED,
|
||||
|
||||
/** A message request and secondary confirmation is needed for an individual with less than 2 common groups */
|
||||
INDIVIDUAL_FEW_CONNECTIONS,
|
||||
|
||||
/** A message request is needed for an individual since they have been hidden */
|
||||
INDIVIDUAL_HIDDEN
|
||||
}
|
||||
|
||||
@@ -5,14 +5,18 @@ import android.content.res.ColorStateList
|
||||
import android.text.Html
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.text.HtmlCompat
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import org.signal.core.util.dp
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols
|
||||
import org.thoughtcrime.securesms.messagerequests.MessageRequestBarColorTheme.Companion.resolveTheme
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.Debouncer
|
||||
import org.thoughtcrime.securesms.util.padding
|
||||
import org.thoughtcrime.securesms.util.views.LearnMoreTextView
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
@@ -22,6 +26,7 @@ import org.thoughtcrime.securesms.util.visible
|
||||
class MessageRequestsBottomView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : ConstraintLayout(context, attrs, defStyleAttr) {
|
||||
private val showProgressDebouncer = Debouncer(250)
|
||||
|
||||
private val title: TextView
|
||||
private val question: LearnMoreTextView
|
||||
private val accept: MaterialButton
|
||||
private val block: MaterialButton
|
||||
@@ -34,6 +39,7 @@ class MessageRequestsBottomView @JvmOverloads constructor(context: Context, attr
|
||||
init {
|
||||
inflate(context, R.layout.message_request_bottom_bar, this)
|
||||
|
||||
title = findViewById(R.id.message_request_title)
|
||||
question = findViewById(R.id.message_request_question)
|
||||
accept = findViewById(R.id.message_request_accept)
|
||||
block = findViewById(R.id.message_request_block)
|
||||
@@ -51,6 +57,7 @@ class MessageRequestsBottomView @JvmOverloads constructor(context: Context, attr
|
||||
question.setOnLinkClickListener(null)
|
||||
|
||||
updateButtonVisibility(messageRequestState)
|
||||
updateTitleVisibility(messageRequestState)
|
||||
|
||||
when (messageRequestState.state) {
|
||||
MessageRequestState.State.INDIVIDUAL_BLOCKED -> {
|
||||
@@ -91,25 +98,8 @@ class MessageRequestsBottomView @JvmOverloads constructor(context: Context, attr
|
||||
}
|
||||
|
||||
MessageRequestState.State.INDIVIDUAL,
|
||||
MessageRequestState.State.INDIVIDUAL_FEW_CONNECTIONS -> {
|
||||
question.text = HtmlCompat.fromHtml(
|
||||
context.getString(
|
||||
R.string.MessageRequestBottomView_do_you_want_to_let_s_message_you_they_wont_know_youve_seen_their_messages_until_you_accept,
|
||||
bold(recipient.getShortDisplayName(context))
|
||||
),
|
||||
0
|
||||
)
|
||||
accept.setText(R.string.MessageRequestBottomView_accept)
|
||||
}
|
||||
|
||||
MessageRequestState.State.INDIVIDUAL_HIDDEN -> {
|
||||
question.text = HtmlCompat.fromHtml(
|
||||
context.getString(
|
||||
R.string.MessageRequestBottomView_do_you_want_to_let_s_message_you_you_removed_them_before,
|
||||
bold(recipient.getShortDisplayName(context))
|
||||
),
|
||||
0
|
||||
)
|
||||
question.setText(R.string.MessageRequestBottomView_do_you_want_to_let_s_message_you_they_wont_know_youve_seen_their_messages_until_you_accept)
|
||||
accept.setText(R.string.MessageRequestBottomView_accept)
|
||||
}
|
||||
|
||||
@@ -126,6 +116,22 @@ class MessageRequestsBottomView @JvmOverloads constructor(context: Context, attr
|
||||
report.visible = !messageState.reportedAsSpam
|
||||
}
|
||||
|
||||
private fun updateTitleVisibility(messageState: MessageRequestState) {
|
||||
title.visible = !messageState.isBlocked
|
||||
if (title.visible) {
|
||||
title.text = SignalSymbols.getSignalSymbolText(
|
||||
context = context,
|
||||
text = context.getString(R.string.AboutSheet__review_requests_carefully),
|
||||
glyphStart = SignalSymbols.Glyph.ERROR_TRIANGLE,
|
||||
glyphStartWeight = SignalSymbols.Weight.REGULAR,
|
||||
glyphStartSizeSp = 14
|
||||
)
|
||||
question.padding(top = 0)
|
||||
} else {
|
||||
question.padding(top = 16.dp)
|
||||
}
|
||||
}
|
||||
|
||||
fun showBusy() {
|
||||
showProgressDebouncer.publish { busyIndicator.visibility = VISIBLE }
|
||||
buttonBar.visibility = INVISIBLE
|
||||
|
||||
@@ -238,6 +238,16 @@ private fun Content(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
if (!model.isSelf && !model.profileSharing && !model.systemContact) {
|
||||
AboutRow(
|
||||
startIcon = ImageVector.vectorResource(id = R.drawable.symbol_person_question_24),
|
||||
text = stringResource(id = R.string.AboutSheet__profile_names_are_not_verified),
|
||||
endIcon = ImageVector.vectorResource(id = R.drawable.symbol_chevron_right_compact_bold_16),
|
||||
modifier = Modifier.align(alignment = Alignment.Start),
|
||||
onClick = onUnverifiedProfileClicked
|
||||
)
|
||||
}
|
||||
|
||||
if (model.isSelf && (model.memberLabel != null || model.canEditMemberLabel)) {
|
||||
MemberLabelRow(
|
||||
memberLabel = model.memberLabel,
|
||||
@@ -267,16 +277,6 @@ private fun Content(
|
||||
)
|
||||
}
|
||||
|
||||
if (!model.isSelf && !model.profileSharing && !model.systemContact) {
|
||||
AboutRow(
|
||||
startIcon = ImageVector.vectorResource(id = R.drawable.symbol_person_question_24),
|
||||
text = stringResource(id = R.string.AboutSheet__profile_names_are_not_verified),
|
||||
endIcon = ImageVector.vectorResource(id = R.drawable.symbol_chevron_right_compact_bold_16),
|
||||
modifier = Modifier.align(alignment = Alignment.Start),
|
||||
onClick = onUnverifiedProfileClicked
|
||||
)
|
||||
}
|
||||
|
||||
if (!model.isSelf && model.verified) {
|
||||
AboutRow(
|
||||
startIcon = ImageVector.vectorResource(id = R.drawable.symbol_safety_number_24),
|
||||
|
||||
@@ -102,7 +102,7 @@ fun TransferAccountScreen(
|
||||
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
SignalSymbol(SignalSymbols.Weight.REGULAR, SignalSymbols.Glyph.LOCK)
|
||||
SignalSymbol(SignalSymbols.Glyph.LOCK)
|
||||
append(" ")
|
||||
append(stringResource(id = R.string.TransferAccount_messages_e2e))
|
||||
},
|
||||
|
||||
@@ -296,7 +296,7 @@ private fun initializeGiphyMp4(lifecycle: Lifecycle, videoContainer: ViewGroup,
|
||||
val callback = GiphyMp4ProjectionRecycler(holders)
|
||||
|
||||
GiphyMp4PlaybackController.attach(list, callback, maxPlayback)
|
||||
list.addItemDecoration(GiphyMp4ItemDecoration(callback) {}, 0)
|
||||
list.addItemDecoration(GiphyMp4ItemDecoration(callback), 0)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
Reference in New Issue
Block a user