Add username onboarding banner in new chat screen.

This commit is contained in:
Greyson Parrelli
2026-09-10 17:10:15 -04:00
committed by Cody Henthorne
parent 0a6e4fa152
commit c6ff93af98
7 changed files with 173 additions and 4 deletions
@@ -14,6 +14,12 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
@@ -49,6 +55,8 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.conversation.NewConversationUiState.UserMessage
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity
import org.thoughtcrime.securesms.profiles.manage.EditProfileActivity
import org.thoughtcrime.securesms.profiles.username.SetUpUsernameBanner
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.ui.RecipientLookupFailureMessage
@@ -141,6 +149,8 @@ private fun NewConversationScreen(
}
override fun onInviteToSignal() = context.startActivity(AppSettingsActivity.invite(context))
override fun onSetUpUsername() = context.startActivity(EditProfileActivity.getIntentForUsernameEdit(context))
override fun onSetUpUsernameBannerDismissed() = viewModel.dismissSetUpUsernameBanner()
override fun onRefresh() = viewModel.refresh()
override fun onUserMessageDismissed(userMessage: UserMessage) = viewModel.clearUserMessage()
override fun onContactsListReset() = viewModel.clearShouldResetContactsList()
@@ -275,6 +285,8 @@ private interface NewConversationUiCallbacks :
fun onRemoveConfirmed(recipient: Recipient)
fun onBlockConfirmed(recipient: Recipient)
fun onUserMessageDismissed(userMessage: UserMessage)
fun onSetUpUsername()
fun onSetUpUsernameBannerDismissed()
fun onBackPressed()
object Empty : NewConversationUiCallbacks {
@@ -295,6 +307,8 @@ private interface NewConversationUiCallbacks :
override fun onRefresh() = Unit
override fun onContactsListReset() = Unit
override fun onUserMessageDismissed(userMessage: UserMessage) = Unit
override fun onSetUpUsername() = Unit
override fun onSetUpUsernameBannerDismissed() = Unit
override fun onBackPressed() = Unit
}
}
@@ -320,7 +334,20 @@ private fun NewConversationRecipientPicker(
findByPhoneNumber = callbacks
)
},
modifier = modifier.fillMaxSize()
modifier = modifier.fillMaxSize(),
belowSearchBar = {
AnimatedVisibility(
visible = uiState.showSetUpUsernameBanner,
enter = fadeIn(tween(150)) + expandVertically(tween(200)),
exit = fadeOut(tween(100)) + shrinkVertically(tween(200))
) {
SetUpUsernameBanner(
onSetUpClick = callbacks::onSetUpUsername,
onDismissClick = callbacks::onSetUpUsernameBannerDismissed,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
}
)
}
@@ -34,7 +34,13 @@ class NewConversationViewModel : ViewModel() {
private val TAG = Log.tag(NewConversationViewModel::class)
}
private val internalUiState = MutableStateFlow(NewConversationUiState())
private val internalUiState = MutableStateFlow(
NewConversationUiState(
showSetUpUsernameBanner = SignalStore.account.isPhoneNumberless &&
SignalStore.account.username == null &&
!SignalStore.uiHints.hasDismissedSetUpUsernameBanner()
)
)
val uiState: StateFlow<NewConversationUiState> = internalUiState.asStateFlow()
private val contactsManagementRepo = ContactsManagementRepository(AppDependencies.application)
@@ -176,6 +182,11 @@ class NewConversationViewModel : ViewModel() {
fun clearUserMessage() {
internalUiState.update { it.copy(userMessage = null) }
}
fun dismissSetUpUsernameBanner() {
SignalStore.uiHints.markHasDismissedSetUpUsernameBanner()
internalUiState.update { it.copy(showSetUpUsernameBanner = false) }
}
}
data class NewConversationUiState(
@@ -184,7 +195,8 @@ data class NewConversationUiState(
val isRefreshingContacts: Boolean = false,
val shouldResetContactsList: Boolean = false,
val pendingDestination: RecipientId? = null,
val userMessage: UserMessage? = null
val userMessage: UserMessage? = null,
val showSetUpUsernameBanner: Boolean = false
) {
sealed interface UserMessage {
sealed interface Info : UserMessage {
@@ -33,6 +33,7 @@ public class UiHintValues extends SignalStoreValues {
private static final String HAS_SEEN_VERIFY_AUTO_SHEET = "uihints.has_seen_verify_auto_sheet";
private static final String HAS_DISMISSED_MEMBER_LABEL_ABOUT_OVERRIDE_WARNING = "uihints.has_dismissed_member_label_about_override_warning";
private static final String HAS_SEEN_ADMIN_DELETE_EDUCATION_DIALOG = "uihints.has_seen_admin_delete_education_dialog";
private static final String DISMISSED_SET_UP_USERNAME_BANNER = "uihints.dismissed_set_up_username_banner";
UiHintValues(@NonNull KeyValueStore store) {
super(store);
@@ -72,6 +73,14 @@ public class UiHintValues extends SignalStoreValues {
return getBoolean(HAS_CONFIRMED_DELETE_FOR_EVERYONE_ONCE, false);
}
public void markHasDismissedSetUpUsernameBanner() {
putBoolean(DISMISSED_SET_UP_USERNAME_BANNER, true);
}
public boolean hasDismissedSetUpUsernameBanner() {
return getBoolean(DISMISSED_SET_UP_USERNAME_BANNER, false);
}
public boolean hasSetOrSkippedUsernameCreation() {
return getBoolean(HAS_SET_OR_SKIPPED_USERNAME_CREATION, false);
}
@@ -0,0 +1,105 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.profiles.username
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.IconButtons
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcons
import org.thoughtcrime.securesms.R
import org.signal.core.ui.R as CoreUiR
/**
* Prompts a phone-numberless user without a username to create one. Dismissal is permanent.
*/
@Composable
fun SetUpUsernameBanner(
onSetUpClick: () -> Unit,
onDismissClick: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(36.dp))
.background(colorResource(R.color.set_up_username_banner_background))
.defaultMinSize(minHeight = 72.dp)
.padding(start = 20.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
) {
Icon(
imageVector = ImageVector.vectorResource(CoreUiR.drawable.symbol_at_24),
contentDescription = null,
tint = colorResource(CoreUiR.color.signal_light_colorOnSurface),
modifier = Modifier.size(24.dp)
)
Text(
text = stringResource(R.string.SetUpUsernameBanner__choose_a_username_so_others_can_connect_with_you),
style = MaterialTheme.typography.bodyMedium,
color = colorResource(CoreUiR.color.signal_light_colorOnSurface),
modifier = Modifier
.weight(1f)
.padding(start = 12.dp)
)
Buttons.Small(
onClick = onSetUpClick,
colors = ButtonDefaults.buttonColors(
containerColor = colorResource(R.color.set_up_username_banner_button_background),
contentColor = colorResource(CoreUiR.color.signal_light_colorNeutralInverse)
)
) {
Text(text = stringResource(R.string.SetUpUsernameBanner__set_up))
}
IconButtons.IconButton(
onClick = onDismissClick,
size = 32.dp
) {
Icon(
imageVector = SignalIcons.X.imageVector,
contentDescription = stringResource(R.string.SetUpUsernameBanner__dismiss),
tint = colorResource(CoreUiR.color.signal_light_colorOnSurface)
)
}
}
}
@DayNightPreviews
@Composable
private fun SetUpUsernameBannerPreview() {
Previews.Preview {
SetUpUsernameBanner(
onSetUpClick = {},
onDismissClick = {},
modifier = Modifier.padding(16.dp)
)
}
}
@@ -83,7 +83,8 @@ fun RecipientPicker(
listBottomPadding: Dp? = null,
clipListToPadding: Boolean = ContactSelectionArguments.Defaults.RECYCLER_CHILD_CLIPPING,
callbacks: RecipientPickerCallbacks,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
belowSearchBar: @Composable () -> Unit = {}
) {
Column(
modifier = modifier
@@ -114,6 +115,8 @@ fun RecipientPicker(
.padding(horizontal = 16.dp)
)
belowSearchBar()
RecipientSearchResultsList(
displayModes = displayModes,
selectionLimits = selectionLimits,
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="set_up_username_banner_background">#FFE5E8F8</color>
<color name="set_up_username_banner_button_background">#FFC2C8EE</color>
</resources>
+8
View File
@@ -3513,6 +3513,14 @@
<!-- Button label that dismisses the screen without creating a username -->
<string name="ConnectWithUsernamesDialogFragment__not_now">Not now</string>
<!-- SetUpUsernameBanner -->
<!-- Body of a banner on the new message screen prompting the user to create a username -->
<string name="SetUpUsernameBanner__choose_a_username_so_others_can_connect_with_you">Choose a username so others can connect with you.</string>
<!-- Button label that takes the user to the username creation screen -->
<string name="SetUpUsernameBanner__set_up">Set up</string>
<!-- Accessibility label for the button that permanently dismisses the banner -->
<string name="SetUpUsernameBanner__dismiss">Dismiss</string>
<!-- ThumbnailView -->
<string name="ThumbnailView_Play_video_description">Play video</string>
<!-- Content description to describe an icon that indicates a video has captions -->