Update conversation header and message request UI.

This commit is contained in:
Cody Henthorne
2026-04-13 10:21:12 -04:00
committed by jeffrey-signal
parent c2d927029a
commit 4756b8d70b
47 changed files with 1983 additions and 1057 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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